Chapter 5. Abstracting over Context: Type Classes and Extension Methods

In previous editions of this book, this chapter was titled Implicits after the mechanism used to implement many powerful idioms in Scala. Scala 3 begins the migration to new language constructs that emphasize purpose over mechanism, both to make learning and using these idioms easier and to address some shortcomings of the prior implementations. The transition will happen over several 3.X releases of Scala to make it easier, especially for existing code bases. Therefore, I will cover both the Scala 2 and 3 techniques, while emphasizing the latter.

All of these idioms fall under the umbrella abstracting over context. We saw a few examples already, such as the ExecutionContext parameters needed in many Future methods, discussed in ATasteOfFutures. We’ll see many more idioms now in this chapter and the next. In all cases, the idea of “context” will be some situation where an extension to a type, a transformation to a new type, or an insertion of values automatically is desired for easier programming. Frankly, in all cases, it would be possible to live without the tools described here, but it would require more work on the user’s part. This raises an important point, though. Make sure you use these tools judiciously; all constructs have pros and cons.

The most sweeping changes introduced in Scala 3 are to the type system and to how we define and use context abstractions. The changes to the latter are designed to make the purpose and application of these abstractions more clear. The underlying implicit mechanism is still there, but it’s now easier to use for specific purposes. Not only do the changes make intention more clear, they also eliminate some boilerplate previously required when using implicits and fix other drawbacks of the Scala 2 idioms.

Four Changes

If you know Scala 2 implicits, the changes in Scala 3 can be summarized as follows:1

Given Instances

Instead of declaring implicit terms (i.e. vals or methods) to be used to resolve implicit parameters, the new given clause specifies how to synthesize the required term from a type. The change deemphasizes the previous distinction where you had to know when to declare an instance vs. a class. Most of the time you will specify that a particular class should be used to satisfy the need for an implicit value and the compiler does the rest.

Using Clauses

Instead of using the keyword implicit to declare an implicit parameter list for a method, you use the keyword using. The different keyword eliminates several ambiguities and it allows a method definition to have more than one implicit parameter list, which are now called using clauses.

Given Imports

When you use wild cards in import statements, they no longer import given instances along with everything else. Instead, you use the given keyword to explicitly ask for givens to be imported.

Implicit Conversions

For the special case of given terms that are used for implicit conversions between types, they are now declared as given instances of a standard Conversion class. All other forms of implicit conversions will be phased out.

This chapter explores the context abstractions for extending types with additional state and behavior using extension methods and type classes, which are defined using given instances. We’ll also cover given imports and implicit conversions. In AbstractingOverContextPart2, we’ll explore using clauses and the specific idioms they support.

Extension Methods

In Scala 2, if we wanted to simulate adding new methods to existing types, we had to do an implicit conversion to a wrapper type that implements the method. Scala 3 adds extension methods that allow us to extend a type with new methods without conversion. By themselves, extension methods only allow us to add one or more methods, but not new fields for additional state, nor is there a mechanism for implementing a common abstraction. We’ll address those limitations when we discuss type classes.

But first, why not just modify the original source code? You may not have that option, for example if it’s a third-party library. Also, adding too many methods and fields to classes makes them very difficult to maintain. Keep in mind that every modification to an existing type forces users to recompile their code, at least. This is especially annoying if the changes involve functionality they don’t even use.

Context abstractions help us avoid the temptation to create types that contain lots of utility methods that are used only occasionally. Our types can avoid mixing concerns. For example, if some users want toJSON methods on a hierarchy of types, like our Shapes in ASampleApplication, then only those users are affected.

Hence, the goal is to enable ad hoc additions to types in a principled way. By principled, type implementations can remain focused on their core abstractions, while additional behaviors can be added only where needed, as opposed to making global modifications that affect all users. These tools also preserve type safety.

However, a drawback of this separation of concerns is that the separate toJSON functionality needs to track changes in the code for the type hierarchy. If a field is renamed, the compiler will catch it for us. If a new field is added, for example Shape.color, it will be easy to miss.

Let’s explore an example. Recall that we used the pair construction idiom, a -> b, to create tuples (a, b), which is popular for creating Map instances:

val map = Map("one" -> 1, "two" -> 2)

In Scala 2, this is done using an implicit conversion to a library type ArrowAssoc in Predef (some details omitted for simplicity):

implicit final class ArrowAssoc[A](private val self: A) {
  @infix def -> [B](y: B): (A, B) = (self, y)
}

Here is how implicit conversion works in Scala 2. When the compiler sees the expression "one" -> 1, it sees that String does not have the -> method. However, ArrowAssoc[T] is in scope, it has this method, and the class is declared implicit. So, the compiler can emit code to create an instance of ArrowAssoc[String], with the string "one" passed as the self argument, followed by code to call ->(1) to construct and return the tuple ("one", 1).

If ArrowAssoc were not declared implicit, the compiler would not attempt to use it for this purpose.

Let’s re-implement this using a Scala 3 extension method. To avoid ambiguity with ->, let’s use ~> instead, but it works identically:

// src/script/scala/progscala3/contexts/ArrowAssocExtension.scala

scala> import scala.annotation.{alpha, infix}                1

scala> extension [A,B] (a: A):                               2
     |   @infix @alpha("arrow2") def ~>(b: B): (A, B) = (a, b)
def extension_~>[A, B](a: A)(b: B): (A, B)

scala> "one" ~> 1
val res0: (String, Int) = (one,1)
1

Use the @infix annotation to allow infix operator notation, i.e., "one" ~> 1. Use @alpha to define an alphanumeric name in byte code for this method.

2

The syntax for defining an extension method. Any type parameters used by the methods that follow must go after the keyword extension. (The whitespace is arbitrary.) Next we define the method ~>, like we would if this were a “regular” type.

Note the signature the compiler reports for the generated method. It is named extension_~> and it takes two parameter lists. The first one is for the target instance of type A being extended. The second list is the same one specified when we declared the ~> method. We’ll see this naming convention again for anonymous given instances below.

Now, when the compiler sees "one" ~> 1, it will look for a corresponding extension_~> that is in scope and type compatible in the left-hand and right-hand types. Our definition satisfies this requirement for all types. Then the compiler will emit code to call the extension method. No wrapping in a new instance is required. Hence, implicit conversion is eliminated.

Let’s complete an example we started in OperatorOverloading, where we showed that parameterized types with two parameters can be written with infix notation, but at the time, we didn’t know how to support using the same type name as an operator for constructing instances. Specifically, we defined a type !! allowing declarations like Int !! String, but we couldn’t define a value of this type using the same literal syntax, for example, 2 !! "two". Now we can do this by defining an extension method !! as follows:

// src/script/scala/progscala3/contexts/InfixTypeRevisited.scala

scala> import scala.annotation.{alpha, infix}

scala> @alpha("BangBang") case class !![A,B](a: A, b: B)          1

scala> extension [A,B] (a: A) def !!(b: B): A !! B = !!(a, b)     2
def extension_!![A, B](a: A)(b: B): A !! B

scala> val ab1: Int !! String = 1 !! "one"                        3
     | val ab2: Int !! String = !!(1, "one")
val ab1: Int !! String = !!(1,one)
val ab2: Int !! String = !!(1,one)
1

The same case class defined in OperatorOverloading.

2

The extension method definition. When only one method is defined, you can omit the colon (or curly braces) and even define it on the same line as shown.

3

This line failed to compile before, but now the extension method is applied to Int and invoked with the String argument "one".

Tip

When defining just one extension method for a type, the colon at the end of the opening line or curly braces can be omitted. For consistency and easier reading, consider always using the colon or braces.

Both of our examples did not need to add additional fields to the target type nor was there an interface that made sense to implement. When those are required, we’ll use type classes, discussed next.

There was a “context” for both extensions. Users only need -> or !! in certain, limited circumstances. These would not be good methods to add to the source code for all types! With extension methods, we get the best of both worlds, calling “methods” like -> when we need them, while keeping types as focused as possible.

So far we have extended classes. What about extension methods on objects? An object can be thought of as a singleton. To get its type, use Foo.type:

scala> object Foo:
     |   def one: Int = 1

scala> extension (foo: Foo.type) def two: String = "two"
def extension_two(foo: Foo.type): String

scala> Foo.one
     | Foo.two
val res0: Int = 1
val res1: String = two

Type Classes

The next step beyond extension methods is to add not only methods to types, but also state (fields) and to implement an abstraction, so all type extensions are done uniformly. A term that is popular for these kinds of extensions is type classes, which comes from the Haskell language, where this idea was pioneered. The word class in this context is not the same as Scala’s OOP concept of classes, which can be confusing.

As an example, suppose we have a collection of Shapes from ASampleApplication and we want the ability to call a toJSON method on them that returns a JSON representation appropriate for each type? If we write someShape.toJSON, We want the Scala compiler to invoke some mechanism that implements this functionality.

Scala 3 Type Classes

A type class defines an abstraction with optional state (fields) and behavior (methods). They provide a another way to implement mixin composition (TraitsInterfacesAndMixinsInScala). The abstraction is valuable for ensuring that all “instances” of the type class follow the same protocol uniformly.

First, we need a trait for the state and behavior we want to add. To keep things simple, we’ll return JSON-formatted strings, not objects from some JSON library (of which there are many…):

// src/main/scala/progscala3/contexts/json/ToJSON.scala
package progscala3.contexts.json

trait ToJSON[T]:
  extension (t: T) def toJSON(name: String, level: Int): String

  protected val INDENTATION = "  "
  protected def indentation(level: Int): (String,String) =
    (INDENTATION * level, INDENTATION * (level+1))

This is the Scala 3 type class pattern. We define a trait with a type parameter and we define extension methods. Although we don’t actually use the type parameter in the body of this particular type class, the parameter will be essential for disambiguating one type class instance, such as the one for Circle, from another, such as the one for Rectangle.

The public method users care about is toJSON. The protected method, indentation, and immutable state, INDENTATION, are implementation details.

The type class pattern solves the limitation discussed above for extension methods alone. We can define and implement an abstraction and we can add arbitrary state as fields.

Now we create instances for our Shapes:

// src/main/scala/progscala3/contexts/typeclass/new1/ToJSONTypeClasses.scala
package progscala3.contexts.typeclass.new1

import progscala3.introscala.shapes.{Point, Shape, Circle, Rectangle, Triangle}
import progscala3.contexts.json.ToJSON

given ToJSON[Point]:                                            1
  extension (point: Point) def toJSON(name: String, level: Int): String =
    val (outdent, indent) = indentation(level)
    s""""$name": {
      |${indent}"x": "${point.x}",
      |${indent}"y": "${point.y}"
      |$outdent}""".stripMargin

given ToJSON[Circle]:                                           2
  extension (circle: Circle) def toJSON(name: String, level: Int): String =
    val (outdent, indent) = indentation(level)
    s""""$name": {
      |${indent}${circle.center.toJSON("center", level + 1)},
      |${indent}"radius": ${circle.radius}
      |$outdent}""".stripMargin

// And similarly for Rectangle and Triangle

@main def TryJSONTypeClasses() =
  println(Circle(Point(1.0,2.0), 1.0).toJSON("circle", 0))
  println(Rectangle(Point(2.0,3.0), 2, 5).toJSON("rectangle", 0))
  println(Triangle(
    Point(0.0,0.0), Point(2.0,0.0), Point(1.0,2.0)).toJSON("triangle", 0))
1

The given keyword declares a conversion for ToJSON[Point]. The extension method for ToJSON is implemented as required for point

2

A given for ToJSON[Circle].

Running TryJSONTypeClasses prints the following:

> runMain progscala3.contexts.typeclass.new1.TryJSONTypeClasses
...
"circle": {
  "center": {
    "x": "1.0",
    "y": "2.0"
  },
  "radius": 1.0
}
...
Note

If you use braces, the colon on the given line is replaced with an opening curly brace and a corresponding closing brace is required. The same applies for the definition of the anonymous ToJSON subtype, of course.

There’s a flaw with our implementation, though. If we put those shapes in a sequence, shapes, and try shapes.foreach(s ⇒ println(s.toJSON("shape", 0))), we get an error that Shape doesn’t have a toJSON method. Polymorphic dispatch doesn’t work here.

What if we add a given for Shape that delegates to the others?

given ToJSON[Shape]:
  extension (shape: Shape) def toJSON(name: String, level: Int): String =
    shape.toJSON(name, level)

Seems legit, but the compiler says we have an “infinite recursion”. Again, we aren’t actually calling a polymorphic method toJSON defined in the old-fashioned way for the hierarchy. So, the call shape.toJSON(level) attempts to call the extension method recursively.

What about pattern matching on the type of Shape?

given ToJSON[Shape]:
  extension (shape: Shape) def toJSON(name: String, level: Int): String =
    shape match
      case c: Circle    => c.toJSON(name, level)
      case r: Rectangle => r.toJSON(name, level)
      case t: Triangle  => t.toJSON(name, level)

We still get an infinite recursion at runtime, but the compiler can’t detect it! So, instead, let’s call the compiler generated toJSON implementations directly. A synthesized object is output for each specific given:

// src/main/scala/progscala3/contexts/typeclass/new2/ToJSONTypeClasses.scala
...
given ToJSON[Shape]:
  extension (shape: Shape) def toJSON(name: String, level: Int): String =
    shape match
      case c: Circle    =>
        given_ToJSON_Circle.extension_toJSON(c)(name, level)
      case r: Rectangle =>
        given_ToJSON_Rectangle.extension_toJSON(r)(name, level)
      case t: Triangle  =>
        given_ToJSON_Triangle.extension_toJSON(t)(name, level)
...
@main def TryJSONTypeClasses() =
  val c = Circle(Point(1.0,2.0), 1.0)
  val r = Rectangle(Point(2.0,3.0), 2, 5)
  val t = Triangle(Point(0.0,0.0), Point(2.0,0.0), Point(1.0,2.0))
  println("==== Use shape.toJSON:")
  Seq(c, r, t).foreach(s => println(s.toJSON("shape", 0)))
  println("==== call toJSON on each shape explicitly:")
  println(c.toJSON("circle", 0))
  println(r.toJSON("rectangle", 0))
  println(t.toJSON("triangle", 0))

The output of …typeclass.new2.TryJSONTypeClasses (not shown) indicates that calling shape.toJSON and circle.toJSON (for example) now both work as desired, but we are relying on an obscure implementation detail that could change in a future release of the compiler. Note the naming conventions, given_… and extension_…, which we saw previously.

There is an easy fix. We can give names to the givens and then call them:

// src/main/scala/progscala3/contexts/typeclass/new3/ToJSONTypeClasses.scala
...
given ToJSON[Shape]:
  extension (shape: Shape) def toJSON(name: String, level: Int): String =
    shape match
      case c: Circle    => circleToJSON.extension_toJSON(c)(name, level)
      case r: Rectangle => rectangleToJSON.extension_toJSON(r)(name, level)
      case t: Triangle  => triangleToJSON.extension_toJSON(t)(name, level)

given circleToJSON as ToJSON[Circle]:
  ...

Note the as keyword after the name. Instead of the synthesized name given_ToJSON_Circle, it will be circleToJSON, and similarly for the other shapes. We must still use the synthesized method name for the extension method, but at least we have more control over the object name.

Note

When used as shown, as is a “soft” reserved word. You can still use it as an identifier elsewhere. However, given is always reserved.

At this point, if we still want to simulate polymorphic behavior, consider refactoring the code to move the implementations of the toJSON methods for each concrete Shape to a regular helper object. Then call its methods instead of using the compiler generated objects. See …typeclass.new4.TryJSONTypeClasses in the code examples for one approach.

Tip

Keep the given instances as concise as possible. Consider moving some of the code to “regular” types that operate as helpers.

Finally, note that Point and the concrete subtypes of Shape are not related in the type hierarchy (well, except for AnyRef way at the top). Hence, this extension mechanism is ad hoc polymorphism, because the polymorphic behavior of toJSON is not tied to the type system, as it would be in subtype polymorphism. Subtype polymorphism is nice for allowing parent types to declare behaviors that can can be defined in subtypes. We had to hack around this missing feature for toJSON! For completeness, recall that we discussed a third kind of polymorphism, parametric polymorphism, in AbstractTypesVsParameterizedTypes, where containers like Container[A] behave uniformly for any type A.

Sometimes a type class will define members that make more sense as the analogs of companion object members, rather than instance members. To see this, let’s look at type classes for Semigroup and Monoid. Semigroup generalizes the notion of addition or composition. You know how addition works for numbers, and even strings can be “added”. Monoid adds the idea of a “unit” value. If you add zero to a number, you get the number back. If you add a string to an empty string, you get the first string back.

Here are the definitions for these types:2

// src/script/scala/progscala3/contexts/MonoidTypeClass.scala

trait Semigroup[T]:
  extension (t: T):
    def combine(other: T): T                                         1
    def <+>(other: T): T = t.combine(other)

trait Monoid[T] extends Semigroup[T]:
  def unit: T                                                        2

given StringMonoid as Monoid[String]:                                3
  def unit: String = ""
  extension (s: String) def combine(other: String): String = s + other

given IntMonoid as Monoid[Int]:
  def unit: Int = 0
  extension (i: Int) def combine(other: Int): Int = i + other
1

Define an instance extension method combine and an alternative operator methods <+> that calls combine. Note that combining one element with another of the same type returns a new element of the same type, like adding numbers. For given instances of the type class, we will only need to define combine.

2

The definition for unit, i.e., zero for addition of numbers. It’s not defined as an extension method, but an object method, because we only need one instance of the value for all T.

3

Instances of the type class for String and Int. Note how unit and combine are defined.

The Monoid combine operation is associative, so here are examples of both instances in action.

"2" <+> ("3" <+> "4")             // "234"
("2" <+> "3") <+> "4"             // "234"
StringMonoid.unit <+> "2"         // "2"
"2" <+> StringMonoid.unit         // "2"

2 <+> (3 <+> 4)                   // 9
(2 <+> 3) <+> 4                   // 9
IntMonoid.unit <+> 2              // 2
2 <+> IntMonoid.unit              // 2

Notice how each unit is referenced. This is why we gave these given instances explicit names, so it’s easier to remember what to call them, instead of the default given_Monoid_String, etc.

Finally, we don’t actually need to define separate instances for each numeric type. Here is how to implement it once for a T for which Numeric[T] exists:

given NumericMonoid[T](using num: Numeric[T]) as Monoid[T]:
  def unit: T = num.zero
  extension (t: T) def combine(other: T): T = num.plus(t, other)

2.2 <+> (3.3 <+> 4.4)             // 9.9
(2.2 <+> 3.3) <+> 4.4             // 9.9

BigDecimal(3.14) <+> NumericMonoid.unit
NumericMonoid[BigDecimal].unit <+> BigDecimal(3.14)
//tag::numericdefinition[]

When accessing NumericMonoid[BigDecimal].unit, the type parameter could be inferred when it was used as the argument for the extension method <+>. However, when it was used as the instance to be extended with <+>, the type parameter was required.

Scala 2 Type Classes

To implement the same type class and instances in Scala 2 sytanx (which is still allowed), you write an implicit conversion that wraps the Point and Shape instances in a new instance of a type that has the toJSON method, then call the method.

First, we need a slightly different ToJSON trait, because the extension method code in ToJSON won’t work with Scala 2:

// src/main/scala/progscala3/contexts/typeclass/old/ToJSONOldTypeClasses.scala
package progscala3.contexts.typeclass.old

import scala.language.implicitConversions                       1

trait ToJSONOld[T]:
  def toJSON(level: Int): String                                2

  protected val INDENTATION = "  "
  protected def indentation(level: Int): (String,String) =
    (INDENTATION * level, INDENTATION * (level+1))
1

We must enable implicit conversions.

2

Now this is a regular method. In the previous ToJSON implementation, it was an extension method.

Indiscriminate use of implicit conversions can be confusing for code comprehension and it sometimes leads to unexpected behavior. Therefore, implicit conversions are treated as an optional feature by Scala. This means you must enable the feature explicitly with the import statement used for implicitConversions or use the global -language:implicitConversions compiler flag.

Now here is an implementation of a toJSON type class instances for Scala 2, which also works in Scala 3. We’ll only show the implementations for Point and Circle:

implicit final class PointToJSON(
    point: Point) extends ToJSONOld[Point]:                     1
  def toJSON(name: String, level: Int): String =
    val (outdent, indent) = indentation(level)
    s""""$name": {
      |${indent}"x": "${point.x}",
      |${indent}"y": "${point.y}"
      |$outdent}""".stripMargin

implicit final class CircleToJSON(
    circle: Circle) extends ToJSONOld[Circle]:                  2
  def toJSON(name: String, level: Int): String =
    val (outdent, indent) = indentation(level)
    s""""$name": {
      |${indent}${circle.center.toJSON("center", level + 1)},
      |${indent}"radius": ${circle.radius}
      |$outdent}""".stripMargin
...

@main def TryJSONOldTypeClasses() =
  val c = Circle(Point(1.0,2.0), 1.0)
  val r = Rectangle(Point(2.0,3.0), 2, 5)
  val t = Triangle(Point(0.0,0.0), Point(2.0,0.0), Point(1.0,2.0))
  println(c.toJSON("circle", 0))
  println(r.toJSON("rectangle", 0))
  println(t.toJSON("triangle", 0))
1

The type class “instance” that implements ToJSONOld.toJSON for Point. It is called a type class instance following Haskell conventions, but we actually declare a class for it, which can be confusing.

2

The type class instance that implements toJSON for Circle.

Because these classes are declared as implicit, when the compiler sees circle.toJSON(), for example, it will look for an implicit conversion in scope that returns some wrapper type that has this method.

The output of TryJSONOldTypeClasses works as expected. However, we didn’t solve the problem of iterating through some Shapes and calling toJSON polymorphically. You can try that yourself.

We didn’t declare our implicit classes as cases classes. In fact, Scala doesn’t allow an implicit class to also be a case class. It wouldn’t make much sense anyway, because the extra, auto-generated code for the case class would never be used. Implicit classes have a very narrow purpose. Similarly, declaring them final is recommended to eliminate some potential surprises when the compiler resolves which type classes to use.

If you need to support Scala 2 code for a while, then using the original type class pattern will work for a few versions of Scala 3. However, in most cases, it will be better to migrate to the new type class syntax, because it is more concise and purpose-built, and it doesn’t require implicit conversions.

Implicit Conversions

We saw that an implicit conversion called ArrowAssoc was used in the Scala 2 library to implement the "one" -> 1 idiom, whereas we could use an extension method in Scala 3. We also saw implicit conversions used for type classes in Scala 2, while Scala 3 combines extension methods and givens to avoid doing conversions.

Hence, in Scala 3, the need to do implicit conversions is greatly reduced, but it hasn’t disappeared completely. Sometimes you will want to convert between types for other reasons. Consider the following example that defines types to represent Dollars, Percentages, and a person’s Salary, where the gross salary and the percentage to deduct for taxes are encapsulated. When constructing a Salary instance, we want to allow users to enter Doubles, for convenience:

// src/main/scala/progscala3/contexts/NewImplicitConversions.scala
package progscala3.contexts
import scala.language.implicitConversions

case class Dollars(amount: Double):
  override def toString = f"$$$amount%.2f"

case class Percentage(amount: Double):
  override def toString = f"${(amount*100.0)}%.2f%%"

case class Salary(gross: Dollars, taxes: Percentage):
  def net: Dollars = Dollars(gross.amount * (1.0 - taxes.amount))

Note that we import scala.language.implicitConversions. The Dollars class encapsulates a Double for the amount, with toString overridden to return the familiar “$dollars.cents” output. Similarly, Percentage wraps a Double and overrides toString.

Let’s try it:

@main def TryImplicitConversions() =
  given Conversion[Double,Dollars] = d => Dollars(d)            1
  given Conversion[Double,Percentage] = d => Percentage(d)

  val salary = Salary(100_000.0, 0.20)
  println(s"salary: $salary. Net pay: ${salary.net}")
1

The syntax for declaring a given conversion from Double to Dollars and a second conversion from Double to Percentage.

Running this example prints the following:

salary: Salary($100000.00,20.00%). Net pay: $80000.00

The declaration of the Conversion[Double,Dollars] given is shorthand for the following longer form:

given Conversion[Double,Dollars]:
  def apply(d: Double): Dollars = Dollars(d)

By the way, if you define the given for converting Doubles to Dollars in the REPL, observe what happens:

...
scala> given Conversion[Double,Dollars] = d => Dollars(d)
def given_Conversion_Double_Dollars: Conversion[Double, Dollars]

scala> given dd as Conversion[Double,Dollars] = d => Dollars(d)
def dd: Conversion[Double, Dollars]

In our type classes above, objects were generated. Here, methods are generated. As we saw previously, for an anonymous given, the generated name follows the convention given_….

Scala 3 still supports the Scala 2 mechanism of using an implicit method for conversion:

implicit def toDollars(d: Double): Dollars = Dollars(d)

Rules for Implicit Conversion Resolution

Here is a summary of the lookup rules used by the compiler to find and apply conversions conversions. I’ll use given and given instances to refer to both new and old style conversions:

  1. No conversion will be attempted if the object and method combination type check successfully.

  2. Only given instances for conversion are considered.

  3. Only given instances in the current scope are considered, as well as givens defined in the companion object of the target type.

  4. Given conversions aren’t chained to get from the available type, through intermediate types, to the target type. Only one conversion will be considered.

  5. No conversion is attempted if more than one possible conversion could be applied and have the same scope. There must be one and only one, unambiguous possibility.

Type Class Derivation

TODO: Verify which mixins support derivation at the time Scala 3 is released. Also verify maturity of the implementation For example, derives Eql shouldn’t be necessary for enum and case class definitions, but it appears to be necessary. What is the final Scala 3 implementation?

Type class derivation is the idea that we should be able to automatically generate type class given instances as long as they obey a minimum set of requirements. A class uses the new keyword derives, which works like extends or with, to trigger derivation.

For example, Scala 3 introduces scala.Eql, which restricts use of the comparison operators == and != for instances of arbitrary types. Normally, it’s allowed to do these comparisons, but when the compiler flag -language:strictEquality or the import statement import scala.language.strictEquality is used, then the comparison operators are only allowed in certain specific contexts. Here is an example:

// src/main/scala/progscala3/contexts/Derivation.scala

package progscala3.contexts
import scala.language.strictEquality

enum Tree[T] derives Eql:
  case Branch(left: Tree[T], right: Tree[T])
  case Leaf(elem: T)

@main def TryDerived() =
  import Tree._
  val l1 = Leaf("l1")
  val l2 = Leaf(2)
  val b = Branch(l1,Branch(Leaf("b1"),Leaf("b2")))
  assert(l1 == l1)
  // assert(l1 != l2)   // Compilation error!
  assert(l1 != b)
  assert(b  == b)
  println(s"For String, String: ${implicitly[Eql[Tree[String],Tree[String]]]}")
  println(s"For Int, Int: ${implicitly[Eql[Tree[Int],Tree[Int]]]}")
  // Compilation error:
  // println(s"For String, Int: ${implicitly[Eql[Tree[String],Tree[Int]]]}")

Because of the derives Eql clause in the Tree declaration, the equality checks in the assertions are allowed. The derives Eql clause has the effect of generating the following given instance:

given Eql[Tree[T], Tree[T]] = Eql.derived

Eql.derived is the following:

object Eql:
  object derived extends Eql[Any, Any]
  ...

Furthermore, T will be constrained to types with given Eql[T, T] = Eql.derived. What all this effectively means that we can only compare Tree[T] instances for the same T types.

The terminology used is Tree is the deriving type and the Eql instance is a derived instance.

In general, any type T defined with a companion object that has the derived instance or method can be used with derives T clauses. We’ll discuss how methods are implemented in TypeClassDerivationImplementationDetails, after we have learned the metaprogramming details required. The reason Eql and the strictEquality language feature were introduced is discussed in MultiversalEquality.

Tip

If you want to enforce stricter use of comparison operators, use -language:strictEquality, but expect to add derives Eql to many of your types.

Givens and Imports

In ATasteOfFutures we imported an implicit ExecutionContext, scala.concurrent.ExecutionContext.Implicits.global. The name of the enclosing object Implicits reflects a common convention in Scala 2 for making implicit definitions more explicit in code that uses them, at least if you pay attention to the import statements.

Scala 3 introduces a new way to control imports of givens and implicits, which provides an effective alternative form of visibility, as well as allowing developers to use wild-card imports frequently while restricting if and when givens and implicits are also imported.

Consider the following example adapted from the Dotty documentation:

// src/script/scala/progscala3/contexts/GivenImports.scala

object O1:
  val name = "O1"
  val m(s: String) = s"$s, hello from $name"
  class C1
  given c1 as C1
  class C2
  given c2 as C2

Now consider these import statements:

import O1._             // Imports everything EXCEPT the givens, c1 and c2
import O1.{given _}     // Imports ONLY the givens, c1 and c2
import O1.{given c1}    // Imports just c1 explicitly
import O1.{given _, _}  // Imports everything in O1

A given import selector also brings old style implicits into scope.

What if the given instances are anonymous and you don’t want to use the wild card?

object O2:
  class C1
  given C1
  class C2
  given C2
  given intOrd as Ordering[Int]
  given listOrd[T: Ordering] as Ordering[List[T]]

You can import by type. Note the ? wild card for the type parameter, which means both Ordering givens will be imported:

import O2.{given C1, given Ordering[?]}

Because this is a breaking change in how _ wild cards work for imports, it is being implemented gradually:

  • In Scala 3.0 an old-style implicit definition can be brought into scope either by a _ or a given _ wildcard selector.

  • In Scala 3.1 an old-style implicit accessed through a _ wildcard import will give a deprecation warning.

  • In some version after 3.1, old-style implicits accessed through a _ wildcard import will give a compiler error.

Finally, the older Scala 2 implicit conversions are still allowed, where an implicit def is used, for example:

implicit def doubleToDollars(d: Double): Dollars = Dollars(d)

Unlike the Scala 2 alternative to extension methods, we don’t need an implicit class here, like ArrowAssoc above, because Dollars has all the methods we need. This method would be invoked to do the conversion exactly the same way as the given Conversion[Double,Dollars] above.

Resolution Rules for Givens and Extension Methods

Extension methods and given definitions obey the same scoping rules as other declarations, i.e., they must be visible to be considered. The previous examples scoped the extension methods to packages, such as the new1, new2, etc. packages. They were not visible unless the package contents were imported or we were already in the scope of that package.

Within a particular scope, there could be several candidate givens or extension methods that the compiler might use for a type extension. The Dotty documentation has the details for Scala 3’s resolution rules. I’ll summarize the key points here. Givens are also used to resolve implicit parameters in method using clauses, which we’ll explore in the next chapter. The same resolution rules apply.

I’ll use the term “given” in the following discussion to include given instances, extension methods, and Scala 2 implicit methods, values, and classes, depending on the scenario. Resolving to a particular given happens in the following order:

The compiler always puts some library givens in scope, while other library givens require an import statement. For example, Predef extends a type called LowPriorityImplicits, which makes the givens defined in Predef lower priority when potential conflicts arise with other givens in scope. The rational is that the other givens are likely to be user defined or imported from special libraries, and hence more “important” to the user.

Build Your Own String Interpolator

Let’s look at a final example of extension, one that lets us define our own string interpolation capability. Recall from InterpolatedStrings that Scala has several built-in ways to format strings through interpolation. For example:

val (first, last) = ("Buck", "Trends")
println(s"Hello, ${first} ${last}")

There are also StringContext methods named f and raw, where f supports printf format directives and raw doesn’t interpret escape characters:

scala> val pi=3.14159

scala> f"$pi%5.3f or ${pi}%7.5f"
val res0: String = 3.142 or 3.14159

scala> raw"	 $pi 
 $pi again"
val res1: String = t 3.14159 n 3.14159 again

We’ll look at a simplistic implementation of a SQL query compiler named sql. When the compiler sees an expression like sql"SELECT $column FROM $table;", it will be translated to the following:

StringContext("SELECT ", "FROM ", ";").sql(column, table)

Note how the embedded expressions become arguments to sql, while the other string tokens are arguments to StringContext.apply. However, scala.StringContext doesn’t have a sql method, so an implicit conversion to another type or an extension method is required.

Let’s define sql for StringContext. For simplicity, it will only handle SQL queries of the form sql"SELECT columns FROM table;" with the columns and table strings specified as part of the string or using embedded expressions. The extracted column and table names are returned in an instance of a simple case class SQLQuery. It would be possible to use the same approach with a real SQL parser and return a query object for a library like JDBC. I won’t show the whole implementation (which is somewhat of a “hack” for this simple case), but just the declarations:

// src/main/scala/progscala3/contexts/SQLStringInterpolator.scala
package progscala3.contexts

object SimpleSQL:
  case class SQLQuery(columns: Seq[String] = Nil, table: String = "")

  extension (sc: StringContext):
    def sql(values: String*): SQLQuery =
      // Extract the column names and table name.
      SQLQuery(columns, table)

See the SQLStringInterpolator source code for the full details. Here is how to use it:

scala> import progscala3.contexts.SimpleSQL._

scala> val query1 = sql"SELECT one, two, three FROM t1;"
val query1: ...SimpleSQL.SQLQuery = SQLQuery(Vector(one, two, three),t1)

scala> val cols = Seq("four", "five").mkString(", ")
     | val table = "t2"
     | val query2 = sql"SELECT $cols FROM $table;"
val cols: Seq[String] = List(four, five)
val table: String = t2
val query2: ...SQLQuery = SQLQuery(Vector(four, five),t2)

As shown, custom string interpolators can return any type you want, not just a new String, like s, f, and raw return. Hence, they can function as instance factories that are driven by data encapsulated in strings.

The Expression Problem

Let’s step back for a moment and ponder what we just accomplished in the previous example. We added new functionality to an existing library type without editing the source code for it!

This desire to extend modules without modifying their source code is called the Expression Problem, a term coined by Philip Wadler.

Object-oriented programming solves this problem with subtyping, more precisely called subtype polymorphism. We program to abstractions and use derived classes when we need changed behavior. Bertrand Meyer coined the term Open/Closed Principle to describe a principled OOP approach, where base types declare the behaviors as abstract that should be open for extension or variation in subtypes, while keeping invariant behaviors closed to modification. The base types are never modified for extension.

Scala certainly supports this technique, but it has drawbacks. What if it’s questionable that we should have that behavior defined in the type hierarchy in the first place? What if the behavior is only needed in a few contexts, while for most contexts, it’s just a burden that the code carries around?

It can be a burden for several reasons. First, the extra, mostly-unused code is a maintenance burden. Developers have to understand it, even when working on other aspects of the code, so they don’t break it inadvertently. Second, it’s also inevitable that most defined behaviors will be refined over time. Every change to a feature that some clients aren’t using forces unwanted updates on client code.

This problem led to the Single Responsibility Principle, a classic design principle that encourages us to define abstractions and implement types with just a single behavior.

Still, in realistic scenarios, it’s sometimes necessary for an object to combine several behaviors. For example, a service often needs to “mix in” the ability to log messages. Scala makes these mixin features relatively easy to implement, as we saw in TraitsInterfacesAndMixinsInScala. We can even declare instances that mix in traits without first defining a class.

In general, mixins, extension methods, and type classes provide robust and principled solutions to the Open/Closed Principle while allowing the core implementations of types to obey the Single Responsibility Principle.

Wise Use of Type Extensions

Why not take an extreme approach and define types with very little state and behavior (sometimes called anemic types), then add most behaviors using mixin traits, type classes, extension methods, even implicit conversions?

First, when using a type class or implicit conversion, the resolution algorithm requires more work by the compiler than just finding the logic inside the type’s original definition. Also, there can be more boilerplate writing extensions compared to the alternative of defining constructs inside the type. Therefore, a project that over-uses these tools is a project that is slow to build, as well as potentially hard to comprehend when reading the code.

Another problem for the extension mechanisms explored in this chapter is that you effectively lose several benefits of object orientation!

First, code evolution can be challenging if the extensions depend on details of the types they extend, like our ToJSON example in the last chapter. The details have to be coordinated in more than one place, the locations of the extensions, as well as the type definitions themselves. Fortunately, coupling between type definitions and extensions are limited to the public interfaces, as an extension has no access to private or protected members of a type.

A second issue is the loss of object-oriented method dispatch. We had to do some hacking to support Shape.toJSON in a polymorphic way.

If instead, toJSON were declared abstract in Shape and implemented in Circle, Rectangle, etc., then this code would work with the usual object-oriented dispatch rules.

Most of the time, the core domain logic belongs in the type definition. Ancillary behaviors, like serializing to JSON and logging, belong in mixins or type classes. However, if your applications use toJSON frequently for your domain classes, it might be a good idea to move this behavior into the type definitions, on balance.

When should use use type classes and extension methods vs. mix-in composition? For example, recall the Logging trait example we saw in TraitsInterfacesAndMixinsInScala. If the trait has orthogonal state and behavior, like logging, that can be mixed into many different objects, then a mixin trait is often best. If the behavior has to be defined carefully for each type, like toJSON, then type classes are best.

Recap and What’s Next

We started our exploration of context abstractions in Scala 2 and 3, beginning with tools to extend types with additional state and behavior, such as type classes, extension methods, and implicit conversions.

Part 2 explores using clauses, which work with given instances to address particular design scenarios and to simplify user code.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.19.234.150