Kotlin: без Best Practices и жизнь не та. Часть 1

в 8:08, , рубрики: kotlin, Программирование

Привет! Данная статья о наболевших проблемах при программировании на Kotlin. В частности, затрону несколько тем, вызывающих больше всего неоднозначности – использование it в лямбда-выражениях, злоупотребление функциями из файла Standard.kt и краткость написания vs. читаемость кода.

Предыстория

Я начал смотреть на Kotlin около года назад (начиная с Milestone 12) и активно применял его для написания своих Android-приложений. После двух лет написания Android-приложений на языке Java писать на Kotlin было глотком свежего воздуха — код был намного компактнее (никаких тебе анонимных классов, появились функциональные фичи), а сам язык намного выразительнее (extension-функции, лямбда-функции) и безопаснее (null safety).

Когда язык вышел в релиз, я без капли сомнения начал писать на нём свой новый проект на работе, попутно расхваливая его своим коллегам (в своей небольшой компании я единственный Android-разработчик, остальные разрабатывают на Java клиент-серверные приложения). Я понимал, что после меня новому члену команды придется учить этот язык, что на мой взгляд в данном случае не являлось проблемой — этот язык очень похож на Java и через 3-5 дней после прочтения официальной документации на нём уже можно начать уверено писать.

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

// Намного лучше читается, когда выход из функции следует сразу за единственным Safe-call ("?."), после чего идет получение имени отдельной строчкой
val user = response?.user ?: return
val name = user.name.toLowerCase()

// Хуже читается, когда сразу несколько разных действий совмещено на одной строчке
val name = response?.user?.name.toLowerCase() ?: return

Так как я был единственным программистом, быстро понял эту закономерность и неявно выработал для себя правило предпочитать читаемость кода его краткости. Всё бы было ничего, пока мы не взяли на стажировку начинающего Android-программиста. Как я и ожидал, после прочтения официальной документации по языку он быстро освоил Kotlin, имея за плечами опыт программирования на Java, но потом стали происходить странные вещи: каждое code review вызывало между нами получасовые (а иногда и часовые) дискуссии на тему того, какие конструкции языка лучше использовать в тех или иных ситуациях. Иными словами, мы начали вырабатывать стиль программирования на Kotlin в нашей компании. Я считаю, что эти дискуссии возникали по той причине, что в документации, являющейся входной точкой в мир Kotlin, не приведено тех самых Best Practices, а именно когда лучше НЕ использовать данные фичи и что лучше использовать вместо этого. Именно поэтому я и решил написать данную статью.

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

Проблемы в языке

«It» сallback hell

Данная проблема заключается в том, что в Kotlin разрешено не именовать единственный параметр функции обратного вызова. Он по умолчанию будет иметь имя «it». Пример:

 /** Интерфейс с методом call, который принимает один параметр и ничего не возвращает */
interface Callback {
    fun call(parameter: Any?) 
}

fun execute(callback: Callback) { 
    ...
    callback(parameter)
    ...
}

/** Пример вызова. Kotlin позволяет писать как execute { ... }, так и execute({ ... }), выберем более краткий вариант */
execute {
    if (it is String) { // Доступ к parameter через переменную it, проверка что он имеет тип String
        ....
    }
    ....
}

Однако когда мы имеем несколько вложенных функций, может возникнуть путаница:

execute {
    execute {
        execute {
            if (it is String) { // it относится к последнему по вложенности вызову execute
                ....
            }
            ....
        }
    }
}

execute {
    execute {
        execute { parameter ->
            if (it is String) { // здесь it относится уже к предпоследнему по вложенности вызову execute, так как параметр последнего имеет другое имя
                ....
            }
            ....
        }
    }
}

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

executeRequest { // здесь it - это экземпляр класса Response
    if (it.body() == null) return
    executeDB { // здесь it - это экземпляр класса DatabaseHelper
        it.update(user)
        executeInBackgroud { // здесь it - это экземпляр класса Thread
            if (it.wait()) ... 
            ....
        }
    }
}

Здесь приведена дискуссия на тему читаемости кода, использующего it. Мое мнение — it сильно помогает сокращать код и повышает его понятность для простых функций, но как только мы имеем дело со вложенной функцией обратного вызова, лучше давать имена параметрам обеих функций:

 // Простая функция
executeInBackgroud {
    if (it.wait()) ... 
     ....
}

// вложенная функция
executeRequest { response ->
    if (response.body() == null) return
    executeDB { dbHelper ->
        dbHelper.update(user)
        ...
    }
}

Злоупотребление функциями из файла Standard.kt

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

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

Первый пример — функция let, которая по сути выполняет 2 задачи: позволяет вызвать код, если какое-то значение не равно null и перекладывает это значение в переменную it:

response?.user?.let {
    val name = it.name // в it теперь лежит объект user
}

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

val user = response?.user ?: return
val name = user.name

В третьих, let добавляет лишний уровень отступа, что ухудшает читаемость кода. Почитать по поводу данной функции можно здесь, здесь и здесь. Моё мнение — данная функция вообще не нужна в языке, единственный плюс от нее — помощь с null safety. Однако даже этот плюс можно решить другими более изящными и понятными способами (предварительная проверка на null при помощи ?: или просто if).

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

with(dbHelper) {
    update(user)
    delete(comment)
}

// вышеприведенный код эквивалентен следующему:
dbHelper.update(user)
dbHelper.delete(comment)

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

with(dbHelper) {
    val user = query(user.id)
    user.name = name
    user.address = getAddress() // getAddress() не относится к объекту dbHelper
    ....
    update(user)
    val comment = getLatestComment() // getLatestComment() также не относится к объекту dbHelper
    ....
    delete(comment)
}

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

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

Автор: skatset

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js