четверг, 11 июня 2020 г.

[prog.thoughts] Пару слов благодарности в адрес статической типизации

У нас тут подходит к концу четвертая неделя внезапного спурта по реализации неожиданно свалившегося на нас проекта. Написано уже порядка 12KLOC на C++17. И уже недели две с половиной лично я временами ловлю себя на том, что сегодня уже мало чего помню о том, что именно, почему и как было сделано три дня назад.

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

Тут еще нужно сказать, что C++ в принципе не тот язык, на котором можно писать быстро и качественно. Вне зависимости от цены.

Но у C++ есть очень важное полезное качество -- это статическая типизация. Которая в условиях, когда нужно сделать много и быстро, зачастую просто напросто спасает.

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

Но это достаточно тривиальные примеры. Давеча довелось столкнуться с несколько другой ситуацией. В первой реализации одного механизма, в котором ограничивалось количество байт для чтения/записи, ограничение (квота) задавалась просто типом std::size_t. Но затем первая реализация начала эволюционировать в сторону усложнения и возникла потребность интерпретировать нулевое значение квоты специальным образом. При этом пропала возможность использовать в коде простое сравнение quote < bytes_to_read.

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

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

А в C++, при желании, можно было бы для своего типа квоты перегрузить операторы сравнения и равенства. И тогда бы код вообще не пришлось бы править.

Так что статическая типизация в условиях гонки за сроками очень сильно повышает коэффициент спокойного сна.


Еще, если не сильно увлекаться auto, аннотации типов в коде очень облегчают чтение как своего старого, так и чужого кода. Вообще удивительно, но многословность C++ и все вот эти длинные template, typename, nodiscard, namespace, unique_ptr и пр. при чтении кода изрядно замыленным глазом начинают работать в плюс языку. А вот то, что записывается какими-то короткими сочетаниями букв, бывает, что и проскакивает незамеченными.

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

std::size_t m_bytes_written{ 0u };

оказывается выгоднее, чем более компактный аналог:

std::size_t m_bytes_written{};

Ну и еще раз хочется сказать спасибо людям, благодаря которым появился C++11 и развился до C++17 (и продолжает развиваться дальше). Потому что та же самая возможность инициализировать члены класса/структуры прямо по месту их описания, а не в отдельно написанного конструкторе (как это было в C++98), так же очень и очень сильно упрощает жизнь и делает код надежнее.


Отдельно хочется сказать про тесты. Про те тесты, которые может и должен писать разработчик.

Тесты -- это очень важно и очень полезно. Хорошие тесты в дополнение к статической типизации поднимают коэффициент спокойного сна еще выше.

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

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

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

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

Но тесты обходятся недешево.

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