Обложка статьи «Когда вместо Boolean лучше использовать Enum и почему»

Когда вместо Boolean лучше использовать Enum и почему

Перевод статьи «Don’t Use Boolean Arguments, Use Enums»

Автор перевода Алина Уткина

Boolean — один из первых типов данных, которые изучают начинающие программисты. Почему бы и нет? Логический тип максимально прост, ведь принимает всего два значения: true и false.

Прим.ред. Речь идёт о языках программирования, в которых Boolean не может быть null.

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

Передача логического значения функции — воистину ужасная привычка. Она немедленно усложняет сигнатуру метода, громко провозглашая, что функция выполняет более одной операции.

Р. Мартин, «Чистый код»

Пример использования Boolean

Предположим, что группа разработчиков создаёт модуль для управления состоянием пользователя. Один из программистов настаивает на использовании логического типа, так как в требованиях указано только два значения: ONLINE и OFFLINE. Несмотря на то, что большая часть команды не согласна с таким предложением, его принимают, ведь реализация условий с использованием Boolean выглядит простой и быстрой.

В конце концов, в коде появляются такие функции:

func setUserState(isUserOnline : Bool)

Вскоре к команде присоединяется новый разработчик, который не понимает, за что отвечает следующая строка:

setUserState(true)

Кто-то из команды предлагает изменить название функции на setUserOnline(), и поначалу этого решения оказывается достаточно. Но затем всё превращается в сущий кошмар, ведь требование дополняется необходимостью ввести ещё один статус: BLOCKED. Есть несколько способов решить поставленную задачу. Разберём их подробнее.

Одна булева переменная и три состояния

Boolean обычно принимает два значения. Но в некоторых языках, таких как Java, где у примитивных типов есть классы обёртки, можно использовать null для присвоения третьего состояния. В нашем примере для определения статуса BLOCKED будет использовано значение null.

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

Кроме того, в некоторых сценариях будет сложно отличить false от null. Возьмём, к примеру, свойство game.isPlaying. Значение true будет чётко указывать на то, что игра запущена. Но что если значение равно false или null? false означает, что игра на паузе или остановлена? А на что в таком случае указывает null?

Как видите, значение false недостаточно информативно, и наличие третьего состояния, привязанного кnull, только усложнит логику кода.

К тому же, что произойдёт, если разработчиков попросят добавить ещё один статус —EXPIRED? Становится понятно, что способ с использованием одной булевой переменной не подходит.

Несколько булевых переменных

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

func setUserState(
   isUserOnline : Bool,
   isUserBlocked : Bool,
   isUserExpired : Bool
)

И вот какие проблемы их поджидают.

Появление скрытых зависимостей

Простое с виду расширение перечня добавит скрытые зависимости и новые комбинации состояний.

Теперь будет две скрытые зависимости: isUserOnlineisUserExpiredи isUserOnlineisUserBlocked. Придётся обрабатывать дополнительные условия, чтобы избежать конфликтующих состояний. Например, пользователь со статусом BLOCKEDили EXPIREDне может быть со статусомONLINE. Примеры таких условий:

#Condition 1: isUserOnline: false and isUserExpired: true
#Condition 2: isUserOnline: false and isUserBlocked: true

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

Проблемы с безопасностью и читаемостью

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

setUserState(true, false, false)

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

Группа разработчиков из примера избежит перечисленных проблем, если вместо Boolean использует Enum.

Использование Enum

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

enum UserStates{
   case active
   case inactive
   case blocked
   case expired
}

Рассмотрим главные преимущества Enum.

Чёткость и содержательность

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

//Функция, которая принимает несколько булевых аргументов:
setUserState(true, false, false)

//Функция, которая принимает элемент перечисления:
setUserState(UserStates.active)

Простое масштабирование

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

Высокая типобезопасность

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

Однако не все языки поддерживают Enum. В таких случаях вы можете создать собственные типы. Например, в JavaScript можно «заморозить» константы в объекте:

const UserState = {
   ACTIVE: 1,
   INACTIVE: 2,
   BLOCKED: 3,
   EXPIRED: 4
};
Object.freeze(UserState);

Прим.ред. Стоит отметить, что кастомные реализации Enum могут ухудшить читаемость и производительность кода. В языках, которые не поддерживают перечисляемый тип, иногда лучше предпочесть Boolean.

Заключение

Boolean — это не плохо. Его можно использовать, если вы уверены, что имеете дело с двумя взаимоисключающими состояниями, или если это стандартный метод, который подразумевает принятие булевого аргумента (например setEnabled(true)).

Но зачастую требования меняются и расширяются. В таких случаях стоит потратить чуть больше времени на реализацию Enum — скорее всего, в будущем это окупится.