Привет, Хабр! Данная статья о наболевших проблемах при программировании на Kotlin. В частности, затрону несколько тем, вызывающих больше всего неоднозначности – использование it в лямбда-выражениях, злоупотребление функциями из файла Standard.kt и краткость написания vs. читаемость кода.
Я начал смотреть на Kotlin около года назад (начиная с Milestone 12) и активно применял его для написания своих Android-приложений. После двух лет написания Android-приложений на языке Java писать на Kotlin было глотком свежего воздуха — код был намного компактнее (никаких тебе анонимных классов, появились функциональные фичи), а сам язык намного выразительнее (extension-функции, лямбда-функции) и безопаснее (null safety).
Когда язык вышел в релиз, я без капли сомнения начал писать на нём свой новый проект на работе, попутно расхваливая его своим коллегам (в своей небольшой компании я единственный Android-разработчик, остальные разрабатывают на Java клиент-серверные приложения). Я понимал, что после меня новому члену команды придется учить этот язык, что на мой взгляд в данном случае не являлось проблемой — этот язык очень похож на Java и через 3-5 дней после прочтения официальной документации на нём уже можно начать уверено писать.
Спустя какое-то время я начал замечать, что в некоторых случаях нужно бить себя по рукам и писать более длинный, но понятный код, нежели краткий и менее понятный. Пример:
Так как я был единственным программистом, быстро понял эту закономерность и неявно выработал для себя правило предпочитать читаемость кода его краткости. Всё бы было ничего, пока мы не взяли на стажировку начинающего Android-программиста. Как я и ожидал, после прочтения официальной документации по языку он быстро освоил Kotlin, имея за плечами опыт программирования на Java, но потом стали происходить странные вещи: каждое code review вызывало между нами получасовые (а иногда и часовые) дискуссии на тему того, какие конструкции языка лучше использовать в тех или иных ситуациях. Иными словами, мы начали вырабатывать стиль программирования на Kotlin в нашей компании. Я считаю, что эти дискуссии возникали по той причине, что в документации, являющейся входной точкой в мир Kotlin, не приведено тех самых Best Practices, а именно когда лучше НЕ использовать данные фичи и что лучше использовать вместо этого. Именно поэтому я и решил написать данную статью.
Сразу хочу оговорить, что я не пытаюсь доказать истинность моих утверждений, а пытаюсь обсудить как же всё-такие правильно писать те или иные вещи на Kotlin.
Данная проблема заключается в том, что в Kotlin разрешено не именовать единственный параметр функции обратного вызова. Он по умолчанию будет иметь имя «it». Пример:
Однако когда мы имеем несколько вложенных функций, может возникнуть путаница:
На небольших фрагментах когда это может не казаться такой проблемой, однако если над кодом работают несколько человек и такая функция с вложенным вызовом имеет 10-15 строчек, то легко потерять, кому же на самом деле принадлежит it на данном уровне вложенности. Ситуация ухудшается, если в каждом уровне вложенности используется имя it для какой-то операции. В этом случае понимание такого кода сильно ухудшается.
Здесь приведена дискуссия на тему читаемости кода, использующего it. Мое мнение — it сильно помогает сокращать код и повышает его понятность для простых функций, но как только мы имеем дело со вложенной функцией обратного вызова, лучше давать имена параметрам обеих функций:
Для тех кто не знает, в файле Standard.kt находится множество полезных функций. Здесь приведено подробное описание для чего нужна каждая из них.
Проблемы с этими функциями начинаются тогда, когда программист начинает их использовать слишком часто.
Первый пример — функция let, которая по сути выполняет 2 задачи: позволяет вызвать код, если какое-то значение не равно null и перекладывает это значение в переменную it:
Первый недостаток данной функции пересекается с темой предыдущего раздела — появляется переменная it, которая добавляет возможных ошибок. Второй недостаток — с использованием этой функции код не читается как английский текст. Намного лучше написать следующим образом:
В третьих, let добавляет лишний уровень отступа, что ухудшает читаемость кода. Почитать по поводу данной функции можно здесь, здесь и здесь. Моё мнение — данная функция вообще не нужна в языке, единственный плюс от нее — помощь с null safety. Однако даже этот плюс можно решить другими более изящными и понятными способами (предварительная проверка на null при помощи ?: или просто if).
Что касается остальных функций, то они должны применятся крайне редко и осторожно. Возьмем, к примеру, with. Она позволяет не указывать каждый раз объект, на котором нужно вызвать функцию:
Проблема начинается там, где данные вызовы перемешаны с другим кодом, не относящимся к объекту dbHelper:
В данном случае приходится постоянно следить за тем, кому же на самом деле принадлежит та или иная функция, что значительно снижает читаемость. Пример со вложенным использованием with приводить не буду, и так понятно, какой спагетти-код получится в итоге.
О других наболевших вещах напишу в следующей статье, потому что это уже успела разрастись.
Update:
Так получилось, что пока я подготавливал материал для второй части статьи, было опубликовано видео презентации Антона Кекса, которое не только полностью затрагивало все пункты моей второй статьи, но и содержало некоторые дополнительные важные моменты. Но самое главное, что в этом видео также есть комментарии разработчиков. Я решил, что вторая статья будет уже не в том формате, что первая (а будет говорить что есть такая-то проблема, в видео об этом сказано на такой-то минуте), так что пока что я не буду писать второй части, по крайней мере пока не обнаружу новых проблем в языке. Всем, кто ждал продолжения, советую просмотреть видео презентации.
Предыстория
Я начал смотреть на 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». Пример:
/** Здесь параметр это callback, который принимает один параметр и ничего не возвращает */ fun execute(callback: (Any?) -> Unit) { ... 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 приводить не буду, и так понятно, какой спагетти-код получится в итоге.
О других наболевших вещах напишу в следующей статье, потому что это уже успела разрастись.
Update:
Так получилось, что пока я подготавливал материал для второй части статьи, было опубликовано видео презентации Антона Кекса, которое не только полностью затрагивало все пункты моей второй статьи, но и содержало некоторые дополнительные важные моменты. Но самое главное, что в этом видео также есть комментарии разработчиков. Я решил, что вторая статья будет уже не в том формате, что первая (а будет говорить что есть такая-то проблема, в видео об этом сказано на такой-то минуте), так что пока что я не буду писать второй части, по крайней мере пока не обнаружу новых проблем в языке. Всем, кто ждал продолжения, советую просмотреть видео презентации.
