Как стать автором
Обновить

Человекочитаемый код

Уровень сложностиПростой
Время на прочтение10 мин
Количество просмотров11K

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

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

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

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

Простые слова понять легко. 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, чтобы вы могли включать их одновременно. Вы ожидаете, что электрик оценит потребление нового прибора и сразу заменит вам автомат.

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

Теги:
Хабы:
Всего голосов 18: ↑16 и ↓2+21
Комментарии23

Публикации

Истории

Ближайшие события

27 августа – 7 октября
Премия digital-кейсов «Проксима»
МоскваОнлайн
28 сентября – 5 октября
О! Хакатон
Онлайн
3 – 18 октября
Kokoc Hackathon 2024
Онлайн
10 – 11 октября
HR IT & Team Lead конференция «Битва за IT-таланты»
МоскваОнлайн
25 октября
Конференция по росту продуктов EGC’24
МоскваОнлайн
7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн