Читаемость
Создание читаемого API — это не только написание чистого кода. Для этого нужен продуманный дизайн, упрощающий интеграцию и использование. В этом разделе рассматривается, как повысить читаемость API: структурировать библиотеку с учетом композиции, использовать предметно-ориентированные языки (DSL) для лаконичной и выразительной настройки, а также применять функции и свойства-расширения для создания понятного и удобного в сопровождении кода.
Отдавайте предпочтение явной композиции
Библиотеки часто предоставляют продвинутые операторы, позволяющие настраивать поведение. Например, операция может позволять пользователям указывать собственные структуры данных, сетевые каналы, таймеры или наблюдателей жизненного цикла. Однако добавление таких возможностей настройки в виде дополнительных параметров функций может значительно усложнить API.
Вместо добавления новых параметров для настройки эффективнее спроектировать API так, чтобы разные варианты поведения можно было компоновать друг с другом. Например, в API Flow корутин буферизация и объединение значений реализованы как отдельные функции. Их можно объединять в цепочки с более простыми операциями, такими как filter и map, вместо того чтобы каждая базовая операция принимала параметры для управления буферизацией и объединением значений.
Еще один пример — API модификаторов в Jetpack Compose. Он позволяет компонентам Composable принимать один параметр Modifier, отвечающий за распространенные настройки, такие как отступы, размеры и цвет фона. Благодаря этому каждому компоненту Composable не нужно принимать отдельные параметры для таких настроек, что упрощает API и снижает его сложность.
Box(
modifier = Modifier
.padding(10.dp)
.onClick { println("Box clicked!") }
.fillMaxWidth()
.fillMaxHeight()
.verticalScroll(rememberScrollState())
.horizontalScroll(rememberScrollState())
) {
// Box content goes here
}
Используйте DSL
Библиотека Kotlin может стать значительно понятнее, если предоставить DSL для построения объектов. DSL позволяет лаконично описывать повторяющиеся данные предметной области. Например, рассмотрим следующий пример серверного приложения на основе Ktor:
fun Application.module() {
install(ContentNegotiation) {
json(Json {
prettyPrint = true
isLenient = true
})
}
routing {
post("/article") {
call.respond<String>(HttpStatusCode.Created, ...)
}
get("/article/list") {
call.respond<List<CreateArticle>>(...)
}
get("/article/{id}") {
call.respond<Article>(...)
}
}
}
Этот код настраивает приложение, устанавливает плагин ContentNegotiation, сконфигурированный для использования сериализации Json, и задает маршрутизацию, благодаря которой приложение обрабатывает запросы к различным конечным точкам /article.
Подробное описание создания DSL см. в разделе Типобезопасные построители. При создании библиотек стоит учитывать следующее:
Функции, используемые в DSL, — это функции-построители, последним параметром которых является лямбда с получателем. Благодаря такому устройству эти функции можно вызывать без скобок, что делает синтаксис понятнее. Передаваемую лямбду можно использовать для настройки создаваемого объекта. В примере выше лямбда, переданная функции
routing, используется для настройки параметров маршрутизации.Фабричные функции, создающие экземпляры классов, должны называться так же, как возвращаемый тип, и начинаться с заглавной буквы. Это можно увидеть в примере выше, где создается экземпляр
Json. Такие функции также могут принимать параметры-лямбды для настройки. Подробнее см. в разделе Соглашения о написании кода.Поскольку во время компиляции невозможно проверить, заданы ли все обязательные свойства внутри лямбды, переданной функции-построителю, рекомендуем передавать обязательные значения в качестве параметров функции.
Использование DSL для создания объектов не только повышает читаемость, но и улучшает обратную совместимость, а также упрощает подготовку документации. Например, рассмотрим следующую функцию:
fun Json(prettyPrint: Boolean, isLenient: Boolean): Json
Эта функция могла бы заменить DSL-построитель Json{}. Однако подход с DSL имеет заметные преимущества:
С DSL-построителем проще поддерживать обратную совместимость, чем с этой функцией: для добавления новых параметров настройки достаточно добавить новые свойства (или, в других примерах, новые функции). Это изменение обратно совместимо, в отличие от изменения списка параметров существующей функции.
Кроме того, так проще создавать и поддерживать документацию. Можно описать каждое свойство отдельно в месте его объявления, вместо того чтобы описывать множество параметров функции в одном месте.
Используйте функции и свойства-расширения
Для повышения читаемости рекомендуем использовать функции и свойства-расширения.
Классы и интерфейсы должны определять основную концепцию типа. Дополнительные возможности и сведения следует оформлять как функции и свойства-расширения. Благодаря этому читателю понятно, что дополнительные возможности можно реализовать поверх основной концепции, а дополнительные сведения — вычислить на основе данных типа.
Например, тип CharSequence (который также реализует String) содержит только самую необходимую информацию и операторы для доступа к содержимому:
interface CharSequence {
val length: Int
operator fun get(index: Int): Char
fun subSequence(startIndex: Int, endIndex: Int): CharSequence
}
Большая часть функций, обычно связанных со строками, определена в виде функций-расширений. Все их можно реализовать поверх основных концепций и базового API типа:
inline fun CharSequence.isEmpty(): Boolean = length == 0
inline fun CharSequence.isNotEmpty(): Boolean = length > 0
inline fun CharSequence.trimStart(predicate: (Char) -> Boolean): CharSequence {
for (index in this.indices)
if (!predicate(this[index]))
return subSequence(index, length)
return ""
}
Подумайте о том, чтобы объявлять вычисляемые свойства и обычные методы как расширения. По умолчанию в виде членов следует объявлять только обычные свойства, переопределения и перегруженные операторы.
Не используйте логический тип в качестве аргумента
Рассмотрим следующую функцию:
fun doWork(optimizeForSpeed: Boolean) { ... }
Если добавить эту функцию в API, ее можно будет вызывать так:
doWork(true) doWork(optimizeForSpeed=true)
При первом вызове невозможно понять назначение логического аргумента, если только вы не читаете код в IDE с включенными подсказками имен параметров. Именованные аргументы проясняют намерение, однако нельзя заставить пользователей всегда придерживаться такого стиля. Поэтому для повышения читаемости не следует использовать логические типы в качестве аргументов.
В качестве альтернативы API может предоставить отдельную функцию для задачи, управление которой выполняет логический аргумент. У этой функции должно быть описательное имя, указывающее на ее назначение.
Например, для интерфейса Iterable доступны следующие расширения:
fun <T, R> Iterable<T>.map(transform: (T) -> R): List<R>
fun <T, R : Any> Iterable<T>.mapNotNull(
transform: (T) -> R?
): List<R>
Вместо единственного метода:
fun <T, R> Iterable<T>.map(
includeNullResults: Boolean = true,
transform: (T) -> R
): List<R>
Еще один удачный подход — использовать класс enum для определения различных режимов работы. Он подходит, если режимов несколько или если предполагается, что со временем они могут измениться.
Используйте числовые типы по назначению
В Kotlin определен набор числовых типов, которые можно использовать в API. Вот как их применять по назначению:
Используйте типы
Int,LongиDoubleдля арифметических операций. Они представляют значения, с которыми выполняются вычисления.Не используйте арифметические типы для сущностей, не связанных с арифметикой. Например, если представить идентификатор как
Long, пользователи могут попытаться сравнивать идентификаторы, полагая, что они присваиваются по порядку. Это может привести к ненадежным или бессмысленным результатам либо создать зависимости от реализаций, которые могут измениться без предупреждения. Лучше определить специализированный класс для абстракции идентификатора. Для создания таких абстракций без потери производительности можно использовать встраиваемые классы-значения. Примером служит классDuration.Типы
Byte,FloatиShortопределяют размещение в памяти. Они позволяют ограничить объем памяти, доступный для хранения значения, например в кэшах или при передаче данных по сети. Используйте эти типы только тогда, когда исходные данные гарантированно помещаются в диапазон типа и вычисления не требуются.Типы беззнаковых целых чисел
UByte,UShort,UIntиULongследует использовать, чтобы задействовать весь диапазон положительных значений, доступный в заданном формате. Они подходят для сценариев, в которых нужны значения, выходящие за пределы диапазона знаковых типов, или для взаимодействия с нативными библиотеками. Однако не используйте их, если предметной области нужны только неотрицательные целые числа.
Следующий шаг
В следующей части руководства вы узнаете о согласованности.
© 2010–2026 JetBrains s.r.o. and Kotlin Programming Language contributors
Licensed under the Apache License, Version 2.0.
https://kotlinlang.org/docs/api-guidelines-readability.html