Пишите код, который легко удалять и отлаживать
Простой в отладке код — это код, который не дурачит вас. Трудно отлаживать код со скрытым поведением, с плохой обработкой ошибок, с неопределённостями, недостаточно или избыточно структурированный, или находящийся в процессе изменения. В конце концов вы всегда сталкиваетесь с кодом, который не можете понять.
Если проект относительно старый, то вы можете встретить код, про который вообще забыли, и если бы не журнал коммитов, то поклялись бы, что эти строки писали не вы. По мере разрастания проекта становится всё труднее запоминать, что делают разные куски кода. И ситуация усугубляется, если код делает не то, что, вроде бы, должен делать. И когда нужно изменить код, который вы не понимаете, приходится разбираться жёстко: отлаживать.
Умение писать код, который легко отлаживать, начинается с понимания, что вы ничего не помните о ранее написанном.
[sendpulse-form id=”278″]
Хороший код содержит очевидные ошибки
Существует популярное мнение, что «писать понятный код» означает «писать чистый код». Проблема в том, что степень «чистоты» сильно зависит от контекста. Чистый код может быть жёстко прописан в системе, а иногда какой-нибудь грязный хак написан так, что его легко отключить. Иногда код считается чистым, потому что вся грязь куда-то распихана. Хороший код не обязательно является чистым.
Чистота больше характеризует степень гордости (или стыда), испытываемой разработчиком в отношении этого кода, а не простоту сопровождения или изменения. Лучше вместо чистого дайте нам скучный код, изменения в котором очевидны: я обнаружил, что люди охотнее дорабатывают кодовую базу, если фрукт висит достаточно низко и его легко сорвать. Самым лучшим может быть код, на который вы только взглянули и сразу поняли, как он работает.
- Код, который не пытается создать уродливую проблему, чтобы хорошо выглядеть, или скучную проблему, чтобы выглядеть интересно.
- Код, ошибки в котором очевидны, а поведение понятно, в отличие от кода без очевидных ошибок и с неясным поведением.
- Код, в котором задокументировано, в чём именно он не идеален, в отличие от кода, стремящегося к совершенству.
- Код с таким очевидным поведением, что любой разработчик может придумать несметное количество разных способов изменения этого кода.
Иногда код бывает настолько противным, что любые попытки сделать его чище только усугубляют ситуацию. Написание кода без понимания последствий своих действий можно также расценивать как ритуал вызова удобного в сопровождении кода.
Я не хочу сказать, что чистый код — это плохо, но иногда стремление к чистоте больше смахивает на сметание мусора под коврик. Удобный в отладке код не обязательно будет чистым; а код, напичканный проверками или обработками ошибок редко бывает удобочитаем.
В компьютере всегда проблемы
В компьютере проблемы, и программа упала во время последнего выполнения.
Приложение должно первым делом удостовериться, что оно запускается из известного, хорошего, безопасного состояния, прежде чем пытаться что-то сделать. Иногда копии состояния просто нет, потому что пользователь её удалил или апгрейдил компьютер. Программа упала во время последнего выполнения и, парадоксально, при первом выполнении тоже.
К примеру, при чтении или записи состояния в файл могут возникнуть такие проблемы:
- Файл отсутствует.
- Файл повреждён.
- Файл более старой версии, или более новой.
- Не завершено последнее изменение в файле.
- Файловая система вам врёт.
Эти проблемы не новы, базы данных сталкиваются с ними с древних времён (1970-01-01). Использование чего-то вроде SQLite поможет справиться со многими подобными неприятностями, но если программа упала при последнем выполнении, то код может работать с ошибочными данными и/или ошибочным образом.
К примеру, с программами, выполняемыми по расписанию, обязательно случится что-нибудь из этого списка:
- Программа запустится дважды в один час из-за перехода на летнее время.
- Программа запустится дважды, потому что оператор забыл, что она уже работает.
- Программа запустится с опозданием из-за окончания свободного места на диске или таинственных облачных или сетевых проблем.
- Программа будет запускаться дольше часа, что может привести к задержке последующих вызовов программы.
- Программа будет запускаться не в то время дня.
- Программа неизбежно будет выполняться незадолго до какого-нибудь пограничного времени, например, полуночи, конца месяца или года, и будет сбоить из-за вычислительных ошибок.
Создание устойчивого ПО начинается с написания такого ПО, которое считает, что упало в предыдущий раз, и падает, если не знает, что нужно делать. Самое лучшее в бросании исключения и оставлении комментария в стиле «это не должно произойти» заключается в том, что когда это неизбежно произойдёт, у вас будет фора для отладки своего кода.
Программа даже не обязана восстанавливаться после сбоя, достаточно позволить ей сдаться и не ухудшить ситуацию. Маленькие проверки, генерирующие исключения, могут сэкономить недели на отслеживание в логах, а простой файл блокировки (lock file) может сэкономить часы на восстановление из бэкапа.
Код, который легко отлаживать, это:
- код, который прежде чем сделать то, о чём его просят, проверяет, всё ли в порядке;
- код, который позволяет легко вернуться к хорошо известному состоянию и попытаться снова;
- а также код с уровнями защиты, заставляющими ошибки проявляться как можно раньше.
Ваша программа воюет сама с собой
Самая большая DoS-атака в истории Google исходила от нас самих (потому что наши системы очень велики). Хотя время от времени кто-нибудь пытается проверить нас на прочность, но всё же мы способны навредить себе больше других.
Это относится ко всем нашим системам
Астрид Аткинсон, инженер Long Game
Программы всегда падают во время последнего выполнения, всегда не хватает процессора, памяти, места на диске. Все воркеры долбятся в пустую очередь, все пытаются повторить сбойный и давно устаревший запрос, и все серверы одновременно встают на паузу во время сборки мусора. Система не просто сломана, она постоянно пытается сломать саму себя.
Большие трудности может вызывать даже проверка работы системы.
Реализовать проверку работы сервера может быть легко, но только если он не обрабатывает запросы. Если вы не проверяете длительность непрерывной безотказной работы, то вполне возможно, что программа падает между проверками. Инициировать баги могут и проверки состояния (health check): мне доводилось писать проверки, которые приводили к падению системы, которую должны были защищать. Дважды, с разницей в три месяца.
Код для обработки ошибок неизбежно приведёт к обнаружению ещё большего количества ошибок, которые нужно обрабатывать, многие из которых возникают из-за самой обработки ошибок. Аналогично, оптимизации производительности зачастую являются причиной возникновения узких мест в системе. Приложение, которое приятно использовать в одной вкладке, превращается в проблему, будучи запущенным в 20 копиях.
Другой пример: воркер в конвейере работает слишком быстро и потребляет доступную память до того, как к ней обратится следующая часть конвейера. Это можно сравнить с пробками на дорогах: они возникают из-за повышения скорости движения, и в результате затор растёт в противоположном движению направлении. Так и оптимизации могут порождать системы, которые падают под высокой или тяжёлой нагрузкой, зачастую какими-то таинственными способами.
Иными словами: чем быстрее система, тем сильнее на неё давление, и если вы не позволите системе немного противодействовать, то не удивляйтесь, если она треснет.
Противодействие — одна из форм обратной связи системы. Программа, которую легко отлаживать, вовлекает пользователя в цикл обратной связи, позволяет увидеть все поведения внутри системы, случайные, намеренные, желаемые и не желаемые. Вы можете легко инспектировать такой код, видеть и понимать происходящие с ним изменения.
Если сейчас вы что-то оставите неоднозначным, позднее придётся это отлаживать
Иными словами, вам должно быть легко отслеживать переменные в программе и понимать, что происходит. Возьмите какие-нибудь подпрограммы с кошмарной линейной алгеброй, вы должны стремиться представить состояние программы как можно очевиднее. Это значит, что посреди программы нельзя изменить назначение переменной, поскольку использование одной переменной для двух разных целей — смертный грех.
Также это означает, что нужно тщательно избегать проблемы полупредиката (semi-predicate problem), никогда не использовать одно значение (count
) для представления пары значений (boolean
, count
). Нужно избегать возвращения положительного числа для результата и при этом возвращать -1
, если ничто не соответствует. Дело в том, что можно легко оказаться в ситуации, когда вам понадобится нечто вроде “0, but true
” (причём именно такая фича есть в Perl 5); или когда вы создаёте код, трудно сочетаемый с другими частями системы (-1
для следующей части программы может быть не ошибкой, а корректным входным значением).
Помимо использования одной переменной для двух целей, не рекомендуется использовать две переменные для одной цели, особенно если это булевы. Я не хочу сказать, что плохо использовать два числа для хранения диапазона, но использование булевых для обозначения состояния программы часто замаскированный конечный автомат.
Когда состояние не проходит сверху вниз, то есть в случае эпизодического цикла, лучше всего предоставить состоянию собственную переменную и очистить логику. Если внутри объекта у вас набор булевых, то замените их на переменную под названием state
и используйте enum (или строку, если это где-то необходимо). Выражения if
станут выглядеть if state == name
, а не if bad_name && !alternate_option
.
Даже если вы делаете явную машину состояний, есть вероятность напутать: иногда код может иметь внутри две скрытые машины состояний. Однажды я замучился писать HTTP-прокси, пока не сделал каждую машину явной, отследил состояние подключения и отдельно его пропарсил. Когда объединяешь две машины состояний в одну, может быть трудно добавлять новое состояние или точно понимать, какое состояние должно быть у чего-то.
Речь идёт скорее о создании кода, который не придётся отлаживать, чем лёгкого в отладке. Если выработать список корректных состояний, будет гораздо легче отбросить некорректные, не пропустив случайно одно-два.
Случайное поведение — это ожидаемое поведение
Когда вы не понимаете, что делает структура данных, эти пробелы в знаниях заполняют пользователи: любое поведение кода, намеренное или случайное, в конце концов будет на что-то опираться. Многие популярные языки программирования поддерживают хеш-таблицы, которые можно итерировать, и которые в большинстве случаев сохраняют порядок после вставки.
В одних языках поведение хеш-таблицы соответствует ожиданиям большинства пользователей, итерируясь по ключам в порядке их добавления. В других языках хеш-таблица при каждом итерировании возвращает ключи в другом порядке. В этом случае некоторые пользователи жалуются, что поведение недостаточно случайное.
К сожалению, любой источник случайности в вашей программе в конце концов будет использоваться для статистической симуляции, а то и ещё хуже — криптографии; а любой источник упорядочивания будет использоваться для сортировки.
В базах данных некоторые идентификаторы содержат чуть больше информации, чем другие. Создавая таблицу, разработчик может выбирать между разными типами первичного ключа. Правильный выбор — UUID, или что-нибудь неотличимое от него. Недостатком других вариантов является то, что они могут раскрывать информацию об упорядочивании и идентификации. То есть не просто a == b
, но a <= b
, и под другими вариантами подразумеваются автоинкрементные ключи.
При использовании автоинкрементного ключа база данных присваивает номер каждой строке таблицы, добавляя по 1 при вставке новой строки. И возникает неясность сортировки: люди не знают, какая часть данных является канонической. Иными словами, вы сортируете по ключу или по временной метке? Как и в случае с хеш-таблицей, люди сами выберут правильный ответ. А другая проблема заключается в том, что пользователи легко могут предугадать соседние записи с другими ключами.
Зато любая попытка перехитрить UUID потерпит неудачу: мы уже пробовали использовать почтовые индексы, телефонные номера и IP-адреса, и каждый раз с треском проваливались. UUID, быть может, и не сделает ваш код удобнее в отладке, но зато менее подверженное случайностям поведение означает меньшее количество неприятностей.
Из ключей можно извлечь информацию не только об упорядочивании. Если в базе данных вы создаёте ключи на основе других полей, тогда люди будут отбрасывать данные и восстанавливать их из ключа. И возникнет две проблемы: когда состояние программы хранится в нескольких местах, копиям будет очень легко не соглашаться друг с другом; и синхронизировать их будет труднее, если вы не уверены, какое из них нужно менять или какое изменили.
Что бы вы ни разрешили делать своим пользователям, они это сделают. Написание лёгкого в отладке кода означает продумывание способов его неправильного использования, а также того, как люди могут взаимодействовать с ним в целом.
Отладка — задача, в первую очередь, социальная, и лишь потом техническая
Когда проект разделён на компоненты и системы, может быть гораздо тяжелее находить баги. Поняв, как возникает проблема, вы можете скоординировать изменения в разных частях, чтобы исправить поведение. Исправление багов в больших проектах требует не столько их поиска, сколько убеждения людей в существовании этих багов, или в самой возможности существования.
Баги есть в ПО, потому что никто не уверен целиком, кто и за что отвечает. То есть труднее отлаживать код, когда ничего не записано, обо всём приходится спрашивать в Slack, и никто не отвечает, пока не придёт какой-нибудь один знаток.
Это можно исправить с помощью планирования, инструментов, процессов и документации.
Планирование — способ избавления от стресса постоянного пребывания на связи, структуры управления инцидентами. Планы позволяют информировать покупателей, освобождать людей, которые на связи уже слишком долго, а также отслеживать проблемы и вносить изменения для уменьшения будущих рисков. Инструменты — способ снижения требований для выполнения какой-то работы, чтобы она стала доступнее другим разработчикам. Процесс — способ снятия функций управления с отдельных участников и передачи команде.
Люди и способы взаимодействия будут меняться, но процессы и инструменты сохранятся по мере преобразования команды. Речь не о том, что одно важнее другого, а о том, что одно создано для поддержки изменений в другом. Процесс можно использовать и для снятия функций управления с команды. Это не всегда хорошо или плохо, но всегда есть какой-то процесс, даже если он не прописан. И акт его документирования — первый шаг к тому, чтобы позволить другим людям изменить этот процесс.
Документация — это нечто большее, чем текстовые файлы. Это способ передачи ответственности, как вы вводите людей в работу, как сообщаете об изменениях тем, на кого эти изменения повлияли. Написание документации требует больше эмпатии, чем при написании кода, и больше навыков: не существует простых флагов компилятора или проверок типов, и легко можно написать много слов, ничего так и не задокументировав.
Без документации нельзя ожидать, что другие будут принимать обоснованные решения, или даже согласятся с последствиями использования ПО. Без документации, инструментов или процессов невозможно разделить ношу сопровождения или хотя бы заменить людей, которые сейчас решают задачу.
Стремление к облегчению отладки применимо не только к самому коду, но и к связанным с кодом процессам, это помогает понять, в чью шкуру вам нужно влезть, чтобы исправить код.
Код, который легко отлаживать, прост в объяснении
Бытует мнение, что если при отладке объясняешь кому-то проблему, то сам в ней разбираешься. Для этого даже не нужен другой человек, главное заставить себя объяснить ситуацию с нуля, объяснить порядок воспроизведения. И зачастую этого достаточно, чтобы прийти к нужному решению.
Если бы. Иногда, когда мы просим о помощи, то просим не то, что нужно. Это настолько распространённое явление, что оно получило название «Проблема X-Y» (The X-Y Problem): «Как мне получить последние три буквы имени файла? А? Нет, я имел в виду расширение».
Мы говорим о проблеме в терминах решения, которое мы понимаем, и говорим о решении в терминах последствий, которых опасаемся. Отладка — это трудное постижение неожиданных последствий и альтернативных решений, она требует от программиста самого тяжёлого: признать, что он понял что-то неправильно.
Оказывается, это не было ошибкой компилятора.