Котлин — это не Java

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

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

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

Однако есть одна загвоздка: Kotlin — это не Java. Это может показаться довольно очевидным, но это важно отметить. Если ваша команда начнет разрабатывать код на Kotlin (или Scala) так же, как это было на Java, в итоге вы получите кодовую базу, которая не получит преимуществ от инструментов языка.

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

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

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

Позвольте компилятору проверить ваш код

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

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

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

Доказательства системы типов

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

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

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

fun multiplyTail(list: List<Int>): Int =
  list.first() * list.drop(1).sum()

Эта функция принимает в качестве параметра список целых чисел и возвращает одно целое число. Другими словами, его домен — это весь возможный список целых чисел любой длины, а его домен — любое целое число. Таким образом, эта функция в основном сопоставляет каждый возможный список целых чисел со значением типа Int… за исключением одного конкретного случая. Если задан пустой список, то функция выдаст исключение вместо того, чтобы обычно возвращать целое число: наша функция не определена для случая пустого списка.

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

@JvmInline
value class NonEmptyList<T> private constructor(
    private val list: List<T>
): List<T> by list {
    companion object {
        fun <T> of(firstElement: T, vararg tail: T): NonEmptyList<T> = 
            NonEmptyList(listOf(firstElement) + tail)
            
        fun <T> fromList(other: List<T>): List<T> {
            require(other.isNotEmpty(), { "List cannot be empty" })
            return NonEmptyList(other)
        }
    }
}

fun multiplyTail(list: NonEmptyList<Int>): Int =
  list.first() * list.drop(1).sum()

fun main() {
    println("Result: ${multiplyTail(NonEmptyList.of(1, 2, 3))}") // This works
    println("Result: ${multiplyTail(NonEmptyList.of())}") // This doesn't compile
    println("Result: ${multiplyTail(NonEmptyList.fromList(listOf()))}") // This compiles, but fails in runtime.
}

В этом примере мы создали тип NonEmptyList<T>, который не может быть сконструирован, если не выполняется ограничение наличия непустого списка (ну, возможно, это возможно, если вы начнете играть с хакерским отражением, но не если мы строго соблюдаем правила видимости языка). Делая это, мы снова неформально доказываем, что наличие экземпляра NonEmptyList подразумевает, что внутренний список не пуст. В мире Scala это можно было бы назвать уточненным типом. Несмотря на то, что это не распространенный шаблон в Kotlin, позже в этой статье мы увидим, что они все же весьма полезны при некоторых обстоятельствах.

Другой пример доказательств, которые мы можем сделать с типами, — использование типа Nothing. Этот тип представляет собой нижний тип в Котлине и является обитаемым, то есть его домен пуст. Что это означает с точки зрения доказательств, так это то, что единственный способ иметь значение типа Nothing в определенной точке кода, учитывая тот факт, что домен типа пуст, заключается в том, что код недоступен или, в контексте функции, возвращающей этот тип, что он не может нормально возвращаться, кроме как выбрасывая исключение. Например, взгляните на эти три примера:

fun thisFunctionCannotBeEverCalled(value: Nothing) {
    
}

fun infiniteLoop(): Nothing {
    while (true) {}
}

fun raiseListEmptyException(): Nothing {
    throw IllegalArgumentException("List cannot be empty")
}

Используя тип Nothing, мы доказываем разные утверждения:

  • В первом примере мы доказываем, что, поскольку нет значения, которое могло бы удовлетворить входным параметрам, эта функция никогда не может быть вызвана.
  • Во втором и третьем примерах мы доказываем, что функция никогда не вернется в нормальное состояние. Это особенно полезно для информирования компилятора о том, что некоторые ветки нашего кода недоступны, чтобы он мог скомпилировать наш код на основе этого инварианта. Например, в Java, если вы хотите быстро выйти из приложения CLI, вы сделаете что-то вроде:
public static void main(String[] args) {
  String firstArg;
  if (args.length == 0) {
    System.err.println("Invalid args count");
    System.exit(1);
    return;
  } else {
    firstArg = args[0];
  }
 
  System.out.println(firstArg);
}

Очевидно, что оператор return в блоке if не нужен, потому что после вызова exit(1) процесс всегда останавливает свое выполнение, и этот вызов никогда не вернется. Однако, если мы ее не напишем, программа не скомпилируется, поскольку Java не может гарантировать, что переменная firstArg не была инициализирована до ее первого использования; он не может сделать вывод, что первая ветвь if никогда не вернется. В Kotlin функция exitProcess определяется путем возврата Nothing в качестве результата по этой причине. Вот как это выглядит:

fun main(args: Array<String>) {
  val firstArg: String
  if (args.isEmpty()) {
    println("Invalid args count")
    exitProcess(1)
  } else {
    firstArg = args.first()
  }
 
  println(firstArg);
}

И наоборот, в этом случае код компилируется без каких-либо проблем, потому что благодаря типу Nothing, возвращаемому функцией exitProcess, Kotlin может сделать вывод, что первая ветвь if никогда не вернется, и, следовательно, если код достигает последнего println, это потому, что код прошел через ветвь else условия.

Запретить использование!! Оператор с первого дня

Это, вероятно, одно из самых важных конкретных указаний, которые следует рассмотреть применительно к Kotlin, и, действительно, в Detekt по умолчанию активны некоторые правила для отлова этого.

Одной из наиболее распространенных ошибок времени выполнения, которые мы встречаем в Java, является хорошо известная ошибка NullPointerException, и она возникает из-за того, что система типов считает допустимым значение «null» как часть домена любого непримитивного типа. Это из самых основ, что требует переосмысления: система типов, которая позволяет иметь значение, означающее «отсутствие значения» внутри домена типа, проблематична, потому что всегда будет один случай, который вам нужно обрабатывать специально для каждого отдельного непримитивного значения в вашем коде.

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

И наоборот, Kotlin обрабатывает этот конкретный случай, позволяя указать маркер (?), который отличает тип, который может содержать значение, допускающее значение NULL, от типа, который не может. Это решение похоже, но не совсем то же самое, на такие языки, как Rust или Scala, которые представляют собой отсутствие значения с «типом-оболочкой», который имеет два возможных варианта: один, где значение присутствует и предоставляет доступ к этому значению; и другой, когда просто нет ценности. В любом случае, эти подходы позволяют вам обрабатывать ситуации, когда доказано, что значение всегда присутствует, иначе, чем те, где значения может и не быть. В последнем случае это заставит программу всегда обрабатывать нулевой регистр, тем самым предотвращая любые ошибки NullPointerException.

Тем не менее, есть еще две основные возможные ситуации, в которых Kotlin все еще может поднять NullPointerException:

Первый — при вызове кода Java с нулевыми значениями или использовании объектов, возвращаемых кодом Java: в этих случаях Kotlin не может быть уверен, должны ли объекты быть нулевыми при вызове кода Java или возвращаемые значения из кода Java могут быть нулевыми, если они явно не помечены аннотациями @Nullable или @NotNull. В этих случаях разработчик должен применять или не применять правила, чтобы всегда проверять допустимость значений NULL для возвращаемых значений для предотвращения дальнейших ошибок в коде Kotlin.

Второй — при использовании оператора !!: этот оператор позволяет напрямую обращаться к значениям типов, помеченных как обнуляемые, без дополнительных проверок. Таким образом, открывается возможность поднять NullPointerExceptions. И это причина, по которой его не следует использовать. Злоупотребление этим оператором приведет к тому, что наш код Kotlin не принесет пользы; это могло быть написано на Java с точки зрения проблем с нулевым значением.

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

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

  • Проверка входных данных: если ваша система получает набор входных данных, которые могут быть обнуляемыми, например те, которые представлены data class, где все его поля помечены как обнуляемые, и ваша система проверяет необнуляемость всех этих полей, убедитесь, что вы распространяете результаты этой проверки за пределы своих функций проверки. Вы можете сделать это, определив новый класс с теми же полями, что и исходный data class, но со всеми полями, помеченными как необнуляемые. Делая это, вы сообщаете компилятору и остальной системе, что вы должным образом проверили, что эти значения не равны нулю, и вы можете использовать это ограничение во всей остальной системе.
    В более общем плане, хорошей практикой, позволяющей компилятору лучше проверять наш код, является представление этого значения более точным типом, который подтверждает это ограничение. Конечно, к этому утверждению нужно относиться с осторожностью, так как попытка представить результат каждой проверки, включая такие вещи, как размеры списка, целые знаки и т. д., может быть настолько многословной, что на самом деле она того не стоит. Примером этой проверки может быть что-то вроде этого:
data class CreateUserRequest(
  val name: String?,
  val email: String?
)

data class ValidatedCreateUserRequest(
  val name: String,
  val email: String
)

fun validateCreateUserInput(input: CreateUserRequest): ValidatedCreateUserRequest {
  if (input.name == null) {
    throw BadRequestException("Name cannot be null")
  }

  if (input.email == null) {
    throw BadRequestException("Email cannot be null")
  }

  return ValidatedCreateUserRequest(name, email)
}
  • Поле в моей модели всегда будет не нулевым, только если другое поле в моей модели также не является нулевым: в этом случае хорошей рекомендацией будет поместить эти два поля в другую модель, которая сама может быть обнуляемой, чтобы ограничение допустимости нулевых значений этих полей было связано вместе. Например:
// Instead of this

data class Person(
  val name: String,
  val tshirtType: TShirtType?,
  val tshirtColor: Color?
)

// We can rearrange our model like this
data class PersonTShirt(
  val tshirtType: TShirtType,
  val tshirtColor: Color
)

data class Person(
  val name: String,
  val tshirt: PersonTShirt?
)

Сделать недопустимые состояния непредставимыми

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

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

data class Employee(
  val name: String?,
  val email: String?,
  val position: String,
  val directReports: List<Employee>?
)

В этом примере мы можем определить, что единственными допустимыми позициями являются SALES_REP и SALES_MANAGER, и только последняя позиция может иметь отчеты.

С этими данными мы можем внести некоторые улучшения в нашу модель, чтобы сделать некоторые недопустимые значения непредставимыми:

  • Во-первых, в этом случае мы можем предположить, что имя и адрес электронной почты обязательны, потому что может быть только сотрудник с этими атрибутами. Тогда мы можем удалить маркер обнуления.
  • Во-вторых, положение очень расплывчато. Позиция может быть определена более точно с помощью перечисления или закрытого класса, чтобы ограничить количество возможных значений.
  • В-третьих, поскольку отчет мог предоставить только менеджер по продажам, мы можем изменить нашу модель, включив поле directReports в запечатанный класс, который зависит от позиции, поэтому мы можем получить доступ к этому значению только тогда, когда мы сопоставляем позицию, что также позволит нам удалить маркер допустимости значений NULL.

Применяя эти предложения, наша окончательная модель будет выглядеть так:

sealed class EmployeePosition {
  object SalesRep : EmployeePosition()
  data class SalesManager(val reports: List<Employee>): EmployeePosition
}

data class Employee(
  val name: String?,
  val email: String?,
  val position: EmployeePosition
)

Изысканные типы

В нашем примере выше мы определили адрес электронной почты сотрудника в виде строки. Но при этом есть некоторые проблемы:

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

Один шаблон, который мы можем применить здесь, заключается в использовании того, что довольно распространено в мире Scala, а именно уточненных типов. Эти типы являются просто обертками или, может быть, просто маркерами, в зависимости от того, как мы это реализуем, вокруг других типов, которые ограничивают область возможных значений исходного типа. Например, у нас может быть уточненный тип, который ограничивает Int положительным значением или строкой, ограниченной регулярным выражением. Это позволяет нам быть более точными с нашими типами. Мы можем рассматривать наш тип NonEmptyList из одного из наших предыдущих примеров как уточненный тип: список, который удовлетворяет условию, что он содержит хотя бы один элемент.

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

@JvmInline
value class Email private constructor(val value: String) {
    companion object {
        private val EMAIL_PATTERN = // ...
        
        fun of(value: String): Email {
            if (EMAIL_PATTERN.matchEntire(value) == null) {
                throw IllegalArgumentException("Value $value is not a valid email because it doesn't match pattern ${EMAIL_PATTERN}")
            }
            return Email(value)
        }
    }
}

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

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

Разумные пределы

Хорошо, идея сделать недопустимые состояния непредставимыми звучит неплохо, но должен же быть предел. В зависимости от вашего варианта использования может быть чрезвычайно сложно добиться идеального соответствия между доменом ваших данных и вашими бизнес-правилами. Например, возможно, вы не заинтересованы в уточнении каждого отдельного числа в вашем приложении, чтобы доказать, что они положительные, отрицательные или между двумя числами (особенно это последнее, что может быть сложно, поскольку Kotlin не поддерживает что-то вроде const generics). Возможно, в таких ситуациях вы просто предпочитаете вернуться к классической предыдущей проверке параметров в начале функции и завершить ее.

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

Изменчивость — корень всех зол… в основном

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

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

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

Обработка ошибок

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

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

Проблема возникает, однако, с введением лямбда-функций в Java 8. И именно здесь вы начинаете ощущать, что с ними сложно комбинировать исключения, потому что функции, определяющие выбрасываемые исключения, очень сложно скомпоновать вместе. И у вас может получиться такая ситуация:

public String processElement(String value) throws SomethingWentBadException {
 // ...
}

public List<String> processList(List<String> input) {
  return input.stream()
           .map(this::processFilter) // This fails to compile!
           .collect(Collectors.toList());
}

Это проблема! Потому что функция фильтра не ожидает, что переданная лямбда что-то выкинет, но в нашем случае это происходит, и она не компилируется. Так что либо вы делаете это:

public List<String> processList(List<String> input) {
  return input.stream().filter((e) -> {
    try {
      return processFilter(e);
    } catch (SomethingWentBadException ex) {
      logger.error("Error!", ex);
    }
  }).collect(Collectors.toList());
}

… что ужасно не только с точки зрения стиля кода, но и из-за того, что вы подавляете возникновение исключений, как и ожидалось. Или вы используете классический Lombok @SneakyThrows, который также самоуверен, потому что он не позволит вам обработать это исключение, если оно понадобится вам в другой части вашего кода. Это также скроет исключение из подписи метода. Последним вариантом также может быть определение вашей собственной функции карты, которая поддерживает создание исключений.

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

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

Еще одним интересным подходом было бы использование типа Either известной Arrow Library. Эта библиотека, среди прочего, определила тип Either<L, R>, определение которого можно упростить следующим образом:

sealed class Either<out L, out R> {
  data class Left<L>(val value: L): Either<L, Nothing>
  data class Right<R>(val value: R): Either<Nothing, R>
}

По сути, тип Either позволяет нам хранить либо значение типа L, либо значение типа R. В случае обработки ошибок мы можем использовать его для сохранения значения справа, когда все прошло нормально, и значения слева, если что-то случилось. Это позволит нам сохранить там причину ошибки (сохранение «правильного» значения справа, а ошибки слева — это просто соглашение, но ему удобно следовать, потому что Arrow API имеет несколько полезных инструментов, основанных на таком расположении). Как мы будем хранить ошибки в этой левой части, зависит от разработчика, и они могут быть любыми исключениями, вложенными в тип Both или запечатанные классы, которые точно описывают все возможные ошибки, которые может вызвать функция, чтобы они могли быть сопоставлены с шаблоном вызывающими объектами.

Использование типа Either означает, что мы можем восстановить видимость ошибок, которую мы потеряли с удалением проверенных исключений в Kotlin и необходимостью снова обрабатывать ошибки, но в более компонуемом стиле.

Тем не менее, этот подход не свободен от недостатков, и есть несколько, которые следует учитывать:

  • Это более многословно, чем обычные исключения, и иногда его сложнее изучить. Если ваша команда когда-либо работала с этим раньше, это может усложнить кривую обучения.
  • Вызов кода Java и вызов кода Java или кода, который использует исключение для обработки ошибок, становится сложнее: каждый раз, когда вам нужно использовать код, который может генерировать исключения, вам, вероятно, нужно обернуть эти вызовы в функции, которые могут обрабатывать исключения, которые происходят внутри, и сопоставить их с правильным экземпляром Either, чтобы вы могли распространять эту ошибку через ваше приложение обработки исключений на основе Either. Это добавляет дополнительный шаг каждый раз, когда вы хотите вызывать API такого типа из своего приложения.
  • Вам все равно придется иметь дело с исключениями: это не обязательно недостаток, но просто то, что нужно учитывать. Все исключения нельзя просто заменить обработкой ошибок в стиле Either, в основном потому, что есть исключения, запускаемые через стандартную библиотеку Kotlin, которые вы не можете изменить. Но это также то, как обработка ошибок работает в Rust, например: для ошибок, вызванных ошибками ввода-вывода, системными ошибками и т. д., для распространения ошибки используется Result (эквивалент Both в Rust). Но если происходит фатальная ошибка, например, попытка доступа к индексу за пределами списка, возникает паника, что было бы эквивалентно нашим исключениям. При таком способе обработки ошибок фатальные ошибки по-прежнему вызывают исключения, но другие ошибки, которые могут быть устранены, такие как сетевые ошибки, ошибки проверки и т. д., могут распространяться с помощью монады Either.

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

Наконец, объяснив, как мы будем обрабатывать ошибки с помощью Arrow в Kotlin, мы можем закончить наш пример:

fun processElement(value: String): Either<SomethingWentWrongException, String> {
    // ...
}

fun processList(input: List<String>): Either<SomethingWentWrongException, List<String>> {
    return input.traverse(::processElement)
}

Корутины или не корутины?

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

  • Вы зависите от библиотек, которые сильно зависят от локальных переменных потока? Если это так, вам следует потратить несколько минут, чтобы выяснить, сколько усилий может потребоваться вам для создания Элементов контекста сопрограммы, которые вам потребуются, чтобы сделать эту библиотеку совместимой с сопрограммами.
  • Сильно ли вы зависите от зависимостей, использующих блокирующий ввод-вывод? Если это так, я все еще вижу преимущества в использовании Coroutines, потому что у них есть обширный API, который все еще может помочь вам распараллелить нисходящие запросы, создать таймеры, отменяемые запросы и т. д. Но крайне важно учитывать, что потоки сопрограммы никогда не должны блокироваться длительными процессами, включая вызовы, которые развивают дорогостоящие вычисления ЦП или блокируют вызовы ввода-вывода. И это проблема, потому что нет точного линтера, который мог бы предупредить разработчика, что он блокирует вызовы из потока сопрограммы. Это то, что следует учитывать, поскольку это еще один источник возможных ошибок, которые могут легко снизить производительность вашего приложения.

Но что, если вы разрабатываете, например, недолговечную функцию AWS Lambda или приложение CLI? Имеет ли смысл иметь сопрограммы? Ну, для меня это имело смысл, потому что в разных частях моего приложения мне нужно было делать параллельные запросы к моим нижестоящим службам. Сопрограммы позволили мне создать приложение более универсально и просто, чтобы я мог использовать их при необходимости. Если вы чувствуете, что это может никогда не случиться с вами, вероятно, нет смысла идти по этому пути.

Заключение

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