все коды для программирования
Как писать читаемый код
Бывает, что посмотрев на старый код, мы говорим: «Его проще переписать, чем поменять». Печально, если речь идет о нашем собственном коде, с такой любовь написанном несколько лет назад. 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 мск. До встречи!
Что такое красивый код, и как его писать?
1. Вступление
Сталкиваясь с необходимостью контролировать работу других программистов, начинаешь понимать, что, помимо вещей, которым люди учатся достаточно легко и быстро, находятся проблемы, для устранения которых требуется существенное время.
Сравнительно быстро можно обучить человека пользоваться необходимым инструментарием и документацией, правильной коммуникации с заказчиком и внутри команды, правильному целеполаганию и расстановке приоритетов (ну, конечно, в той мере, в которой сам всем этим владеешь).
Но когда дело доходит собственно до кода, все становится гораздо менее однозначно. Да, можно указать на слабые места, можно даже объяснить, что с ними не так. И в следующий раз получить ревью с абсолютно новым набором проблем.
Профессии программиста, как и большинству других профессий, приходится учиться каждый день в течение нескольких лет, а, по большому счету, и всю жизнь. Вначале ты осваиваешь набор базовых знаний в объеме N семестровых курсов, потом долго топчешься по различным граблям, перенимаешь опыт старших товарищей, изучаешь хорошие и плохие примеры (плохие почему-то чаще).
Говоря о базовых знаниях, надо отметить, что умение писать красивый профессиональный код — это то, что по тем или иным причинам, в эти базовые знания категорически не входит. Вместо этого, в соответствующих заведениях, а также в книжках, нам рассказывают про алгоритмы, языки, принципы ООП, паттерны дизайна…
Да, все это необходимо знать. Но при этом, понимание того, как должен выглядеть достойный код, обычно появляется уже при наличии практического (чаще в той или иной степени негативного) опыта за плечами. И при условии, что жизнь “потыкала” тебя не только в сочные образцы плохого кода, но и в примеры всерьез достойные подражания.
В этом-то и заключается вся сложность: твое представление о “достойном” и “красивом” коде полностью основано на личном многолетнем опыте. Попробуй теперь передать это представление в сжатые сроки человеку с совсем другим опытом или даже вовсе без него.
Но если для нас действительно важно качество кода, который пишут люди, работающие вместе с нами, то попробовать все же стоит!
2. Зачем нам нужен красивый код?
Обычно, когда мы работаем над конкретным программным продуктом, эстетические качества кода заботят нас далеко не в первую очередь.
Нам гораздо важнее наша производительность, качество реализации функционала, стабильность его работы, возможность модификации и расширения и т.д.
Но являются ли эстетические качества кода фактором, положительно влияющим на вышеперечисленные показатели?
Мой ответ: да, и при этом, одним из самых важных!
А теперь, чтобы от общих слов перейти к конкретике, давайте сделаем обратный ход и скажем, что именно читаемый и управляемый код обычно воспринимается нами как красивый и профессионально написанный. Соответственно, на обсуждении того, как добиться этих качеств, мы далее и сосредоточимся.
3. Три базовых принципа.
Переходя к изложению собственного опыта, отмечу, что, работая над читаемостью и управляемостью своего и чужого кода, я постепенно пришел к следующему пониманию.
Вне зависимости от конкретного языка программирования и решаемых задач, для того, чтобы фрагмент кода в достаточной степени обладал этими двумя качествами необходимо, чтобы он был:
Поэтому дальше я постараюсь подробно пояснить их суть, а также описать набор основных техник, с помощью которых можно привести свой код в соответствие с этими принципами.
4. Линеаризация кода.
Мне кажется, что из трех базовых принципов, именно линейность является самым неочевидным, и именно ей чаще всего пренебрегают.
Наверное, потому что за годы учебы (и, возможно, научной деятельности) мы привыкли обсуждать от природы нелинейные алгоритмы с оценками типа O(n3), O(nlogn) и т.д.
Это все, конечно, хорошо и важно, но, говоря о реализации бизнес-логики в реальных проектах, обычно приходится иметь дело с алгоритмами совсем другого свойства, больше напоминающими иллюстрации к детским книжкам по программированию. Что-то типа такого (взято из гугла):
Таким образом, с линейностью я связываю не столько асимптотическую сложность алгоритма, сколько максимальное количество вложенных друг в друга блоков кода, либо же уровень вложенности максимально длинного подучастка кода.
Например, идеально линейный фрагмент:
И совсем не линейный:
Именно “куски” второго типа мы и будем пытаться переписать при помощи определенных техник.
Примечание: поскольку здесь и далее нам потребуется примеры кода для иллюстрации тех или иных идей, сразу условимся, что они будут написаны на абстрактном обобщенном C-like языке, кроме тех случаев, когда потребуются особенности конкретного существующего языка. Для таких случаев будет явно указано, на каком языке написан пример (конкретно будут встречаться примеры на Java и Javascript).
4.1. Техника 1. Выделяем основную ветку алгоритма.
В подавляющем большинстве случаев в качестве основной ветки имеет смысл взять максимально длинный успешный линейный сценарий алгоритма.
Давайте для сравнения рассмотрим вариант, где на нулевом уровне находится альтернативная ветка, вместо основной:
Как видно, уровень вложенности значительной части кода вырос, и смотреть на код в целом уже стало менее приятно.
4.2. Техника 2. Используем break, continue, return или throw, чтобы избавиться от блока else.
Разумеется, неверным был бы вывод, что вообще никогда не нужно использовать оператор else. Во-первых, не всегда контекст позволяет поставить break, continue, return или throw (хотя часто как раз таки позволяет). Во-вторых, выигрыш от этого может быть не столь очевиден, как в примере выше, и простой else будет выглядеть гораздо проще и понятней, чем что-либо еще.
Ну и в-третьих, существуют определенные издержки при использовании множественных return в процедурах и функциях, из-за которых многие вообще расценивают данный подход как антипаттерн (мое личное мнение: обычно преимущества все-таки покрывают эти издержки).
Поэтому эта (и любая другая) техника должна восприниматься как подсказка, а не как безусловная инструкция к действию.
4.3. Техника 3. Выносим сложные подсценарии в отдельные процедуры.
Т.к. в случае “алгоритма ремонта” мы довольно удачно выбрали основную ветку, то альтернативные ветки у нас все остались весьма короткими.
Поэтому продемонстрируем технику на основе “плохого” примера из начала главы:
Обратите внимание, что правильно выбрав имя выделенной процедуре, мы, кроме того, сразу же повышаем самодокументированность кода. Теперь для данного фрагмента в общих чертах должно быть понятно, что он делает и зачем нужен.
Однако следует иметь в виду, что та же самодокументированность сильно пострадает, если у вынесенной части кода нет общей законченной задачи, которую этот код выполняет. Затруднения в выборе правильного имени для процедуры могут быть индикатором именно такого случая (см. п. 6.1).
4.4. Техника 4. Выносим все, что возможно, во внешний блок, оставляем во внутреннем только то, что необходимо.
4.5. Техника 5 (частный случай предыдущей). Помещаем в try. catch только то, что необходимо.
Надо отметить, что блоки try. catch вообще являются болью, когда речь идет о читаемости кода, т.к. часто, накладываясь друг на друга, они сильно повышают общий уровень вложенности даже для простых алгоритмов.
Бороться с этим лучше всего, минимизируя размер участка внутри блока. Т.е. все строки, не предполагающие появление исключения, должны быть вынесены за пределы блока. Хотя в некоторых случаях с точки зрения читаемости более выигрышным может оказаться и строго противоположный подход: вместо того, чтобы писать множество мелких блоков try..catch, лучше объединить их в один большой.
Кроме того, если ситуация позволяет не обрабатывать исключение здесь и сейчас, а выкинуть вниз по стеку, обычно лучше именно так и поступить. Но надо иметь в виду, что вариативность тут возможна только в том случае, если вы сами можете задавать или менять контракт редактируемой вами процедуры.
4.6. Техника 6. Объединяем вложенные if-ы.
Тут все очевидно. Вместо:
4.7. Техника 7. Используем тернарный оператор (a? b: c) вместо if.
Иногда имеет смысл даже написать вложенные тернарные операторы, хотя это предполагает от читателя знания приоритета, с которым вычисляются подвыражения тернарного оператора.
Но злоупотреблять этим, пожалуй, не стоит.
Заметим, что инициализация переменной var1 теперь осуществляется одной единственной операцией, что опять же сильно способствует самодокументированности (см п. 6.8).
4.8. Суммируя вышесказанное, попробуем написать полную реализацию алгоритма ремонта максимально линейно.
На этом можно было бы и остановиться, но не совсем здорово выглядит то, что нам приходится 3 раза вызывать bye() и, соответственно, помнить, что при добавлении новой ветки, его придется каждый раз писать перед return (собственно, издержки множественных return).
Можно было бы решить проблему через try. finally, но это не совсем правильно, т.к. в данном случае не идет речи об обработке исключений. Да и делу линеаризации такой подход бы сильно помешал.
Давайте сделаем так (на самом деле, я тут применил п. 5.1 еще до того, как его написал):
Если вы думаете, что мы сейчас записали что-то тривиальное, то, в принципе, так и есть. Однако уверяю, что во многих живых проектах вы увидели бы совсем другую реализацию этого алгоритма…
5. Минимизация кода.
Думаю, было бы лишним пояснять, что уменьшая количество кода, используемого для реализации заданного функционала, мы делаем код гораздо более читаемым и надежным.
В этом смысле, идеальное инженерное решение — это, когда ничего не сделано, но все работает, как требуется. Разумеется, в реальном мире крайне редко доступны идеальные решения, и поэтому у нас, программистов, пока еще есть работа. Но стремиться стоит именно к такому идеалу.
5.1. Техника 1. Устраняем дублирование кода.
О копипасте и вреде, от него исходящем, сказано уже так много, что добавить что-то новое было бы сложно. Тем не менее, программисты, поколение за поколением, интенсивно используют этот метод для реализации программного функционала.
Разумеется, самым очевидным методом борьбы с проблемой является вынесение переиспользуемого кода в отдельные процедуры и классы.
При этом всегда возникает проблема выделения общего из частного. Зачастую даже не всегда понятно, чего больше у похожих кусков кода: сходства или различий. Выбор тут делается исключительно по ситуации. Тем не менее, наличие одинаковых участков размером в пол-экрана сразу говорит о том, что данный код можно и нужно записать существенно короче.
Стоит также упомянуть весьма полезную технику устранения дублирования, описанную в п. 4.3.
Распространить ее можно дальше одних лишь операторов if. Например, вместо:
Или обратный вариант. Вместо:
5.2. Техника 2. Избегаем лишних условий и проверок.
Лишние проверки — зло, которое можно встретить практически в любой программе. Сложно описать, насколько самые тривиальные процедуры и алгоритмы могут быть засорены откровенно ненужными, дублирующимися и бессмысленными проверками.
Особенно это касается, конечно же, проверок на null. Как правило, вызвано подобное извечным страхом программистов перед вездесущими NPE и желанием лишний раз от них перестраховаться.
Далеко не редким видом ненужной проверки является следующий пример:
Не смотря на свою очевидную абсурдность, встречаются такие проверки с впечатляющей регулярностью. (Cразу же оговорюсь, что пример не касается тех редких языков и сред, где оператор new() может вернуть null. В большинстве случаев (в т.ч. в Java) подобное в принципе невозможно).
Сюда же можно включить десятки других видов проверок на заведомо выполненное (или заведомо не выполненное) условие.
Вот, например, такой случай:
Встречается в разы чаще, чем предыдущий тип проверок.
Третий пример чуть менее очевиден, чем первые два, но распространен просто повсеместно:
Как видно, автор данного отрезка панически боится нарваться на невалидный объект, поэтому проверяет его перед каждым чихом. Не смотря на то, что иногда такая стратегия может быть оправдана (особенно, если proc1() и proc2() экспортируются в качестве API), во многих случаях это просто засорение кода.
Еще одним полезным подходом является применение паттерна NullObject, предполагающего использование объекта с ничего не делающими, но и не вызывающими ошибок, методами вместо “опасного” null. Частным случаем такого подхода можно считать отказ от использования null для переменных-коллекций в пользу пустых коллекций.
Сюда же относятся специальные null-safe библиотеки, среди которых хотелось бы выделить набор библиотек apache-commons для Java. Он позволяет сэкономить огромное количество места и времени, избавив от необходимости писать бесконечные рутинные проверки на null.
5.3. Техника 3. Избегаем написания “велосипедов”. Используем по максимуму готовые решения.
Велосипеды — это почти как копипаст: все знают, что это плохо, и все регулярно их пишут. Можно лишь посоветовать хотя бы пытаться бороться с этим злом.
Большую часть времени перед нами встают задачи или подзадачи, которые уже множество раз были решены, будь то сортировка или поиск по массиву, работа с форматами 2D графики или long-polling сервера на Javascript. Общее правило заключается в том, что стандартные задачи имеют стандартное решение, и это решение дает нам возможность получить нужный результат, написав минимум своего кода.
Порой есть соблазн, вместо того, чтобы что-то искать, пробовать и подгонять, быстренько набросать на коленке свой велосипед. Иногда это может быть оправдано, но если речь идет о поддерживаемом в долгосрочной перспективе коде, минутная “экономия” может обернуться часами отладки и исправления ошибок на пропущенных корнер-кейсах.
С другой стороны, лишь слегка затрагивая достаточно обширную тему, хотелось бы сказать, что иногда “велосипедная” реализация может или даже должна быть предпочтена использованию готового решения. Обычно это верно в случае, когда доверие к качеству готового решения не выше, чем к собственному коду, либо в случае, когда издержки от внедрения новой внешней зависимости оказываются технически неприемлемы.
Тем не менее, возвращаясь к вопросу о краткости кода, безусловно, использование стандартных (например, apache-commons и guava для Java) и нестандартных библиотек является одним из наиболее действенных способов уменьшить размеры собственного кода.
5.4. Техника 4. Оставляем в коде только то, что действительно используется.
“Висящие” функции, которые никем нигде не вызываются; участки кода, которые никогда не выполняются; целые классы, которые нигде не используются, но их забыли удалить — уверен, каждый мог наблюдать такие вещи в своем проекте, и может быть, даже воспринимал их, как должное.
На деле же, любой код, в том числе неиспользуемый, требует плату за свое содержание в виде потраченного внимания и времени.
Поскольку неиспользуемый код реально не выполняется и не тестируется, в нем могут содержатся некорректные вызовы тех или иных процедур, ненужные или неправильные проверки, обращения к процедурам и внешним библиотекам, которые больше ни для чего не нужны, и множество других сбивающих с толку или просто вредных вещей.
Таким образом, удаляя ненужные и неиспользуемые участки, мы не только уменьшаем размеры кода, но и, что не менее важно, способствуем его самодокументированности.
5.5. Техника 5. Используем свои знания о языке и полагаемся на наличие этих знаний у читателя.
Одним из эффективных способов сделать свой код проще, короче и понятней является умелое использование особенностей конкретного языка: различных умолчаний, приоритетов операций, кратких форм записи и т.д.
В качестве иллюстрации приведу наиболее, с моей точки зрения, яркий пример для языка Javascript.
Очень часто при разборе строковых выражений можно увидеть такие нагромождения:
Выглядит пугающе, в том числе и с точки зрения “а не забыл ли автор еще какую-нибудь проверку”. На самом деле, зная особенность языка Javascript, в большинстве подобных случаев всю проверку можно свести до тривиальной:
Аналогично, сравните следующие формы записи:
Второй вариант выглядит проще, благодаря использованию специфичной семантики логических операций в скриптовых языках.
6. Самодокументированный код.
Термин “самодокументированность” наиболее часто употребляется при описании свойств таких форматов, как XML или JSON. В этом контексте подразумевается наличие в файле не только набора данных, но и сведений об их структуре, о названиях сущностей и полей, задаваемых этими данными.
Читая XML файл мы, даже ничего не зная о контексте, почти всегда можем составить представление о том, что описывает данный файл, что за данные в нем представлены и даже, возможно, как они будут использованы.
Распространяя эту идею на программный код, под термином “самодокументированность” хотелось бы объединить множество свойств кода, позволяющих быстро, без детального разбора и глубокого вникания в контекст понять, что делает данный код.
Хотелось бы противопоставить такой подход “внешней” документированности, которая может выражаться в наличии комментариев или отдельной документации. Не отрицая необходимости в определенных случаях того и другого, отмечу, что, когда речь идет о читаемости кода, методы самодокументирования оказываются значительно более эффективными.
6.1. Техника 1. Тщательно выбираем названия функций, переменных и классов. Не стоит обманывать людей, которые будут читать наш код.
Самое главное правило, которое следует взять за основу при написании самодокументированного кода — никогда не обманывайте своего читателя.
Если поле называется name, там должно храниться именно название объекта, а не дата его создания, порядковый номер в массиве или имя файла, в который он сериализуется. Если метод называется compare(), он должен именно сравнивать объекты, а не складывать их в хэш таблицу, обращение к которой можно будет найти где-нибудь на 1000 строк ниже по коду. Если класс называется NetworkDevice, то в его публичных методах должны быть операции, применимые к устройству, а не реализация быстрой сортировки в общем виде.
Сложно выразить словами, насколько часто, несмотря на очевидность этого правила, программисты его нарушают. Думаю, не стоит пояснять, каким образом это сказывается на читаемости их кода.
Чтобы избежать таких проблем, необходимо максимально тщательно продумывать название каждой переменной, каждого метода и класса. При этом надо стараться не только корректно, но и, по возможности, максимально полно охарактеризовать назначение каждой конструкции.
Если этого сделать не получается, причиной обычно являются мелкие или грубые ошибки дизайна, поэтому воспринять такое надо минимум как тревожный “звоночек”.
Очевидно, так же стоит минимизировать использование переменных с названиями i, j, k, s. Переменные с такими названиями могут быть только локальными и иметь только самую общепринятую семантику. В случае i, j, это могут счетчики циклов или индексы в массиве. Хотя, по возможности, и от таких счетчиков стоит избавляться в пользу циклов foreach и функциональных выражений.
Переменные же с названиями ii, i1, ijk42, asdsa и т.д, не стоит использовать никогда. Ну разве что, если вы работаете с математическими алгоритмами… Нет, лучше все-таки никогда.
6.2. Техника 2. Стараемся называть одинаковые вещи одинаково, а разные — по-разному.
Одна из самых обескураживающих трудностей, возникающих при чтении кода — это употребляемые в нем синонимы и омонимы. Иногда в ситуации, когда две разных сущности названы одинаково, приходится тратить по несколько часов, чтобы разделить все случаи их использования и понять, какая именно из сущностей подразумевается в каждом конкретном случае. Без такого разделения нормальный анализ, а следовательно и осмысленная модификация кода, невозможны в принципе. А встречаются подобные ситуации намного чаще, чем можно было бы предположить.
Примерно то же самое можно сказать и об “обратной” проблеме — когда для одной и той же сущности/операции/алгоритма используется несколько разных имен. Время на анализ такого кода может возрасти по сравнению с ожидаемым в разы.
Вывод простой: в своих программах к возникновению синонимов и омонимов надо относиться крайне внимательно и всеми силами стараться подобного избегать.
6.3. Техника 3. “Бритва Оккама”. Не создаем сущностей, без которых можно обойтись.
Как уже говорилось в п. 5.4., любой участок кода, любой объект, функция или переменная, которые вы создаете, в дальнейшем, в процессе поддержки, потребует себе платы в виде вашего (или чужого) времени и внимания.
Из этого следует самый прямой вывод: чем меньше сущностей вы введете, тем проще и лучше в итоге окажется ваш код.
Типичный пример “лишней” переменной:
Очевидно, переменная increasedSum является лишней сущностью, т.к. описание объекта, который она хранит (sum + 1) характеризует данный объект гораздо лучше и точнее, чем название переменной. Таким образом код стоит переписать следующим образом (“заинлайнить” переменную):
Если далее по коду сумма нигде не используется, можно пойти и дальше:
Инлайн ненужных переменных — это один из способов сделать ваш код короче, проще и понятней.
Однако применять его стоит лишь в случае, если этот прием способствует самодокументированности, а не противоречит ей. Например:
В этом случае инлайн переменной descr вряд ли пойдет на пользу читаемости, т.к. данная переменная используется для представления определенной сущности из предметной области, а, следовательно, наличие переменной способствует самодокументированности кода, и сама переменная под “бритву Оккама” не попадает.
Обобщая принцип, продемонстрированный на данном примере, можно заключить следующее: желательно в своей программе создавать переменные/функции/объекты, только если они имеют прототипы в предметной области. При этом, в соответствии с п. 6.1, надо стараться, чтобы название этих объектов максимально ясно выражало это соответствие. Если же такого соответствия нет, вполне возможно, что использование переменной/функции/объекта лишь перегружает ваш код, и их удаление пойдет программе только на пользу.
6.4. Техника 4. Всегда стараемся предпочитать явное неявному, а прямое — косвенному.
Общий принцип прост: любые явно выписанные алгоритмы или условия уже являются самодокументированными, т. к. их назначение и принцип действия уже описаны ими же самими. Наоборот, любые косвенные условия и операции с сайд-эффектами сильно затрудняют понимание сути происходящего.
Можно привести грубый пример, подразумевая, что жизнь подкидывает подобные примеры совсем не редко.
Оценить разницу в читаемости, думаю, несложно.
6.5. Техника 5. Все, что можно спрятать в private (protected), должно быть туда спрятано. Инкапсуляция — наше все.
Говорить о пользе и необходимости следования принципу инкапсуляции при написании программ в данной статье я считаю излишним.
Хотелось бы только подчеркнуть роль инкапсуляции, как механизма самодокументирования кода. В первую очередь, инкапсуляция позволяет четко выделить внешний интерфейс класса и обозначить его “точки входа”, т. е. методы, в которых может быть начато выполнение кода, содержащегося в классе. Это позволяет человеку, изучающему ваш код, сохранить огромное количество времени, сфокусировавшись на функциональном назначении класса и абстрагировавшись от деталей реализации.
6.6. Техника 6. (Обобщение предыдущего) Все объекты объявляем в максимально узкой области видимости.
Принцип максимального ограничения области видимости каждого объекта можно распространить шире, чем привычная нам инкапсуляция из ООП.
Очевидно, такое объявление переменной someobj затрудняет понимание ее назначения, т. к. читающий ваш код будет искать обращения к ней в значительно более широкой области, чем она используется и реально нужна.
Нетрудно понять, как сделать этот код чуть-чуть лучше:
Ну или, если переменная нужна для единственного вызова, можно воспользоваться еще и п. 6.3:
Отдельно хотелось бы оговорить случай, когда данная техника может не работать или работать во вред. Это переменные, инициализируемые вне циклов. Например:
Если создание объекта через createSomeObj() — дорогая операция, внесение ее в цикл может неприятно сказаться на производительности программы, даже если читаемость от этого и улучшится.
Такие случаи не отменяют общего принципа, а просто служат иллюстрацией того, что каждая техника имеет свою область применения и должна быть использована вдумчиво.
6.7. Техника 7. Четко разделяем статический и динамический контекст. Методы, не зависящие от состояния объекта, должны быть статическими.
Смешение или вообще отсутствие разделения между статическим и динамическим контекстом — не самое страшное, но весьма распространенное зло.
Приводит оно к появлению ненужных зависимостей от состояния объекта в потенциально статических методах, либо вообще к неправильному управлению состоянием объекта. Как следствие — к затруднению анализа кода.
Поэтому необходимо стараться обращать внимание и на данный аспект, явно выделяя методы, не зависящие от состояния объекта.
6.8. Техника 8. Стараемся не разделять объявление и инициализацию объекта.
Данный прием позволяет совместить декларацию имени объекта с немедленным описанием того, что объект из себя представляет. Именно это и является ярким примером следования принципу самодокументированности.
Если данной техникой пренебречь, читателю придется для каждого объекта искать по коду, где он был объявлен, нет ли у него где-нибудь инициализации другим значением и т.д. Все это затрудняет анализ кода и увеличивает время, необходимое для того, чтобы в коде разобраться.
Именно поэтому, например, функциональные операции из apache CollectionUtils и guava Collections2 часто предпочтительней встроенных в Java foreach циклов — они позволяют совместить объявление и инициализацию коллекции.
Если мы используем Java 8, можно записать чуть короче:
Ну и стоит упомянуть случай, когда разделять объявление и инициализацию переменных так или иначе приходится. Это случай использования переменной в блоках finally и catch (например, для освобождения какого-нибудь ресурса). Тут уже ничего не остается, кроме как объявить переменную перед try, а инициализировать внутри блока try.
6.9. Техника 9. Используем декларативный подход и средства функционального программирования для обозначения, а не сокрытия сути происходящего.
Данный принцип может быть проиллюстрирован примером из предыдущего пункта, в котором мы использовали эмуляцию функционального подхода в Java с целью сделать наш код понятнее.
Для большей наглядности рассмотрим еще и пример на Javascript (взято отсюда: http://habrahabr.ru/post/154105).
Ну а примеры использования функционального подхода, убивающие читаемость… Давайте на этот раз сэкономим свои нервы и обойдемся без них.
6.10. Техника 10. Пишем комментарии только, если без них вообще не понятно, что происходит.
Также как разновидность подобного компромисса следует расценивать необходимость спецификации контракта метода/класса/процедуры при помощи комментариев, если его не получается явно выразить другими языковыми средствами (см п. 5.2).
7. Философское заключение.
Закончив изложение основных техник и приемов, с помощью которых можно сделать код чуть красивее, следует еще раз явно сформулировать: ни одна из техник не является безусловным руководством к действию. Любая техника — лишь возможное средство достижения той или иной цели.
В нашем случае целью является получение максимально читаемого и управляемого кода. Который одновременно будет приятен с эстетической точки зрения.
Изучая примеры, приведенные в текущей статье, можно легко заметить, что, как сами исходные принципы (линейность, минимальность, самодокументированность), так и конкретные техники не являются независимыми друг от друга. Применяя одну технику мы можем также косвенно следовать и совсем другой. Улучшая один из целевых показателей, мы можем способствовать улучшению других.
Однако, у данного явления есть и обратная сторона: нередки ситуации, когда принципы вступают в прямое противоречие друг с другом, а также со множеством других возможных правил и догм программирования (например с принципами ООП). Воспринимать это надо совершенно спокойно.
Встречаются и такие случаи, когда однозначно хорошего решения той или иной проблемы вообще не существует, или это решение по ряду причин нереализуемо (например, заказчик не хочет принимать потенциально опасные изменения в коде, даже если они способствуют общему улучшению качества).
В программировании, как и в большинстве других областей человеческой деятельности, не может существовать универсальных инструкций к исполнению. Каждая ситуация требует отдельного рассмотрения и анализа, а решение должно приниматься исходя из понимания особенностей ситуации.
В целом, этот факт нисколько не отменяет полезности как вывода и понимания общих принципов, так и владения конкретными способами их воплощения в жизнь. Именно поэтому я надеюсь, что все изложенное в статье может оказаться действительно полезно для многих программистов.
Ну и еще пара слов о том, с чего мы начинали — о красоте нашего кода. Даже зная о “продвинутых” техниках написания красивого кода, не стоит пренебрегать самыми простыми вещами, такими, как элементарное автоформатирование и следование установленному в проекте стилю кодирования. Зачастую одно лишь форматирование способно сотворить с уродливым куском кода настоящие чудеса. Как и разумное группирование участков кода с помощью пустых строк и переносов.
Не стоит забывать и о таком средстве, как статические анализаторы кода, которые позволяют выявить множество проблем (в том числе, описанных в данной статье) автоматически, благо сейчас они встроены в большинство популярных IDE.
И самое последнее. Всегда стоит помнить о субъективности эстетического восприятия вещей. Поэтому уделяя внимание этому вопросу, следует с пониманием относиться к чужим вкусам и привычкам.