1. Overview

Scala 3 uses Dotty, a next-generation compiler for Scala that implements many language features and improvements.

In this article, we’re going to introduce Dotty and its fundamentals. Also, we’ll give an overview of some of the important changes that Scala 3 has introduced.

2. What Are DOT and Dotty?

The primary goal of the Scala programming language is to provide a synthesis of three elements:

  1. Types
  2. Functions
  3. Objects

Other languages covered a subset of these elements – for example, Common Lisp (function and object), Java (type and objects), Haskell, and ML (functions and types) – but Scala is the first language whose goal is to support the synthesis of these three elements.

Scala 3 will be a big step towards realizing the full potential of the fusion of OOP and FP at a typed level. So, the DOT Calculus is the foundation behind this fusion (DOT = OOP + FP).

The DOT, which stands for Dependent Object Types, has been introduced as the foundation of Scala 3. The idea behind DOT is to capture the essence of Scala and make progress only with precise and strong foundations.

Dotty is a compiler for the development of Scala 3 with DOT Calculus. DOT is a type-calculus used to prove that Dotty’s language specification and its type system are sound. DOT validates the language specification correctness formally. The compiler research team has been developing this to have a core and sound understanding of the foundation of Scala 3’s type system.

DOT is a small programming language with a type system. It is small but very expressive. It’s mostly a Scala subset in which other features can be encoded. It is simpler than Scala, and all Scala code can be desugared to the DOT calculus.

Scala 2 and 3 are fundamentally the same languages, but the compiler behind Scala 3 is based on Dotty work. The binary name for Scala 2’s (and below) compiler is scalac, but for Scala 3, the compiler name is dotc.

Scala 3 has dropped some unsound and useless features to make the language smaller and more regular. It has added some new constructs to increase its expressiveness. Also, it has changed some constructs to remove warts and increase simplicity, consistency, and usability.

The range of changes is large, but the ordinary Scala 2 code will also work on Scala 3. In this article, we enumerate the most important changes.

3. Type System

Scala 3 is going to have a simple, sound, and consistent type system. Let’s review some important changes in its type system:

3.1. Existential Types

*Existential Types (T forSome { type X}) and Type Projections (T # A) are dropped.* They were unsound and made a type system more difficult to interact with other constructs. Note that projection on concrete types is still supported.

3.2. Intersection and Union Types

Compound Types (T with U) replaced with Intersection Types (T & U) and Union Types (T | U). With both unions and intersections, Scala’s subtype hierarchy becomes a lattice, which helps the compiler to easily find the least upper bounds and greatest lower bounds. As a result, the compiler is more sound when inferring data types.

Let’s define a parse method that returns a union type:

def parse(input: String): Int | String = 
  try
    input.toInt 
  catch
    case _ => "Not a number"

A union type A | B comprises all values of type A and also all values of type B. Unions are duals of intersection types. A | B contains all members/properties that A and B have in common. They are commutative, so A | B is the same type as B | A. We can use pattern matching to decide if A | B is A or B.

Let’s see an example of intersection types:

trait Show:
  def show: String

trait Closable:
  def close: Unit

type Resource = Show & Closable

def shutdown(resource: Resource) = 
  println(s"Closing resource ${resource.show}")
  resource.close

object res extends Show, Closable:
  override def show = "resource#1"
  override def close = println("Resource closed!")

shutdown(res)

Type A & B represents values that are of type A and B at the same time. A & B has all members/properties of A and all members/properties of B. Intersection is commutative, which means A & B is the same as B & A, but the with compound type isn’t commutative. In the long run, they will be replaced by compound types.

3.3. Type Lambda

*Type Lambda is introduced with a nice syntax, [X] =>> F[X].* It’s a higher-kind type that takes a type parameter X. This essentially gives us a type T, which typically will refer to this variable X.

They are functions from types to types:

trait Monad[F[_]] {
  def pure[A](x: A): F[A]
  def flatMap[A, B](fa: F[A])(f: A => F[B]): F[B]
}

trait EitherInstance[E] extends Monad[[T] =>> Either[E, T]] {
  override def pure[A](x: A) = ???
  override def flatMap[A, B](fa: Either[E, A])(f: A => Either[E, B]) = ???
}

The previous emulation of type lambda in Scala 2 using structural types is awful and uses general type projection operator #, which has been eliminated:

trait EitherInstance[E] extends Monad[({ type T[X] = Either[E, X] })#T] {
  override def pure[A](x: A) = ???
  override def flatMap[A, B](fa: Either[E, A])(f: A => Either[E, B]) = ???
}

4. Traits

So far in Scala 2, we didn’t have a way to pass parameters to a trait. There was a workaround to cope with this problem: We could make the trait an abstract class and fill its parameters in the subclass.

This solution works as far as we don’t run into problems with early initialization. The problem is that the parameters of the abstract class are initialized after the evaluation of the subclass constructor.

With Scala 3, we don’t run into these problems since we can pass parameters into traits:

trait Base(val msg: String)
class A extends Base("Foo")
class B extends Base("Bar")

Traits, like classes, can have parameters. Their arguments are evaluated before the trait is initialized.

5. Enums

Before Scala 3, writing enumeration was awkward. Also, we had a lot of boilerplate to write a simple ADT. There was a lack of expressiveness. Scala 3 has a built-in enum keyword that helps us write enumerations and ADTs. This one construct supports both enumeration and ADTs.

With enum, we can define a type comprising a set of named values. Let’s suppose we want to write a data type name Color that contains a fixed number of named values:

enum Color:
  case Red, Green, Blue

To make our enum compatible with Java enums, we can extend java.lang.Enum:

enum Color extends java.land.Enum[color]:
  case Red, Green, Blue

They can be parameterized:

enum Color(code: String):
  case Red    extends Color("#FF0000")
  case Green  extends Color("#00FF00")
  case Blue   extends Color("#0000FF")

Also, we can write very expressive ADTs with enums:

enum Option[+T]:
  case Some(x: T)
  case None

6. Implicits

Implicits have too many puzzlers in Scala 2 — there’s too much head-scratching. Sometimes, they’re hard to understand, error-prone, easily misused, or overused, with many rough edges. Some of that’s unavoidable because it’s an advanced programming topic, while some of it is avoidable and just plain annoying.

In Scala 2, implicit have a variety of use cases. Let’s review some of the most important ones:

  • Providing Contextual Environment
  • Writing Type Class Instances
  • Extension Methods
  • Implicit Conversions

Implicits in Scala 2 convey mechanism over intent. Scala 3 focuses on intent rather than the mechanism by introducing new keywords like given and using. Note that for compatibility issues, Scala 2 implicits will remain available in parallel for a long time.

6.1. Providing Contextual Environment

The context consists of external information/parameters that can be implicitly understood by our program.

In Scala 2, we used implicit parameters to pass contextual environment into our programs. They are a fundamental way to abstract over the context.

For example, if we have a program that needs ExecutionContext, we create an implicit parameter for this type:

import scala.concurrent._
import scala.concurrent.duration._

implicit val ec: scala.concurrent.ExecutionContext = 
  ExecutionContext.global

def square(i: Int)(implicit val ec: ExecutionContext): Future[Int] = 
  Future(i * i) 

In Scala 3, the using keyword allows us to pass the contextual environment to the function implicitly. Also, the given keyword helps us to provide an instance of that contextual type:

import scala.concurrent.ExecutionContext
import scala.concurrent.Await
import scala.concurrent.duration._

given ExecutionContext = 
  ExecutionContext.global

import scala.concurrent.Future
def square(i: Int)(using ec: ExecutionContext): Future[Int] = {
  Future(i * i) 
}

6.2. Writing Type Class Instances

One of the most important use-cases of Implicits in Scala 2 is to define an instance of type classes:

trait Ord[T] {
  def compare(x: T, y: T): Int
}

implicit intInstance: Ord[Int] = new Ord[Int] {
  override def compare(x: Int, y: Int) =
    if (x < y) -1 else if (x > y) +1 else 0
}

Scala 3 has a special keyword for writing an instance of a type class, by using the given keyword:

trait Ord[T]:
  def compare(x: T, y: T): Int

given Ord[Int] with
  override def compare(x: Int, y: Int) =
    if (x < y) -1 else if (x > y) +1 else 0

6.3. Extension Methods

Writing extension methods in Scala 2 has some boilerplate. We should write a wrapper class for that type and then, by using the implicit function, wrap that type into the new extended class:

import scala.language.implicitConversions

class RichInt(i: Int) {
  def square = i * i
}

object RichInt {
  implicit def richInt(i: Int): RichInt = new RichInt(i)
}

Scala 3 has a special keyword – extension – for writing extension methods that is simple and has a concise syntax:

extension (i: Int) def square = i * i

6.4. Implicit Conversions

In Scala 2, implicit conversions could sometimes be a source of unexpected bugs and issues, so we apply extra caution when using them.

Let’s write an implicit conversion that converts a String into Int with Scala 2 syntax:

import scala.language.implicitConversions
implicit def string2Int(str: String): Int = Integer.parseInt(str)
def square(i: Int) = i * i

In Scala 3, to provide implicit conversion from type A to B, we need to provide an instance of Conversion[A, B]:

import scala.language.implicitConversions
given Conversion[String, Int] = Integer.parseInt(_)
def square(i: Int) = i * i

Implicit conversions in Scala 3 are hard to misuse. They eliminate surprising behaviors. Using this method is less error-prone than the same method in Scala 2.

7. Conclusion

In this article, we reviewed some important changes in Scala 3. It is a complete overhaul of the Scala language that preserves the compatibility with Scala 2 in most cases.

Its type system becomes more mature and principled. The language becomes more opinionated by introducing new keywords and constructs.


« 上一篇: Cats Effects简介