Правильная типизация: недооцененный аспект чистого кода

в 15:06, , рубрики: c++, design patterns, typing, Блог компании Издательский дом «Питер», высокая производительность, книги, ооп, Программирование, Профессиональная литература

Здравствуйте, коллеги.

Не так давно наше внимание привлекла почти готовая книга издательства Manning «Programming with types», подробно рассматривающая важность правильной типизации и ее роль при написании чистого и долговечного кода.

Правильная типизация: недооцененный аспект чистого кода - 1

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

Mars Climate Orbiter

Космический аппарат Mars Climate Orbiter отказал при посадке и развалился в марсианской атмосфере, поскольку в программном компоненте, разработанном компанией Lockheed, давалось значение импульса, измеренное в фунт-силах/сек., тогда как другой компонент, разработанный NASA, принимал значение импульса в ньютонах/сек.

Можно представить компонент, разработанный NASA, примерно в таком виде:

// Не развалится при условии, что >= 2 N s
void trajectory_correction(double momentum)
{
    if (momentum < 2 /* N s */)
    {
        disintegrate();
    }
    /* ... */
}

Также можно представить, что компонент Lockheed вызывал вышеприведенный код вот так:

void main()
{
    trajectory_correction(1.5 /* lbf s */);
}

Фунт-сила в секунду (lbfs) равна примерно 4,448222 ньютонов в секунду (Ns). Таким образом, с точки зрения Lockheed, передавать 1,5 lbfs в trajectory_correction должно быть совершенно нормально: 1,5 lbfs равно примерно 6,672333 Ns, гораздо выше порогового значения 2 Ns.

Проблема заключается в интерпретации данных. В итоге компонент NASA сравнивает lbfs с Ns без преобразования и ошибочно интерпретирует ввод в lbfs как ввод в Ns. Поскольку 1,5 меньше 2, орбитальный аппарат развалился. Это известный антипаттерн, который называется «зацикленность на примитивах» (primitive obsession).

Зацикленность на примитивах

Зацикленность на примитивах проявляется, когда мы используем примитивный тип данных для представления значения в предметной области задачи и допускаем такие ситуации, как описанная выше. Если представлять почтовые коды как числа, телефонные номера как строки, Ns и lbfs как числа двойной точности, то именно такое и случается.

Гораздо безопаснее было бы определить простой тип Ns:

struct Ns
{
    double value;
};

bool operator<(const Ns& a, const Ns& b)
{
    return a.value < b.value;
}

Аналогичным образом можно определить простой тип lbfs:

struct lbfs
{
    double value;
};

bool operator<(const lbfs& a, const lbfs& b)
{
    return a.value < b.value;
}

Теперь можно реализовать типобезопасный вариант trajectory_correction:

// Не развалится, поскольку импульс >= 2 N s
void trajectory_correction(Ns momentum)
{
    if (momentum < Ns{ 2 })
    {
        disintegrate();
    }
    /* ... */
}

Если вызвать это с lbfs, как в вышеприведенном примере, то код просто не скомпилируется из-за несовместимости типов:

void main()
{
    trajectory_correction(lbfs{ 1.5 });
}

Обратите внимание, как информация о типах значений, которая обычно указывается в комментариях, (2 /*Ns */, /* lbfs */) теперь втягивается в систему типов и выражается в коде: (Ns{ 2 }, lbfs{ 1.5 }).

Разумеется, можно предусмотреть приведение lbfs к Ns в виде явного оператора:

struct lbfs
{
    double value;

    explicit operator Ns()
    {
        return value * 4.448222;
    }
};

Вооружившись таким приемом, можно вызвать trajectory_correction при помощи статического приведения:

void main()
{
    trajectory_correction(static_cast<Ns>(lbfs{ 1.5 }));
}

Здесь правильность кода достигается путем умножения на коэффициент. Приведение также можно выполнить неявно (воспользовавшись ключевым словом implicit), и в этом случае приведение будет применяться автоматически. В качестве эмпирического правила здесь можно пользоваться одним из коанов Python:

Явное лучше неявного

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

Пространство состояний

Беды случаются, когда программа завершает работу в плохом состоянии. Типы помогают сузить поле для их возникновения. Попробуем трактовать тип как множество возможных значений. Например, bool это множество {true, false}, где переменная данного типа может принимать одно из двух этих значений. Аналогично, uint32_t это множество {0 ...4294967295}. Рассматривая типы таким образом, можно определить пространство состояний нашей программы как произведение типов всех живых переменных в определенный момент времени.

Если у нас есть переменная типа bool и переменная типа uint32_t, то наше пространство состояний будет равно {true, false} X {0 ...4294967295}. Это всего лишь означает, что обе переменные могут находиться в любых возможных для них состояниях, а, поскольку переменных у нас две, программа может оказаться в любом комбинированном состоянии двух этих типов.

Все становится гораздо интереснее, если рассмотреть функции, инициализирующие значения:

bool get_momentum(Ns& momentum)
{
    if (!some_condition()) return false;

    momentum = Ns{ 3 };

    return true;
}

В вышеприведенном примере мы берем Ns по ссылке и инициализируем, если выполняется некоторое условие. Функция возвращает true, если значение было правильно инициализировано. Если функция по какой-либо причине не может задать значение, то она возвращает false.

Рассматривая данную ситуацию с точки зрения пространства состояний, можно сказать, что пространство состояний – это произведение bool X Ns. Если функция возвращает true, это означает, что импульс был задан, и является одним из возможных значений Ns. Проблема вот в чем: если функция возвращает false, это означает, что импульс не был задан. Импульс так или иначе принадлежит ко множеству возможных значений Ns, но не является валидным значением. Часто случаются баги, при которых случайно начинает распространяться следующее недопустимое состояние:

void example()
{
    Ns momenum;

    get_momentum(momentum);

    trajectory_correction(momentum);
}

Вместо этого мы попросту должны делать так:

void example()
{
    Ns momentum;

    if (get_momentum(momentum))
    {
        trajectory_correction(momentum);
    }
}

Однако, есть и лучший способ, при котором это можно делать принудительно:

std::optional<Ns> get_momentum()
{
    if (!some_condition()) return std::nullopt;

    return std::make_optional(Ns{ 3 });
}

Если использовать optional, то пространство состояний у этой функции значительно уменьшится: вместо bool X Ns получим Ns + 1. Эта функция вернет либо валидное значение Ns, либо nullopt, чтобы обозначить отсутствие значения. Теперь у нас просто не может появиться недопустимого Ns, которое стало бы распространяться в системе. Также теперь становится невозможным забыть проверить возвращаемое значение, поскольку optional не может быть неявно преобразовано в Ns – нам понадобится специально распаковать его:

void example()
{
    auto maybeMomentum = get_momentum();

    if (maybeMomentum)
    {
        trajectory_correction(*maybeMomentum);
    }
}

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

С такой точки зрения выбрасывание исключений – это нормально, так как соответствует вышеописанному принципу: функция или вернет результат, или выбросит исключение.

RAII

RAII означает Resource Acquisition Is Initialization («Получение ресурса есть инициализация»), но в большей степени этот принцип связан с высвобождением ресурсов. Название впервые возникло в C++, однако, этот паттерн может быть реализован на любом языке (см., например, IDisposable из .NET). RAII обеспечивает автоматическую очистку ресурсов.

Что такое ресурсы? Вот несколько примеров: динамическая память, соединения с базой данных, описатели ОС. В принципе, ресурс – нечто, забираемое из внешнего мира и подлежащее возвращению после того, как он нам станет не нужен. Мы возвращаем ресурс при помощи соответствующей операции: высвобождаем его, удаляем, закрываем, т.д.

Поскольку эти ресурсы являются внешними, они не выражены явно в нашей системе типов. Например, если мы выделим фрагмент динамической памяти, то получим указатель, по которому должны будем вызвать delete:

struct Foo {};

void example()
{
    Foo* foo = new Foo();

    /* Используем foo */

    delete foo;
}

Но что случится, если мы забудем это сделать, или что-то помешает нам вызвать delete?

void example()
{
    Foo* foo = new Foo();

    throw std::exception();

    delete foo;
}

В данном случае мы больше не вызываем delete и получаем утечку ресурса. В принципе, такая очистка ресурсов вручную является нежелательной. Для динамической памяти у нас есть unique_ptr, помогающий нам ею управлять:

void example()
{
    auto foo = std::make_unique<Foo>();

    throw std::exception();
}

Наш unique_ptr – это объект стека, поэтому, стоит ему выйти из области видимости (когда функция выбросит исключение или при раскрутке стека, когда было выброшено исключение), вызывается его деструктор. Именно этот деструктор реализует вызов delete. Соответственно, нам больше не приходится управлять ресурсом-памятью – мы передаем эту работу обертке, которая ею владеет и отвечает за ее высвобождение.

Подобные обертки существуют (или их можно создать) для любых других ресурсов (например, OS HANDLE из Windows можно обернуть в тип, и в таком случае его деструктор будет вызывать CloseHandle).

Основной вывод в данном случае – никогда не заниматься очисткой ресурсов вручную; либо используем имеющуюся обертку, либо, если подходящей обертки для вашего конкретного сценария не существует – реализуем ее сами.

Заключение

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

  • Объявление и использование более сильных типов (в противовес зацикленности на примитивах).
  • Сокращение пространства состояний, возврат результата или ошибки, а не результата или ошибки.
  • RAII и автоматическое управление ресурсами.

Итак, типы отлично помогают сделать код безопаснее и приспособить его для переиспользования.

Автор: ph_piter

Источник

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


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