О чем эта статья?

В данной статье я хочу показать, почему развитая система типов в языке программирования это здорово. Я подробно расскажу как устроен класс 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>. Мы можем предоставлять одно из значений, не предоставляя другое.

А теперь, рассмотрим три ключевых момента, почему все устроено именно так:

  1. sealed class Either - у нашего класса Either всего два возможных состояния и других быть не может. Для реализации этого свойства и его гарантии на этапе компиляции отлично подходит sealed class.

  2. <out A, out B> - эта конструкция позволяет подставлять вместо самих A и B любой из их наследников (в том числе Nothing)

  3. Either<A, Nothing>() - именно за счет Nothing гарантируется, что если у нас есть значение A, то значения B быть не может и наоборот. Значение одного из типов - Nothing.

Именно сочетание этих трех приемов вместе и дает возможность реализовать типобезопасный класс Either.

Заключение

Мы рассмотрели несколько важных аспектов системы типов в Kotlin и смогли написать свою типобезопасную реализацию класса Either. Это моя первая статья, поэтому буду рад любому фидбэку :)

Комментарии (8)


  1. max-zhilin
    25.04.2024 07:27

    Спасибо, познавательно.

    А out нужен только для наследников? Без него можно использовать только конкретный тип, но не наследник?


    1. ivan_anikin Автор
      25.04.2024 07:27
      +1

      Если не указывать ни in, ни out, то дженерик является инвариантным.
      В таком случае будет между Desk<User> и Desk<Manager> нет никакой иерархии наследования. Экземпляр с наследником создать тоже получится, но без ковариантности не получится его использовать там, где ожидался Desk<User>


      1. demoth1
        25.04.2024 07:27

        > И точно так же выглядит иерархия для классов с generic-параметром Desk за счет ковариантности.

        Я думаю здесь помогла бы диаграмка именно для самих generic классов (Desk + sub/superclass), так как ко(нтр)вариантность наиболее явно проявляется как раз для них, а не для параметров типов (User в данном случае).


  1. max-zhilin
    25.04.2024 07:27

    А выведение типов тут не работает? Нельзя написать типа:

    var a = Either.Left(null)

    a = Either.Right("string")


    1. ivan_anikin Автор
      25.04.2024 07:27
      +1

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

      В твоем примере a имеет тип Either.Left<Nothing?> - потому что указано значение null и не ясно это Int?, String? или Any?, компилятор сам по себе тип не расширяет, поэтому в данном примере он считает, что это самый минимальный тип и это Nothing?.

      Для понятности я заменю строчку с нуллом на var a = Either.Left(1) , тогда тип будет Either.Left<Int> это то же самое, что и Either<Int, Nothing> по определению класса Left.

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

      var a: Either<Int?, String> = Either.Left(null)
      a = Either.Right("string")


      1. max-zhilin
        25.04.2024 07:27

        Ясно. Было бы круто не указывать тип в месте создания переменной, видимо, единственный способ это сделать - отказаться от дженериков и в объявлении Either указать конкретные типы A и B. Либо абстрактный Either унаследовать в конкретный EitherIntOrString (условно).


        1. ivan_anikin Автор
          25.04.2024 07:27

          Проблема решается заданием интерфейса и дополнительными функциями вокруг Either.

          Правильный тип выведется за счет типа интерфейса.

          interface MyService {
            fun f(): Either<Error, String>
          }
          
          enum class Error {
            NO_DATA
          }
          
          class MyImpl : MyService {
          
            fun getData(): String?
            
            override fun f() = either {
              val data = getData()
              ensure(data != null) {
                NO_DATA
              }
              data
            }
          }

          Более подробно такой подход к работе с ошибками можно в статье из документации Arrow


  1. Gerbylev
    25.04.2024 07:27

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