Как написать чистый код, который легко читать

Людям свойственно забывать ранее написанный текст. А к программированию и коду это относится в первую очередь.

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

  • выбор названий переменных, классов и функций;
  • функции помощника;
  • кодовые комментарии;
  • перечисления, словари, запечатанные классы и пр.;
  • формирование пакетов и наименование.

Насколько полезны сокращения?

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

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

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

Далеко не всегда сокращенный код экономит время. К тому же в современных IDE для этого есть полезная функция автозавершения кода.

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


Теперь рассмотрим методики упрощения кода. Примеры будут даны на Kotlin. Но их смысл применим к большинству платформ и языков.

Как выбирать имена классов, переменных и функций?

Я рассматриваю две важные особенности при выборе имен объектов программного обеспечения (ПО), которые включают:

  • классы, структуры, объекты;
  • переменные, значения, ссылки, указатели;
  • функции, методы, алгоритмы, команды;
  • интерфейсы, протоколы, абстракции.

По сути, это все, что использует программист.

Насколько описательным должно быть имя?

Присваиваемые объектам ПО имена должны выражать их предназначение, но без лишних подробностей.

Детали процесса обычно не нужны. Важен контекст объекта, особенно на уровне функций и переменных. Степень детализации зависит от конкретного контекста.

Рассмотрим три примера:

  1. getFormattedDate(date: String) : String;
  2. getYYYYMMDDFormattedDate(date: String) : String;
  3. getYYYYMMDDFormattedDateFromIso8601Format(date: String) : String.

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

В этом контексте я всегда использую что-то подобное примеру 3. Пример 1 очень неоднозначен для этого случая.

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

Добавляйте необходимую информацию, чтобы устранять любую двусмысленность.

Пример 1 подойдет для одноразовой программы, которая единожды преобразует один формат в другой. Добавления излишней информации следует избегать.

Расширенная функциональность усложняет именование

Чаще всего (хотя и не всегда) проблемы с тем, чтобы выбрать имя, возникают для объектов ПО с расширенными и несвязанными концептуально возможностями.

Степень концептуальной взаимосвязи объектов ПО называется связностью (cohesion).

Степень связности объектов ПО должна сообщать о возможностях их группировки и разделения.

Рассмотрим на примере этот процесс с разных точек зрения. Предположим, есть четыре объекта ПО:

  1. StoreUserInCloud;
  2. StoreUserOnDisk;
  3. StoreMessage;
  4. EditUserUI.

Во первых, эти объекты связаны с данными реального мира. С этой точки зрения мы видим, что StoreUserInCloud, StoreUserOnDisk, а также EditUserUI используют одинаковую модель информации: User.

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

  • пользовательский интерфейс (обычно “View”);
  • логика (обычно относится к таким объектам, как контроллеры и представление);
  • модель (хранилище данных и доступ к нему или само состояние в зависимости от вашего определения).

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


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

Как использовать вспомогательные функции

Вспомогательные функции (helper functions), особенно в сочетании с подходящими для них именами, могут значительно улучшить читаемость кода. Эти функции также дают возможность применять основной принцип архитектуры ПО  —  разделение ответственностей (separation of concerns).

Создаем головоломку Sudoku

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

Ранее я работал над большой, но взаимосвязанной частью программы: конструктор Sudoku , который использует графовые структуры данных и алгоритмы (Graph data structures and algorithms, DSA). Даже если вы не знакомы с Sudoku или DSA, полагаю, сможете в целом прочитать код.

Полный исходный код здесь.

Процесс создания игры-головоломки Sudoku можно разбить на пять шагов.

  • Создание узлов (nodes) головоломки, представленных плитками (tiles).
  • Создание ребер (edges) головоломки. В данном случае edges  —  это синоним для взаимосвязей между плитками: строка, столбец и подгруппа.
  • Заполнение (добавление) некоторых значений в структуре, чтобы упростить решение.
  • Решение головоломки.
  • Раскрытие определенного количества плиток для помощи пользователю.

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

internal fun buildNewSudoku(
boundary: Int,
difficulty: Difficulty
): SudokuPuzzle = buildNodes(boundary, difficulty)
.buildEdges()
.seedColors()
.solve()
.unsolve()

Хотя идея “Nodes” и “Edges” является техническим определением в теории графов, этот код четко отражает пять выбранных шагов.

Не рассматривая кодовую базу полностью, хочу отметить, как вспомогательные функции способствуют удобочитаемости вопреки логике:

internal fun SudokuPuzzle.buildEdges(): SudokuPuzzle {
this.graph.forEach {
val x = it.value.first.x
val y = it.value.first.y

it.value.mergeWithoutRepeats(
getNodesByColumn(this.graph, x)
)

it.value.mergeWithoutRepeats(
getNodesByRow(this.graph, y)
)

it.value.mergeWithoutRepeats(
getNodesBySubgrid(this.graph, x, y, boundary)
)

}
return this
}

internal fun LinkedList<SudokuNode>.mergeWithoutRepeats(new: List<SudokuNode>) {
val hashes: MutableList<Int> = this.map { it.hashCode() }.toMutableList()
new.forEach {
if (!hashes.contains(it.hashCode())) {
this.add(it)
hashes.add(it.hashCode())
}
}
}

internal fun getNodesByColumn(graph: LinkedHashMap<Int,
LinkedList<SudokuNode>>, x: Int): List<SudokuNode> {
val edgeList = mutableListOf<SudokuNode>()
graph.values.filter {
it.first.x == x
}.forEach {
edgeList.add(it.first)
}
return edgeList
}
//...

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

  • Они заменяют фрагмент кода, который что-то делает.
  • Этому фрагменту кода можно дать описательное имя.

Решение вопроса о том, следует ли что-то хранить в функции или делегировать во вспомогательную функцию, определяется методом проб и ошибок.

Как использовать комментарии к коду

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

Как использовать комментарии при разработке новых функций

Сталкиваясь с функциями, которые мне сложно написать, я описываю их функциональность (что они делают) простым языком или псевдокодом.

Особенности описания со временем у меня менялись, поэтому ищите подходящие для вас методы. В предыдущих примерах я пропустил комментарии:

/**
* 1. Создание карты, содержащей n*n узлов.
* 2. для каждого соседнего узла (согласно правилам судоку) добавить Edge в хэш-набор
* - По столбцу
* - По ряду
* - По подгруппе размера n
*
* LinkedHashMap: я решил использовать LinkedHashMap, потому что он сохраняет порядок
* элементов, размещенных на карте, но также позволяет выполнять поиск по хеш-коду, который
* генерируется значениями x и y.
*
* Что касается LinkedList в каждом сегменте (элементе) карты, предположим, что первый элемент
* — это узел в hashCode(x, y), а последующие — это ребра этого элемента.
* Помимо упорядочения первого элемента в качестве заголовка LinkedList, остальные
* не обязательно упорядочивать каким-либо особым образом.
*
*
* */


internal fun buildNodes(n: Int, difficulty: Difficulty): SudokuPuzzle {
val newMap = LinkedHashMap<Int, LinkedList<SudokuNode>>()

(1..n).forEach { xIndex ->
(1..n).forEach { yIndex ->
val newNode = SudokuNode(
xIndex,
yIndex,
0
)
val newList = LinkedList<SudokuNode>()
newList.add(newNode)
newMap.put(
newNode.hashCode(),
newList
)
}
}
return SudokuPuzzle(n, difficulty, newMap)
}

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

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

Поклонники Test Driven Development могут попробовать записать шаги псевдокода алгоритма перед написанием теста:

/**
В процессе привязки, вызываемом представлением в onCreate. Проверить текущее состояние пользователя, записать этот результат в
* vModel, показать график загрузки, выполнить некоторую инициализацию
*
* а. Пользователь анонимный
* б. Пользователь зарегистрирован
*
* а:
* 1. Просмотр загрузки дисплея
* 2. Проверить наличие вошедшего в систему пользователя из auth: null
* 3. записать null в пользовательское состояние vModel
* 4. вызов при запуске процесса
*/
@Test
fun `On bind User anonymous`() = runBlocking {

//...
}

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

Как эффективно использовать построчные комментарии кода

Комментарии к строкам кода я чаще всего пишу в двух случаях.

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

Безусловно самый сложный алгоритм Sudoku в моей программе  —  solver algorithm (алгоритм решателя). Он на самом деле очень большой и здесь представлен лишь фрагмент:

internal fun SudokuPuzzle.solve()
: SudokuPuzzle {
//назначенные узлы (не включая узлы из seedColors()
val assignments = LinkedList<SudokuNode>()

///отслеживать неудачные попытки присваивания для наблюдения за бесконечными циклами
var assignmentAttempts = 0
//два этапа возврата, partial — это половина набора данных, full — полный перезапуск.
var partialBacktrack = false

var fullbacktrackCounter = 0

//от 0 - граница, показывающая насколько "придирчив" алгоритм к присвоению новых значений
var niceValue: Int = (boundary / 2)

//чтобы избежать слишком раннего упрощения
var niceCounter = 0

//работа с копией
var newGraph = LinkedHashMap(this.graph)
//все узлы со значением от of 0 (uncolored)
val uncoloredNodes = LinkedList<SudokuNode>()
newGraph.values.filter { it.first.color == 0 }.forEach { uncoloredNodes.add(it.first) }

while (uncoloredNodes.size > 0) {
//...
}
//...
}

При просмотре этого огромного алгоритма уже в середине я мог забывать использованные переменные.

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

Например, печально известный API Java Calendar использует индексацию месяцев с отсчетом от нуля. Вероятно, это глупо, я не знаю ни одного стандарта, в котором год начинается с нулевого месяца.

Не могу поделиться с вами кодом, так как он проприетарный. Но в текущей кодовой базе нашей команды везде есть комментарии для операторов random — 1.

Как использовать Enums & Dictionaries

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

Например, мне нужен был способ ограничить количество плиток, включенных в новую головоломку Sudoku, учитывая:

  • размер головоломки (4, 9 и 16 плиток на столбец/строку/подгруппу);
  • сложность головоломки (легкая, средняя, сложная).

После обширного тестирования я пришел к следующим значениям модификаторов:

enum class Difficulty(val modifier:Double) {
EASY(0.50),
MEDIUM(0.44),
HARD(0.38)
}

data class SudokuPuzzle(
val boundary: Int,
val difficulty: Difficulty,
val graph: LinkedHashMap<Int, LinkedList<SudokuNode>>
= buildNewSudoku(boundary, difficulty).graph,
var elapsedTime: Long = 0L
)//...

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

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

enum class SolvingStrategy {
BASIC,
ADVANCED,
UNSOLVABLE
}

internal fun determineDifficulty(
puzzle: SudokuPuzzle
): SolvingStrategy {
val basicSolve = isBasic(
puzzle
)
val advancedSolve = isAdvanced(
puzzle
)
//если головоломка становится не разрешима, возвращаем текущую стратегию
if (basicSolve) return SolvingStrategy.BASIC
else if (advancedSolve) return SolvingStrategy.ADVANCED
else {
puzzle.print()
return SolvingStrategy.UNSOLVABLE
}
}

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

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

Как организовать и именовать пакеты, папки и каталоги?

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

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

Два основных подхода к организации пакетов:

  • пакет как архитектурный слой;
  • пакет как функция.

Пакет как слой

Пакет как слой  —  первая и наименее удачная система из использованных мной. Идея обычно основана на следовании некоторым архитектурным паттернам, таким как MVC, MVP, MVVM и т.д.

Если взять MVC в качестве примера, структура пакета верхнего уровня будет выглядеть следующим образом:

  • модель;
  • вид;
  • контроллер.

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

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

Этот подход обычно можно улучшить, добавив “слоев” для специфики:

  • UI;
  • модель;
  • API;
  • хранилище;
  • домен;
  • общий.

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

Пакет как функция

Пакет как функция имеет свои недостатки, но его, как правило, проще просматривать и читать. Это опять же при условии, что вы даете пакетам хорошие имена.

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

Для приложения-соцсети структура может быть такой:

  • таймлайн;
  • друзья;
  • профиль пользователя;
  • сообщения;
  • детали сообщения.

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

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

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

Однако обычно я рекомендуемое второе решение.

Реализуем структуру гибридного пакета

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

  • таймлайн;
  • друзья;
  • сообщения;
     — все сообщения;
     — разговор;
     — детали сообщения.
  • API;
     — график;
     — пользователь;
     — сообщение.
  • компоненты пользовательского интерфейса.

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

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

Заключение

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

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

Читайте также:

Читайте нас в TelegramVK и Дзен


Перевод статьи Ryan Michael Kay: Clear Code: How To Write Code That Is Easy To Read

Предыдущая статьяКак ускорить отклик и повысить производительность при помощи кэширования Redis
Следующая статья5 модулей Python для исследования Вселенной