Объединение типов

Используемый для типов | оператор создает так называемый тип объединения (union type). Тип А | B представляет значения, которые относятся либо к типу A, либо к типу B.

В следующем примере метод help принимает параметр с именем id типа объединения Username | Password, который может быть либо Username, либо Password:

case class Username(name: String)
case class Password(hash: Hash)

def help(id: Username | Password) =
  val user = id match
    case Username(name) => lookupName(name)
    case Password(hash) => lookupPassword(hash)
  // ...

help реализуется используя pattern matching.

Этот код является гибким и типобезопасным решением. Если попытаться передать тип, отличный от Username или Password, компилятор пометит это как ошибку:

help("hi")   // error: Found: ("hi" : String)
             //        Required: Username | Password         

Ошибка также будет получена, если попытаться добавить case в выражение match, которое не соответствует типам Username или Password:

case 1.0 => ???   // ERROR: this line won’t compile

Альтернатива объединенным типам

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

Предварительное планирование иерархии классов

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

trait UsernameOrPassword
case class Username(name: String) extends UsernameOrPassword
case class Password(hash: Hash) extends UsernameOrPassword
def help(id: UsernameOrPassword) = ...

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

Теговые объединения

Другой альтернативой является задание отдельного типа перечисления, например:

enum UsernameOrPassword:
  case IsUsername(u: Username)
  case IsPassword(p: Password)

Перечисление UsernameOrPassword представляет собой помеченное объединение Username и Password. Однако этот способ моделирования объединения требует явной упаковки и распаковки, и, например, Username не является подтипом UsernameOrPassword.

Вывод типов объединения

Компилятор присваивает типу объединения выражение, только если такой тип явно задан. Например, рассмотрим такие значения:

val name = Username("Eve")   
// name: Username = Username(name = "Eve")   
val password = Password(123) 
// password: Password = Password(hash = 123)

В этом примере показано, как можно использовать тип объединения при привязке переменной к результату выражения if/else:

val a = if true then name else password
// a: Object = Username(name = "Eve")
val b: Password | Username = if true then name else password
// b: Password | Username = Username(name = "Eve")

Типом a является Object, который является супертипом Username и Password, но не наименьшим супертипом, Password | Username. Если необходим наименьший супертип, его нужно указать явно, как это делается для b.

Типы объединения являются двойственными типам пересечения. И как & с типами пересечения, | также коммутативен: A | B того же типа, что и B | А.

Детали

Взаимодействие в сопоставлении с образцом

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

case _: A | B => ...

эквивалентен:

case (_: A) | B => ...

, а не:

case _: (A | B) => ...

Правила подтипа

Правила подтипа:

Из этих правил следует, что наименьшая верхняя граница (LUB - least upper bound) множества типов есть объединение этих типов.

Мотивация

Основная причина введения типов объединения в Scala заключается в том, что они позволяют гарантировать, что для каждого набора типов всегда можно сформировать конечный LUB.

Кроме того, типы объединения являются полезной конструкцией при попытке дать типы существующим API с динамической типизацией, поэтому они являются неотъемлемой частью TypeScript и даже частично реализованы в Scala.js.

Соединение типа union

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

Дано:

trait C[+T]
trait D
trait E
class A extends C[A] with D
class B extends C[B] with D with E

Объединение A | B - это C[A | B] & D.

Вывод типа

При выводе типа результата определения (переменной или метода - val, var, def), если тип, который собираемся вывести, является типом объединения, он заменяется объединением.

Пример:

import scala.collection.mutable.ListBuffer
val x = ListBuffer(Right("foo"), Left(0))
val y: ListBuffer[Either[Int, String]] = x

Этот код проходит проверку типов, потому что аргумент предполагаемого типа ListBuffer в правой части x - Left[Int, Nothing] | Right[Nothing, String] - был расширен до Either[Int, String]. Если бы компилятор не сделал этого расширения, последняя строка не проходила бы проверку типов, потому что ListBuffer инвариантен в своем аргументе.

Элементы

Элементы типа union являются элементами его объединения.

Пример

Следующий код не проходит проверку типов, так как метод hello не является членом AnyRef, который является объединением A | B.

trait A { def hello: String }
trait B { def hello: String }

def test(x: A | B) = x.hello // error: value `hello` is not a member of A | B

С другой стороны, допускается следующее:

trait D
trait E
trait C { def hello: String }
trait A extends C with D
trait B extends C with E

def test(x: A | B) = x.hello // ok, т.к. `hello` - элемент объединения A | B, который является подтипом C

При этом необходимо отметить, что в метод test нельзя передать экземпляр С, потому что A | B - подтип C, но не наоборот:

val a: A = new A:
  def hello = "Hello, A!"
val b: B = new B:
  def hello = "Hello, B!"
test(a)
// res0: String = "Hello, A!"
test(b)
// res1: String = "Hello, B!"
val c: C = new C:
  def hello = "Hello, C!"
test(c)
// error:
// Found:    (App0.this.c : App0.this.C)
// Required: App0.this.A | App0.this.B
// test(c)
//      ^

Проверка полноты в сопоставлении с образцом

Если селектор совпадения с образцом является типом объединения, совпадение считается исчерпывающим, если охватываются все части объединения.

Пример:

trait A { def hello: String }
trait B { def hello: String }

def matching(x: A | B): String =
  x match
    case a: A => a.hello
    case b: B => b.hello
    case c: C => c.hello // Лишнее

-- [E030] Match case Unreachable Warning: --------------------------------------
5 |    case c: C => c.hello
  |         ^^^^   |         Unreachable case

Стирание типов

Стертый тип для A | B является наименьшей верхней границей (erased least upper bound) стираемых типов A и B. Цитата из документации TypeErasure#erasedLub, стертый LUB вычисляется следующим образом:


Ссылки: