Объединения
Определение
В Libretto нет механизма наследования, а для реализации полиморфизма используются либо объединения, либо трейты.
Объединения являются более простым и естественным средством организации единой работы с разными типами данных.
Например, есть последовательность целых чисел (1, 2, 3), её типом будет Int+ (несколько значений, нет пустоты). Есть последовательность строк ("abc", "def"), её типом будет String+ (несколько значений, нет пустоты).
Но можно сделать последовательность, которая содержит и целые числа, и строки: (1, "abc", 2, 3, "def"). В обычных ОО-языках типом элемента был бы общий предок, а если такого нет, то наиболее общий класс (Object в Java, например).
В Libretto же типом элемента этой последовательности будет объединение (Int | String). Это означает, что элемент - это Int или String. Знак | и обозначает или. При непосредственном использование объединения тип берётся в скобках, а порядок перечисления типов не имеет значения.
А значит смешанная последовательность будет иметь тип (Int | String)+ или (String | Int)+ (несколько значений, нет пустоты):
def main = {
fix t1: (Int | String)+ = (1, "abc", 2, 3, "def")
fix t2: (String | Int)+ = (1, "abc", 2, 3, "def")
}
Можно использовать и несколько типов, а не только два (порядок тоже не имеет значения):
def main = {
fix t: (Int | String | Real)+ = (1, "abc", 2, 3, "def", 3.14)
}
Но в перечислении через | (вне зависимости от числа) могут быть указаны только типы без кардинальности. Не допускаются Any, (), -. Т.е. это могут быть структуры (в том числе предопределенные), трейты и другие объединения:
trait T
struct S(v: Int)
def main = {
fix t: (T | S | (Int | String))? = 123
}
Вложенность объединений игнорируется. В этом примере переменная t по определению может содержать трейт T, структуру S, структуру Int, структуру String, но реально содержит только число (Int).
Компилятор умеет выводить тип объединений:
def main = {
fix t = (1, "abc")
println: type(t)
}
Выдаёт
(libretto/Int | libretto/String)+
В этом примере тип неизменяемой переменной t будет (libretto/Int | libretto/String)+.
union
Чтобы постоянно не писать длинные перечисления типов для объединения, можно задать краткое имя при помощи union:
union IntOrString = Int | String
def main = {
fix t: IntOrString* = (1, "abc")
println: type(t)
}
Выдаёт
(libretto/Int | libretto/String)*
В union внешние скобки можно не указывать. Требования к перечисляемым типам точно такое же: типы без кардинальности. Не допускаются Any, (), -. Это могут быть структуры (в том числе предопределенные), трейты и другие объединения (в тои числе заданные через union). Зацикливание union не допускается: union A = Int | A вызовет ошибку компиляции.
Например, можно использовать union только с одним типом:
struct S(v: Int)
union Alias = S
def main = {
fix t: Alias = S(123)
}
Но для создания структуры должен использоваться метод S, а создать через Alias(123) невозможно, поскольку такой структуры нет, а тип Alias является только кратким именем для S.
Наследования против объединения
Для включения какого-то типа (класса) в иерархию наследования нужно этот тип (класс) наследовать от какого-то родительского типа (класса). Если тип (класс) в сторонней библиотеке, то это может быть невозможно. Если нужно включить класс в несколько иерархий, то придется использовать множественное наследование, которое может быть недоступно в языке.
В объединение можно включать тип без всяких действий со стороны типа. И вне зависимости от его происхождение. Можно включать тип и в несколько объединений сразу.
struct S(v: Int)
union Union1 = S | Int
union Union2 = S | String
def main = {
fix t = S(123)
fix u1: Union1+ = (t, 123)
fix u2: Union2+ = (t, "abc")
}
Здесь S используется в двух объединения одновременно.
Объединения и case
Объединения можно разобрать при помощи case:
union U = Int | String
def work(v: U*) = {
v.{
case i: Int = println("Int: " + i)
case s: String = println("String: " + s)
}
}
def main = {
work: (1, "abc", 2)
}
Выдаёт:
Int: 1
String: abc
Int: 2
Важно, что здесь разбираются все случаи объединения: Int или String. Если добавить else = (с пустой правой частью), то компилятор проверит, что в case действительно разобраны все варианты объединения:
union U = Int | String
def work(v: U*) = {
v.{
case i: Int = println("Int: " + i)
case s: String = println("String: " + s)
else =
}
}
def main = {
work: (1, "abc", 2)
}
Если поменять определение на union U = Int | String | Real, то компилятор выдаст ошибку, поскольку не разобран случай с Real.
Использование разбора при помощи case (или аналога) в обычных ОО-языках имеет проблему расширения. Если иерархия классов не является закрытой, то её подкласс может быть определен позже, но уже существующий код, разбирающий объекты этой иерархии при помощи case, не будет знать об этом подклассе, что нарушит семантику программы.
В Libretto такой проблемы нет, поскольку объединение закрыто, добавить в него тип снаружи невозможно. Поэтому можно гарантировать полный разбор всех случаев при помощи case.
Вызов методов и полиморфизм
Если объединение встречается в контексте, то выбор метода будет производиться при помощи автоматического разбора этого объединения:
union U = Int | String
def Int work = println("Int: " + this)
def String work = println("String: " + this)
def main = {
(1, "abc", 2).work
}
Выдаёт:
Int: 1
String: abc
Int: 2
Но для всех типов объединения должен быть подходящий метод. Не обязательно непосредственно, возможно и с обобщением:
def Int work = println("Int: " + this)
def Any work = println("Any: " + this)
def main = {
(1, "abc", 2, 3.14).work
}
Выдаёт:
Int: 1
Any: abc
Int: 2
Any: 3.14
Для Int используется метод work с контекстом Int, а для Real, String - с контекстом Any.
Возможность выбора метода по контексту объединения - это и есть возможность реализации полиморфизма, подобного тому, что в ОО-языках делается при помощи наследования.
Не обязательно, чтобы результаты методов для каждого типа объединения имели одинаковый тип. Компилятор автоматически выведет тип с использованием объединения, если эти типы отличаются:
def Int work = this.real // Real
def String work = this + "!" // String
def main = {
fix t = (1, "abc").work
println: type(t)
}
В этом примере тип t будет (Real | String)+, поскольку типом work с контекстом Int будет Real, а с контекстом String - String.
Методы могут быть и неявные. Например, методы доступа к полям структур, которые создаются автоматически.
struct S1(title: String)
struct S2(title: String, value: Int)
struct S3(title: String)
def main = {
fix seq = (S1("S1"), S2("S2", 123), S3("S3"))
seq.title.${
println: $
}
}
Выдаёт
S1
S2
S3
Достаточно того, что у всех структур есть поле с одинаковым именем title, которое доступно и через объединение этих структур.
Обобщения
В настоящее время обобщения объединения не поддерживается. Но, возможно, в будущем будет возможно написать union List[A] = Nil | Cons[A].
Объединения в контексте определения метода
Объединение можно указать в контексте определения метода:
def (Int | String | Real) work = println: this
def main = {
(1, "abc").work
}
Выдаёт:
1
abc
При выборе метода для объединения этот контекст будет учитываться:
union IntOrString = Int | String
def IntOrString work = println: "(Int | String):" + this
def Real work = println: "Real: " + this
def main = {
(1, "abc", 3.14).work
}
Выдаёт:
(Int | String):1
(Int | String):abc
Real: 3.14
В текущей версии Libretto объединение в контексте разворачивается на несколько методов при компиляции:
Например, если есть определение:
def (Int | String) work = println: "(Int | String):" + this
То при компиляции оно будет развёрнуто на два:
def Int work = println: "(Int | String):" + this
def String work = println: "(Int | String):" + this
Это историческое решение, но при обычном использовании это не даёт особенностей.