Spec-Zone.ru › Kotlin 1.9

Читаемость

В этом разделе рассматриваются соображения по согласованности API и приводятся следующие рекомендации:

  • Использование DSL-строителя

  • Использование функций-конструкторов, где это применимо

  • Правильное использование функций-членов и расширяющих функций

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

Согласованность 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

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

Использование функций-конструкторов, где это применимо

Иногда можно упростить внешний вид вашего 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:

  • Предсказуемость

  • Отлаживаемость

  • Совместимость с предыдущими версиями

Последнее изменение: 25 июля 2023 г.
Введение Предсказуемость

© 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

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API