воскресенье, 19 сентября 2021 г.

[prog.philosophy] Пример того, как я "усложняю" код на ровном месте. И пояснение почему так делаю

Сегодня хочу немного поговорить о своем взгляде на то, как следует писать код. Можно даже сказать, что речь пойдет о философии, сформировавшейся за почти три десятилетия, что я в программизме.

Преамбула

Суть в том, что при написании кода есть отдельный класс ошибок, вызванных невнимательностью. Ну, например, в POSIX есть функция pipe, которая создает анонимный пайп. В качестве параметра в эту функцию передается указатель на массив из двух int-ов. В случае успеха в этот массив будет помещено два хэндла: первый для чтения из пайпа, второй для записи в пайп.

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

Достаточно часто этот класс ошибок провоцируется такой штукой, как последовательно идущими аргументами функции одного типа. Например, два-три-четыре и более int-ов подряд. Вот, скажем:

int x = ...;
int cx = ...;
int y = ...;
int cy = ...;
define_rect(x, cx, y, cy);

Корректен ли вызов define_rect? Может быть он должен был быть записан как:

int x = ...;
int cx = ...;
int y = ...;
int cy = ...;
define_rect(x, y, cx, cy);

?

К сожалению, ошибки, допущенные по невнимательности, временами доставляют очень много хлопот. Я до сих пор с ужасом вспоминаю случай, когда команда из 6 человек в авральном режиме в течении нескольких часов занималась поиском ошибки в приложении. Ошибки, которая была вызвана тем, что в методе подключения к БД параметры username и password были обычными string-ами и в одном из мест эти два параметра были перепутаны местами. Шесть человек, несколько часов, в авральном режиме, вынужденно задержавшись на работе сверх положенного. Суммарно это стоимость, как минимум, двух рабочих дней одного разработчика.

Это не единичный случай, такого рода косяки случаются регулярно, просто в памяти откладываются самые яркие.

Походив по подобным граблям достаточно много, сам я с большим уважением отношусь к такому направлению, как correct by construction. Грубо говоря, код должен быть таким, чтобы его легко было использовать правильно и сложно (если вообще возможно) неправильно.

Одним из инструментов correct by construction является использование типов. Простыми словами, на каждую сущность мы выделяем собственный тип. Так, если нам нужна "координата", то мы вводим тип coordinate, если нам нужен тип "размер", то мы вводим тип size, если нужен "логин пользователя", то тип "username", а если "пароль пользователя" -- то тип "password". Тогда наш код будет выглядеть вот так:

coordinate x = ...;
size cx = ...;
coordinate y = ...;
size cy = ...;
define_rect(x, cx, y, cy);

username test_user = "test";
password test_passwd = "test";
db.connect(test_user, test_passwd);

И компилятор ударит нам по рукам, если мы перепутали аргументы местами.

В некоторых языках программирования есть т.н. strong typedefs: для уже существующего типа можно создать подтип (псевдоним), который будет считаться компилятором совершенно новым типом. Так, посредством strong typedef можно сказать, что size есть подтип int-а. И иметь для size все те же возможности, что есть у int-а. Но компилятор при этом будет считать size и int разными типами.

Но вот в С++ этой функциональности нет. Поэтому в C++ приходится делать дополнительные приседания, чтобы получить должный эффект. И о примерах таких приседаний и пойдет ниже.

Амбула

Придерживаться принципов correct by construction на словах -- это одно, а вот воплощать их в жизнь при написании обычного, рутинного кода -- это несколько другое.

Как говорится, в теории между теорией и практикой нет разницы, а на практике...

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

Зачем все это описывать?

Ну, во-первых, почему-то ощущается потребность объяснить почему из под моей руки выходит такой "замороченный" код.

Во-вторых, меня кормит то, что я пишу код для заказчиков. Кто-то из них читал, читает или будет читать мой блог. ИМХО, будет правильно, если потенциальный клиент узнает о том, как я отношусь к работе и как пишу код. Если его не устраивает то, на что я буду тратить свое время (= его деньги) и то, что в итоге получится, то это сэкономит нам и время, и нервы.

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

Задача и опробованные решения

В проекте, над которым мы сейчас работаем, приходится использовать Unix-овые пайпы. Как именованные, так и анонимные. Анонимные пайпы создаются как раз упомянутой выше функцией pipe.

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

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

Фокус в том, что при создании разных экземпляров анонимного пайпа нужно указывать разный набор флагов, которые должны быть применены к хендлам пайпа через вызов fcntl. Скажем, где-то нужно задать O_NONBLOCK и O_DIRECT, а где-то O_NONBLOCK и FD_CLOEXEC.

Тривиальный вариант:

anonymous_pipe_holder_t
make(
  int read_end_fcntl_bits_to_clear,
  int read_end_fcntl_bits_to_set,
  int write_end_fcntl_bits_to_clear,
  int write_end_fcntl_bits_to_set);

даже не рассматривался. Поскольку писать и, особенно, разбираться спустя какое-то время с кодом вида:

auto wakeup_pipe = make(0, O_NONBLOCK|O_DIRECT, O_NONBLOCK, O_DIRECT);

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

Поэтому я попробовал придумать что-то еще.

Опробованный вариант №1

Первое желание -- это объединение значений bits_to_clear и bits_to_set в одну структуру, чтобы передавать один аргумент вместо двух. Отсюда появляется тип fcntl_flags_t, который содержит два int-овых значения.

Но если сделать этот тип простой структурой:

struct fcntl_flags_t
{
  int m_bits_to_clear;
  int m_bits_to_set;
};

то ее использование устраняет лишь часть мест, где можно совершить ошибку по неосторожности. Например:

make(fcntl_flags_t{0, O_NONBLOC|O_DIRECT}, fcntl_flags_t{O_NONBLOCK, O_DIRECT});

Тут уже в make просто так int-ы не засунешь, компилятор уже будет бить по рукам. Но все равно из подобной записи не понятно, какой из fcntl_flags_t относится к read_end, а какой к write_end. Так же непонятно, какой из int-ов в инициализации fcntl_flags_t относится к bits_to_clear, а какой к bits_to_set.

Для того, чтобы разбираться с read_end и write_end можно ввести дополнительные структуры:

struct read_end_flags_t
{
  fcntl_flags_t m_flags;
};

struct write_end_flags_t
{
  fcntl_flags_t m_flags;
};

Что позволяет нам уже записывать вызов make вот так:

make(read_end_flags_t{...}, write_end_flags_t{...});

Уже лучше. И компилятор, ежели чего, по рукам ударит. И при чтении кода не нужно отвлекаться на чтение документации.

Теперь по поводу значений для bits_to_clear и bits_to_set.

Если бы в C++ у функций/методов/конструкторов были именованные параметры, как в некоторых других языках, то можно было бы поступить как-то так (псевдокод):

make(
  read_end_flags_t{
    fcntl_flags_t{bits_to_clear:0, bits_to_set: O_NONBLOCK|O_DIRECT}
  },
  write_end_flags_t{...} );

Но в C++ именованных параметров нет. Поэтому было решено вместо передачи значений в конструктор fcntl_flags_t сделать методы-сеттеры, которые используются следующим образом:

fcntl_flags_t{}.bits_to_clear(O_NONBLOCK).bits_to_set(O_DIRECT);

Далее все вспомогательные типы (fcntl_flags_t, read_end_flags_t, write_end_flags_t), которые относятся к созданию анонимного пайпа, нужно было поместить в какое-то пространство имен, дабы они лежали где-то в своем, строго ограниченном чуланчике.

Для этого было решено использовать не пространство имен, а структуру abstract_pipe_maker_t со статическим методом make (да, недолгий опыт работы с Java все-таки оказал непоправимое воздействие на психику):

struct anonymous_pipe_maker_t
{
  class fcntl_flags_t {...};
  struct read_end_flags_t {...};
  struct write_end_flags_t {...};

  static anonymous_pipe_holder_t
  make(read_end_flags_t read_end_flags, write_end_flags_t write_end_flags) {...}
};

А использовалось все это вот так:

return anonymous_pipe_maker_t::make(
      anonymous_pipe_maker_t::read_end_flags_t{
            anonymous_pipe_maker_t::fcntl_flags_t{}
                  .set_bits( O_NONBLOCK | O_DIRECT )
      },
      anonymous_pipe_maker_t::write_end_flags_t{
            anonymous_pipe_maker_t::fcntl_flags_t{}
                  .clear_bits( O_NONBLOCK )
                  .set_bits( O_DIRECT )
      } );

Получается безопасно по типам, что хорошо.

Но слишком уж громоздко, что плохо, т.к. ухудшает читаемость.

Опробованный вариант №2

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

От этой проблемы было решено избавиться за счет того, чтобы флаги для fcntl хранились прямо в read_end_flags_t и write_end_flags_t. В итоге получилось вот так:

return anonymous_pipe_maker_t::make(
      anonymous_pipe_maker_t::read_end_flags_t{}
            .set_bits( O_NONBLOCK | O_DIRECT ),
      anonymous_pipe_maker_t::write_end_flags_t{}
            .clear_bits( O_NONBLOCK )
            .set_bits( O_DIRECT ) );

Уже почти хорошо. Текст читать уже легче, хотя некоторая избыточность все еще сохраняется.

С этой избыточностью можно было бы смириться, если бы не то обстоятельство, что реализация read_end_flags_t/write_end_flags_t оказалась гораздо сложнее, чем хотелось бы.

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

Достигать это копипастой ну совершенно не хотелось.

А простой вариант с общим базовым классом fcntl_flags_holder_t, в котором определяются методы set_bits/clear_bits/bits_to_clear/bits_to_set, и от которого затем наследуются read_end_flags_t/write_end_flags_t, не сработал по причине типа возвращаемого значения для set_bits/clear_bits. Эти сеттеры должны возвращать ссылку на fcntl_flags_holder_t, иначе не получится делать цепочки вызовов сеттеров. Но возвращать ссылку на fcntl_flags_holder_t нельзя, т.к. результатом выражения:

read_end_flags_t{}.set_bits(O_NONBLOCK|O_DIRECT)

тогда будет fcntl_flags_holder_t&&, а не read_end_flags_t&&.

Чтобы получить желаемый результат потребовалось сделать fcntl_flags_holder_t шаблоном, да не простым, а CRTP. А это уже показалось оверкилом.

Опробованный вариант №3

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

return make_anonymous_pipe(
      read_end_fcntl_flags()
            .set_bits( O_NONBLOCK | O_DIRECT ),
      write_end_fcntl_flags()
            .clear_bits( O_NONBLOCK )
            .set_bits( O_DIRECT ) );

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

enum class anonymous_pipe_end { read, write };

template< anonymous_pipe_end >
class [[nodiscard]] fcntl_flags_holder_t {...};

Что позволяет записать прототип функции make_anonymous_pipe как:

anonymous_pipe_holder_t
make_anonymous_pipe(
   fcntl_flags_holder_t< anonymous_pipe_end::read > read_end_flags,
   fcntl_flags_holder_t< anonymous_pipe_end::write > write_end_flags );

Далее нужно было сделать так, чтобы конструирование экземпляров fcntl_flags_holder_t<V> записывалось лаконично. Для чего были сделаны две простые фабрики:

inline fcntl_flags_holder_t< anonymous_pipe_end::read >
read_end_fcntl_flags() noexcept { return {}; }

inline fcntl_flags_holder_t< anonymous_pipe_end::write >
write_end_fcntl_flags() noexcept { return {}; }
И последнее, что осталось сделать -- это избавиться от структуры anonymous_pipe_maker_t, которая всего лишь играла роль маленького пространства имен. Вот так и получился третий вариант, который пока что и оставлен в качестве основного.

Варианты, которые не были опробованы

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

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

Например, можно было бы сделать так, чтобы функции-фабрики make не было вообще, а нужные параметры передавались бы прямо в конструктор anonymous_pipe_holder_t. Но пока что у меня есть ощущение, что более выгодно иметь класс anonymous_pipe_holder_t, который отвечает только за закрытие пайпа. И в довесок к нему функцию-фабрику. Потому, что есть подозрение, что функций-фабрик может оказаться больше одной (как минимум, одна будет бросать исключения, а вторая будет возвращать коды ошибок).

Так что вопрос целесообразности наличия функции-фабрики здесь мы рассматривать не будем. А посмотрим на то, как еще можно было бы передавать параметры в make_anonymous_pipe чтобы получить и надежность, и читабельность.

Например, можно было бы поступить так:

enum class read_end_fcntl_bits_to_clear_t : int {};
enum class read_end_fcntl_bits_to_set_t : int {};
enum class write_end_fcntl_bits_to_clear_t : int {};
enum class write_end_fcntl_bits_to_set_t : int {};

anonymous_pipe_holder_t
make_anonymous_pipe(
  read_end_fcntl_bits_to_clear_t read_end_clear,
  read_end_fcntl_bits_to_set_t read_end_set,
  write_end_fcntl_bits_to_clear_t write_end_clear,
  write_end_fcntl_bits_to_set_t write_end_set );

Что использовалось бы в коде так:

auto pipe = make_anonymous_pipe(
  read_end_fcntl_bits_to_clear_t{0},
  read_end_fcntl_bits_to_set_t{O_NONBLOCK|O_DIRECT},
  write_end_fcntl_bits_to_clear_t{O_NONBLOCK},
  write_end_fcntl_bits_to_set_t{O_DIRECT} );

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

Возможно, более читабельный вариант дал бы такой подход:

enum class fcntl_bits_to_clear_t : int {};
enum class fcntl_bits_to_set_t : int {};

struct read_end_fcntl_flags_t
{
  fcntl_bits_to_clear_t m_bits_to_clear;
  fcntl_bits_to_set_t m_bits_to_set;
};

struct write_end_fcntl_flags_t
{
  fcntl_bits_to_clear_t m_bits_to_clear;
  fcntl_bits_to_set_t m_bits_to_set;
};

anonymous_pipe_holder_t
make_anonymous_pipe(
  read_end_fcntl_flags_t read_end_flags,
  write_end_fcntl_flags_t write_end_flags );

Что позволило бы писать так:

auto pipe = make_anonymous_pipe(
  read_end_fcntl_flags_t{
    fcntl_bits_to_clear_t{0},
    fcntl_bits_to_set_t{O_NONBLOCK|O_DIRECT}
  },
  write_end_fcntl_flags_t{
    fcntl_bits_to_clear_t{O_NONBLOCK},
    fcntl_bits_to_set_t{O_DIRECT}
  } );

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

Но меня смущает здесь то, что fcntl_bits_to_clear_t нужно указывать всегда, даже когда никаких флагов сбрасывать и не нужно.

Сколько это все стоит?

Это зависит.

Во-первых, зависит от навыков, привычки замечать подобное и уже наработанных в прошлом приемов по избежанию ошибок "по невнимательности". Чем чаще вы что-то подобное применяете на практике, тем дешевле вам обходится разработка в таком стиле.

Во-вторых, по трудозатратам здесь можно выделить две важных составляющих:

  1. Время на придумывание хорошего решения. К сожалению, это недетерминированный фактор. Из описанных выше трех опробованных решений первые два были придуманы очень быстро. А вот третье пришло в голову спустя два или три часа после того, как я закончил работать. На реализацию же ушло минут 10, вряд ли больше. Но вот как оценивать те три часа в течении которых у меня в мозгах крутилась мысль, что вариант №2 не очень хорош... Я не знаю.
  2. Документирование всех этих вспомогательных типов и функций. По моему субъективному опыту, придумать и написать код -- это одно. Это может потребовать, скажем, 30 минут. А потом еще 15-20 минут уйдет на то, чтобы как-то задокументировать все это. Может быть больше.

В-третьих, нужно смотреть, пытаетесь ли вы применить correct by construction в коде с высокими требованиями к производительности/ресурсоемкости или нет. Если да, то здесь потребуется дополнительное внимание и оценка накладных расходов. Это дополнительные ресурсы. В описанном мной случае оценивать влияние на производительность не нужно было, т.к. в работающей программе анонимные пайпы создаются редко и стоимость этой операции можно вообще не рассматривать.

Так что все это не бесплатно. А вот на сколько не бесплатно зависит от обстоятельств.

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

Заключение

В заключение хочу сказать следующее: этот текст является попыткой рассказать о том, как я подхожу к программированию и почему я подхожу к программированию именно так. К программированию вообще и к программированию на C++ в частности. А это важно, т.к. C++ опасный язык, не прощающий ошибок и не имеющий при этом ряда возможностей, которые могли бы упростить жизнь разработчику (strong typedefs, именованные параметры).

Cчитаю этот стиль программирования оправданным и это проистекает как из моего опыта разработки ПО, так и из специфики моей работы в последние 7-8 лет (библиотеки, которые затем используются разными людьми в разных проектах и разных условиях).

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

Если вы со мной не согласны, то в этом нет проблемы. По крайней мере до тех пор, пока вы не заказываете разработку у нас и ваш заказ не оказывается в моих руках.

Так что если вы считаете, что все это дурость, глупость, стремление переусложнить на ровном месте, желание повыделываться или подрочить вприсядку, то нет проблем. Наверняка для вас работает что-то другое. Или вы верите в это. Мне без разницы.


Дисклаймер по поводу использованного примера со сбросом O_NONBLOCK

Читатели, которые имеют больший опыт программирования под Unix, чем я, могут спросить, а зачем нужно сбрасывать O_NONBLOCK для только что созданного анонимного пайпа (особенно если он создается через pipe, а не через pipe2)?

Тут ответ простой: из-за того, что мне редко приходится иметь дело с Unix-овыми пайпами я предпочитаю перестраховаться и вручную сбросить O_NOBLOCK. Хотя, подозреваю, делать этого для только что созданного пайпа не нужно.

Однако, для обсуждения подхода о том, как correct by construction реализуем на практике, этот момент я считаю несущественным.

Комментариев нет: