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

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

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

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

Простые слова понять легко. Code-golf оставь для leetcode

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

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

Примеры

m = n = 3

# тяжеловато
for i in range(m*n):
    print(i//n, i%n)

# проще
for i in range(m):
    for j in range(n):
        print(i, j)
matrix = [
       [0, 0, 0],
       [1, 1, 1],
       [2, 2, 2],
]

# тяжеловато
flat = [num for row in matrix for num in row]

# проще, хоть и длиннее
flat = []
for row in matrix:
   for num in row:
        flat.append(num)

Единый стиль важнее авторского почерка

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

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

Мы пишем много кода в команде, зачастую в одних и тех же модулях, в одних и тех же функциях. Перед тем как начать писать что‑то новое стоит не полениться и изучить, а в каком стиле написан окружающий код. И я говорю не только про отступы и camel_case: какие термины используются, как принято называть функции, какие паттерны наиболее часто используются. Если в проекте много Mixin‑ов, я использую их вместо делегирования, хоть и терпеть их не могу. Если проект придерживается анемичной модели со множеством value‑объектов, то не стоит создавать развесистый rich‑объект.

Программирование — это не только свобода творчества, но и гибкость и умение владеть различными видами «кунг‑фу».

Примеры

# один стиль создания словарей
a = {x: x*2 for x in range(20)}

# другой стиль, который не сочетается в одном модуле с предыдущим стилем
b = {}
for i in range(10):
    b[i] = i*3
# получим ка скидку для покупки
discount = get_discount(purchase)

# в соседней функции делаем то же самое, 
# но почему-то покупку называем p, а скидку бонусом,
# хотя стоило бы придерживаться одной терминологии
bonus = get_discount(p)

Чем меньше контекста в голове, тем понятнее

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

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

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

Пример

# примерно так обычно выглядит плохо читаемая функция, создающия отчет
x = {}
y = {}
z = []
for day in get_days_in_month:
   z.append[0.0]

# получаем параметры месячного отчета
for ... 
... тут строк 50 кода, заполняющих x

# получаем покупки за месяц
while ...
... тут еще стро 50 кода, заполняющих y

for item, val in x:
    for purchase, price in y:
... тут заполняем z

#======================================================================
# а так она может выглядеть, есть разбить ее на подфункции
z = []
for day in get_days_in_month:
   z.append[0.0]

# переменные появляются только в момент их использования
month_params = get_report_params(month)
month_purchases = get_month_purchases(month)
for item, val in month_params:
    for purchase, price in month_purchases:
        . . . тут заполняем z

Один объект - один ответ на вопрос: "что делает?"

Почему интересно читать детективы? Потому что кроме запутанного сюжета там присутствуют неоднозначные персонажи. До конца книги не всегда понятно: положительный или отрицательный герой, глупый или хитрый. Мы читаем и пытаемся разгадать и сюжет, и героев.

Но код — не детектив. Надо затратить минимум усилий на понимание сюжета. Если вспомнить детские сказки, то там все понятно: волк — злой, лиса — хитрая, заяц — трусливый. Хороший код — это сказка, и в этом смысле тоже.

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

Если объект и ходит в базу, и агрегирует данные, и формирует отчеты, то это — Богообъект. Стоит разгрузить его, отдать работу объектам: репозиторию, агрегатору и компилятору отчета. А объект, раз он был нам так дорог и могущественен, пусть учится делегировать свою работу другим, а сам только координирует их работу.

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

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

Пример

# и швец, и жнец, и на дуде игрец, и в хоре певец, и в бою молодец
class Processer:
    def calculate_price(params):
        ...

    def get_discount(user, price):
        ...

    def process_purchase(user, content):
        ...

#=========================================================
# но лучше пусть каждый отвечает за свою небольшую работу
class Price:
    def __init__(content, discount):
        ...

class Discount:
    def __init__(content, user):
        ...

class Account:
    def make_purchase(content):
        ...

Название должно отвечать на вопрос: что делает, а не как

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

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

Пример

# какая-то таблица. И только создатель знает, 
# что она используется для матчинга объектов
MERGE_TABLE = [
	{... rule1 ... },
	{... rule2 ... },
	{... rule3 ... },
]

def rule_matcher(objects: List) -> List:
    . . .

#===================================================
# теперь намного понятнее, что это правила группировки наших объектов
OBJECTS_GROUPING_RULES = [
	{... rule1 ... },
	{... rule2 ... },
	{... rule3 ... },
]

def group_objects(objects: List) -> List:
    . . .

Если ружье висит на стене, то оно должно выстрелить. Удали бесполезный код!

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

Например, можно придумать красивую структуру REST клиента, которая поддерживает вызы разных версий API, а методы автоматически создают request по swagger файлу. Но если вы всегда используете одну версию API и обновления в swagger файлы не приходят к вам раз в месяц, то поддерживать и понимать код, который используется для запроса будет отнимать больше сил, чем решать потенциальные изменения в работе с API.

Пример

# очень красивая архитектура, 
# где каждому URL-у клиентского API заготовлен отдельный класс
class ApiMethod:
    def __init__(swagger_file, method_name):
        ...
    def send(params):
        ... 

# и даже о версионировании мы подумали
class ApiVersion:
    def __init__(version, methods):
        ...

class PartnerClient:
    def __init__(versions):
        ...

    def send(version, method):
        ...

# ====================================================
# но прямо сейчас там всего два метода и никакое версионирование не планируется
# намного более понятен просто класс для клиентского API
class Client:
    def __init__(credentials):
        ...

    def get_purchases(params):
        ...

    def create_account(params):
        ...

Если не наследуешь API, делегируй!

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

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

Допустим, класс Покупка имеет метод «применить бонус», класс Скидка тоже имеет метод «применить бонус». Зачем писать код два раза. Давайте отнаследуем Скидку от Покупки. Но Скидка не является покупкой, эти объекты в бизнес модели имеют совершенно разное поведение и ответственность. Возможно, здесь стоит сделать mixin с методом «применить бонус», а быть может, вообще делегировать применение бонуса в отдельный класс и передавать ему объект типа «Цена».

Еще одно правило для наследования — это наследование API. API — в смысле набора публичных методов и их аргументов у объекта. Кто пишет на pycharm мог заметить, что когда в наследуемом объекте меняешь объявление __init__ метода, то он ругается, что это не соответствует API родителя. И это правило должно соблюдаться не только для __init__. Если хотите изменить набор аргументов в наследуемом методе, то стоит подумать над новым методом, то есть сделать не изменение, а расширение. А быть может, будет удобнее вообще сделать делегирование.

Пример

class Cache:
    def get_object(self, key):
        …

# Нужен объект, который умеет вытаскивать данные из кеша.
# Ну раз он будет ходить в кеш, то значит это кеш?
class FilteringCache(Cache):
    def get_object(self, key, filter):
        …

# ===================================================

class Cache:
    def get_object(self, key):
        …

# Нет, это не значит, что мы можем считать его кешом.
# Но мы можем считать, что он использует объект Кеш 
# и делегирует ему работу с кешом, а сам только фильтрует
class FilteringCache:
    _cache = Cache()

    def get_object(self, key, filter):
        return filter(
            self._cache.get_object(key)
        )

Чисто там, где не мусорят

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

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

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

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

Комментарии (23)


  1. markmariner
    20.07.2023 10:06
    +4

    Альберт Эйнштейн не говорил того, что написано на картинке, по крайней мере об этом не осталось упоминаний.

    https://en.wikiquote.org/wiki/Albert_Einstein#Misattributed


    1. IvanPetrof
      20.07.2023 10:06

      Пользуясь случаем, хочу спросить. Только я не вижу в этой статье картинки?

      Мобильный хром.


      1. markmariner
        20.07.2023 10:06

        А она в списке статей есть, а когда открываешь саму статью -- уже нет.


        1. IvanPetrof
          20.07.2023 10:06

          Причём, если включен мобильный режим браузера, то ты картинку не видишь ни в ленте ни в статье. А если "режим пк", то видно только в ленте.

          есть и друге статьи, где такой же эффект, а есть, в которых картинка не пропадает. Закономерности пока не понял.


          1. 1dNDN
            20.07.2023 10:06
            +2

            Автор отдельно пишет кат, отдельно статью.
            Если картинку он вставил только в кат, то она будет видна только там


  1. z250227725
    20.07.2023 10:06
    +3

    И всё равно, когда другой программист начнет работать с вашим кодом, он спросит "Какой болван это написал?" :)

    А если серьезно - красиво написано. Про удаление ненужного когда я аж всгрустнул - у нас всё комментируют. Но на практике всегда возникают вопросы.

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

    Короткие функции по 20 строк - здорово. Только теперь нужно скакать между функциями и держать несколько контекстов в голове.


    1. brakerkir Автор
      20.07.2023 10:06
      +3

      Обычно смотрят и берут первый попавшийся образец, а потом оказывается, что это был неудачный пример.

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

      Короткие функции по 20 строк - здорово. Только теперь нужно скакать между функциями и держать несколько контекстов в голове.

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


      1. shornikov
        20.07.2023 10:06

        был немного душнилой :)

        Сам то я за-за. Но почитаешь про современный мир и кажется что за "а почему у тебя CamelCase?" можно вылететь с работы. )


  1. tri_tuza_v_karmane
    20.07.2023 10:06

    Не могу не оставить это здесь:

    https://habr.com/ru/articles/743114/


    1. brakerkir Автор
      20.07.2023 10:06
      +4

      Читал, читал. Не согласен, не согласен :)

      Но эти две статьи точно должны быть рядом. Спасибо, что принесли эту ссылку.


  1. slonopotamus
    20.07.2023 10:06
    -3

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

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


    1. mvv-rus
      20.07.2023 10:06
      +1

      Ну почему же? Template method ("шаблонный метод") — это вполне себе паттерн, один из самых древних, кстати.
      И использует он именно наследование, и именно чтобы не дублировать большую часть кода: она пишется один раз в корневом для иерархии классе.


      1. slonopotamus
        20.07.2023 10:06

        1. mvv-rus
          20.07.2023 10:06
          +1

          Так все-таки, как там насчет Template method — это тоже «антипаттерн»? Если да, то почему?
          Нет, я, конечно знаю, что для реализции специфического поведения, в принципе, вместо наследования и Template method часто можно использовать композицию и Strategy. Однако бывают случаи, когда наследлование+Template method явно лучше: когда для реализации специфического поведения требуются внутренняяя информация и вспомогательные, совершенно не нужные для внешних потребителей, внутренние методы.
          При наследовании такие компоненты базового класса просто помечаются как доступные только наследникам (общепринятое ключевое слово — protected). А при композиции их нужно либо выводить на общедоступный интерфейс (public), нарушая тем самым инкапсуляцию, либо вводить понятие «дружественных» классов для доступа к ним (что в ряде языков — C# например — реализуется весьма неудобно). И вот в таких случаях наследование использовать предпочтительнее.
          PS А вот интерфейсы (первая ваша ссылка) — это действительно полезное добавление к классическому ООП. Впрочем, в свое время в C++, куда их тогда не завезли, я успешно обходился вместо них абстрактными виртуальными классами, просто не используя излишнюю из функциональность.
          PPS Я бы предпочел, чтобы вы свое мнение своими словами аргументировали, а не ссылками на тексты ваших авторитетов: мнение авторитета не всегда является истинным, особенно — для тех, кто не является его последователем.


      1. michael_v89
        20.07.2023 10:06

        Технически эти ситуации похожи, но логически разные. Отличие сложно описать словами, но обычно оно связано с вопросами "Как это код будет меняться в будущем? Действительно ли нам надо, чтобы при изменении в этом методе поведение менялось всегда для всех наследников? Могут ли эти 2 класса меняться независимо? Может ли один класс существовать без другого?".


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


        1. mvv-rus
          20.07.2023 10:06

          Я не утверждал, что наследование — это (почти) всегда правильное решение. Я опровергал утверждение оппонента, что наследование — это (почти) всегда неправильное решение (именно так я понимаю жаргонное слово «антипаттерн»).


  1. gluck59
    20.07.2023 10:06

    Читал такое у дядюшки Боба, но ладно... Эйнштейн так Эйнштейн.

    В проекте где я сейчас работаю, тимлид настаивает на соблюдении стиля write-only: один раз написал а дальше трава не расти. Поэтому 1000-строчные и 2000-строчные файлы, где серверный язык вперемешку с запросами к БД щедро сдобрен джаваскриптом и html, считаются единственно правильным стилем, а за новый модуль из локальных модели, контроллера и вьюхи меня даже как-то наказали.

    Даже отбить пробелами проверку и присвоение считается чем-то плохим, а типичные имена функций и переменных выглядят так:

    while (r=ab(q)) {
    ...200 строк кода без отступов и пробелов...
    }
    

    но тимлид непреклонен: "вот есть стиль и ты должен!"

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


    1. mvv-rus
      20.07.2023 10:06
      +1

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


      1. gluck59
        20.07.2023 10:06

        Затем что код пишется один раз и для компилятора, а поддерживается годами и человеком?


        1. mvv-rus
          20.07.2023 10:06

          Дык, пусть тимлид его и поддерживает дальше — сам или наймет на ваше место настоящего программиста (который не боится труднойстей, как в той статье описано). А вы будете писать красивый чистый код где-нибудь в другом месте. Что вас в таком варианте не устраивает?
          PS Кстати, вы напрасно думаете, что этот код — он обязательно write-only. При прокачке соответсвующего навыка он вполне читаем: я, например, за свою жизнь такой код читал неоднократно. И можете, кстати, рассматривать свою нынешнюю работу как прокачку этого навыка: он по жизни время от времени пригождается.


          1. gluck59
            20.07.2023 10:06

            Сорри, но ответ на этот вопрос может выйти далеко за рамки этой статьи.


            1. mvv-rus
              20.07.2023 10:06

              Ну да, тут, к примеру, недавно была статья про IT-профсоюз — и это скорее к ее теме.


    1. brakerkir Автор
      20.07.2023 10:06

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

      Если подход тимлида устраивает бизнес: скорость разработки, качество проекта, прогнозируемость сроков - то его политика вполне оправдана. Здесь надо для себя просто решать, готов ли ты работать так, как тут заведено? Можно искать плюсы не в красивом коде, а в каких-то других показателях, например, пользы от твоего быстрого говнокода для бизнеса. А то что код пишешь в помойку, ну просто смириться, что твой код - это расходный материал в этом проекте.

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