«Писать программное обеспечение так, как будто мы единственный человек, который когда-либо должен его понимать, — это одна из самых больших ошибок и ложных предположений, которые можно сделать». — Каролина Щур

В предыдущем посте я описал, как Kotlin заменил Java в качестве предпочтительного языка для серверной разработки. В этом посте я расскажу о некоторых функциях Kotlin, которые, по моему мнению, делают его отличным выбором для программирования на стороне сервера, и о том, как мы используем некоторые из них в Kingfisher.

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

Сокращение «церемоний»

«Язык программирования является низкоуровневым, когда его программы требуют внимания к несущественным». — Алан Перлис

В влиятельной статье Фреда Брукса Нет серебряной пули — сущность и случайность в программной инженерии обсуждались два вида сложности: существенная, присущая проблеме, которую мы пытаемся решить; и случайное, что является следствием конкретных методов/подходов, которые мы используем для ее решения.

У Kotlin есть несколько функций, которые убирают некоторые «церемонии» вокруг кодирования; это уменьшает случайную сложность (синтаксис языка программирования), позволяя читателю сосредоточиться на существенной сложности (логике, которую реализует программа). Эти функции включают в себя:

Нет терминаторов операторов

«Совершенство достигается не тогда, когда нечего добавить, а тогда, когда нечего больше убрать». — Антуан де Сент-Экзюпери

В отличие от некоторых других языков (например, Java) Kotlin не использует определенный символ для завершения операторов; компилятор достаточно умен, чтобы понять, где заканчивается оператор, без явного указания разработчиком каждый раз.

Вывод типа

«…хорошее решение — это не наращивание кода, а его дистилляция». ― Роберт Нистром

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

Классы данных

Вы хотели банан, но получили гориллу, держащую банан и все джунгли». — Джо Армстронг (цитата по Питеру Сейбелу, Coders at Work)

Иногда все, что вам нужно, это данные, без связанного поведения домена. И поскольку нас не интересует механическое поведение, которое должны реализовать эти объекты, чтобы удовлетворить требования JVM, было бы неплохо скрыть его от глаз.

IDE уже давно могут автоматически писать эти методы для нас, но это по-прежнему оставляет «шум» в исходном коде.
Шум уменьшается при чтении кода в IDE (где методы могут быть скрыты от глаз), но в какой-то степени оно все же есть; и он по-прежнему присутствует при просмотре запроса на вытягивание вне среды IDE.

Ломбок может автоматически генерировать эти методы во время компиляции. Это удерживает их от исходного кода, но требуемые аннотации все же добавляют некоторый шум. Кроме того, использование Lombok вводит еще одну зависимость, которой нужно управлять.

Классы данных избегают всего шума, и они встроены.

Выражение состояния

«Разгадывать тайны убийства — это нормально, но вам не нужно разбираться в коде. Вы должны быть в состоянии прочитать это». Стив МакКоннелл

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

Именованные параметры

«Размышляя о своем опыте модификации кода, я вижу, что трачу гораздо больше времени на чтение существующего кода, чем на написание нового кода. Поэтому, если я хочу сделать свой код дешевым, я должен сделать его легко читаемым». — Кент Бек

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

Мы особенно замечаем это в модульных тестах: когда данные, которые мы передаем, не имеют отношения к проверкам, которые мы хотим выполнить, мы часто пытаемся указать это, передавая «пустые» значения, такие как 0 или null, или пустое String, или пустое Collection или any().
Однако в некоторых случаях такие значения на самом деле имеют смысл, и без имен может быть трудно определить разницу, особенно при беглом чтении или просмотре запроса на вытягивание вне IDE.
> С именами рецензенту будет легче понять, важны ли эти «пустые» параметры и какую ценность добавляет тест.

В Java, чтобы предоставить имена, которые помогут сделать код более явным/не требующим пояснений, нам потребуется ввести дополнительные локальные переменные; но это также может вынудить нас расширить область значения, скрывая наше намерение — читателю придется подумать, является ли полученная область преднамеренной или просто нежелательным последствием желания добавить имя.

Однако мы не всегда используем именованные параметры; например, в следующем коде имена параметров не предоставляют никакой дополнительной информации для читателя, дублирование просто добавляет «шум»:
function(param1 = param1, param2 = param2, param3 = param3)

В этих случаях мы предпочитаем быть более краткими:
function(param1, param2, param3)

Обнуляемые типы

«Я называю это своей ошибкой на миллиард долларов». — Тони Хоар

С null явным понятием в системе типов Kotlin мы знаем, когда нам нужно учитывать вероятность того, что данные могут быть недоступны, и компилятор гарантирует, что мы никогда не передадим null функции, которая этого не ожидает.

Мы по-прежнему время от времени используем шаблон Нулевой объект, но в основном только тогда, когда нам нужно предоставить неактивную реализацию для Spring для автоподключения.

Закрытые классы и исчерпывающее описание "когда"

«Есть два способа построения дизайна программного обеспечения. Один из способов — сделать его настолько простым, чтобы в нем явно не было недостатков. А другой способ — сделать его настолько сложным, чтобы не было очевидных недостатков». — Тони Хоар

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

Исчерпывающие when позволяют нам легко идентифицировать соответствующий поток управления для текущего состояния домена — все состояния идентифицируются индивидуально, без двусмысленности else или default как «универсальные», и компилятор гарантирует, что мы реализовали поток управления для всех возможные состояния.

Встроенные классы

Улучшение инкапсуляции

«После того, как объект ускользает, вы должны предположить, что другой класс или поток могут злонамеренно или небрежно использовать его неправильно. Это веская причина для использования инкапсуляции: она делает более практичным анализ программ на правильность и затрудняет случайное нарушение проектных ограничений». ― Брайан Гетц, Параллелизм Java на практике

Одной из проблем нашей старой кодовой базы было отсутствие вездесущего языка; это была особая проблема, когда данные моделировались с использованием типов из стандартной библиотеки Java (чаще всего String) или примитивов (таких как int), а не как пользовательский тип, специфичный для предметной области.

Например, представляют ли экземпляры String со следующими именами в разных частях устаревшей кодовой базы одну и ту же концепцию одной предметной области или несколько разных концепций предметной области?:

  • номер_клиента
  • клиентNbr
  • идентификатор клиента
  • custNum
  • customNum
  • идентификатор клиента

На самом деле все они представляли одну и ту же концепцию; но экземпляры String с именем «customerRef» представляли другую концепцию!

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

Это также привело к распространению валидации (например, проверки наличия данных и их ожидаемых форм или диапазонов) по кодовой базе; и часто он дублировался, что затрудняло внесение изменений (например, увеличение длины ссылки на клиента).

Другой пример: «storeId» и «branchCode». Оба они были уникальными идентификаторами магазина, но представляли несколько разные концепции предметной области; они были получены из разных систем, поэтому не существовало ограничения, что любой конкретный идентификатор будет глобально уникальным для обоихнаборов.
Если бы мы передали storeId функции, которая ожидал branchCode, это может привести к сбою во время выполнения из-за того, что значение было отклонено как недопустимое, или, что еще хуже, может произойти сбой, но для неправильного магазина (например, отправка товара не в то место)!

«Успешное программное обеспечение всегда меняется». — Фред Брукс

«Для каждого желаемого изменения сделайте его легким (предупреждение: это может быть сложно), а затем сделайте легкое изменение» — Кент Бек

Использование типов для определения вездесущего языка также упрощает рефакторинг.

Если мы решим переименовать концепцию (например, с StoreId на StoreRef), IDE точно знает, где используется тип, и компилятор дважды проверит изменения. Внесение того же изменения, пытаясь найти и переименовать Strings, гораздо более подвержено ошибкам, даже с помощью IDE.

Точно так же изменение реализации также является простым рефакторингом, например: изменение встроенного типа на обычный тип; или изменение обернутого значения с Boolean на enum или с Int на Double.

«Существует искусство знать, где что следует проверять, и следить за тем, чтобы программа быстро не сработала, если вы допустили ошибку. Такой выбор — часть искусства упрощения». — Уорд Каннингем

«Я стараюсь думать в основном с точки зрения предварительных условий, проверки в конструкторе и начала функции». — Брэд Фицпатрик

Используя функции Kotlin init и require в наших встроенных классах, мы можем СУШИТЬ наш код, реализуя шаблон разбирать, не проверять на краях наших приложений. Это хорошо согласуется с другими шаблонами, которые мы часто используем, такими как функциональное ядро, императивная оболочка и гексагональная архитектура. И это создает единый источник правды, помогая нам сделать недопустимые состояния непредставимыми в наших моделях предметной области, что упрощает основной код предметной области за счет уменьшения количества возможных путей через него.

Влияние на производительность

«Программы должны быть написаны для того, чтобы люди их читали, и лишь случайно для того, чтобы машины выполняли их». ― Гарольд Абельсон, Структура и интерпретация компьютерных программ

Обычно мы следуем совету Абельсона. В нашей устаревшей системе мы могли бы сделать это, создав свои собственные типы Java для обеспечения повсеместного использования языка, вместо того, чтобы просто использовать стандартные библиотечные типы. Но, учитывая, насколько часто они использовались, использование доменных типов в качестве оболочек значительно увеличило бы использование памяти и, следовательно, вызвало бы серьезные проблемы с производительностью.

«Мы должны забыть о малой эффективности, скажем, примерно в 97% случаев… Тем не менее, мы не должны упускать наши возможности в эти критические 3%». — Дональд Кнут

Учитывая влияние оберток, точка зрения Кнута о 3% была актуальна. Даже если влияние использования обертывающих типов доменов на производительность было в целом приемлемым, всегда были некоторые обстоятельства, при которых они действительно вызывали неприемлемые проблемы.
И даже если бы мы отказались от оболочек только в этих критических случаях, мы все равно повторно заразили бы код потенциальной путаницей относительно того, представляют ли String и тип с похожим именем одно и то же понятие предметной области или нет.

Но встроенные классы Kotlin позволяют нам избежать этого компромисса между производительностью и выразительностью: компилятор гарантирует, что функции могут получать параметры только тех определенных типов, которые они ожидают (например, функция, ожидающая BranchCode, никогда не может быть отправлена ​​StoreId или String), но он также удаляет оболочку из сгенерированного байт-кода (вместо прямой ссылки на базовый String), поэтому дополнительная память не используется и производительность во время выполнения не снижается.

Примечание: есть несколько сценариев, в которых компилятор Kotlin не может удалить оболочку, в этих случаях все еще происходит снижение производительности во время выполнения.

«Лучшие программы пишутся так, чтобы вычислительные машины могли выполнять их быстро и чтобы люди могли их ясно понимать». ― Дональд Кнут, Избранные статьи по компьютерным наукам

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

Например, многие наши сервисы имеют дело с Strings на периферии, например, при взаимодействии с HTTP или базами данных. Если мы определяем пользовательские типы для нашей модели домена, мы обычно подвергаемся двойному штрафу за перевод в каждой транзакции:
String -> DomainType -> String
Если мы используем встроенный класс, обертывающий String вместо обычного типа домена тогда мы потенциально избегаем необходимости каких-либо переводов во время выполнения, но у нас все еще может быть более выразительная и безопасная модель предметной области, чем если бы мы напрямую использовали необработанные Strings повсюду.

Если компилятор Kotlin не может отказаться от оболочки, влияние встроенного класса на производительность будет не хуже, чем при использовании обычного доменного типа.

Последние мысли

«Самая большая проблема в командах разработчиков программного обеспечения — убедиться, что все понимают, что делают все остальные». — Мартин Фаулер

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

Легкость понимания достигается за счет сочетания:

  • Удобочитаемость — насколько легко отличить существенную сложность от случайной сложности.
  • Выразительность — насколько ясно это выражает намерение (существенная сложность / логика предметной области)

Котлин легко читается. Он следует идее соглашение важнее конфигурации и избегает некоторых аспектов случайной сложности, делая шаблон неявным, что позволяет разработчикам сосредоточиться на существенной сложности.

Kotlin предоставляет нам несколько инструментов, которые позволяют разработчикам явно и недвусмысленно выражать существенную сложность. Когда я читаю код Kotlin, я ловлю себя на том, что задаюсь такими вопросами, как «Были ли эти данные проверены?» или «Может ли эта ссылка быть нулевой?» или «Какие состояния данных охватываются этим путем кода?» гораздо реже, чем когда я читаю Java.

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

Если вы хотите присоединиться к нам в нашем путешествии, пожалуйста, посетите нашу страницу вакансий.

Спасибо за прочтение.