О чем эта статья?
В данной статье я хочу показать, почему развитая система типов в языке программирования это здорово. Я подробно расскажу как устроен класс Either из библиотеки Arrow, разберу особенности системы типов Kotlin - sealed-иерархии, ковариантность и Nothing, без которых решение задачи становится практически невозможным.
Я попробую провести небольшой ликбез о таких на первый взгляд сложных вещах на понятном практическом примере.
Что такое Either?
Either - это класс, который содержит в себе значение одного из двух типов. По сути, это тип либо A, либо B (either A or B). Далее по тексту я буду делать акцент на фразе либо-либо, чтобы подчеркнуть, что речь идет о типах класса Either.
Важно: не путайте Either с типом пары (Pair), потому что пара содержит в себе два значения одновременно, а Either содержит одно значение одного из двух типов.
Где он может пригодиться? Давайте разбираться на примере.
Допустим, мы выполняем поход в сервис по API и хотим вернуть либо полезные данные, либо сообщить об ошибке, если во время вызова что-то пошло не так.
data class Data(val payload: String)
enum class Error {
UNAUTHORIZED,
FORBIDDEN,
BAD_REQUEST,
INTERNAL_ERROR;
}
interface MyService {
fun getData(): Either<Error, Data>
}Мы определили класс Data, содержащий полезные данные, которые возвращаются в случае, если все прошло без ошибок.
Класс Error, который содержит перечисление возможных ошибок.
Интерфейс MyService с функцией getData, которая возвращает либо данные, либо ошибку.
По соглашению, тип ошибки обычно располагается слева и называется Left, а тип полезных данных называется Right и располагается справа (спасибо, кэп).
Казалось бы, в чем проблема? Можно реализовать тип просто в виде пары с проверкой ограничений в конструкторе.
class Either<A : Any, B : Any>(val a: A?, val b: B?) {
init {
require(a == null && b == null || a != null && b != null) {
"Either should have only one value!"
}
}
}Проблема такого решения в том, что оно не обеспечивает типобезопасность. Мы хотим гарантировать, что предоставлено ровно одно значение, а другого не существует. В примере выше, это свойство проверяется во время исполнения и в случае его нарушения выкидывается исключение.
Еще одна проблема реализации выше - использование null для того, чтобы представить отсутствующее значение. Это ограничивает область применения нашего класса и запрещает использовать опциональные типы.
Если же написать типобезопасную реализацию этого класса, мы сможем гарантировать нужные нам свойства программы еще на этапе компиляции, а не во время исполнения, что снижает количество ошибок во время разработки.
Давайте рассмотрим приемы, необходимые для построения правильного класса Either.
Sealed классы и интерфейсы
Начнем с важной фичи системы типов Kotlin - это sealed-иерархии классов (они же алгебраические типы данных). Объявление класса или интерфейса с модификатором sealed запрещает создание наследников класса (или реализаций интерфейса) в других модулях и делает иерархию полностью известной на этапе компиляции.
Это, например, позволяет компилятору проверять ветки выражения when.
sealed interface Animal {
fun say()
}
class Cat : Animal {
override fun say() = println("meow")
fun purr() = println("cat can purr")
}
class Dog : Animal {
override fun say() = println("woof")
fun howl() = println("dog can howl")
}
fun test(animal: Animal) {
when (animal) {
is Cat -> animal.purr()
is Dog -> animal.howl()
}
}
fun main() {
test(Cat())
test(Dog())
}В данном примере за счет запечатанной (sealed) иерархии классов компилятор понимает, что выражение when перебрало все возможные варианты и нет необходимости в ветке else. При этом если появится какой-то новый наследник, то возникнет ошибка на этапе компиляции, а не во время выполнения.
Вернемся к нашему классу Either. У него всего два состояния - либо значение класса A, либо значение класса B. Попробуем представить его в виде sealed класса.
sealed class Either<A, B> {
class Left<A>(val a: A)//: Either<A, ???>
class Right<B>(val b: B)//: Either<???, B>
}Теперь у нашего класса существует два наследника (состояния) и мы на полпути к решению задачи. Однако сейчас они не являются наследниками Either, потому что не реализуют тип для значения B. Возникает проблема - как предоставить ровно одно значение, да еще и любого типа, который указал пользователь? Чтобы решить эту проблему, на помощь приходит ковариантность типов.
Ковариантность типов (in / out)
Ковариантность типов (как и инвариантость, и контрвариантность) используется в классах с generic-параметрами и показывает как именно они наследуются между собой.
В примере ниже, мы объявляем класс Desk с ковариантным параметром User. Это разрешает присваивания класса Desk с любым наследником класса User там, где ожидается Desk<User>.
open class User
class Manager : User()
class Engineer : User()
class Desk<out U : User>(val user: U)
fun main() {
val desk: Desk<User> = Desk(Manager())
}Any? Nothing.
Еще одна особенность системы типов в Kotlin - наличие типов Any и Nothing.
С Any более менее все знакомы - это тип, который является родителем любого другого типа. А вот тип Nothing, его полная противоположность, встречается в современных языках программирования реже. Nothing - это тип, который является наследником любого типа.
Само по себе это не имеет смысла - понятно, что невозможно занаследовать все классы сразу, поэтому экземпляр Nothing не может быть создан. Сам класс имеет приватный конструктор, а тип означает значение, которое не существует. Например, функция, которая всегда кидает исключение, будет иметь тип возврата Nothing.
public class Nothing private constructor()Тогда отношение классов из предыдущего примера выглядит следующим образом.

И точно так же выглядит иерархия для классов с generic-параметром Desk за счет ковариантности.
Собираем воедино
Итак, теперь мы знаем, как представить значение, которое не существует и как разрешить подставлять тип-наследник вместо исходного типа за счет ковариантности.
Теперь, вооружившись всем необходимым, напишем свою итоговую реализацию Either.
sealed class Either<out A, out B> {
class Left<A>(val value: A): Either<A, Nothing>()
class Right<B>(val value: B): Either<Nothing, B>()
}
fun main() {
val a: Either<Int?, String> = Either.Left(null)
val b: Either<Int, String> = Either.Right("string")
}Сначала про использование. В примере видно, что в качестве Either<Int?, String> можно использовать либо Either.Left<Int?>, либо Either.Right<String>. Мы можем предоставлять одно из значений, не предоставляя другое.
А теперь, рассмотрим три ключевых момента, почему все устроено именно так:
sealed class Either- у нашего класса Either всего два возможных состояния и других быть не может. Для реализации этого свойства и его гарантии на этапе компиляции отлично подходит sealed class.<out A, out B>- эта конструкция позволяет подставлять вместо самих A и B любой из их наследников (в том числе Nothing)Either<A, Nothing>()- именно за счет Nothing гарантируется, что если у нас есть значение A, то значения B быть не может и наоборот. Значение одного из типов - Nothing.
Именно сочетание этих трех приемов вместе и дает возможность реализовать типобезопасный класс Either.
Заключение
Мы рассмотрели несколько важных аспектов системы типов в Kotlin и смогли написать свою типобезопасную реализацию класса Either. Это моя первая статья, поэтому буду рад любому фидбэку :)
