Бывает, что посмотрев на старый код, мы говорим: «Его проще переписать, чем поменять». Печально, если речь идет о нашем собственном коде, с такой любовь написанном несколько лет назад. Head of Developer Relations в Evrone Григорий Петров в своем докладе на TechLead Conf 2020 разобрал проблемы, которые приводят к такой ситуации, и рассказал, как бороться с Software complexity problem.

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

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

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

Художник рисует мазками

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

Можно сказать, что программисты тоже пишут код своеобразными «мазками»: идентификатор за идентификатором, оператор за оператором, expression, statement, строчка за строчкой получаются творения в 10, в 100, в 1000 строк кода.

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

Поэтому одна из главных проблем, существующих в разработке софта — это то, что мы называем Software complexity problem, что можно перевести на русский как «проблема сложности программ».

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

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

Проблемы программирования

  • Нулевая цена копирования;

    Если мы уже «построили ракету» — написали софт, у нас нет необходимости писать точно такой же еще раз, чтобы сделать его чуть-чуть лучше. Если разработчик работает над кодом, значит раньше подобного им написано не было. Иначе код был бы скопирован.

  • Нет понимания «как правильно»;

    Индустрия программирования очень молода, мы еще не успели подготовить лучшие практики и не знаем, как «правильно» писать софт. Прямо сейчас можно наблюдать, как монолит объектно-ориентированного программирования, который последние 20 лет был незыблемым, сдает позиции функциональному программированию. Многие топовые разработчики сейчас говорят о том, что наследование — это не очень хороший способ декомпозиции кода. А в языках программирования последнего десятилетия (например, в Rust) в принципе нет классов, как таковых. И они неплохо себя чувствуют.

  • Отсутствие фундаментального образования;

    Из-за молодости индустрии и нулевой цены копирования, в среде программистов отсутствует фундаментальное образование. Есть computer science, но это science. Она про науку, и имеет примерно такое же отношение к прикладной разработке софта как астрономия — к разработке телескопов. Программист, который в университете 6 лет учил алгоритмы и структуры данных, почти ничего не знает про систему управления версиями, идентификаторы, про то, как писать читаемый код и рассказывать этим кодом истории.

  • Сложно посмотреть, «как делают другие».

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

Интуитивное мышление, когнитивные искажения

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

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

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

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

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

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

Борьба со сложностью

К сожалению для нас, сложность из кода нельзя убрать. Ведь она — это та польза, которую приносит написанная нами программа. 

Но сложность можно перераспределить! Именно об этом пойдет речь в статье. 

  • Память;

Гиппокамп — это часть мозга, которая, предположительно, имеет отношение к формированию памяти. Известно, что когда выходит из строя гиппокамп, ломается память. 

Как это происходит, не совсем ясно.  Но существует такая закономерность, как «Кошелек Миллера»: когда человек смотрит на новые для себя объекты, в среднем, он может удержать в фокусе внимания от 5 до 9 из них. 

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

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

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

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

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

Библиотеки и фреймворки для языков программирования могут стать способом перевести информацию из кратковременной памяти в долговременную. 

Если программист много лет пишет на Python и использует requests, он к ним привыкает. Типичные конструкции использования requests — как сделать запрос, как передать и получить JSON, как решать вопросы скорости и задержек — ему привычны. Код, который использует библиотеку, становится для программиста читаемым. В таком коде больше нет сложности. По крайней мере, для этого конкретного программиста.

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

Точно также работает стандарт кодирования, «coding style». Код разработчиков может быть читаемым друг для друга, но только если они хотя бы несколько месяцев поживут с ним. Нейрофизиология утверждает, что несколько месяцев и несколько сотен повторений нужны нашей памяти, чтобы выстроить долговременные связи long-term potentiation, чем бы они ни были.

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

Но память — это долго. Это самый простой, но и самый длительный по времени способ борьбы со сложностью.

  • Декомпозиция;

Второй по популярности способ — это декомпозиция на части по 5 элементов.

Посмотрим на эволюцию типичной сферической программы в вакууме.

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

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

Через некоторое время, когда абстракции языка программирования исчерпывают себя, и строчек становится несколько десятков тысяч, разработчики начинают с интересом смотреть в сторону DSL:  YAML, JSON, Ruby, XML и т.д. 

Особенно большой интерес проявляется в Java, где XML-конфиги к программам — просто стандарт де-факто. Но даже если команда не пишет на Java, она с большим удовольствием выкладывает и перераспределяет избыточную сложность в JSON, YAML и в другие места, которые сможет найти. 

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

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

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

  • Метаинформация.

Прежде всего метаинформация нужна не компилятору и языку программирования, а людям.

Она как дорожные указатели, которые расставлены по коду. Там, где сложности скопилось слишком много, ее разделяют на части с указанием того, что в этих частях находится.   Основная часть при этом одна, все такая же огромная, но внешние «дорожные указатели» позволяют посмотреть на нее под разными углами.  

Главный, основной, фундаментальный дорожный указатель — идентификаторы. 

Идентификаторы — это переменные, константы, названия функций и классов — все те имена, которые мы даем сущностям в коде.

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

????????????? или Мосгорводоканалстрой — это одна сущность для нашего мозга.

Идентификатор здорово помогает писать читаемый код, если отвечает на вопрос «что это?». Программист пришел в проект, посмотрел на идентификатор, и ему сразу понятно, что. В современных языках программирования для этого есть PascalCase, camelCase, snake_case. Выбирая конкретный стиль, мы выбираем то, что привычнее нашей команде.

В Go все очень тяжело со сложностью, потому что язык практически не предоставляет синтаксического сахара. В книге «Как писать на языке программирования Go» есть параграф про эволюцию идентификаторов. Там написано о том, как бороться с когнитивной сложностью в коде. Выбирая имя для идентификатора, авторы предлагают смотреть на то, что находится рядом с этим идентификатором. Если там что-то очень простое (функция, 2-3 строчки кода, которые очевидны), то идентификатор может быть i или v:

v => users => admin_users

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

После идентификаторов идут комментарии, которые отвечают уже на вопрос «зачем это?».

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

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

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

Например: 

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

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

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

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

В роли документации могут выступать и тесты. Поскольку они показывают пути выполнения. 

В последние 5-10 лет в динамические языки программирования пришли типы. Они выполняют роль своеобразных «капканов» для ошибок. Когда программист пишет код, он может воспользоваться Gradual подходом современных языков. И там, где сложность повышена, добавить типы, чтобы в коде расставились несколько «капканов». 

Если программист через какое-то время (например, спустя полгода) воспользуется этим кодом неправильно, «капкан» сработает, подчеркнет ему  строчку в IDE красным, и все сразу станет понятно.

Gradual подход к перераспределению сложности

Gradual подход к написанию читаемого кода по больше части вращается вокруг цифры 5. 

Есть несколько способов перераспределения сложности:

  • Gradual decomposition;

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

  • Gradual meta information;

    Если мы понимаем, что у нас уже есть распределение на множество частей, начинаем добавлять метаинформацию: давать описательные имена идентификаторам, чтобы они отвечали на вопросы «что это?» и «зачем это?». 

  • Gradual typing.

    Наконец, когда сложность продолжает скапливаться, мы добавляем типы, как «капканы» на будущее. Чтобы по возвращению к этому коду через какое-то время, «капканы» сработали и защитили нас.

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

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

Если в команде есть разработчики разного уровня (например джуниоры и сеньоры), они не смогут писать код, который будет читаем друг для друга. То, что не является новинкой для сеньора, который 20 лет пишет код, для джуниора ею будет. Поэтому код, который напишет сеньор, будет очень простой, понятный, хорошо читаемый — но для сеньоров. А для джуниоров количество «нового» и, соответственно, сложности в таком коде будет зашкаливать. 

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

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

Но в большинстве случаев Gradual подход, который крутится вокруг цифры 5, является неплохой стартовой точкой.

Конференция, полностью посвященная инженерным процессам и практикам, TechLead Conf 2021 пройдет 12 и 13 апреля. Билеты можно приобрести здесь. Вы можете успеть купить их до повышения цены!

А пока мы все ждем апреля, приглашаем вас на Quality Assurance Webinar. На нем поговорим о пирамиде тестирования, узнаем, как найти UI тесты, которые легко могут быть перенесены на более низкие уровни, и разберемся в инфраструктуре тестирования в браузерах.

Мероприятие начнется 21 января в 18:00 мск. До встречи!