[Перевод] Чистый код: как писать код, который легко читать
Для начала мы обсудим, зачем вам вообще может понадобиться писать более читаемый, а не краткий код. Затем мы рассмотрим стратегии, как это делать в случае:
- Именования переменных, классов и функций
- Вспомогательных функций
- Комментариев к коду
- Перечислений/словарей/запечатанных классов и так далее
- Упорядочивания и именования пакетов
Вспоминаю, как, будучи джуном, я думал, что более эффективно использовать аббревиатуры имён для идентификаторов (по сути, любых конструкций в коде, которым мы, разработчики, можем давать имена).
Моя логика была проста: если у меня это занимает меньше времени, то так я быстрее справлюсь с работой.
Эта логика имела бы смысл, если бы были истинными следующие условия:
- Мне или кому-то другому никогда не придётся читать или исправлять то, что я написал в прошлом
- Я нечасто забываю при чтении функции, в чём смысл одной или нескольких переменных
- Мне время от времени не приходится писать действительно сложный и запутанный код
- Я могу переименовывать функции, классы или свойства внешних библиотек с непонятными или нелогичными названиями во что-то более разумное
Смысл в том, что я очень редко бывал в ситуациях, когда краткость действительно экономила время. Более того, современные IDE имеют полезную функцию под названием «автодополнение кода», которая и так позволяет существенно меньше нажимать на клавиши.
У вас может быть другое мнение, и это абсолютно нормально! Можете взять из этой статьи только самое полезное для вас, а об остальном забыть.
Теперь я поделюсь тем, что упрощает чтение моего кода для меня и других. В примерах кода я буду использовать Kotlin, однако приведённые рассуждения применимы к большинству платформ и языков.
Чтобы понять, как называть программные сущности (software entity), я учитываю два важных аспекта. Понятие «программные сущности» относится к одной из следующих концепций:
- Классы, структуры, объекты
- Переменные, значения, ссылки, указатели
- Функции, методы, алгоритмы, команды
- Интерфейсы, протоколы, абстракции
По сути, это всё, чему программист самостоятельно выбирает имена.
Насколько описательным должно быть имя
Моя цель при именовании программных сущностей такова: имя должно уменьшать любые разночтения о том, что делает программная сущность или чем она является.
Подробности о том, как она что-то делает, обычно не нужны.
Важен контекст (всё окружающее) программной сущности, особенно на уровне функций и переменных (и тому подобного). Что-то в зависимости от контекстм может потребовать больше или меньше подробностей.
Давайте рассмотрим три примера:
getFormattedDate(date: String) : String
getYYYYMMDDFormattedDate(date: String) : String
getYYYYMMDDFormattedDateFromIso8601Format(date: String) : String
Приложение для продакшена, над которым я сейчас работаю (на момент написания статьи), часто требует преобразовывать даты между разными форматами.
В этом контексте я точно буду использовать что-то вроде третьего примера, потому что первый пример слишком неоднозначен для требований нашего проекта.
Можно выбрать и другой вариант: изменить имя параметра во втором примере на что-то типа iso8601Date
. Хотя я рекомендую в своей кодовой базе обеспечивать согласованность подходов, вы можете поэкспериментировать и подобрать то, что подходит вам.
Смысл в том, чтобы добавить максимальный объём необходимой информации для устранения любых двусмысленностей.
Если бы я писал простенькую программу, которая всего лишь преобразует один формат в другой, то первый пример вполне бы подошёл. Я не говорю, что нужно добавлять больше информации, чем это необходимо.
Чем больше делает код, тем сложнее дать ему имя
Если у вас возникли сложности с подбором имени, чаще всего (но не всегда) это вызвано тем, что код делает слишком много действий, которые концептуально не связаны друг с другом.
Степень концептуальной связи программных сущностей называется связностью (cohesion).
Степень связности или несвязности программных сущностей должна давать вам понять, как они должны группироваться или разделяться.
Этот процесс можно выполнять с разных точек зрения, которые я постараюсь объяснить на примере.
Рассмотрим четыре программные сущности:
StoreUserInCloud
StoreUserOnDisk
StoreMessage
EditUserUI
Первая точка зрения: мы можем учитывать информацию реального мира, которой касаются эти сущности. С этой точки зрения, мы видим, что StoreUserInCloud
, StoreUserOnDisk
и EditUserUI
используют одинаковую модель информации: пользователя (User).
Однако существует и другая точка зрения, которую мы должны учитывать, особенно при проектировании программ с графическим интерфейсом пользователя (GUI). Каждая программа с GUI может быть разбита на три принципиальных слоя:
- Интерфейс пользователя (обычно называющийся «View»)
- Логика (обычно она относится к таким элементам, как Controller и Presenter)
- Модель (хранение данных и доступ к ним или само состояние, в зависимости от определения)
Имейте в виду, что эта трёхуровневая концепция является обобщением, которой часто бывает недостаточно. Как бы то ни было, с этой точки зрения, StoreMessage
имеет больше общего с другими сущностями хранения, чем EditUserUI
.
Какой бы способ разделения вы ни выбрали, вы поймёте, что он работает, если подбор имён для программных сущностей упростится.
Вспомогательные функции, особенно дополненные хорошими практиками именования функций, способны существенно повысить читаемость кода. Кроме того, вспомогательные функции позволяют воспользоваться базовым принципом архитектуры ПО: разделением ответственности.
Как создавать головоломки судоку
Мы рассмотрим практический пример, чтобы продемонстрировать активное использование вспомогательных функций. Представьте, насколько сложнее был бы код, если бы всё это оказалось одной гигантской функцией!
В прошлом я работал над большой, но связной частью программы: генератором судоку, использующим структуры данных и алгоритмы графов. Даже если вы не знакомы с судоку и графами, то всё равно разберётесь с основным принципом.
Полный исходный код можно найти здесь.
Мы можем разбить процесс генерации играбельной головоломки судоку на пять этапов:
- Создание узлов головоломки (обозначающих клетки)
- Создание рёбер головоломки (в данном случае под рёбрами подразумеваются взаимоотношения между клетками: строками, столбцами или подсетками)
- Порождение (добавление) значений в структуру, чтобы ускорить её решение
- Решение головоломки
- Процесс, обратный решению головоломки с сокрытием нескольких клеток, чтобы в игру можно было играть
В функции, которую я вызываю для создания головоломки, использовалось нечто похожее на шаблон проектирования Builder («Строитель»):
internal fun buildNewSudoku(
boundary: Int,
difficulty: Difficulty
): SudokuPuzzle = buildNodes(boundary, difficulty)
.buildEdges()
.seedColors()
.solve()
.unsolve()
Хотя концепции узлов (node) и рёбер (edge) являются строгими определениями в рамках теории графов, видно, этот код чётко отражает пять этапов, которые я выбрал.
Мы не будем рассматривать всю кодовую базу, я просто хочу подчеркнуть, как вспомогательные функции разбивают логику на части и повышают читаемость:
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.mergeWithoutRepeats(new: List) {
val hashes: MutableList = this.map { it.hashCode() }.toMutableList()
new.forEach {
if (!hashes.contains(it.hashCode())) {
this.add(it)
hashes.add(it.hashCode())
}
}
}
internal fun getNodesByColumn(graph: LinkedHashMap>, x: Int): List {
val edgeList = mutableListOf()
graph.values.filter {
it.first.x == x
}.forEach {
edgeList.add(it.first)
}
return edgeList
}
//...
Вспомогательные функции обеспечивают два преимущества:
- Они подменяют фрагмент кода, который что-то делает
- Этому фрагменту кода можно дать описательное имя
Чтобы решить, нужно ли оставить что-то в функции или перенести во вспомогательную функцию, требуется процесс проб и ошибок.
Лично я считаю, что комментарии к коду имеют два основных предназначения. Первое — это описание того, что я делаю, когда собираюсь писать сложную функцию.
Второе гораздо проще: устранить любые неопределённости касательно строки или блока кода.
Как использовать комментарии для проектирования новых функций
Когда я сталкиваюсь с функциями, которые, по моему мнению, будет сложно писать, я описываю то, что делает функция, либо открытым текстом, либо псевдокодом.
Этот процесс за годы моей работы менялся, и я рекомендую вам найти то, что подходит вам.
В примерах из предыдущего раздела я не указал комментарии к коду:
/**
* 1. Генерируем Map, содержащую n*n узлов.
* 2. Для каждой соседней клетки (по правилам судоку) добавляем в hashset по ребру (Edge)
* - По столбцу
* - По строке
* - По подсетке размером n
*
* LinkedHashMap: я решил использовать LinkedHashMap, поскольку она сохраняет порядок
* помещаемых в Map элементов, и в то же время позволяет выполнять поиск
* по хэш-коду, который генерируется по значениям x и y.
*
* Что касается LinkedList в каждом элементе map, мы предполагаем, что
* первый элемент - это узел в hashCode(x, y), а последующие элементы -
* это рёбра этого элемента.
* Кроме упорядочивания первого элемента как заголовка LinkedList,
* остальные элементы не нужно никаким образом упорядочивать.
*
*
* */
internal fun buildNodes(n: Int, difficulty: Difficulty): SudokuPuzzle {
val newMap = LinkedHashMap>()
(1..n).forEach { xIndex ->
(1..n).forEach { yIndex ->
val newNode = SudokuNode(
xIndex,
yIndex,
0
)
val newList = LinkedList()
newList.add(newNode)
newMap.put(
newNode.hashCode(),
newList
)
}
}
return SudokuPuzzle(n, difficulty, newMap)
}
Уровень подробностей, которые я вношу в эти комментарии, зависит от контекста. Если я работаю в команде, то стараюсь делать их гораздо короче, чем показано выше; оставляю только то, что мне кажется необходимым.
Показанный выше пример был личным проектом для обучения, которым я хотел поделиться с другими; поэтому я даже описал свой мыслительный процесс выбора типов, используемых для описания судоку.
Любители разработки через тестирование (Test Driven Development) могут попробовать перед написанием теста составить этапы алгоритма на псевдокоде:
/**
* On bind process, called by view in onCreate. Check current user state, write that result to
* vModel, show loading graphic, perform some initialization
*
* a. User is Anonymous
* b. User is Registered
*
* a:
* 1. Display Loading View
* 2. Check for a logged in user from auth: null
* 3. write null to vModel user state
* 4. call On start process
*/
@Test
fun `On bind User anonymous`() = runBlocking {
//...
}
Это позволит вам спроектировать модуль на более высоком уровне абстракции, прежде чем писать реализацию. Время, которое вы потратите на проектирование более высоких уровней абстракции, может в долговременной перспективе сэкономить вам время.
Как эффективно использовать внутристрочные комментарии к коду
Существует две основных ситуации в которых я пишу внутристрочные комментарии:
- Когда я чувствую, что предназначение строки или блока кода не будет понятно мне или кому-то другому при прочтении в будущем
- Когда мне нужно вызвать произвольную библиотечную функцию с плохим именем, над которым у меня нет контроля
Самый сложный алгоритм в моей программе — это алгоритм солвера. На самом деле он такой длинный, что я могу опубликовать здесь только его фрагмент:
internal fun SudokuPuzzle.solve()
: SudokuPuzzle {
//узлы, значения которым были присвоены (не включая узлы, порождённые из seedColors()
val assignments = LinkedList()
//отслеживаем неудачные попытки присвоения, чтобы избежать бесконечных циклов
var assignmentAttempts = 0
//два этапа поиска с возвратом, частичный для половины массива данных, полный для полного перезапуска
var partialBacktrack = false
var fullbacktrackCounter = 0
//от 0 - границы, обозначает, насколько "разборчив" будет алгоритм при присвоении новых значений
var niceValue: Int = (boundary / 2)
//чтобы избежать слишком раннего удобного решения
var niceCounter = 0
//работаем с копией
var newGraph = LinkedHashMap(this.graph)
//все узлы, имеющие значение 0 (бесцветные)
val uncoloredNodes = LinkedList()
newGraph.values.filter { it.first.color == 0 }.forEach { uncoloredNodes.add(it.first) }
while (uncoloredNodes.size > 0) {
//...
}
//...
}
Это было необходимо, потому что читая этот огромный алгоритм, я часто забывал, что делают переменные.
Ещё одна ситуация, в которой я добавляю внутристрочный комментарий: когда мне нужно объяснить или напомнить себе о коде, над которым у меня нет контроля.
Например, печально известный Java Calendar API использует для месяцев индексацию от 0. На самом деле это очень глупо, поскольку мне неизвестен ни один стандарт (да мне и не важно, существует ли такой), где январь обозначается как 0.
Не могу опубликовать код, потому что он проприетарный, но достаточно сказать, что в кодовой базе моей нынешней команды есть комментарии, объясняющие наличие множества - 1
.
Существуют и другие названия для этих типов конструкций кода, но мне знакомы эти два. Предположим, у вас есть ограниченное множество значений, которое вы используется для обозначения чего-то.
Например, мне нужно как-то ограничить количество клеток, включённых в новую головоломку судоку, на основании следующих параметров:
- Размер головоломки (4, 9 или 16 клеток на столбец/строку/подсетку)
- Сложность головоломки (Easy, Medium, Hard)
После тщательного тестирования я пришёл к следующим значениям, используемым в качестве модификаторов:
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>
= 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, то структура пакетов верхнего уровня будет выглядеть так:
- model
- view
- controller
Первая проблема такого подхода — он подразумевает, что любой класс или функция идеально подходят к одному из этих слоёв. На практике такое случается редко.
Кроме того, я нахожу такой подход наименее читаемым, поскольку верхний уровень сообщает только самые общие подробности о том, что стоит ожидать внутри каждого пакета.
Обычно этот подход можно усовершенствовать добавлением новых «слоёв» для большей конкретики:
- ui
- model
- api
- repository
- domain
- common
Это вполне неплохо подойдёт для небольших кодовых баз, в которых все разработчики знакомы с общим паттерном и используемым стилем.
Как упорядочивать пакеты по функциональности
Структура «пакеты по функциональности» имеют свои недостатки, однако в общем случае её проще читать и ориентироваться в ней. При этом снова подразумевается, что вы даёте пакетам хорошие имена.
Понятие «функциональность» (feature) сложно описать, но в целом я определю его следующим образом: экран/страница или набор экранов/страниц, определяющих основную часть функциональности для пользователей или клиентов.
Например, для приложения платформы соцсети структуру можно рассматривать так:
- timeline
- friends
- userprofile
- messages
- messagedetail
Основная проблема методики «пакеты по функциональности» противоположна проблеме «пакетов по слоям»: почти всегда будут существовать программные сущности, используемые в нескольких функциональностях.
У этой проблемы есть два решения. Первая — дублировать код в каждой функциональности.
Можете не верить, но дублирование программных сущностей в некоторых ситуациях может быть невероятно полезным в условиях энтерпрайз-разработки.
Однако я не рекомендовал бы использовать это как основное правило.
Как использовать гибридную структуру пакетов
Обычно я рекомендую разработчикам решение, которое называю гибридным подходом. Оно очень простое, гибкое и чаще всего удовлетворяет всем требованиям:
- timeline
- friends
- messages
— allmessages
— conversation
— messagedetail - api
— timeline
— user
— message - uicomponents
Не воспринимайте этот пример слишком серьёзно, я пытаюсь передать в нём общую идею: всё, что относится к функциональности, находится в соответствующем пакете функциональности. Всё, что является общим для функциональностей, находится в отдельном пакете, расположенном на том же уровне или уровнем выше.
Повторюсь, определение слоя — довольно размытая концепция, поэтому не следуйте этому правилу слепо. Критически думайте о том, что понятно, особенно тем, кто незнаком с проектом.
Большинство моих предпочтений по читаемости кода и стилю появились благодаря тестированию разных подходов. Иногда я видел, как эти подходы используют другие, иногда они приходили ко мне естественным образом.
Если вы сможете поставить себя на место того, кто меньше знаком с кодом или программой, то вам проще будет сделать так, чтобы ваш код читался как книга.