Polymorphism

Petra Bierleutgeb

@pbvie

in Scala

Polymorphism

A single interface/method/function can be used with different types and...

many

forms

...choose a specific implementation based on the provided type

...work independently of the provided type, while still providing full type-safety

Different kinds

of polymorphism

Polymorphism

Ad-hoc Polymorphism

Parametric Polymorphism

Classification

Christopher Strachey
1967

Polymorphism

Ad-hoc Polymorphism

Parametric Polymorphism

Classification

polymorphic function that acts differently depending on the type of the given argument(s)

generic function or data type that works independently of the actual type - while still providing type safety

Polymorphism

Ad-hoc
Polymorphism

Parametric
Polymorphism

Classification

Peter Wegner and Luca Cardelli
1985

Inclusion Polymorphism
(subtyping)

Polymorphism

Ad-hoc
Polymorphism

Parametric
Polymorphism

Classification

Inclusion Polymorphism
(subtyping)

allows to use a subtype S of T
wherever a T is required

Essential Scala

(kindly provided by underscore)

>>> Book Tip <<<

Parametric Polymorphism

aka Generics

# Parametric Polymorphism

  • sometimes called Generics
    • generic functions/methods
    • generic data types
  • implementation/behavior does not depend on type
  • maintains type-safety
class Box[T](private val element: T) {
  def peek(): T = element
}

# Type Safety

class Box(private val element: Any) {
  def peek: Any = element
}

val i: Int = 3
val b = new Box(i)

val peeked: Any = b.peek
class Box[T](private val element: T) {
  def peek: T = element
}

val i: Int = 3
val b = new Box(i)

val peeked: Int = b.peek

with parametric polymorphism

without parametric polymorphism

# Type Safety

case class Dog(name: String, age: Int, favoriteToy: String)

val listOfDogs: List[Dog] = List(
  Dog("max", 2, "ball"),
  ...
)

listOfDogs.foreach(dog => println(s"${dog.name}'s favorite toy is ${dog.favoriteToy}"))

List[T] is a polymorphic data type

compiler knows element dog is of type Dog

# Type Safety

List[Dog]

def second[T](l: List[T]): Option[T] = l match {
  case _ :: second :: _ => Some(second)
  case _ => None
}

val secondDog: Option[Dog] = second(listOfDogs)
  • type param T is bound to type Dog when we pass listOfDogs to the method
def second(l: List[Dog]): Option[Dog] = ...

polymorphic function

# Conclusion

  • parametric polymorphism can be used to abstract over types
  • makes programs more expressive and improves type-safety
  • implementation is independent of the type parameter

Ad-hoc Polymorphism

Overloading

# Ad-hoc Polymorphism

  • polymorphic methods that accept arguments of different types
  • aka function/operator/method overloading
  • compiler selects implementation based on type
  • unlike parametric polymorphism: implementations for different types may do different things
def combine(a: Int, b: Int): Int             = a + b
def combine(a: String, b: String): String    = s"$a$b"
def combine(a: Boolean, b: Boolean): Boolean = a && b

combine(1, 3)
combine("abc", "def")
combine(true, false)
combine(List(123), List(456)) // ooops...this one won't compile

# Ad-hoc Polymorphism

  • overloaded methods can have a different number of parameters, in a different order, and with different types
  • a method is therefore uniquely identified by a combination of all these characteristics

     

def combine(a: Int, b: Int): Int             = a + b
def combine(a: String, b: String): String    = s"$a$b"
def combine(a: Boolean, b: Boolean): Boolean = a && b
def combine(a: Int, b: String): String       = s"${a.toString}$b"
def combine(a: String, b: Int): String       = s"$a${b.toString}"

combine(1, 3)
combine("abc", "def")
combine(true, false)
...

# Ad-hoc Polymorphism

  • watch out for type erasure!

// oops...doesn't compile!
def combine(l: List[Int]): String    = l.sum.toString
def combine(l: List[String]): String = l.mkString(",")

List => String

List => String

[error]       def combine(l: List[Int]): String at line 30 and
[error]       def combine(l: List[String]): String at line 32
[error]       have same type after erasure: (l: List)String

compilation error:

# Ad-hoc Polymorphism

// oops...doesn't compile!
def combine(l: List[Int]): Int       = l.sum
def combine(l: List[String]): String = l.mkString(",")

List => Int

List => String

  • watch out for type erasure!

works...but not recommended

# Early-Binding

  • overloading/dispatching is decided at compile-time
trait Animal {
  def name: String
}

final case class Dog(name: String) extends Animal
final case class Cat(name: String) extends Animal

def greeting(a: Animal): String = s"hello animal named ${a.name}"
def greeting(d: Dog): String    = s"hello dog named ${d.name}"
def greeting(c: Cat): String    = s"hello cat named ${c.name}"

# Early-Binding

  • What is the output of this program?
def greeting(a: Animal): String = s"hello animal named ${a.name}"
def greeting(d: Dog): String    = s"hello dog named ${d.name}"
def greeting(c: Cat): String    = s"hello cat named ${c.name}"
val d1: Dog    = Dog("max")
val d2: Animal = Dog("max")
val c1: Cat    = Cat("garfield")

println(greeting(d1))
println(greeting(d2))
println(greeting(c1))
hello dog named max
hello dog named max
hello cat named garfield
hello dog named max
hello animal named max
hello cat named garfield

A

B

# Early-Binding

  • What is the output of the program?
def greeting(a: Animal): String = s"hello animal named ${a.name}"
def greeting(d: Dog): String    = s"hello dog named ${d.name}"
def greeting(c: Cat): String    = s"hello cat named ${c.name}"
val d1: Dog    = Dog("max")
val d2: Animal = Dog("max")
val c1: Cat    = Cat("garfield")

println(greeting(d1))
println(greeting(d2))
println(greeting(c1))
hello dog named max
hello animal named max
hello cat named garfield

B

# Conclusion

  • method overloading
  • implementation selected based on type of parameters
  • called method is decided at compile-time (early binding)
  • overloading for parameterized types suffers from type erasure
  • in Scala: ad-hoc polymorphism often encountered in form of the Typeclass pattern

Subtyping

Inclusion Polymorphism

# Subtyping Polymorphism

  • relation between types that requires that we can
    use some subtype T wherever its supertype S is expected
  • in Scala, such relationships are described by extending traits and/or classes

# Subtyping Polymorphism

trait Animal {
  def name: String
  def greeting: String = s"hello animal named $name"
}

case class Dog(name: String) extends Animal {
  override val greeting: String = s"hello dog named $name"
}

case class Cat(name: String) extends Animal {
  override val greeting: String = s"hello cat named $name"
}
  • subtypes must provide implementations for all abstract fields and methods defined on the supertype
  • fields/methods already defined on the supertype can be overriden

# Subtyping Polymorphism

def greet(toBeGreeted: Animal): Unit = println(toBeGreeted.greeting)
val d1: Dog    = Dog("max")
val d2: Animal = Dog("max")
val c1: Cat    = Cat("garfield")

greet(d1)
greet(d2)
greet(c1)

# Late Binding

def greet(toBeGreeted: Animal): Unit = println(toBeGreeted.greeting)
val d1: Dog    = Dog("max")
val d2: Animal = Dog("max")
val c1: Cat    = Cat("garfield")

greet(d1)
greet(d2)
greet(c1)

What's the output of this program?

hello dog named max
hello dog named max
hello cat named garfield
hello dog named max
hello animal named max
hello cat named garfield

B

A

# Late Binding

def greet(toBeGreeted: Animal): Unit = println(toBeGreeted.greeting)
val d1: Dog    = Dog("max")
val d2: Animal = Dog("max")
val c1: Cat    = Cat("garfield")

greet(d1)
greet(d2)
greet(c1)

What's the output of this program?

hello dog named max
hello dog named max
hello cat named garfield

A

# Early/Late Binding

  • Scala has
    • early binding for method parameters
    • late binding for the object on which the method is called

# Subtyping: good parts

  • good for modeling ADTs
    • pattern using sealed traits/abstract classes and final case classes
  • easy to add new data without modifying existing code

# Subtyping: not-so-good parts

  • modify existing source code when adding new functionality
  • can't add traits on external code
  • only one implementation per type
  • complex/inflexible hierarchies
  • the (in)famous return-current-type problem

Variance and Type Bounds

Fine-tuning

# Variance

  • define subtyping relationships between types with type parameters
sealed trait Animal {
  def name: String
}
final case class Dog(name: String, favoriteToy: String) extends Animal
final case class Cat(name: String, favoriteFood: String) extends Animal

trait Box[T] {
 ...
}
  • if there's a Box[A] and B is a subtype of A, should Box[B] be a subtype of Box[A]?

# Invariance

  • there's no relation between Box[A] and Box[B], even if there's a sub/supertype relationship between A and B
  • notation: [A] (default in Scala)
trait Box[A] {
  def put(a: A): Unit
  def peek(): A
}

// can we use a Box[Dog] wherever we expect a Box[Animal]?
val dogBox: Box[Dog] = ...
val myAnimal: Animal = dogBox.peek // that's ok

val myCat: Cat = ...
def putAnimalInsideBox(animal: Animal, box: Box[Animal]) = ...

putAnimalInsideBox(myCat, dogBox) // that doesn't look good!
  • in general: if a type appears as produced value and consumed value it has to be invariant

# Covariance

  • Box[B] is a subtype of Box[A], if B is a subtype of A
  • notation: [+A]
class Box[+A](element: A) {
  def peek: A = element
}

def runIt(): Unit = {
  val bA: Box[Animal] = new Box(a)
  val bD: Box[Dog] = new Box(d)
  val bX: Box[Animal] = bD // works
  val myAnimal: Animal = bD.peek // it's ok to store a Dog in myAnimal
}
  • in general: if a type only appears as produced value (as return type), it can be made covariant

# Contravariance

  • Box[B] is a supertype of Box[A], if B is a subtype of A
  • notation: [-A]
trait Greeter[-A] {
  def greet(a: A): String
}

class AnimalGreeter extends Greeter[Animal] {
  override def greet(a: Animal): String = s"hello ${a.name}"
}

def doGreeting(greeter: Greeter[Dog]): Unit = {
  println(greeter.greet(dog))
}

val greeter = new AnimalGreeter
doGreeting(greeter)
  • in general: if a type only appears as consumed value (as parameter type), it can be made contravariant
  • limits the allowed types for a type parameter
  • [A <: B]          A must be a subtype of B
  • [A >: B]          A must be a supertype of B

# Type Bounds

Type Classes

The Pattern

# What is a Typeclass

  • first appeared in Haskell
  • form of ad-hoc polymorphism
    • Philip Wadler and Stephen Blott:
      How to make ad-hoc polymorphism less ad hoc
  • allows implementing an interface for possibly unrelated types
  • existing code doesn't have to be modified when adding new data types or implementing functionality for new types
  • Typeclasses in Scala: a pattern, not a language feature

# Using Typeclasses

  • Scala standard lib defines some Typeclasses along with instances for common types, e.g.  Ordering[T]
// simplified
trait Ordering[T] {
  def compare(x: T, y: T): Int
}
  • we can implement instances of a typeclass for

    • our own types

    • third-party types

    • and have multiple/different implementations per type

# Implementing Ordering[T] for Cat

case class Cat(name: String, age: Int)

implicit val catOrdering: Ordering[Cat] = new Ordering[Cat] {
  override def compare(x: Cat, y: Cat): Int = x.age - y.age
}
val unsortedCats = List(Cat("a", 3), Cat("b", 1), Cat("c", 7))
val sortedCats   = unsortedCats.sorted(catOrdering)

// sorted takes an implicit ord: Ordering[B] so we could actually write
val alsoSortedCats = unsortedCats.sorted

# Defining Typeclasses

  • The full Typeclass pattern includes
    • the Typeclass itself: interface (trait) with type parameter
    • Typeclass instances
    • interface to use the Typeclass
    • enriched interfaces

:: Example ::

Describable[T]

# Describable[T]

  • Describable[T] provides a single method `describe` that returns an informal description of T
  • Goal:
// unrelated data types
case class Cat(name: String, age: Int)
case class Coffee(name: String, origin: String)

val garfield = Cat("Garfield", 3)
val coffee   = Coffee("Yirgacheffe", "Ethiopia")

println(garfield.describe)
// Cats: Furry rulers of the world. This one is named Garfield.

println(Describable[Coffee].describe(coffee))
// Coffee: Beans of Life. This one is from Ethiopia.

# The Typeclass Interface

  • interface/trait that takes a type parameter
trait Describable[T] {
  def describe(t: T): String
}

# Typeclass Instances

  • works, but not very useful yet
    • explicitly select instance
    • not better than writing describeCat, describeCoffee,... methods
val catDescribable: Describable[Cat] = new Describable[Cat] {
  override def describe(c: Cat): String = 
    s"Cats: Furry rulers of the world. This one is named ${c.name}"
}

val coffeeDescribable: Describable[Coffee] = new Describable[Coffee] {
  override def describe(c: Coffee): String = 
    s"Coffee: Beans of Life. This one is from ${c.origin}"
}

val cat    = Cat(...)
val coffee = Coffee(...)

catDescribable.describe(cat)
coffeeDescribable.describe(coffee)

# Interface - First attempt

trait Describable[T] {
  def describe(t: T): String
}

val catDescribable: Describable[Cat] = ...
val coffeeDescribable: Describable[Coffee] = ...

object Describer {
  def describe[T](t: T, d: Describable[T]): String = d.describe(t)
}

val cat: Cat = ...
val coffee: Coffee = ...

Describer.describe(cat, catDescribable)
Describer.describe(coffee, coffeeDescribable)

# Interface - with implicits

implicit val catDescribable: Describable[Cat] = ...
implicit val coffeeDescribable: Describable[Coffee] = ...

object Describer {
  def describe[T](t: T)(implicit d: Describable[T]): String = d.describe(t)
}

val cat: Cat = ...
val coffee: Coffee = ...

Describer.describe(cat)
Describer.describe(coffee)

we can pass an instance of any type T for which we have an implicit Describable[T] in scope

# Organizing Implicits

  • possible locations
    • defined in current scope
    • defined in companion object of T
    • defined in companion object of Typeclass
    • imported
  • local and imported implicits take precedence over implicits defined in companion objects
    • possible to define defaults and override them

# ContextBounds

object Describer {
  def describe[T](t: T)(implicit d: Describable[T]): String = d.describe(t)
}

object DescriptionPrinter {
  def print[T](t: T)(implicit d: Describable[T]) = println(Describer.describe(t))
}
  • often, implicit params are only passed through
object DescriptionPrinter {
  def print[T: Describable](t: T) = println(Describer.describe(t))
}
  • context bounds can replace an implicit parameter that takes a type parameter of type T

# ContextBounds

object Describer {
  def describe[T](t: T)(implicit d: Describable[T]): String = d.describe(t)
}
  • implicit parameter can still be summoned using implicitly
object Describer {
  def describe[T: Describable](t: T): String = implicity[Describable[T]].describe(t)
}

# Interface - further improvements

trait Describable[T] {
  def describe(t: T): String
}

object Describer {
  def describe[T](t: T)(implicit d: Describable[T]): String = d.describe(t)
}
  • there's clearly some repetition here
  • gets worse when the typeclass has multiple methods that we want to support

# Interface

trait Describable[T] {
  def describe(t: T): String
}

object Describer {
  def apply[T](implicit d: Describable[T]): Describable[T] = d
}
  • usage:
// usage
Describer[Cat].describe(someCat)
Describer[Coffee].describe(someCoffee)

def printDescription[T : Describable](t: T): Unit = 
  println(Describer[T].describe(t))
  • we can get rid of the intermediate interface if we just want to call the methods defined on the typeclass

implicit instances need to be in scope

# Interface - Conclusion

  • using implicit parameters we can use a common interface for a type T if
    • the interface is implemented for T (typeclass instance)
    • the implementation is in implicit scope
  • by using implicit instances we can
    • have multiple implementations for a single type
    • choose an implementation by bringing the specific instance into scope

# Type Enrichment

  • define an interface that acts like a method defined on the type
// interface syntax
Describer[Cat].describe(someCat)
Describer[Coffee].describe(someCoffee)

// with type enrichment
someCat.describe
someCoffee.describe

# Type Enrichment

  • can be achieved using an implicit class
implicit class DescriberOps[T](val t: T) extends AnyVal {
  def describe(implicit d: Describable[T]): String = d.describe(t)
}

# Putting it all together

// typeclass
trait Describable[T] {
  def describe(t: T): String
}

// interface
object Describer {
  def apply[T](implicit d: Describable[T]): Describable[T] = d
}

// type enrichment
implicit class DescriberOps[T](val t: T) extends AnyVal {
  def describe(implicit d: Describable[T]): String = d.describe(t)
}

// instances
implicit val catDescribable: Describable[Cat] = ...
implicit val coffeeDescribable: Describable[Coffee] = ...

// usage
val garfield = Cat("garfield", 3)
println(garfield.describe)

# Conclusion: Good Parts

  • implementation of interfaces for possibly unrelated types
  • multiple implementations per type (selection via implicits)
  • implementation for external code
  • add new data and new functionality without modifying existing code

# Typeclasses: Not-so-good parts

  • Boilerplate: repetitive pattern needs to be manually implemented
    • syntax improvements coming in Dotty
    • Typeclass derivation (for instances)
  • Hard to model some scenarios
    • e.g. List of objects that are Describable

Upcoming Features

Typeclasses with less boilerplate
and nicer syntax

# Describable[T] Typeclass in Scala 2

// typeclass
trait Describable[T] {
  def describe(t: T): String
}

// interface
object Describer {
  def apply[T](implicit d: Describable[T]): Describable[T] = d
}

// type enrichment
implicit class DescriberOps[T](val t: T) extends AnyVal {
  def describe(implicit d: Describable[T]): String = d.describe(t)
}

// instances
implicit val catDescribable: Describable[Cat] = ...
implicit val coffeeDescribable: Describable[Coffee] = ...

// usage
val garfield = Cat("garfield", 3)
println(garfield.describe)

# Describable[T] in Scala 3

trait Describable[T] {
  def (t: T) describe: String
}
object Describable {
  def apply[T] given Describable[T] = the[Describable[T]]
}
delegate for Describable[Cat] {
  def (c: Cat) describe: String = ...
}
val c1 = Cat("garfield", 3)
println(c1.describe)

new extension method syntax
replaces implicit classes

new keywords given and the replace implicit param and implicitly

new delegate syntax replaces definition of implicit object or val for instances

usage unchanged

Typeclass Derivation

Automatic generation of typeclass instances

Wednesday June 12th, 15:30-16:15

The Shape(less) of Type Class Derivation in Scala 3

Miles Sabin

That's it!

Thank you

>> Question Time <<

Polymorphism in Scala

By Petra Bierleutgeb

Polymorphism in Scala

@ScalaDays2019

  • 1,180
Loading comments...

More from Petra Bierleutgeb