Написать пост

Проблемы с булевым типом

Аватар Типичный программист

Обложка поста Проблемы с булевым типом

Рассказывает автор блога Gatunka

Люди очень любят говорить о техническом долге. Однако если поискать, что создает этот технический долг, то вы найдете много простого повторяющегося шаблонного кода, который ухудшает общую читаемость. Так что я решил разобрать несколько подобных примеров, чтобы определить, можно ли это как-то просто исправить. Сперва я хочу взглянуть на использование логических типов. Булев тип — чрезвычайно полезная концепция в программировании, но он также может являться источником ошибок, которые трудно найти. Я хочу остановиться на некоторых проблемах, которые могут возникнуть из-за неверного использования логических типов, а также предложить несколько вариантов для уменьшения создаваемого ими технического долга.

Асимметрия булева типа

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

			void ProcessBuffer(void *buffer)
		

Но позже мы должны добавить поддержку Unicode. Если бы мы были Windows-программистами, то это был бы UTF-16. Проще всего добавить булев тип с параметром по умолчанию. Таким образом нам не придется менять существующий код.

			void ProcessBuffer(void *buffer, bool is_unicode = false)
		

Но тогда мы должны добавить поддержку UTF-8, потому что наш код получает ввод в виде UTF-8 в некоторых файловых форматах:

			void ProcessBuffer(void *buffer, bool is_unicode = false, bool is_utf8 = false)
		

А теперь возникает ситуация, когда нужно обработать little endian и big endian UTF-16.

			void ProcessBuffer(void *buffer, bool is_unicode = false, bool is_utf8 = false,
                   bool is_utf16be = false)
		

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

			void ProcessBuffer(void *buffer, bool is_unicode = false, bool is_utf8 = false,
                   bool is_utf16be = false, bool normalize = false,
                   bool normalize_D = false)
		

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

			// Обработать buffer как utf-8
ProcessBuffer(buffer, true, false, false, true, false);
		

Можете ли вы найти баг в этом коде? Человеку, читающему этот код, должна быть очевидна проблема. Здесь немало проблем, и никакое количество статического анализа не сможет нам помочь. Основная проблема этого кода — неправильный комментарий. Вместо того, чтобы помочь нам понять код, он на самом деле дает нам ложное представление о назначении логического типа.

Перечисляемый тип FTW

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

			enum {
    eLegacyEncoding = 1,
    eUnicode = 2
} encoding_t;
void ProcessBuffer(void *buffer, encoding_t encoding = eLegacyEncoding)
		
			enum {
    eLegacyEncoding = 1,
    eUnicode = 2, // Оставить для обратной совместимости или реорганизовать
    eUTF16 = 2,
    eUTF8 = 3
} encoding_t;
void ProcessBuffer(void *buffer, encoding_t encoding = eLegacyEncoding)
		
			enum {
    eLegacyEncoding = 1,
    eUnicode = 2, // Оставить для обратной совместимости или реорганизовать
    eUTF16 = 2,   // Оставить для обратной совместимости или реорганизовать
    eUTF16LE = 2,
    eUTF8 = 3,
    eUTF16BE = 4,
} encoding_t;
void ProcessBuffer(void *buffer, encoding_t encoding = eLegacyEncoding)
		
			enum {
    eLegacyEncoding = 1,
    eUnicode = 2, // Оставить для обратной совместимости или реорганизовать
    eUTF16 = 2,   // Оставить для обратной совместимости или реорганизовать
    eUTF16LE = 2,
    eUTF8 = 3,
    eUTF16BE = 4,
} encoding_t;
enum {
    eDoNotNormalize = 1,
    eNormalizeC = 2,
    eNormalizeD = 3
} normalization_t;
void ProcessBuffer(void *buffer, encoding_t encoding = eLegacyEncoding,
                   normalization_t norm = eDoNotNormalize)
		

Теперь заметить баг намного проще:

			// Обработать buffer как utf-8
ProcessBuffer(buffer, eUTF16, eNormalizeC);
		

В самом деле, читаемость улучшилась настолько, что комментарий стал лишним.

Возвращаемые значения

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

			bool IsQueueEmpty()
		

При осмотре в контексте читаемость сохраняется.

			// Close, когда queue пустая:
if (IsQueueEmpty()) {
    Close();
}
		

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

			bool GetMessage(msg_t *message)
		

Дело в том, что каждый интерпретирует это по-своему. Лично я бы предположил, что возвратное значение false означает, что нет новых сообщений, в то время как true означает, что сообщение было возвращено. Тем не менее, с такой же вероятностью может оказаться, что false означает, что есть ошибка, в то время как true — что ошибки нет (но не обязательно, что сообщение было возвращено). Если же вы Win32-программист, вы можете решить, что возвращаемое значение false указывает на то, что было получено сообщение о выходе, а true означает либо другое сообщение, либо ошибку (так функция Win32 GetMessage работает на самом деле). Когда мы пишем подобную функцию, мы знаем ее предназначение, ибо задача, которую мы пытаемся решить, свежа в нашей памяти. Только после того, как приходится вернуться к какому-то коду, над которым вы давно не работали, вы наконец понимаете, насколько нелегко осмыслить подобную функцию.

Почему мы этого не делаем

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

Принципы, которыми стоит руководствоваться

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

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

Тем не менее, есть некоторые исключения:

  • аргументы setter функций для булевых значений;
  • аргументы для функций типа enable/disable;
  • возвращаемые значения getter функций для булевых значений. Например, SetEnabled(bool) и SetVisible(bool);
  • возвращаемые значения для функций, которые явно ждут условия true/false.

Примеры

Итак, я бы хотел поделиться с вами примером бага, который мой коллега недавно нашел в открытом исходном коде одного проекта. Можете ли вы найти ошибку? Она невидима, если нет доступа к прототипам функции.

			if (mysql->options.connect_timeout >= 0 &&
    vio_wait_or_timeout(net->vio, FALSE, mysql->options.connect_timeout * 1000) net.last_errno == CR_SERVER_LOST)
     my_set_error(mysql, CR_SERVER_LOST, SQLSTATE_UNKNOWN,
               ER(CR_SERVER_LOST_EXTENDED),
               "handshake: reading inital communication packet",
               errno);
  goto error;
}
		

Давайте предположим, что мы заменим булев тип FALSE во второй строчке кода на описательное перечисление с названием типа IO_WRITE. Теперь видите проблему?

Перевод «The Trouble With Bools: Part 1»

Лучшая практика
2369