Мартин не раз выступал на эту тему, и я бы хотел собрать здесь всю актуальную информацию о Dotty – новые ключевые возможности и элементы, удаленные за ненадобностью.
Мартин Одерски. План развития Scala на ближайшие несколько лет
Этот пост будет полезен и знатокам, и совсем новичкам, для которых разговор о Dotty я предваряю рассказом об особенностях Scala, а также о том, что лежит в его математической основе.
Scala — мультипарадигменный язык программирования, изначально разработанный под JVM (Java virtual machine). Но в настоящее время также разработаны трансляторы в JavaScript (ScalaJS) и в нативный код (Scala native). Название Scala произошло от Scalable language («масштабируемый язык»). Действительно, на Scala удобно писать как маленькие скрипты из нескольких строчек, которые потом можно запускать в интерпретаторе (read-eval-print loop, REPL), так и сложные системы, запускаемые на кластере из большого количества машин (в частности, системы, построенные с использованием фреймворков akka и Apache spark).
Перед тем как разработать Scala, Мартин Одерски принимал участие в разработке обобщенных типов (generics) для Java, которые появились в Java 5 в 2004 году. Примерно тогда же Мартину пришла идея о создании нового языка для JVM, который не имел бы того огромного багажа обратной совместимости, который на тот момент был у Java. По задумке Мартина новый язык должен был сочетать объектно-ориентированный подход Java с функциональным подходом, аналогичным применяемому в языках Haskell, OCaml и Erlang, и при этом быть строго типизированным языком.
Одной из основных особенностей Scala как строго типизированного языка является поддержка автоматического выведения типов. В отличие от других типизированных языков, где для каждого выражения нужно явно указывать тип, Scala позволяет определять тип переменных, а также возвращаемый тип функции неявным образом. Например, определение константы в Java выглядит следующим образом:
final String s = "Hello world";
Это эквивалентно следующему выражению в Scala:
val s = "Hello world"
Тем не менее, в Scala также можно указать тип выражения явно, например, в случае, когда переменная должна иметь тип, являющийся супертипом от указанного выражения.
val cs: CharSequence = "Hello world"
Правила неявного выведения типов Мартин Одерски считает главной особенностью языка, отличающей его от других. В настоящее время он возглавляет работу над совершенствованием этой системы, а также над ее математическим обоснованием, именуемым DOT-исчислением (DOT-calculus).
DOT-исчисление
DOT расшифровывается как dependent object types, т.е. выведение типов зависимых объектов. Под зависимым типом подразумевается тип, полученный в результате определенной операции. В текущей версии языка уже существует определенный набор правил для выведения типов на основе существующих, например, ограничения по иерархии наследования сверху или снизу либо выведение типа в зависимости от аргумента (path-dependent type). Приведем небольшой пример:
trait A {
type B
def someFunction(b: B): B
}
trait C[X <: D] {
type Y = (X, D)
def fun1(x: X): Y
def fun2(a: A): a.B
}
В данном примере мы определяем два trait-а, A и C. trait A имеет поле-тип B, а также определяет некоторую операцию someFunction, принимающую на вход параметр типа B. Значение типа B определяется в зависимости от конкретной реализации A. trait C имеет параметр-тип X, который должен являться наследником типа D. trait C определяет поле-тип Y, а также две функции: fun1 и fun2. fun1 принимает на вход значение типа X и возвращает значение типа Y. fun2 принимает значение типа A, тип же возвращаемого значения определяется значением поля-типа B у аргумента А.
DOT-исчисление является математической формализацией правил такого выведения. Основные элементы DOT-исчисления:
- Top type (Any) — тип, лежащий на самом верху иерархии, является суперклассом для всех типов.
- Bottom type (Nothing) — тип, лежащий внизу иерархии, является подтипом всех типов.
- Type declaration — объявление типа в указанных границах сверху и снизу.
- Type selection — выведение типа в зависимости от переменной.
- Function — функция, принимающая на вход один или несколько аргументов различных типов и имеющая определенный тип значения.
Кроме того, DOT-исчисление определяет следующий набор допустимых операций над типами:
- Наследование. Любой тип, если он не является пограничным (в нашем случае Any и Nothing), может являться как супертипом, так и подтипом другого типа. Каждый тип является супертипом и подтипом для самого себя.
- Создание структурных типов (Records), включающих в себя другие типы (по аналогии с объектами и структурами для переменных).
- Объединение типов. Результирующий тип будет являться дизъюнкцией полей и операций исходных типов.
- Пересечение типов. Результирующий тип будет являться конъюнкцией полей и операций исходных типов.
- Рекурсивное определение типов.
Детальное рассмотрение DOT выходит за рамки данной публикации. Более подробную информацию про DOT-исчисление можно посмотреть здесь.
Обзор нововведений в Dotty
DOT-исчисление является математической основой для компилятора Dotty. Собственно, это отражено и в его названии.
Сейчас Dotty является экспериментальной платформой для отработки новых языковых концепций и технологий компиляции. Со слов Мартина Одерски, целью разработки Dotty является усиление основных конструкций и избавление от лишних элементов языка. В настоящий момент Dotty развивается как независимый проект, но планируется, что со временем он вольется в основную ветку Scala.
Полный список нововведений можно найти на официальном сайте Dotty. А в этой статье я рассмотрю только те нововведения в Dotty, которые считаю самыми важными.
1. Пересечения типов
Пересечение типов определяется как тип, который одновременно обладает всеми свойствами исходных типов. Допустим, у нас определены некоторые типы A и B:
trait A {
def fun1(): Int
}
trait B {
def fun2(): String
}
Тип С у нас определен как пересечение типов A и B:
type C = A & B
В этом случае мы можем написать следующую функцию:
def fun3(c: C): String = s"${c.fun1()} - ${c.fun2()}"
Как следует из примера, у параметра c мы можем вызвать как метод fun1(), определенный для типа A, так и метод fun2(), определенный для типа B.
В текущей версии компилятора такая возможность поддерживается через конструкцию with, например:
type C = A with B
def fun3(c: C): String = s"${c.fun1()} - ${c.fun2()}"
Между конструкциями & и with есть существенное различие: & является коммутативной операцией, то есть тип A & B эквивалентен типу B & A, в то время как A with B не эквивалентен B with A. Приведем пример:
trait A {
type T = Int
}
trait B {
type T = String
}
Для типа A with B значение типа T равно Int, так как A имеет приоритет над B. В Dotty же для типа A & B тип T будет равен Int & String.
Конструкция with для типов пока что поддерживается в Dotty, однако она объявлена как не рекомендуемая к использованию (deprecated), и в будущем планируется ее убрать.
2. Объединение типов
Объединение типов определяется как тип, обладающий свойствами одного из исходных типов. В отличие от пересечения типов, в текущей версии компилятора scala не существует аналогии для объединения типов. Для значений с объединенным типом в стандартной библиотеке есть тип Either[A,B]. Предположим, у нас определены следующие типы:
case class Person(name: String, surname: String)
case class User(nickname: String)
В этом случае мы можем написать следующую функцию:
def greeting(somebody: Person | User) = somebody match {
case Person(name, surname) => s"Hello, $name $surname"
case User(nickname) => s"Hello $nickname, (sorry, I actually don’t know your real name)"
}
Объединение типов дает нам более краткую форму записи в сравнении с использованием Either в текущей версии языка:
def greeting(somebody: Either[Person, User]) = somebody match {
case Left(Person(name, surname)) => s"Hello, $name $surname"
case Right(User(nickname)) => s"Hello $nickname, (sorry, I actually don’t know your real name)"
}
Объединение типов, как и пересечение, также является коммутативной операцией.
Одним из вариантов использования объединения типов является полное избавление от конструкции null. Сейчас в качестве альтернативы использованию null является конструкция Option, однако так как она реализована как обертка, то это слегка замедляет работу, потому что необходимы дополнительные операции по упаковке и распаковке. С использованием объединения типов разрешение будет осуществляться на этапе компиляции.
def methodWithOption(s: Option[String]) = s match {
case Some(string) => println(string)
case None => println("There’s nothing to print")
}
type String? = String | Null
def methodWithUnion(s: String?) = s match {
case string: String => println(string)
case Null => println("There’s nothing to print")
}
3. Определение наиболее близких подтипов и супертипов
С введением новых операций над такими составными типами, как объединение и пересечение, изменились правила расчета ближайших типов по иерархии наследования. Dotty определяет, что для любых типов T и U ближайшим супертипом будет T | U, а ближайшим подтипом будет T & U. Таким образом формируется так называемая решетка наследования (subtyping lattice). Она — на рисунке ниже.
В текущей реализации Scala ближайший супертип определяется как общий супертип для двух типов. Так, в общем случае, для двух case классов T и U ближайшим супертипом будет Product with Serializable. В Dotty же это однозначно определено как T | U.
Для случая ближайшего подтипа в текущей реализации Scala нет однозначного ответа. Ближайшим подтипом может быть как T with U, так и U with T. Как ранее уже было упомянуто, операция with не является коммутативной, поэтому тип T with U не эквивалентен типу U with T. Dotty устраняет эту неопределенность путем определения ближайшего подтипа как T & U. Операция & коммутативна, поэтому значение однозначно.
val s = "String"
val i = 10
val result = if (true) s else i
В Scala 2.12 значению result будет назначен тип Any. В Dotty, если явно не указать тип для result, ему также будет назначен тип Any. Однако мы можем явно указать тип у result:
val result: String | Int = if (true) s else i
Таким образом мы ограничили множество допустимых значений для result типами String и Int.
4. Лямбда выражения для типов
Одной из самых сложных языковых особенностей в Scala является поддержка так называемых типов высшего порядка (Higher-kinded types). Суть типов высшего порядка — в дальнейшем повышении уровня абстракции при использовании обобщенного программирования. Более подробно про типы высшего порядка рассказывается в этой статье. Мы же рассмотрим конкретный пример, который взят из книги Programming Scala by Dean Wampler and Alex Payne (2nd edition).
trait Functor[A, +M[_]] {
def map2[B](f: A => B): M[B]
}
implicit class SeqFunctor[A](seq: Seq[A]) extends Functor[A, Seq] {
override def map2[B](f: (A) => B): Seq[B] = seq map f
}
implicit class OptionFunctor[A](opt: Option[A]) extends Functor[A, Option] {
override def map2[B](f: (A) => B): Option[B] = opt map f
}
Здесь мы создаем тип Functor, который параметризован двумя типами: тип значения A и тип некоторой обертки M. В Scala выражение M (без параметров) называется конструктором типа. По аналогии с конструкторами объектов, которые могут принимать определенный набор параметров для того, чтобы создать новый объект, конструкторы типов также могут принимать параметры для того, чтобы определить какой-либо конкретный тип. Следовательно, для того, чтобы определить конкретный тип для Functor из нашего примера, должно выполниться несколько этапов:
- Определяется тип для A и B.
- Определяется тип для M[A] и M[B]
- Определяется тип для Functor[A, M]
Таким образом, определить тип для Functor компилятор может только после третьей итерации, поэтому он считается типом высшего порядка. В общем случае типом высшего порядка называется тип, который в качестве параметров принимает как простые типы, так и конструкторы типов.
В приведенном выше примере есть один недостаток: в параметрах типа Functor конструктор типа M принимает один параметр. Допустим, нам нужно написать метод map2, который будет менять значения у Map[K, V], оставляя при этом ключи неизменными. Dean Wampler в своей книге предлагает следующее решение:
implicit class MapFunctor[K,V1](mapKV1: Map[K,V1]) extends Functor[V1,({type ?[?] = Map[K,?]})#?] {
def map2[V2](f: V1 => V2): Map[K,V2] = mapKV1 map {
case (k,v) => (k,f(v))
}
}
В данном примере мы создаем новый конструктор типа ?, который принимает один параметр, замыкая первый параметр K для Map. Данная реализация является достаточно запутанной, так как для того, чтобы создать конструктор типа ?, мы сперва создаём структурный тип {type ?[?] = Map[K,?]}, в котором определяем поле тип ? с одним параметром, и затем вытаскиваем его через механизм проекции типов (от которого в Dotty решили избавиться).
Для таких случаев в Dotty был разработан механизм лямбда-выражений для типов. Его синтаксис имеет следующий вид:
[X] => Map[K, X]
Данное выражение читается как тип, имеющий один параметр, который конструирует тип Map, тип ключа K которого может быть любым, а тип значения равен параметру. Таким образом мы можем написать Functor для работы со значениями в Map следующим образом.
implicit class MapFunctor[K,V1](mapKV1: Map[K,V1]) extends Functor[V1, [X] => Map[K,X]] {
def map2[V2](f: V1 => V2): Map[K,V2] = mapKV1 map {
case (k,v) => (k,f(v))
}
}
Как видно из этого примера, синтаксис лямбда-выражений для типов, введенный в Dotty, позволяет упростить определение класса MapFunctor, избавившись от всех запутанных конструкций.
Лямбда-выражения для типов также позволяют накладывать ограничения по ковариантности и контравариантности на аргументы, например:
[+X, Y] => Map[Y, X]
5. Адаптивность арности функций под кортежи
Данное нововведение является синтаксическим сахаром, упрощающим работу с коллекциями из кортежей (tuple), а также в общем случае со всеми реализациями класса Product (это все case классы).
val pairsList: List[(Int, Int)] = List((1,2), (3,4))
case class Rectangle(width: Int, height: Int)
val rectangles: List[Rectangle] = List(Rectangle(1,2), Rectangle(3,4))
Сейчас для работы с коллекциями такого вида мы можем использовать либо функции с одним аргументом:
val sums = pairsLIst.map(pair => pair._1 + pair_2)
val areas = rectangles.map(r => r.width * r.height)
Либо мы можем использовать частичные функции:
val sums = pairsLIst.map {
case (a, b) => a + b
}
val areas = rectangles.map {
case Rectangle(w, h) => w * h
}
Dotty предлагает более компактный и удобный вариант:
val sums = pairsLIst.map(_ + _)
val areas = rectangles.map(_ * _)
Таким образом для подклассов типа Product Dotty подбирает функцию, арность которой равна арности исходного продукта.
6. Параметры для trait-ов
В Dotty наконец-то добавили возможность задавать параметры при определении trait-ов. Этого не было сделано раньше из-за того, что в случае сложных иерархий наследования значения параметров были неопределенными. В Dotty же ввели дополнительные ограничения на использование параметризованных trait-ов.
- Значения параметров для trait-а может передать только при определении класса, но не другого trait-a
trait A(x: Int)
trait B extends A
trait B1 extends A(42) //не скомпилируется
class C extends A(42)
- При определении класса, реализующего параметризованный trait, необходимо указать для него значения параметра. Если же класс расширяет другой класс, при определении которого trait-у было передано определенное значение, то в этом случае указывать значение наоборот не нужно.
class D extends A //не скомпилируется
class D1 extends C
class D2 extends C with A(84) //не скомпилируется, так как параметр указан при определении класса C
- Класс, расширяющий trait, который является наследником параметризованного trait-а, должен передавать значение через явное указание этого trait-а.
class E extends B //не скомпилируется, так как не указано значение для A
class E extends A(42) with B
7. Неблокирующие lazy значения
В текущей версии Scala отложенная инициализация значений (lazy val) реализована с использованием механизма синхронизации на объекте, в котором оно содержится. Данное решение обладает следующими недостатками:
- Дополнительные затраты на синхронизацию, которые теряют смысл в случае, если значение используется только одним потоком.
- Возможность потенциальной взаимной блокировки, например:
object A {
lazy val a1 = B.b1
lazy val a2 = 42
}
object B {
lazy val b1 = A.a2
}
В случае, если два потока одновременно начинают инициализировать значения a1 и b1, они получают блокировку над объектами A и B соответственно. Так как для инициализации b1 требуется значение a2, которое еще не было проинициализировано в объекте A, второй поток ждёт освобождения блокировки объекта A, держа блокировку на объекте B. В то же время первому потоку нужно обратиться к полю b1, но оно в свою очередь недоступно из-за блокировки вторым потоком объекта B. В итоге у нас возникла взаимная блокировка, или Deadlock. (Данный пример взят из доклада Дмитрия Петрашко)
В Dotty для lazy значений отменили потокобезопасную инициализацию. В случае, когда требуется безопасная публикация значения для использования несколькими потоками, такую переменную необходимо аннотировать как
@volatile
@volatile lazy val x = {... some initialization code …}
8. Перечисления (Enumerations)
В Dotty сделали поддержку для перечислимых типов (enum). Синтаксис для их определения сделали по аналогии с Java.
enum Color {
case Red, Green, Blue
}
Поддержка перечислений реализована на уровне парсинга исходного кода. На этом этапе конструкция enum преобразуется к следующему виду.
sealed class Color extends Enum
object Color {
private def $new(tag: Int, name: String) = {
new Color {
val enumTag = tag
def toString = name
// код для инициализации параметров
}
}
val Red = $new(0, "Red")
val Green = $new(1, "Green")
val Blue = $new(2, "Blue")
}
Как и в Java, перечислимый тип в Dotty также поддерживает параметры:
enum Color(code: Int) {
case Red extends Color(0xFF0000)
case Green extends Color(0x00FF00)
case Blue extends Color(0x0000FF)
}
Таким образом, перечислимые типы обладают всеми свойствами sealed иерархий case классов. Кроме того, перечислимые типы позволяют получить значение по имени, по индексу, либо коллекцию всех допустимых значений.
val green = Color.enumValue(1)
val blue = Color.enumValueNamed("Blue")
val allColors = Color.enumValues
9. Функциональные типы для неявных (Implicit) параметров
В текущей реализации языка Scala неявные (implicit) параметры функций являются каноническим способом для представления контекста выполнения.
def calculate(a: Int, b: Int)(implicit context: Context): Int = {
val x = context.getInt("some.configuration.parameter")
a * x + b
}
В этом примере context передается неявно, его значение берется из так называемого implicit scope.
implicit val context: Context = createContext()
val result = calculate(1,2)
Таким образом, при каждом вызове функции calculate нам нужно передавать только параметры a и b. Компилятор же для каждого такого вызова подставит значение context, взятое из соответствующего implicit scope. Основная проблема текущего подхода заключается в том, что в случае большого количества функций, принимающих одинаковый набор неявных параметров, их нужно указывать для каждой из этих функций.
В Dotty функцию, принимающую неявные параметры, можно представить в виде типа:
type Contextual[T] = implicit Context => T
По аналогии с обычными функциями, которые являются реализацией типа Function, все реализации типа implicit A => B будут являться подтипом следующего trait-а.
trait ImplicitFunction1[-T0, +R] extends Function1[T0, R] {
def apply(implicit x0: T0): R
}
В Dotty предусмотрены различные определения trait-а ImplicitFunction в зависимости от числа аргументов, вплоть до 22-х включительно.
Таким образом, используя тип Contextual, мы можем переопределить функцию calculate следующим образом:
def context: Contextual[Context] = implicitly[Context]
def calculate(a: Int, b: Int): Contextual[Int] = {
val x = context.getInt("some.configuration.parameter")
a * x + b
}
Здесь мы определяем специальную функцию def context, которая нам достаёт нужный Context из окружения. Таким образом, тело функции calculate почти не изменилось, за исключением того, что context теперь вынесен за скобки, и теперь его не нужно объявлять в каждой функции.
Что не вошло в Dotty
В завершение обзора расскажу о тех элементах, которые были удалены из языка. Как правило, необходимость в них исчезла после введения новых, более удобных конструкций либо их реализация стала более проблематичной и начала вызывать конфликты, поэтому оказалось, что их проще убрать.
Со временем Dotty станет основой для новой версии языка Scala, и номер версии скорее всего будет уже 3.x.x. Это значит, что не будет обеспечена обратная совместимость с предыдущими версиями языка 2.х.х. Однако команда разработки Dotty обещает, что будут разработаны специальные инструменты, которые облегчат переход с версии 2.х.х на 3.х.х.
1. Проекции типов
Проекции типов (type projections) — это конструкции вида T#A, где T может быть любым типом, а A — поле-тип у типа T. Например:
trait T {
type A
val a: A
def fun1(x: A): Any
def fun2(x: T#A): Any
}
Предположим, что у нас определены две переменные:
val t1: T = new T { … }
val t2: T = new T { … }
В этом случае, аргументом метода fun1 у t1 может являться только значение t1.a, но не t2.a. Аргументом же метода fun2 могут являться как t1.a, так и t2.a, так как аргумент метода определен как «любое значение поля-типа A у типа T».
Данная конструкция была исключена, так как не является устойчивой и может привести к коллизиям при пересечении типов. Например, код, приведенный ниже, скомпилируется, но приведет к ClassCastException во время выполнения (взято отсюда):
object Test {
trait C { type A }
type T = C { type A >: Any }
type U = C { type A <: Nothing }
type X = T & U
def main(args: Array[String]) = {
val y: X#A = 1
val z: String = y
}
}
Вместо type projections предлагается использовать зависимые типы (path-dependent types) либо неявные (implicit) параметры.
2. Экзистенциальные типы
Экзистенциальные типы (Existential types) показывают, что существует некий неизвестный нам тип, который является параметром для другого типа. Значение этого типа нас не интересует, нам просто важен факт, что он существует. Отсюда и название. Данный вид типов был добавлен в Scala в первую очередь для обеспечения совместимости с параметризованными маской (wildcard) типами в Java. Например, любая коллекция в Java является параметризованной, и в случае, если нас не интересует тип параметра, мы можем задать его через маску следующим образом:
Iterable<?>
если нас не интересует тип параметра, но мы знаем, что на него наложены ограничения, то в этом случае тип определяется так:
Iterable<? extends Comparable>
В Scala данные типы будут определены следующим образом:
Iterable[T] forSome { type T } // тип без ограничений на параметр
Iterable[T] forSome { type T <: Comparable } // тип с ограничениями на параметр
В Scala также есть возможность параметризации типа маской:
Iterable[_] // тип без ограничений на параметр
Iterable[_ <: Comparable] // тип с ограничениями на параметр
В последних версиях эти формы записи являются полностью эквивалентными, поэтому от формы X[T] forSome { type T} было решено отказаться, так как она не согласуется с принципами DOT и влечет за собой дополнительные сложности в разработке компилятора. В целом, конструкция forSome так и не получила широкого распространения, так как является достаточно громоздкой. Сейчас практически везде, где требуется интеграция с типами из Java, сейчас используется конструкция с параметризацией по маске, которую было решено оставить в Dotty.
3. Предварительная инициализация
В Scala trait-ы не имеют параметров. Это создавало сложности в случае, когда trait имеет часть абстрактных параметров, от которых зависят некоторые конкретные параметры. Рассмотрим следующий пример:
trait A {
val x: Int
val b = x * 2
}
class C extends A {
val x = 10
}
val c = new C
В этом случае значение c.b равно 0, а не 20, так как, согласно правилам инициализации, в Scala сначала инициализируется тело trait-а и только затем класса. На момент инициализации поля b значение для x ещё не определено, и поэтому берется 0 как значение по умолчанию для типа Int.
Для решения этой проблемы в Scala был введен синтаксис предварительной инициализации. С его помощью можно исправить баг в предыдущем примере:
class C extends {val x = 10} with A
Недостаток данной конструкции в том, что здесь приходится прибегать к неочевидному решению вместо того, чтобы просто воспользоваться полиморфизмом. С введением параметров для trait-ов необходимость в предварительных инициализаторах отпала, и теперь наш пример можно реализовать более простым и понятным способом:
trait A(x: Int) {
val b = x * 2
}
class C extends A(10)
4. Отложенная инициализация
В Scala существует специальный trait для отложенной инициализации
trait DelayedInit {
def delayedInit(body: => Unit): Unit
}
Классы, реализующие данный trait, при инициализации вызывают метод delayedInit, из которого уже можно вызвать инициализатор для класса через параметр body:
class Test extends DelayedInit {
def delayedInit(body: => Unit): Unit = {
println("This is delayedInit body")
body
}
println("This is class body")
}
Таким образом, при создании объекта new Test мы получим следующий вывод:
This is delayedInit body
This is class body
Trait DelayedInit в Scala объявлен как Deprecated. В Dotty же его совсем исключили из библиотеки в связи с тем, что trait-ы теперь могут быть параметризованы. Таким образом, используя call-by-name семантику, можно добиться аналогичного поведения.
trait Delayed(body: => Unit) {
println("This is delayed body")
body
}
class Test extends Delayed(println("This is class"))
Аналогично при создании new Test вывод будет:
This is delayed body
This is class
5. Процедурный синтаксис
Для унификации объявления функций было решено отказаться от процедурного синтаксиса определения функций, у которых возвращаемый тип Unit. Таким образом, вместо
def run(args: List[String]) {
//Method body
}
теперь нужно писать
def run(args: List[String]): Unit = {
//Method body
}
Стоит заметить, что многие IDE, в частности в IntelliJ IDEA, сейчас автоматически заменяют процедурный синтаксис на функциональный. В Dotty же от него отказались уже на уровне компилятора.
Заключение
В целом Dotty предлагает достаточно простые и интересные решения давно назревших проблем, возникающих при разработке на Scala. Я, например, в своей практике как-то столкнулся с необходимостью написать метод, который на вход должен был принимать объекты нескольких типов, не связанных через иерархию наследования. Пришлось в качестве типа для аргумента использовать Any с последующим pattern matching-ом. В Dotty можно было бы решить эту проблему через объединение типов. Кроме того, мне также не хватает параметров для trait-ов. В некоторых случаях они были бы очень кстати.
В Scala-сообществе, судя по докладам на конференции, тоже ждут выхода Dotty. В частности, в одном докладе, посвященном фреймворку akka, говорили о том, что можно будет сделать акторы типизированными, указав методу receive в качестве параметров тип, объединяющий все сообщения.
Dotty уже можно попробовать: на сайте Dotty есть инструкции по его установке и настройке. Однако авторы пока не рекомендуют его использовать в промышленном коде, так как он все еще является нестабильным.
Об авторе
Меня зовут Александр Токарев, и я занимаюсь разработкой серверного ПО уже более 10 лет. Начинал как PHP разработчик, затем переключился на Java и в последнее время перешел на Scala. C 2015 года работаю в компании CleverDATA, где Scala является одним из основных языков для разработки, наряду с Java и Python. Мы используем Scala в первую очередь для разработки процессов обработки больших объемов данных с применением Apache Spark, а также для построения высоконагруженных REST сервисов для взаимодействия с внешними системами на основе Akka Streams.
Дополнительные материалы
- Доклад Мартина Одерски на конференции Scala Days Copenhagen, май 2017: Видео
- Официальный сайт языка Scala
- Официальный сайт проекта Dotty
- Статья в Википедии про язык Scala
- DOT-исчисление
- Статья The essence of Scala
- Доклад Мартина Одерски про DOT на YOW! Nights, февраль 2017
- Доклад Дмитрия Петрашко, одного из разработчиков Dotty
- Higher-Kinded types
- Implicit function types
Комментарии (51)
RicoSam
26.07.2017 14:40+6«Объединение типов» «Person | User» — вот это огонь!
Nakosika
27.07.2017 12:53Думаю, эта фича должна быть в любой системе типов, как самая базовая. Как же медленно хорошие идеи заполозают в мейнстримные языки, это жесть… А ведь вполне возможно что именно этот твик компилятора позволит избавится от ооп в 50% случаев.
njc
26.07.2017 14:57+5Ох, Dotty прекрасен :) Объединение типов — очень вкусно! Спасибо за статью, очень интересно и познавательно.
alextokarev
26.07.2017 15:06По сути, объединение типов так или иначе уже было где-то рядом, например тип Either, или Coproduct из shapeless. Теперь же они будут поддерживаться из коробки, и, в качестве бонуса, их сделали ближайшим супертипом)
myrslok
26.07.2017 17:45+1Поправьте, если я ошибаюсь, но Either — меченое объединение, а | — нет.
alextokarev
26.07.2017 17:45Меченое — что вы имеете ввиду?
potan
26.07.2017 18:09+1Теоретекомножественную операцию размеченного объединения (в теории категорий — прямую сумму).
myrslok
26.07.2017 18:56+2Для
Either[T,T]
левая и правая ветки отличаются, а дляT|T
— нет. Более выпуклый пример: вOption[String] | Option[Int]
один и тот жеNone
для обоих вариантов, а вEither[Option[String],Option[Int]]
— разные. Так что объединения не будут заменойEither
во всех случаях. То же касаетсяOption
, который можно понимать как прямую сумму чего-то и ничего.
potan
26.07.2017 16:20Вот не уверен я в нужности объединения типов с последующим анализом их через case. По моему это источник ошибок при обобщенном программировании, когда некоторые параметры типа A и B где-нибудь объединенные через A|B вдруг в каком-то случае окажутся одинаковыми или унаследованными один от другого, а в коде он обрабатывается case a:A =>… case b:B =>…
Хотя во многих случаях код станет компактнее и проще, возможность делать нетривиальные ошибки меня пугает.grossws
26.07.2017 16:28Боитесь вещей вида
Seq[T] | List[T]
? Но и в текущем варианте можно напортачить аналогично, сделав сначалаcase s: Seq[T] =>
вmatch
. Я, правда, не помню, что на это скажет компилятор.potan
26.07.2017 17:13+1В текущем так напортачить можно используя тип Any. А это, к счастью, редко кто делает.
grossws
26.07.2017 17:20val obj = List("asdf", "zxcv") obj match { case s: Seq[String] => println(s"seq: ${s}") case l: List[String] => println(s"list: ${l}") }
С warning'ом (unreachable code), но работает как ожидается (выводит
seq: List(asdf, zxcv)
).potan
26.07.2017 17:40Ну по мне странное желание отличать Seq от List во время исполнения.
grossws
26.07.2017 17:54Seq и List просто как пример двух типов, связанных отношением родитель-предок.
В реальности там будут какие-нибудь data object'ы или сообщения с иерархией глубины 3-4 и ветки match'а по 5-10 строк. И приплыли к относительно малозаметному багу.
potan
26.07.2017 18:00В scala вовремя запретили наследоваться от case class…
Да и вообще использовать типы для бизнеслогики — это неправильно.grossws
26.07.2017 18:03К счастью, да. Это несколько спасает. Но case class вполне может имплементировать несколько типажей, как вариант, и проблема останется, скорее всего.
alextokarev
26.07.2017 18:07Почему запретили? Вот, вполне себе валидный код в 2.12:
case class User(name: String) class SuperUser extends User("Super User") println(new SuperUser().name)
potan
26.07.2017 18:17Хмм… Не знал про такую возможность.
Все равно она какая-то странная:
scala> val s = new SuperUser s: SuperUser = User(Super User) scala> s match { case User(n) => n } <console>:16: error: constructor cannot be instantiated to expected type; found : User required: SuperUser s match { case User(n) => n }
saaivs
26.07.2017 16:00+1Много акцента на математическу природу Dotty, но тогда, в математическом смысле, описание в статье смысла Пересечения и Объединения типов как буд-то меняны местами с их теоретико-множественными визави. Что как-минимум противоречит привычной математической интуиции. Это и вправду терминология языка или особенности перевода?
grossws
26.07.2017 16:16+1На самом деле, не совсем противоречит, если посмотреть с другой точки зрения.
type C = A & B
говорит про тип C в котором есть пересечение двух этих типов (который является A and B).
А дезъюнктивный (в некотором смысле сумма) тип является типом-суммой двух исходных. Как тот же Product (и все его наследники, включая различные TupleN) являются типами-произведениями.
alextokarev
26.07.2017 17:37Именно. Используя терминологию алгебраических типов данных (ADT), пересечение типов — это Product(тип-произведение), то есть он обладает свойствами обоих исходных типов, а объединение типов — это CoProduct(тип-сумма), тип, обладающий свойствами одного из исходных типов.
potan
26.07.2017 18:20+2Не совсем так. В ADT произведение, как и сумма, это новый тип, не обладающий свойствами исходных типов.
potan
26.07.2017 17:16+1Часто люди путаются с терминологией наследования — «extends» определяет подтип.
potan
26.07.2017 16:12Пересечение типов умеет использовать одноименные свойства, не проверяя тип?
trait A { def f() = 1 } trait B { def f() = 2 } type C = A|B val x:C = ... x.f()
class C extends A(42) — Это зависимый тип или параметр конструктора?
Что будет, если написать:
def f(x:A(42)) = ... class X extends A(1) val x:X = ... f(x)
Можно ли писать так:
val a: Int = 42 class C extends A(a)
alextokarev
26.07.2017 17:14- Нет. Это как раз задача для применения классического полиморфизма. Пересечение типов нужно скорее для того, чтобы например ограничить диапазон принимаемых типов в аргументе функции для классов, которые не имеют общего супертипа.
- 42 в выражении A(42) является параметром конструктора. Запись
является некорректной, так как типом может являться только A. Для параметризации типов используются квадратные скобки. Подробнее тему параметризации типов я раскрыл в разделе про лямбда выражения для типов.def f(x: A(42)) = ...
- Можно, если trait A и константа a определены в одной области видимости, например так:
object Test { val a: Int = 42 trait A(val i: Int) class C extends A(a) }
Если же trait A(i: Int) определен где-то в другом месте — то нет.
potan
26.07.2017 17:19«чтобы например ограничить диапазон принимаемых типов в аргументе функции для классов, которые не имеют общего супертипа.» — есть же полиморфизм по типу аргумента, зачем такое усложнение?
alextokarev
26.07.2017 17:30+1Это не полиморфизм, это перегрузка методов. Объединение типов можно например использовать в качестве параметра для других типов:
trait Container[A] { def put(a: A): Unit def count: Int } class StringAndIntContainer extends Container[String | Int]
Здесь базовая реализация не предполагает перегрузки метода put, и c помощью объединения типов мы можем создать такой контейнер, который будет принимать на вход String и Int, но компилятор будет запрещать вызывать метод put для других типов.potan
26.07.2017 17:37В данном случае мне кажется более уместным использовать Either. Иначе получается что тип значения начинает играть роль в runtime и на него будет завязана логика.
PHmaster
26.07.2017 20:46+1А как быть с Either в случае, например, List[String | Int | Long | Date | URL | SomethingElse]?
alextokarev
26.07.2017 21:06Или например List[String Either Int Either Long Either Date Either URL Either SomethingElse]
myrslok
27.07.2017 13:14В этом варианте извлекать придется как-то так:
case Right(Right(Right(Left(url))
. Конечно, это ужасно.
myrslok
26.07.2017 20:51+1Почему это плохо?
potan
26.07.2017 21:28Например потому же, почему плохи «магические значения». То есть здесь тип не только определяет множества возможных значений и допустимых операций, но и играет еще и роль магического значения, по которому принемаются некоторые решения runtime. И если мы изменим этот тип, то мы должны проследить, что мы его исправили во всех ветках case, где он проверялся, и компилятор не всегда в состоянии нам помочь.
alextokarev
26.07.2017 17:23Прошу прощения, | — это объединение типов. Оно и имелось ввиду и в исходном комментарии, и в моём ответе.
darkdimius
26.07.2017 20:54+2Ссылка в конце статьи на Higher Kinded types устаревшая и описывает не то что в Dotty.
Самое близкое — https://infoscience.epfl.ch/record/222780?ln=en
spetz911
26.07.2017 21:00+1Жаль только не выйдет подключить систему типов Dotty к JavaScript вместо Flow. В будущем, имхо, будет возможность использовать runtime одного языка, а систему типов другого: c++ с типами haskell или golang с "типизацией" от python.
streetturtle
26.07.2017 21:12+1Со слов Мартина Одерского...
Его зовут Мартин Одерски и фамилия не склоняется же :)
echipachenko
27.07.2017 15:17-3Что за мода такая пошла, каждый кому не лень делает свой язык, который все равно в конечном итоге выполняется на Java VM…
alextokarev
27.07.2017 15:24+2Ну, Scala вместе с Groovy были пионерами в области альтернативных языков для JVM, это уже потом пошло-поехало) Кстати, что касается Scala — сейчас очень активно разрабатывается компилятор в нативный код https://github.com/scala-native/scala-native. Под JVM в своё время было написано огромное количество отличных библиотек, которые грех не переиспользовать.
Nakosika
28.07.2017 13:22+1А на чем им еще выполняться? Jvm на данный момент самая быстрая, плюс миллионы библиотек уже готовы, на некоторых платформах вообще только jvm и есть.
grossws
28.07.2017 13:29на некоторых платформах вообще только jvm и есть
Lua в этом смысле ещё более распространен (но не luajit). Не особо высокая производительность без jit'а, но работает везде, где есть Си и некоторое количество динамической памяти.
Nakosika
28.07.2017 21:42Я так и представил себе что все андроид-разработчики взяли с радостью и энтузазмом перешли на… луа! :D Просто потому что она где-то там работает где есть Си. :)) Забив на статическую типизацию, СДК, производительность, тулзы, либы, наличие специалистов на рынке труда, официальные гайды, и тепе.
(Хинт: на андроиде си прилепливается скотчем в скромных местах где SDK не нужен, а нужен он в 90% мест.)grossws
28.07.2017 22:43Вы о чём спорите? Я лишь опроверг ваше утверждение "на некоторых платформах вообще только jvm и есть", хотя по духу с исходным утверждением согласен, кроме означенного фрагмента.
Где есть jvm почти гарантированно есть плюсы (возможно есть живые имплементации jvm не на плюсах, но я их не встречал), и, уж тем более, си. Разговор не про удобство разработки с использованием конкретного языка/платформы, а про наличие.
Cheater
Именование неудачное…
DOT — декларативный язык описания графов, dotty — утилита из пакета Graphviz (практически индустриальный стандарт в области визуализации графов) для интерактивного редактирования графов.
alextokarev
Тут уж смотря с какой стороны посмотреть. Мне кажется, что у архитекторов «Scala» вызывает ассоциации с лестничными пролетами, а у ценителей оперы — со всемирно известным театром в Милане.
darkdimius
Dotty — кодовое имя на время стабилизации. Релиз будет под именем Scala 3