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

Программирование - это композиция. Обычно мы решаем сложную проблему, разбивая ее на более мелкие и более понятные задачи, которые затем составляются вместе, будь то функции, объекты и т. Д.

Попутно возникают интересные проблемы, когда мы вводим новые типы для выражения концепции в нашем коде, будь то конкретная или абстрактная. И это как раз та тема, которую мы собираемся немного обсудить. Но прежде чем мы начнем, обратите внимание, что доступно довольно много вариантов, и я не собираюсь спорить, какой из них лучше другого. Вместо этого я хотел бы продемонстрировать, как использовать Алгебраические типы данных (ADT) в статически типизированном языке программирования для выполнения одной конкретной задачи, а затем посоветовать вам больше узнать об этом, попробовать, узнать его плюсы и минусы. , и на основе этого принимать собственные решения. Я буду использовать C ++ и Haskell, однако эти идеи можно применить и к другим языкам программирования.

Типы классные, давайте познакомимся с некоторыми

Рассмотрим следующий тип C ++, предназначенный для выражения результата операции чтения по каналу связи:

template <typename T>
struct ReadResult {
  enum class State {
    SUCCESS, FAILURE
  };
  State state;
  T payload;
  std::string error_code;
};

Возникают следующие вопросы: что означает полезная нагрузка, когда состояние равно Failure? Или, что эквивалентно, что означает error_code, когда state равно Success? Возможно, для T. не может даже существовать конструктор по умолчанию.

Я бы сказал, что это недопустимые сценарии. Однако, что касается наших типов, мы допускаем эти сценарии.

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

Сделать это необязательным или нет?

Один из способов решения проблемы - переписать тип, чтобы поднять элементы, которые допускают значение NULL для каждого состояния, в указатели и использовать nullptr, чтобы выразить пустоту, или, альтернативно, поднять члены в необязательные типы:

template <typename T>
struct ReadResult {
  enum class State {
    SUCCESS, FAILURE
  };
  State state;
  std::optional<T> payload;
  std::optional<std::string> error_code;
};

Теперь мы можем представлять «пустые» типы, используя нулевые параметры, то есть std :: nullopt. Код отлично компилируется даже для типа T, который не предоставляет конструктор по умолчанию.

Однако я бы сказал, что он просто исправляет проблему, а не исправляет ее, потому что мы все еще можем представлять недопустимые сценарии, то есть доступ к полезной нагрузке, когда state равно ОТКАЗ.

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

Либо то, либо то

Если мы внимательно проверим ReadResult ‹T›, мы увидим, что состояние не так тесно связано с полезной нагрузкой и error_code Как бы то ни было, возможные состояния и их соответствующие значения должны быть прочно связаны вместе таким образом, чтобы каждое возможное состояние выставляло только те элементы, которые имеют для него смысл.

С точки зрения ADT. Основная проблема заключается в том, что тип ReadResult ‹T› может быть как T (payload) , так и std :: string (error_code) одновременно, другими словами, это тип продукта. Следовательно, набор возможных значений, которые могут населять ReadResult ‹T›, является декартовым произведением набора значений его членов:

#ReadResult ‹T› = #T * #std :: string * 2

Конечный 2 обусловлен возможными значениями State, которые являются либо SUCCESS , либо FAILURE, и, следовательно, # Состояние = 2.

Чтобы выразить идею «позволить каждому состоянию иметь только те члены, которые имеют для него смысл», мы можем использовать тип sum:

template <typename T>
struct Success {
  T payload;
};
struct Failure {
  std::string error_code;
};
template <typename T>
using ReadResult = std::variant<Failure, Success<T>>;

Используя std :: variant ‹Неудача, успех ‹T››, ReadResult ‹T› стал псевдонимом типа.

Вы можете интерпретировать ReadResult ‹T› как «либо неудачу , либо успех ‹T›, но не оба одновременно ». Неудача и Успех ‹T› теперь являются закрытым набором альтернатив для вариант ReadResult ‹T› типа.

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

Наш ReadResult ‹T› представляет собой тип суммы со следующим набором возможных значений:

#ReadResult ‹T› = #Success ‹T› + #Failure = #T + #std :: string

Более того, тот факт, что T может не иметь конструктора по умолчанию, имеет значение только тогда, когда мы находимся в альтернативе Success, но не в случае Failure. Тем не менее, мы все равно можем переместить полезную нагрузку в std :: optional ‹T›, если потребуется.

В качестве примечания, вот эквивалент в Haskell, который имеет встроенную поддержку для написания ADT:

data ReadResult a = Failure String | Success a

Он гласит следующее: «ReadResult является либо Failure из String , либо Успех из а », где полоса | означает или, а a является параметром типа, то есть заполнителем для типа, играющим ту же роль, что и параметр типа шаблона T сделал в версии C ++.

Это довольно распространенный шаблон в Haskell, и стандартная библиотека предоставляет нам удобный тип: ata Either a b = Left a | Правый б

Вместо Failure и Success, Either обращается к своим конструкторам значений как Left и Right соответственно.

Таким образом, мы можем переписать ReadResult как следующий псевдоним типа:

type ReadResult a = Either String a

Вывод

Идея комбинирования типов product и sum довольно действенна и может открыть новые возможности дизайна, которые интересно иметь в нашем наборе инструментов. Они особенно подходят при разработке конечных автоматов, например, игровых движков. Это тот случай, когда состояниям разрешено совместно использовать некоторые атрибуты (продукт), но некоторые атрибуты имеют смысл только для определенных состояний (сумма).

Кроме того, существуют другие, хотя, возможно, более экзотические, виды ADT, построенные на основе произведений и сумм; например, типы PI. Типы, зависящие от значений, могут помочь нам определить мощные инварианты, вычисляемые во время компиляции. Я бы порекомендовал их проверить.

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

И теперь вам, мой коллега-разработчик, решать, какой инструмент лучше подходит для каждого требования, над которым вы работаете. Знайте свои альтернативы (это не каламбур) и выбирайте с умом 😺.

использованная литература

[1] Категории для работающего хакера.

[2] Краткое введение в алгебру типов

[3] Выразительность, типы, допускающие значение NULL, и композиция