Не пишите чистый код, пишите ЧЕТКИЙ (CRISP) код

Вольный перевод статьи Джона Арундела - Don't write clean code, write CRISP code

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

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

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

Я думаю, что хорошие программы должны быть Правильными (Correct), Читаемыми (Readable), Идиоматичными (Idiomatic), Простыми (Simple) и Эффективными (Performant).

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

Правильный (Correct)

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

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

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

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

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

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

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

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

Даже самый тщательно протестированный код не следует считать правильным. Следует предположить, что это утверждение неверно. Увы, но это почти всегда так и есть.

Читаемый (Readable)

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

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

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

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

Например, неправильно выбранное имя переменной может помешать читателю. Использование разных названий для одного и того же или одного и того же имени для разных вещей сбивает с толку. Ненужный вызов функции, добавленный исключительно для удовлетворения некоторого правила, такого как "методы должны содержать менее 10 строк", нарушает поток чтения. Это всё похоже на то, как если бы вы прочитали статью и наткнулись на незнакомое слово: должны ли вы остановиться, чтобы посмотреть его, и рискнуть потерять ход мыслей, или продолжать бороться и пытаться вывести значение из контекста?

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

Вместо этого нам нужно читать код тщательно, последовательно, преднамеренно (intentional) . Нам нужно начать с самого начала и продолжать, пока мы не дойдем до конца. Если не ясно, где начинается программа, то это первое, что нам нужно исправить.

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

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

  1. Что там написано?

  2. Что это значит?

Например, рассмотрим следующую строку:

Нам ясно, что тут написано: происходит увеличение значение некоторой числовой переменной requests на единицу. Что не так просто понять, так это то, что это значит. Что учитывается в requestsпеременной? Почему оно увеличивается? Какое это имеет значение? Откуда requests взялась? Каково её текущее значение? Когда и где будет проверяться это значение? Существует ли какое-то максимальное значение? Что произойдет, когда мы достигнем его? И так далее.

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

Идиоматический (Idiomatic)

Я не думаю, что это совсем подходящее слово: я бы предпочел "обычный" (conventional), но тогда бы это не соответствовало аббревиатуре CRISP. Тем не менее, когда люди говорят "то-то и то-то идиоматично", на самом деле они имеют в виду "это обычный способ сделать это".

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

Аналогичным образом, я думаю, что есть большая ценность в использовании обычных имен для вещей: в обработчике HTTP всегда вызывается указатель запроса r и пишущий ответ w. Если существует универсальная конвенция, ей стоит следовать. У вас наверняка тоже будут свои локальные соглашения в проекте. Например в моем коде bytes.Buffer всегда называется buf , а сравниваемые значения в моих тестах всегда называются want и got и так далее.

err – это хороший пример универсального соглашения: программисты Go всегда используют это имя для обозначения произвольного значения ошибки. Хотя мы не будем повторно использовать имена переменных в одной и той же функции, но мы повторно используем err для всех различных ошибок, которые могут возникнуть во всей функции. Было бы неправильно пытаться избежать этого, создавая варианты имен , такие как err2, err3, и так далее. (хоть это и встречается в коде довольно часто (прим. Пер.))

Почему? Потому что это требует от читателя чуть больше когнитивных усилий. Если вы видите err, ваш мозгу не требуется дополнительных усилий для понимания значения этой переменной. Если вы видите какое-то другое имя, вы должны остановиться, чтобы решить "головоломку". Я называю эти крошечные препятствия когнитивными микроагрессиями. Каждая из них может быть настолько крошечной, что её индивидуальный эффект незначителен, но скоро они накапливаются, и если их будет слишком много, они могут создать кучу проблем.

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

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

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

Еще хорошей практикой для понимания общепризнанных норм и правил являются публичные стандарты например PSR для php, или PEP для python. Увы для Go не так много публичной информации о том как лучше и правильнее писать код, но начать стоит с ознакомления, например, вот с этим перечнем - https://github.com/smallnest/go-best-practices (прим. Пер.)

Простой (Simple)

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

Как отметил Рич Хики, простое – это не то же самое, что простое. "Легкое" (Easy) - это что-то знакомое, малозатратное, то, к чему мы тянемся, не задумываясь. Правда обычно это приводит к "сложному" (complex), поэтому переход от него к "простому" (simple) может потребовать больших усилий и размышлений.

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

Это одна из причин, по которой людям трудно писать простой код: мы все боимся повторяться. Принцип " Не повторяйся "настолько укоренился, что мы даже используем его как глагол – DRY (в русском комьюнити не встрчал такого прим. Пер.) "we need to DRY up this function (как напоминает нам Кальвин, глагол странного языка).

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

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

Простота, как заметил Алан Перлис, не предшествует сложности, а следует за ней. Другими словами, не пытайтесь писать простые программы: сначала напишите программу, а затем сделайте ее простой. Прочитайте код и спросите, что в нем написано, а затем спросите себя, можете ли вы найти более простой способ написать то же самое. Если выяснится, что есть более понятный способ, тогда имеет смысл ему последовать и переписать программу. Или, другими словами: если есть более простой способ достичь результата, выберите его.

Еще один способ как достичь простоты, - это естественность. Любой язык программирования имеет свое собственное Дао, свои собственные естественные формы и структуры, и работа с ними, как правило, дает лучшие результаты, чем работа против них. Например, вы можете попробовать использовать Go, как если бы это была Java, или Python, или Clojure, и в этих языках нет ничего плохого, но проще писать программы на Go так как задумывались программы на Go, и тогда ваши результаты будут намного лучше.

Эффективный (Performant)

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

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

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

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

Справедливости ради, даже неэффективная программа будет работать довольно быстро: как заметил Ричард Фейнман – внутренности компьютера чертовски тупы, но он работает как бешенный. Это не значит, что мы можем позволить себе тратить время впустую, потому что вычисления, умноженные на время, равны энергии, и мы нагреваем нашу планету и без того бешенными темпами. Было бы обидно, если бы мы в конечном итоге испустили несколько лишних мегатонн углерода только потому, что сделали неуклюжий выбор структуры данных.

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

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

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

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

Напоследок