Dotty – будущее языка Scala

в 10:15, , рубрики: dotty, java, scala, Блог компании ГК ЛАНИТ, Ланит, Программирование

В конце мая я оказался среди слушателей конференции Scala Days в Копенгагене. Одним из ключевых спикеров был создатель языка Scala Мартин Одерский. Он рассказал о развитии языка и, в частности, о разработке компилятора, названного Dotty. Планируется, что на основе Dotty будет разработан новый компилятор для версии 3.0.

Мартин не раз выступал на эту тему, и я бы хотел собрать здесь всю актуальную информацию о Dotty – новые ключевые возможности и элементы, удаленные за ненадобностью.

Dotty – будущее языка Scala - 1
Мартин Одерский. План развития 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-исчисления:

  1. Top type (Any) — тип, лежащий на самом верху иерархии, является суперклассом для всех типов.
  2. Bottom type (Nothing) — тип, лежащий внизу иерархии,  является подтипом всех типов.
  3. Type declaration — объявление типа в указанных границах сверху и снизу.
  4. Type selection — выведение типа в зависимости от переменной.
  5. Function — функция, принимающая на вход один или несколько аргументов различных типов и имеющая определенный тип значения.

Кроме того, DOT-исчисление определяет следующий набор допустимых операций над типами:

  1. Наследование. Любой тип, если он не является пограничным (в нашем случае Any и Nothing), может являться как супертипом, так и подтипом другого типа. Каждый тип является супертипом и подтипом для самого себя.
  2. Создание структурных типов (Records), включающих в себя другие типы (по аналогии с объектами и структурами для переменных).
  3. Объединение типов. Результирующий тип будет являться дизъюнкцией полей и операций исходных типов.
  4. Пересечение типов. Результирующий тип будет являться конъюнкцией полей и операций исходных типов.
  5. Рекурсивное определение типов.

Детальное рассмотрение DOT выходит за рамки данной публикации. Более подробную информацию про DOT-исчисление можно посмотреть здесь.

Обзор нововведений в Dotty

DOT-исчисление является математической основой для компилятора Dotty. Собственно, это отражено и в его названии.

Сейчас Dotty является экспериментальной платформой для отработки новых языковых концепций и технологий компиляции. Со слов Мартина Одерского, целью разработки Dotty является усиление основных конструкций и избавление от лишних элементов языка. В настоящий момент Dotty развивается как независимый проект, но планируется, что со временем он вольется в основную ветку Scala.

Dotty – будущее языка Scala - 2

Полный список нововведений можно найти на официальном сайте 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). Она — на рисунке ниже.

Dotty – будущее языка Scala - 3

В текущей реализации 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 из нашего примера, должно выполниться несколько этапов:

  1. Определяется тип для A и B.
  2. Определяется тип для M[A] и M[B]
  3. Определяется тип для 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.

Дополнительные материалы

  1. Доклад Мартина Одерски на конференции Scala Days Copenhagen, май 2017: Видео
  2. Официальный сайт языка Scala
  3. Официальный сайт проекта Dotty
  4. Статья в Википедии про язык Scala
  5. DOT-исчисление
  6. Статья The essence of Scala
  7. Доклад Мартина Одерски про DOT на YOW! Nights, февраль 2017
  8. Доклад Дмитрия Петрашко, одного из разработчиков Dotty
  9. Higher-Kinded types
  10. Implicit function types

Автор: ГК ЛАНИТ

Источник

Поделиться