[Из песочницы] Kotlin: без Best Practices и жизнь не та. Часть 1
Предыстория
Я начал смотреть на 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 приводить не буду, и так понятно, какой спагетти-код получится в итоге.
О других наболевших вещах напишу в следующей статье, потому что это уже успела разрастись.