Эта статья обсуждалась на Hacker NewsLobster.rs и Reddit. Я получил столько ценных комментариев с хорошими идеями, что собрал их в специальное приложение, которое будет в самом конце этого поста!

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

И теперь вы отвечаете за базу кода на C++. Она большая, сложная и специфичная. Вы просто всматриваетесь в неё до тех пор, пока она не начинает фрагментироваться самым интересным образом. Унаследованный код как он есть.

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

Что же теперь делать?

Главное — не пугаться. Поверьте, я многократно оказывался в такой ситуации в самых разных местах (слышу, на галёрке уже ворчат: «а что, база кода на C++ может выглядеть как-то презентабельнее, чем та, что вы описали»?). Из этой ситуации есть выход, причём, не слишком болезненный. У вас найдётся время и на реальное исправление багов, и на добавление фич, и вы успеете даже помечтать о том, как однажды всё это перепишете.

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

Отдавая должное C++ — я не испытываю ненависти к этому языку как к таковому. Он не виноват, что именно им люди привыкли злоупотреблять, и что код на нём неизбежно превращается в жуткую мешанину. Бедняга C++ в данном случае — просто жертва. Не извольте беспокоиться, комитет C++ всё обязательно исправит в стандарте C++45, просто добавив std::cmake в стандартную библиотеку — и вы увидите, как язык от этого просто преобразится, и… хм, что-то я отвлёкся.

Итак, давайте конкретизируем, что нужно сделать в первую очередь:

  1. Добейтесь, чтобы база кода работала на локальной машине, при этом внесите только минимум изменений в код и систему сборки. В идеале лучше вообще без таких изменений обойтись. Пока — никакого серьёзного рефакторинга, даже если совсем неймётся!

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

  3. Затащите проект в XXI век, для этого добавьте в него непрерывную интеграцию, линтеры, фаззинг, автоформатирование, т.д.

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

  5. Если вам всё это удалось — можно поразмыслить о том, не переписать ли часть базы на языке, безопасно работающем с памятью.

В данном случае наша стратегическая цель – привести проект в приемлемое состояние, затратив на это минимум усилий. Под «приемлемым состоянием» мы понимаем уровень безопасности, удобство для разработчика, корректность и производительность кода. Это необходимо всегда держать в уме. Речь не просто о «чистом коде», использовании новейших распиаренных языковых фич, т.п.

Что ж, поехали!

Кстати, всё сказанное здесь также применимо и к базе кода на чистом C, а также к смешанной базе кода на C и C++, так что, если вам интересны и такие ситуации — читайте дальше!

Получите отмашку

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

Программная инженерия – это работа, которой требуется заниматься вдолгую, а не так, чтобы выгореть через несколько месяцев или лет. С этим делом не справишься, если рассчитывать на переработки, либо на потогонку, тем более не справишься в одиночку. Необходимо убедить других людей, что они должны нас поддержать, нужно объяснить им, что мы делаем и зачем. Так мы настроим на нужный лад всех — и начальника, и коллег, и тех, кто с техникой на «вы». Кто знает, может наступит момент, когда вы возвращаетесь из отпуска — и видите, что, пока вас не было, другие люди исправно делали за вас вашу работу.

Всё это означает лишь одно: объясните проблему на пальцах, подкрепив ваши слова несколькими простыми фактами, изложите предлагаемое решение и поясните, сколько времени вам на это нужно. Просто, так ведь? Например, вспоминается одна цитата из «Южного Парка»: «Все персонажи и события, показанные в этом сериале — даже основанные на реальных случаях с реальными людьми — полностью вымышлены».

  • Шеф, позвольте, вот наш новичок потратил 3 недели, чтобы код, наконец, собрался у него на машине, а потом впервые законтрибьютил. Согласитесь, неплохо было бы организовать, чтобы эта работа делалась за несколько минут (делать для этого почти ничего не надо)?

  • Шеф, давайте покажу. Я тут собрал простенькую фаззинговую конфигурацию «тупо вбиваем в приложение произвольные данные и смотрим, что получится». Представляете, она успевает обвалить приложение 253 раза за считанные секунды. Думаю, а что будет, если пользователи попытаются проделать такое с нашим приложением в продакшене?

  • Шеф, знаете, мы тут фиксили несколько неотложных багов, потребовалось на две недели занять этой работой несколько человек, чтобы всё развернуть в прод. Дело в том, что приложение, оказывается, можно собрать строго на этом сервере с древней операционной системой, которая уже восемь лет не поддерживается (если интересно — речь о FreeBSD 9).  Всё падает и падает. Кстати, а когда этот сервер помрёт, мы вообще больше не сможем развёртывать это приложение, без вариантов. Давайте попробуем собирать наше приложение на каком-нибудь недорогом облачном инстансе?

  • Шеф, возникла проблема. Был у нас неуловимый баг в продакшене, досаждавший пользователям. Несколько недель ушло, чтобы его выловить и пофиксить, а оказалось, что он возникает из-за неопределённого поведения (в сторону: так называется проблема в коде, которую очень сложно заметить), данные повреждаются. А когда прогонишь эту программу в линтере, который считается стандартным в отрасли (в сторону: линтер — это программа, отыскивающая проблемы в коде), линтер проблему обнаруживает сразу. Значит, этот инструмент нужно прогонять всякий раз после внесения изменений!

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

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

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

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

  • Я собираюсь много всего изменить в проекте, и сделаю это в отдельной ветке. Буду один месяцами в ней работать. Определённо, когда-то настанет момент и её смерджить! (голос за кадром: мерджить ветку, конечно, никто не собирался)

  • Мы собираемся переписать проект с нуля, давайте увеличим сроки ещё на несколько недель

  • Мы намерены улучшить базу кода, но не представляем, когда с этим справимся,
    и даже не можем точно сформулировать, что собираемся делать.

Ладно, допустим, вы получили добро на работу от всех, кто должен был вам его дать. Теперь подробно разберём весь процесс:

  • Любые изменения должны быть мелкими и выполняться пошагово. Приложение работало до вмешательства и будет работать после. Тесты выполняются, линтеры довольны, для применения изменений не требуется срезать никаких углов (всё это допустимо в виде исключения, но на то они и исключения)

  • Если требуется срочно пофиксить какой-то баг, должна быть возможность сделать это в рабочем порядке, ничего не блокируя

  • Любые изменения должны давать измеримую пользу, следует уметь объяснить и продемонстрировать эти изменения людям, не являющимся экспертами\

  • Если всю работу приостановили или вообще заморозили (например, изменились
    приоритеты, исчерпан бюджет, т.д), мы всё равно должны остаться в плюсе по сравнению
    с состоянием до начала проекта (и этот плюс тоже должен быть тем или иным
    образом измерим)

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

Что ж, перейдём к делу!

Перечислим все платформы, которые собираемся поддерживать

При всей важности этого шага далеко не во всех проектах его выполняют. Занесите эту информацию в файл README (у вас же есть файл README, верно?). Это просто список пар вида <architecture>-<operating-system>, напр., x86_64-linux или aarch64-darwin, и все эти комбинации официально поддерживает ваша база кода. Это принципиально важно не только для того, чтобы ваша сборка работала на каждой из перечисленных платформ, но и, как будет показано ниже, чтобы избавиться от мусорного кода для тех платформ, что мы не поддерживаем.

Если вы совсем скрупулёзны, то можете даже написать, какую именно версию архитектуры поддерживаете, например, ARMV6, а не ARMv7, т.д.

Всё это поможет нам ответить на следующие вопросы:

  • Можно ли рассчитывать на то, что у нас будет аппаратная поддержка для чисел с плавающей точкой, или ОКМД, или SHA256?

  • Нас вообще волнует поддержка 32-разрядных систем?

  • Придётся ли нам когда-либо иметь дело с платформой вида big-endian, где идёт нумерация от старшего к младшему? (Скорее всего, ответ — «нет», никогда так не делали и не будем. Если вам приходилось иметь дело с такими платформами — выскажитесь пожалуйста в комментариях, очень интересно).

  • Может ли char равняться 7 бит?

Вот что ещё важно: в этот список строго обязательно включить рабочие станции сотрудников. И здесь я подхожу к следующему пункту.

Добейтесь, чтобы сборка работала на вашей машине

Вы изрядно удивитесь, узнав, сколько в природе таких баз кода на C++, которые лежат в основе успешных продуктов, приносящих миллионную прибыль — но при этом просто не компилируются. Конечно, может скомпилироваться, если звёзды сойдутся. Но речь не об этом. Я имею в виду надёжный и предсказуемый процесс сборки на всех поддерживаемых вами платформах. Чтобы без возни, без подвигов «я рвал волосы на голове три недели — и вот, наконец, всё скомпилировалось» (здесь вообще такие воспоминания накатывают). Нет. «Всё работает»ТМ.

Здесь сделаю небольшое лирическое отступление. В своё время я серьёзно занимался каратэ. У нас было по 3-4 тренировки в неделю. Отчётливо припоминаю, как один мой наставник мне сказал (вы, наверное, уже вообразили себе такого мудрого смуглого сенсея… нет, мой был лысый белый мужик, как Стив Балмер):

Этот приём ты пока не освоил. Иногда получается, иногда нет. У тебя бывало, что ты ешь суп чайной ложкой, и четыре раза попадаешь в рот, а на пятый промазываешь?

Таких же правил я придерживался и в программной инженерии. «Новая фича работает» — означает, «работает всегда», а не четыре раза из пяти. Соответственно, и сборка не меняется.

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

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

Ещё одно препятствие возникает, когда коду требуется некий платформо-специфичный API, например, io_uring под Linux. В данном случае было бы удобно реализовать shim-прослойку или собирать код в виртуальной машине прямо у вас на рабочей станции. Опять же, не идеальный выход, но лучше, чем ничего.

Ранее мне приходилось делать всё вышеперечисленное, и я остаюсь при мнении, что сборка на своей машине — всё равно наилучший вариант.

Добейтесь, чтобы система проходила тесты на вашей машине

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

Итак, комплект тестов собран. Если некоторые тесты не проходят — отключите их пока. Даже если на прохождение всего тестового комплекта уходит не один час, пытайтесь пройти максимум тестов. О времени побеспокоиться успеем.

Опишите в README, как собирать и тестировать приложение

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

Мы добиваемся, чтобы человек, не являющийся экспертом в C++, смог сам собрать код и выполнить тесты, при этом ни о чём вас не беспокоя.

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

Ищите лёгкие пути, чтобы ускорить сборку и тестирование

Да, именно «лёгкие». Чтобы не пришлось вносить никаких изменений в систему сборки и вообще не пришлось геройствовать (не в первый раз возвращаюсь к этой теме в данной статье, но это важно).

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

  • Собирать и выполнять тесты по вашим зависимостям. Если проект использует unittest++ в качестве фреймворка для тестирования, собранного в качестве подпроекта CMake, то, как оказалось, по умолчанию действует следующее поведение: собираются тесты, входящие в этот фреймворк, и прогоняются раз за разом! Это же безумие. Обычно в CMake есть переменная, через которую можно отказаться от этой функции.

  • Собирайте и выполняйте образцы программ с вашими зависимостями. Ситуация точно как и в предыдущем случае, но на этот раз набедокурено в mbedtls. Опять же, можно отказаться от этой функции в переменной CMake, и проблема решается.

  • Собирайте и выполняйте тесты вашего проекта так, как они сформулированы по умолчанию, но сам проект включайте в качестве подпроекта в другой, более крупный. Что, заданное по умолчанию поведение просто насмехается над вашими зависимостями? Оказывается, и в других проектах творится то же самое! Не скажу,  что я эксперт по CMake, но, по-видимому, не существует стандартного способа исключать тесты из сборки. Поэтому рекомендую добавить сборочную переменную MYPROJECT_TEST, которая по умолчанию не устанавливается, а сборку мы проводим и прогоняем тесты только тогда, когда она специально установлена. Как правило, целенаправленно устанавливают эту опцию только те разработчики, которые непосредственно занимаются проектом. То же касается примеров, генерирования документации, т.д.

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

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

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

  • Если получится — поэкспериментируйте с другим компилятором. Мне попадались проекты, в которых clang работает вдвое быстрее gcc; попадались и такие, где разница не заметна.

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

  • Оптимизация во время линковки (LTO): выкл/вкл/тонкая

  • Разбиение отладочной информации

  • Make или Ninja

  • Выяснить, файловая система какого типа у вас используется, а затем поиграть с её настройками

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

Удалите весь код кроме необходимого

Папа, у тебя тут мёртвые строки кода.

(Угадали отсылку? Что ж, хорошо.)

Мне попадались примеры, когда 30% базы, а то и более, состоит из совершенно мёртвого кода. Да, при каждой сборке вы тратите время на обработку этого кода, а надо было бы сделать рефакторинг. Итак, приступаем к прополке.

Вот несколько рекомендаций, что в данном случае можно сделать:

  • В компиляторе есть набор предупреждений -Wunused-xxx, напр., -Wunused-function. Кое-что они могут отловить, но не всё. Найдите все экземпляры таких предупреждений до единого и разберитесь с каждым. Как правило, не требуется ничего сложнее, чем  удалить код, пересобрать и перезапустить тесты — и всё готово. Иногда это симптом бага, при котором вызывается неверная функция. Поэтому как-то я был не склонен полностью автоматизировать этот шаг. Но, если вы уверены в вашем тестовом наборе — можете попробовать.

  • Линтеры могут находить неиспользуемые функции и поля классов, например, cppcheck. Мой опыт подсказывает, что при такой работе возникает достаточно много ложноположительных результатов, в особенности характерных для виртуальных функций при наследовании. Но положительная сторона работы с этими инструментами — в том, что они досконально вылавливают те неиспользуемые вещи, которых не замечают компиляторы. Поэтому вполне извинительно добавить линтер в свой арсенал, если, конечно, не собираетесь использовать его для непрерывной интеграции (подробнее об этом ниже).

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

  • Помните о списке поддерживаемых платформ? Пришло время к нему обратиться
    и устранить весь код, относящийся к неподдерживаемым платформам. Нашли код,
    который нужен для поддержки древних версий Solaris в проекте, который работал исключительно под FreeBSD? В утиль его. Код, в котором попытались реализовать
    собственный генератор случайных чисел, так как, возможно, таковой отсутствовал
    на той платформе, которую предполагалось поддерживать (оказалось, конечно, что
    он там есть)? В мусор. Сто строк кода на случай, если не поддерживается POSIX 2001, тогда как мы собираемся работать исключительно на современных версиях Linux и macOS? Испепелить. Проверка, используется ли на ЦП хоста нумерация от старшего к младшему и если нет – функция для автоматической перестановки байт? Полно, да когда вы в последний раз вы видели такой допотопный процессор! И, кстати, как вы находите IBM? Этот код добавили целую вечность назад для обслуживания гипотетической фичи, которая так и не вышла? Адью.

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

Линтеры

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

Форматирование кода

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

Санитайзеры

Точно, как и линтеры, эти инструменты могут утянуть вас в кроличью нору. Но, к сожалению, они  абсолютно необходимы для поиска серьёзных сложновычленимых багов,   которые сказываются на работе в продакшене, а также для того, чтобы можно было эти баги исправить. Для начала попробуйте -fsanitize=address,undefined. Как правило, ложноположительных результатов они не дают, поэтому, если санитайзер что-то нашёл — идите и правьте. Кроме того, прогоняйте тесты с включённым санитайзером, так ошибки обнаруживаются легче. Доводилось даже слышать, что некоторые разработчики оставляют часть санитайзеров включёнными даже в боевом коде, так что, если вы располагаете достаточным запасом производительности, определённо, стоит так поступить.

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

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

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

Добавьте конвейер непрерывной интеграции

Как однажды выразился Брайан Кэнтрилл (цитирую по памяти), «я убеждён, что большая часть прошивки просто берётся из домашнего каталога на ноутбуке разработчика». Так что настроить непрерывную интеграцию можно быстро и бесплатно, тем самым автоматизировав все те блага, которые мы успели настроить выше (линтеры, форматирование кода, тесты, т.д.). Именно так можно организовывать стерильную среду перед внесением каждого изменения в продакшен-версию двоичных файлов. Если вы как разработчик пока этого не делаете, сомневаюсь, что вы перебрались в XXI век.

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

Как правило, конвейер имеет вид make all test lint fmt, так что ничего чрезмерно сложного. Просто нужно убедиться, что те проблемы, о которых вы получаете уведомления через  инструменты (линтеры, санитайзеры, т.д.) действительно нарушают работу конвейера — в противном случае, их никто не заметит и не исправит.

Постепенные улучшения кода

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

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

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

Как подсказывает мой опыт, иногда сильно упростить код в проекте можно, всего лишь перейдя на более свежий стандарт C++. Например, заменить на цикл for (auto x : items)  код, вручную выполняющий инкремент указателя — но при этом помнить, что в данном случае «до конца» не означает «до полного конца». Если вам требуется всего лишь std::clamp, напишите его сами.

Переписать ли код на языке, обеспечивающем безопасную работу с памятью?

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

Заключение

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

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

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

Дополнение: управление зависимостями

Это очень субъективный раздел, просто моё особое личное мнение.

На эту тему ведутся жаркие споры, и пока я тщательно её сторонился. Если коротко, в C++ это не делается. Большинство людей ограничивается тем, что использует менеджер пакетов. Легко заметить, что файл README выглядит у них примерно так:

On Ubuntu 20.04: `sudo apt install [100 lines of packages]`

 

On macOS: `brew install [100 lines of packages named slightly differently]`

 

Any other: well you're out of luck buddy. I guess you'll have to pick a mainstream OS and reinstall ¯\_(ツ)_/¯

Кстати, я и сам так делал. Теперь думаю, что это ужасная идея, и вот почему:

  • Как было показано выше, инструкции по установке зависят от конкретного дистрибутива и операционной системы. Хуже того, они могут зависеть от версии дистрибутива. Припоминаю проект, в котором переход с Ubuntu 20.04 на Ubuntu 22.04 растянулся на целые месяцы, поскольку там отличаются готовые версии пакетов (не всякий раз совпадающий пакет вообще найдётся). Таким образом, чтобы обновить дистрибутив, в вашем проекте для этого также нужно обновить 100 зависимостей. Очевидно, это никуда не годится. В идеале нужно обновлять по одной зависимости за раз.

  • Обязательно найдётся какая-нибудь сторонняя зависимость, под которую нет пакета, и вам так или иначе придётся самостоятельно собрать такой пакет из исходников.

  • Никогда не рассчитывайте, что пакет будет собран с нужными вам флагами. Между Fedora и Ubuntu годами идут дискуссии, собирать ли пакеты с включённым указателем кадров (наконец, совсем недавно, всё-таки решили включать). Вспомните, что я писал о санитайзерах. Как вы собираетесь получать зависимости с включённым санитайзером? Ничего не получится. Если хотите ещё примеров — LTO (оптимизация во время линковки), -march, отладочная информация, т.д. Либо при их сборке использовалась не та версия компилятора, которой оперируете вы, и в результате между ними нарушена работа C++ ABI (двоичного интерфейса приложения).

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

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

  • Никогда не удастся добиться, чтобы в разных системах действовала одна и та же версия пакета. Представьте себе, что Алиса работает на macOS, Боб – на Ubuntu, а в продакшене система развернута на FreeBSD. Возникнут причудливые расхождения, которые будут вас очень раздражать, и от которых никак не избавиться.

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

  • Бывает, что в пакетах отсутствует нужная версия той библиотеки (статической
    или динамической), которая вам требуется

Возможно, вы думаете: верно, я буду пользоваться каким-нибудь новеньким суперским менеджером пакетов для C++, Conan, vcpkg и пр. Что ж, не спешите радоваться:

  • Для них требуются внешние зависимости, поэтому процесс непрерывной интеграции у вас усложнится и замедлится  (напр., понадобится выяснить, какая именно версия Python им требуется — и можете быть уверены, это не та версия Python, которая применяется в вашем проекте)

  • Всех версий пакета в них не будет. Например, Conan и mbedtls переходит от версии 2.16.12 сразу к 2.23.0. А что с версиями между этими двумя? Они неисправны, и пользоваться ими не следует? Кто знает! В любом случае, нигде не перечисляются уязвимости безопасности, известные в доступных версиях. Конечно же, попадался мне один проект, в котором использовалась версия 2.17…

  • Может оказаться, что они не поддерживают какую-нибудь критичную для вас
    операционную систему или архитектуру (FreeBSD, ARM, т.д.)

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

Что же я посоветую? Что ж, в помощь вам старые добрые субмодули git и привычка компилировать на основе исходников. Да, это обременительно, но в то же время:

  • Это чертовски просто

  • Это лучше, чем вручную заниматься подбором вендора, так как в git сохраняется история и можно прослеживать отличия в функционале

  • Вы с точностью до коммита знаете, какая именно версия зависимости используется

  • Обновить версию отдельно взятой зависимости не составляет труда — просто выполните git checkout

  • Работает на любой платформе

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

  • Разработчики уже освоились с такими инструментами, даже если у них нет опыта работы с C++

  • Выборка зависимостей происходит совершенно безопасно, а исходники расположены удалённо. Их никто тайком не подменит.

  • Система работает рекурсивно (т.e.: транзитивно — и для зависимостей, которых требуют ваши зависимости)

Скомпилировать любую зависимость в каждом из субмодулей будет не сложнее, чем выполить add_subdirectory в CMake или git submodule foreach make вручную.

Если работать с субмодулями — в самом деле, не вариант, то всё равно остаётся возможность компилировать из исходников, но делать это вручную, единственным скриптом, который выбирает зависимость, а затем собирает её. Реальный пример:   Neovim.

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

Если рассмотреть весь ландшафт менеджеров пакетов для компилируемых языков (Go, Rust, т.д), то все из них, которые я знаю, выполняют компиляцию из исходников. То есть, всё тот же подход минус git плюс автоматизация.

Комментарии от читателей

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

  • Следовало бы сильнее акцентировать важность тестов (расширить тестовый комплект, покрытие кода, т.д.) — но, в то же время, реальная польза от тестового комплекта в C++ достигается лишь тогда, когда выполняешь его под санитайзерами. В противном случае возникает ложная самоуспокоенность.  Согласен на 100%. На мой взгляд, попросту нельзя изменять сложный чужой код, не подкрепляя работу тестами. Причём, да, санитайзеры отловят в тестах столько проблем, что не помешает даже выполнить все ваши тесты по несколько раз в процессе непрерывной интеграции, включая при этом разные санитайзеры.

  • vcpkg — хороший менеджер зависимостей для C++, который решит все ваши проблемы. Мне пока не довелось им попользоваться, так что я просто возьму его на заметку и как-нибудь с ним поэкспериментирую. Если он отвечает тем требованиям, что я перечислил, а также обеспечивает кросс-компиляцию, то, конечно же, он полностью выигрывает у субмодулей git.

  • Nix может послужить в качестве хорошего менеджера зависимостей для C++. Должен признать, что пока меня удручает, насколько Nix сложный и медленный . Может быть, подождём несколько лет, пока он дозреет?

  • Не следует слишком  увлекаться рефакторингом унаследованной базы кода, если вам всего-то требуется исправлять по багу в год. Отчасти соглашусь, но на самом деле это спорный тезис. По моему опыту, баг никогда не удаётся исправить за один раз, что бы там ни  говорили менеджеры. А все полезные практики, как то удаление мёртвого кода, работа с санитайзерами и т.д. будет ценна даже при эпизодических багфиксах. Более того, при таком подходе вы будете замечать больше багов, чем обычно, и их тоже исправите. Вот реплика ещё от одного читателя: Если вам  вверена база кода, то научитесь владеть ею как следует.

  • Удалять код — это большой риск, так как сложно определить наверняка, используется он или нет, а также не полагается ли кто-нибудь именно на такое поведение, которое реализовано здесь. Верно, и именно поэтому я выступаю за удаление такого кода, который никогда не вызывается по данным статических анализаторов — это значит, что он точно не вызывается. Но, конечно же, если есть сомнения — не удаляйте. Моя любимая мозоль здесь — это виртуальные методы, которые очень плохо поддаются статическому анализу (поскольку вся  суть здесь — уловить, какой именно метод вызывать во время выполнения). Обычно их так просто не удалишь. Попробуйте поговорить с вашими специалистами по продажам, менеджерами по продукту, чёрт возьми, да даже с вашими пользователями, если получится. Как правило, если прямо спросить, в ходу ли конкретная фича или платформа, они вам сразу ответят, да или нет — и вы определитесь, что делать дальше. Инженеры понемногу забывают, что пятнадцатиминутный разговор с живыми людьми сильно упрощает технические задачи.

  • Забейте весь ваш код в большую языковую модель (LLM) и начинайте задавать ей вопросы: Я противник LLM и, должен признать, мне никогда не приходила в голову такая идея. Но, возможно, стоит попробовать, если вам удастся проделать это совершенно легальным образом, а ответы модели воспринять со здоровой критикой. Мне очень любопытно, какие ответы она бы дала!

  • Есть инструменты, анализирующие код и выводящие результаты в виде диаграмм, показывающие взаимосвязи классов, т.д. Так можно составить впечатление о коде: никогда не пользовался такими инструментами, но это определённо хорошая идея, и я обязательно попробую её в перспективе

  • Ещё есть этап номер 0: добавьте код в систему контроля исходников, если ещё не сделали этого: Определённо, мне повезло, что я никогда не сталкивался с такими ситуациями, но, конечно же, даже самая убогая система контроля исходников лучше, чем ничего. Я могу это утверждать после того, как поработал с Visual Source Safe, в которой, чтобы изменить файл, нужно приобрести над ним исключительную блокировку, которую затем придётся вручную высвобождать.

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

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

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

  • Воспроизводимые сборки: Когда эта тема всплыла, по ней вышла большая дискуссия. Честно говоря, не думаю, что на типичном C++ реально создать полностью воспроизводимую сборку. Проблемы возникнут уже на уровне компилятора и стандартной библиотеки, поскольку обычно они не учитываются во входных данных сборки. Но, тем не менее, получить воспроизводимую сборку совершенно реально. Именно для этого существует Docker. Я пользуюсь Docker с 2013 года (как он меня достал) и думаю, что польза его сильно преувеличена. Но, опять же, если ничего не поделать, кроме как собирать код внутри Docker, то это лучше, чем ничего.

  • Можно приказать Git проигнорировать какой-нибудь коммит, например, такой, при котором база кода целиком форматируется. В таком случае git blame продолжает работать, а история Git не теряет смысла: Потрясающий совет, я этого раньше не знал, спасибо! Определенно попробую так сделать.

  • Берите из истории статистику VCS и на её основе определяйте, какие области в базе кода сильнее всего перемешивались, а какие обычно изменяются в связке друг с другом: никогда такого не пробовал. Идея интересная, но, кажется, в ней много подводных камней. Как думаете, стоит попробовать?

  • Эта статья касается не только C++, но и унаследованных баз кода на других языках: Спасибо! Я работал в основном с C++, так что изложил проблему со своей колокольни, но приятно такое слышать. Просто опустите конкретику, связанную с C++, например, работу с санитайзерами.

  • В книге «Эффективная работа с унаследованным кодом» эти темы хорошо разобраны: кажется, я так и не прочитал ее от начала до конца, так что спасибо, посмотрю. Помнится, я пролистал её и остался при мнении, что она слишком нарочито выстроена вокруг паттернов объектно-ориентированного проектирования, и в ту пору она мне не сильно пригодилась. Но тут память подводит.

  • В принципе, старайтесь обходиться минимальными изменениями, занимайтесь тем, что реально ценно (приносит лайки, деньги). В целом соглашусь (см. выше пункт о «коде на выданье»). Однако, в случае с типичной крупной базой кода на C++, как только начинаешь присматриваться к ней с точки зрения безопасности, сразу находишь множество уязвимостей, которые необходимо исправить. Причём, в прямую финансовую выгоду это не конвертируется — мы просто снижаем риски. Я считаю, что это исключительно ценно. Конечно же, в некоторых отраслях требования к безопасности выше, чем в других.

Комментарии (0)