Читаемость
В этом разделе рассматриваются соображения по согласованности API и приводятся следующие рекомендации:
Согласованность API
Согласованный и хорошо документированный API имеет решающее значение для хорошего опыта разработки. Это относится к порядку аргументов, общей схеме именования и перегрузкам. Также стоит документировать все соглашения.
Например, если один из ваших методов принимает offset и length в качестве параметров, то и другие методы должны принимать их, а не, например, startIndex и endIndex. Такие параметры, скорее всего, имеют тип Int или Long, и поэтому их легко перепутать.
То же самое относится к порядку параметров: он должен быть согласован между методами и перегрузками. В противном случае пользователи вашей библиотеки могут ошибочно предположить порядок передачи аргументов.
Вот пример, сохраняющий порядок параметров и использующий согласованное именование:
fun String.chop(length: Int): String = substring(0, length)
fun String.chop(length: Int, startIndex: Int) =
substring(startIndex, length + startIndex)
Если у вас есть много похожих методов, назовите их согласованно и предсказуемо. Вот как работает API stdlib: Существуют методы first() и firstOrNull(), single() и singleOrNull(), и так далее. Из их названий понятно, что они все являются парами, и некоторые из них могут возвращать null, а другие могут выбрасывать исключение.
Использование DSL-строителя
«Строитель» — известный шаблон разработки. Он позволяет создавать сложный объект не в одном выражении, а постепенно, получая дополнительную информацию. Когда вам нужен строитель, лучше написать его с использованием DSL-строителя, который совместим с предыдущими версиями и более удобен в использовании.
Каноническим примером DSL-строителя Kotlin является kotlinx.html. Рассмотрим следующий пример:
header("modal-card-head") {
p("modal-card-title") {
+book.book.name
}
button(classes = "delete") {
attributes["aria-label"] = "close"
attributes["_"] = closeModalScript
}
}
Его можно реализовать как традиционный строитель, но это будет значительно более громоздко:
headerBuilder()
.addClasses("modal-card-head")
.addElement(
pBuilder()
.addClasses("modal-card-title")
.addContent(book.book.name)
.build()
)
.addElement(
buttonBuilder()
.addClasses("delete")
.addAttribute("aria-label", "close")
.addAttribute("_", closeModalScript)
.build()
)
.build()
Эта реализация содержит слишком много деталей, которые вам не обязательно нужно знать, и она требует построения каждого объекта в конце.
Ситуация ухудшается, если вам нужно динамически генерировать содержимое строителя в цикле. В этом случае вам нужно создать переменную и динамически перезаписать её:
var buttonBuilder = buttonBuilder()
.addClasses("delete")
for ((attributeName, attributeValue) in attributes) {
buttonBuilder = buttonBuilder.addAttribute(attributeName, attributeValue)
}
buttonBuilder.build()
Внутри DSL-строителя вы можете напрямую вызвать цикл и все необходимые вызовы DSL:
div("tags") {
for (genre in book.genres) {
span("tag is-rounded is-normal is-info is-light") {
+genre
}
}
}
Помните, что внутри фигурных скобок невозможно проверить на этапе компиляции, были ли заданы все необходимые атрибуты. Чтобы избежать этого, передавайте необходимые аргументы как аргументы функции, а не как свойства строителя. Например, если вы хотите, чтобы href был обязательным атрибутом HTML, ваша функция будет выглядеть так:
fun a(href: String, block: A.() -> Unit): A
А не так:
fun a(block: A.() -> Unit): A
Использование функций-конструкторов, где это применимо
Иногда можно упростить внешний вид вашего API, используя функции-конструкторы. Функция-конструктор — это функция, имя которой начинается с заглавной буквы, поэтому она выглядит как конструктор. Этот подход может сделать вашу библиотеку более понятной.
Предположим, вы хотите ввести тип Option в свою библиотеку:
sealed interface Option<T> class Some<T : Any>(val t: T) : Option<T> object None : Option<Nothing>
Вы можете определить реализации всех методов интерфейса Option — map(), flatMap() и так далее. Однако каждый раз, когда пользователи вашего API создают такой Option, они должны написать дополнительную логику для проверки созданного объекта. Например:
fun findById(id: Int): Option<Person> {
val person = db.personById(id)
return if (person == null) None else Some(person)
}
Чтобы избавить пользователей от необходимости каждый раз писать такую проверку, вы можете добавить всего одну строку в свой API:
fun <T> Option(t: T?): Option<out T & Any> =
if (t == null) None else Some(t)
// Usage of the code above:
fun findById(id: Int): Option<Person> = Option(db.personById(id))
Теперь создание валидного Option простое: достаточно вызвать Option(x), и у вас будет безопасный для работы с null, чисто функциональный вариант использования Option.
Еще один случай использования функций-конструкторов — это когда вам нужно вернуть «скрытые» вещи, такие как частный экземпляр или внутренний объект. Например, давайте посмотрим на метод из стандартной библиотеки:
public fun <T> listOf(vararg elements: T): List<T> =
if (elements.isNotEmpty()) elements.asList() else emptyList()
В приведенном коде emptyList() возвращает следующее:
internal object EmptyList : List<Nothing>, Serializable, RandomAccess
Вы можете написать функцию-конструктор, чтобы снизить когнитивную сложность вашего кода и уменьшить размер API:
fun <T> List(): List<T> = EmptyList
// Usage of the code above:
public fun <T> listOf(vararg elements: T): List<T> =
if (elements.isNotEmpty()) elements.asList() else List()
Правильное использование функций-членов и расширяющих функций
Пишите только самую суть API в виде функций-членов, а всё остальное — в виде расширяющих функций. Это поможет вам ясно показать читателю, что является основной функциональностью, а что нет.
Например, рассмотрим следующий класс графа:
class Graph {
private val _vertices: MutableSet<Int> = mutableSetOf()
private val _edges: MutableMap<Int, MutableSet<Int>> = mutableMapOf()
fun addVertex(vertex: Int) {
_vertices.add(vertex)
}
fun addEdge(vertex1: Int, vertex2: Int) {
_vertices.add(vertex1)
_vertices.add(vertex2)
_edges.getOrPut(vertex1) { mutableSetOf() }.add(vertex2)
_edges.getOrPut(vertex2) { mutableSetOf() }.add(vertex1)
}
val vertices: Set<Int> get() = _vertices
val edges: Map<Int, Set<Int>> get() = _edges
}
Этот класс содержит минимальное количество вершин и рёбер в качестве частных переменных, функции для добавления вершин и рёбер и функции-акцессоры, которые возвращают неизменную версию текущего состояния.
Вы можете добавить всю остальную функциональность вне класса:
fun Graph.getNumberOfVertices(): Int = vertices.size fun Graph.getNumberOfEdges(): Int = edges.size fun Graph.getDegree(vertex: Int): Int = edges[vertex]?.size ?: 0
Членами должны быть только свойства, переопределения и функции-акцессоры.
Избегайте использования булевых аргументов в функциях
В идеале читатель должен уметь определить назначение аргумента функции, просто прочитав код. Однако с аргументами типа Boolean это сделать почти невозможно, особенно если вы не используете IDE (например, если вы просматриваете код в системе контроля версий). Использование именованных аргументов может помочь прояснить назначение аргументов, но пока нет способа заставить разработчиков использовать их в IDE. Другой вариант — создать функцию, содержащую действие аргумента Boolean, и дать этой функции описательное имя.
Например, в стандартной библиотеке есть две функции для map():
fun map(transform: (T) -> R): List<R> fun mapNotNull(transform: (T) -> R?): List<R>
Было возможно добавить что-то вроде map(filterNulls: Boolean) и написать код так:
listOf(1, null, 2).map(false) { it.toString() }
Из прочтения этого кода трудно понять, к чему относится false. Однако, если вы используете функцию mapNotNull(), читатели смогут понять логику с первого взгляда:
listOf(1, null, 2).mapNotNull { it.toString() }
Что дальше?
Узнайте о следующих аспектах API:
© 2010–2023 JetBrains s.r.o. and Kotlin Programming Language contributors
Licensed under the Apache License, Version 2.0.
https://kotlinlang.org/docs/jvm-api-guidelines-readability.html