You Gonna Hate This или сказ о том, как должен выглядеть хороший код

Сколько копий поломано и будет еще поломано в поисках идеального кода. Судя по всему пришло время и мне в этом поучаствовать :)

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


Код должен работать

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

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

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

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

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

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

И тут есть важный момент. Я бы хотел, чтобы вы поняли. Это не призыв писать плохой код. Код должен быть хороший. Это призыв к First Thing First — сначала код работает, потом рефакторится.

Теперь поговорим про Shit Happens. Итак, код у нас есть, он даже работает. Вернее, «работает». Давайте посмотрим на простой пример:

public string Do(int x)
{
    using (WebClient xx = new WebClient())
    {
        return xx.DownloadString("https://some.super.url");
    }
}

Это замечательный пример «работающего» кода. Почему? Потому, что он не учитывает, что рано или поздно, наш endpoint отвалиться. Этот пример не учитывает так называемые edge case — пограничные, «плохие случаи». Когда вы начинаете писать код, задумайтесь о том, что может пойти не так. На самом деле я сейчас говорю не только об удаленных вызовах, а обо всех ресурсах, которые находятся вне зоны вашего контроля — пользовательский ввод, файлы, сетевые соединения, даже база данных. Все что может сломаться, сломается в самый неподходящий момент и единственное что вы можете с этим сделать — быть к этому готовым настолько, насколько это возможно.

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

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

Итого:


  • Сначала заставьте код работать,
  • Потом сделайте его хорошим,
  • Не забывайте про edge cases и обработку ошибок.


Теперь поговорим о поддержке кода

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

Кто пишет комментарии на русском? Никто не пишет? Замечательно. В общем одна из проблем — это код на не английском языке. Не надо так. У меня был кусок кода с классами на Норвежском языке, и я просто не мог выговорить их названия. Это было печально. Очевидно, что поддержка такого кода (для не норвежцев) будет не тривиальной задачей. Но такое бывает редко.

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

public string Do(int x)
{
    using (WebClient xx = new WebClient())
    {
        return xx.DownloadString("https://some.super.url");
    }
}

Вы можете понять, что делает метод Do не смотря в реализацию? Вряд ли. Аналогично с названиями переменных. Для того, чтобы понять, что за объект xx нужно искать его объявление. Это отнимает наше время, мешает пониманию того, что, в общих чертах происходит в коде. Поэтому имена должны отражать суть действия или значения. Например, если переименовать метод Do в GetUserName код станет немного понятнее и в ряде случаев нам уже не придется смотреть в его реализацию. Аналогично с названиями переменных в виде x и xx. Правда есть общепринятые исключения в виде e для ошибок, i, k — для счетчиков циклов, n — для размерностей и несколько еще.

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

Вообще, есть такая интересная цитата:


«There are only two hard things in Computer Science: cache invalidation and naming things.» © Phil Karlton

Есть только две сложные вещи в Computer Science: инвалидация кеша и именования.

Вспоминайте ее, когда будете давать имена своим сущностям.

Вторая составляющая читабельного кода это его сложность или структура. Я говорю про тех, кто любит писать шесть вложенных ифов, или вписывать колбек в колбек колбека внутри колбека. В JavaScript есть даже такой термин — Callback Hell.

Говорить про идеальный код легко, а вот писать его - немного сложнее. Самое важное здесь хотя бы не врать самому себе. Если твой код плохой - не надо называть его конфеткой, а брать и доделывать

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


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

Теперь поговорим о еще одной сложной вещи — о том, что хороший код легко менять. Кому знаком термин Big ball of mud? Если кому-то не знаком — посмотрите на картинку.

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

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

В виде советов я бы сформулировал это так:


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

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

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

Итого:


  • Правильное именование,
  • Хорошая структура,
  • Единообразие.


Код должен быть достаточно производительным

Давайте немного похоливарим. Следующее требование которые мы рассмотрим — код должен быть достаточно производительным.

Что я имею ввиду под словом «достаточно»? Наверное, все слышали, что преждевременные оптимизации — это зло, они убивают читабельность и усложняют код. Это правда. Но также правда и то, что вы должны знать свой инструмент, и не писать на нем так, чтобы веб клиент почты загружал Сore I7 на 60%. Вы должны знать типичные проблемы, которые приводят к проблемам с производительностью и избегать их еще на этапе написания кода.

Давайте снова вернемся к нашему примеру:

public string GetUserName(int userId)
{
    using (WebClient http = new WebClient())
    {
        return http.DownloadString("https://some.super.url");
    }
}

У этого кода есть одна проблема — синхронное выполнение загрузки по сети. Это I/O операция которая, заморозит наш поток до момента своего выполнения. В десктопных приложениях это приведет к повисшему интерфейсу, а в серверных, к бесполезной резервации памяти и исчерпания количества запросов к серверу. Просто зная подобные проблемы, вы уже можете писать более оптимизированный код. И в большинстве случаев этого будет достаточно.

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


А теперь поговорим за тесты

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

Зачем нам вообще нужен Code Coverage и тесты? В идеальном мире — не нужны. В идеальном мире код пишут без багов, а требования никогда не меняются. Но мы живем в далеко не идеальном мире, поэтому тесты нам нужны для того, чтобы быть уверенными что код работает правильно (там нет багов) и что код работает правильно после того как что-то поменяли. Это та выгода, которую нам приносят тесты. С другой стороны, даже 100% (в силу специфики расчета метрик) покрыте тестами не гарантирует что вы покрыли абсолютно все. Более того, каждый дополнительный тест еще замедляет разработку так как после изменения фунционала вам придётся еще и обновлять тесты. Поэтому количество тестов должно быть разумным и основная сложность как раз и состоит в том, чтобы найти компромисс между количеством кода и стабильностью системы. Найти эту грань достаточно сложно и универсального рецепта как это сделать нет. Но есть несколько советов, которые вам, возможно помогут это сделать.


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

Не стоит покрывать без большой необходимости


  • Чужие библиотеки — ищите библиотеки, код которых уже покрыт тестами.
  • Инфраструктуру — DI, автомаппинг (если там нет сложного маппинга) и так далее. Для этого есть e2e или интеграционные тесты.
  • Тривиальные вещи — присвоение данных полям, проброс вызовов, и так далее. Почти наверняка вы найдете гораздо более полезные места что бы покрыть их тестами.

Ну вот в общем то и все.


Подведем итоги. Хороший код это -


  • Работающий код
  • Который легко читать,
  • Легко менять,
  • Достаточно быстр,
  • И покрыт тестами в нужном количестве.

Удачи вам в этом нелегком пути. И скорее всего, your gonna hate this. Ну, а если все же нет, — добро пожаловать.

На самом деле это удивительный мир открытий, возможности и творчества. Вот только по соседству тут сидит скука формошлепства, уныние 20+ year of legacy code, и поле из костылей для горящих сроков. Так что я бы скорее назвал это проходом по волосяному мосту (С). Ищите проекты, которыми вы будете гореть. Старайтесь сделать что-то хорошее. Да просто, делайте мир лучше и все будет отлично.

© Habrahabr.ru