Типобезопасные идентификаторы и фантомные типы

в 21:15, , рубрики: c++, haskell

Довольно часто в программе, работающей с базой данных, в качестве идентификаторов сущностей используются значения целочисленного типа (например, long). Но людям свойственно ошибаться, и программист может по ошибке использовать идентификатор одного типа сущности для адресации другой. Такая проблема может долго оставаться незамеченной, если идентификаторы сущностей пересекаются, а такое бывает довольно часто. К счастью, в языках, позволяющих манипулировать типами, коим является C++, есть довольно простое решение этой проблемы.

Постановка проблемы

Предположим, наша программа работает с несколькими типами сущностей. Для примера возьмём виджеты (класс Widget) и гаджеты (класс Gadget):

class Widget {
public:
    long id() const;
    // ...
};

class Gadget {
public:
    long id() const;
    // ...
};

Помимо высокой вероятности ошибки, использование «сырых» типов в качестве идентификаторов существенно снижает читаемость кода. Не очень-то легко понимать код, содержащий множество типов вроде std::vector<long> или std::map<long, long>. Использование синонимов типов:

typedef long WidgetId;
typedef long GadgetId;

позволит программисту писать более выразительный код, манипулируя типами вроде std::map<WidgetId, GadgetId>. Но такой подход решит только проблему читаемости. Компилятор всё ещё не знает, что мы считаем значения типов WidgetId и GadgetId несовместимыми.

Сообщаем компилятору наши намерения

Что бы сделал человек, будь у него потребность оперировать на бумаге множеством абстрактных идентификаторов, чтобы не запутаться во всех этих числах? Думаю, вполне разумным подходом является добавление к идентификаторам метки типа — префикса или суффикса, означающего идентифицируемую сущность. Например, К-12 могло бы означать компьютер за 12 по счёту рабочим местом, а П-12 — двенадцатого по счёту зарегистрированного пользователя.

К счастью, в C++ есть механизм, позволяющий прикреплять к типам метки — шаблоны. Для решения нашей проблемы нам достаточно реализовать класс, параметризованный типом, и хранящий идентификатор:

template <typename ModelType,
          typename ReprType = long>
class IdOf {
public:
    typedef ModelType model_type;
    typedef ReprType repr_type;

    IdOf() : value_() {}

    explicit IdOf(repr_type value) : value_(value) {}

    repr_type value() const { return value_; }

    bool operator==(const IdOf &rhs) const {
        return value() == rhs.value();
    }

    bool operator!=(const IdOf &rhs) const {
        return value() != rhs.value();
    }

    bool operator<(const IdOf &rhs) const {
        return value() < rhs.value();
    }

    bool operator>(const IdOf &rhs) const {
        return value() > rhs.value();
    }

private:
    repr_type value_;
};

Применим новый класс к нашим гаджетам и виджетам:

class Gadget;
class Widget;

typedef IdOf<Gadget> GadgetId;
typedef IdOf<Widget> WidgetId;

class Widget {
public:
    WidgetId id() const;
    // ...
};

class Gadget {
public:
    GadgetId id() const;
    // ...
};

Благодаря тому, как мы определили класс IdOf, следующий код, содержащий логические ошибки, не будет компилироваться:

// This won't compile.
vector<GadgetId> gadgetIds;
gadgetIds.push_back(WidgetId(5));

// This won't compile either.
if (someGadget.id() == someWidget.id()) {
    doSomething();
}

Операции же над идентификаторами одного типа будут работать правильно. Теперь компилятор знает больше о наших намерениях, он не даст нам загрузить гаджет по идентификатору виджета или поместить в вектор идентификатор неправильного типа.

Если нам всё же нужно будет сравнить идентификаторы разных типов, или сравнить идентификатор с «сырым» значением, всегда можно вызвать метод value() явно.

Фантомные типы

Оказывается, трюк, который мы только что провернули с идентификаторами, известен в функциональном программировании довольно давно. Параметризованные типы, не использующие тип-параметр в определении, называются фантомными типами (Phantom Types).
К примеру, в Haskell подобный приём может быть реализован следующим образом:

newtype IdOf a = IdOf { idValue :: Int }
  deriving (Ord, Eq, Show, Read)

Ого, всего лишь пара строк кода! Теперь добавим определения наших моделей:

data Widget = Widget { widgetId :: IdOf Widget }
  deriving (Show, Eq)
data Gadget = Gadget { gadgetId :: IdOf Gadget }
  deriving (Show, Eq)

и проверим желаемое поведение, создав экземпляры разных типов и попробовав сравнить их идентификаторы:

Prelude> let g = Gadget (IdOf 5)
Prelude> let w = Widget (IdOf 5)
Prelude> widgetId w == gadgetId g

<interactive>:1:15:
    Couldn't match type `Gadget' with `Widget'
    Expected type: IdOf Widget
      Actual type: IdOf Gadget
    In the return type of a call of `gadgetId'
    In the second argument of `(==)', namely `gadgetId g'
    In the expression: widgetId w == gadgetId g

Отлично, компилятор (точнее, здесь я использовал для экспериментов интерпретатор ghci) отказался принимать сравнение идентификаторов разного типа. Это как раз то, что нужно.

Эту технику могут можно использовать для привязки к числовым значениям меток валюты, единиц измерения и прочей информации, которая может быть полезна как читателю программы, так и компилятору.

Итоги

Всего один небольшой класс может сохранить нам немало времени, которое пришлось бы потратить на поиск ошибки. Кроме того, использование такого подхода не скажется на производительности и потреблении памяти программы во время выполнения при компиляции с включенной оптимизацией. Версия на Haskell также не вносит дополнительных накладных расходов.

Недостатком является необходимость набирать (и читать) чуть больше букв и, возможно, объяснять идею коллегам, но довольно часто преимущества от более строгой проверки логики компилятором перевешивают недостатки.

Фантомные типы популярны в приложениях, требующих высокой надёжности, где каждая допольнительная проверка, автоматически совершаемая компилятором, сокращает убытки компании. В частности, они активно используются при программировании на OCaml в компании Jane Street и в продуктах банка Standard Chartered, написанных на Haskell (о чём рассказывал Don Stewart на Google Tech Talk 2015).

Нельзя также не упомянуть о мощной библиотеке Boost.Units, позволяющую производить типобезопасные операции над значениями разных типов с автоматическим выводом типа результата.

Автор: roman_kashitsyn

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js