(16) Polymorphism
Polymorphism
Intro
Polymorphism provides a single interface to entities of different types or a single symbol to represent multiple different types.
In programming, it means that
- the function can be applied to arguments of many types, or
- the type can have instances of many types.
Polymorphism in Biology
Polymorphism is borrowed from a principle in biology where an organism or species can have many different forms or stages.
image source: https://www.biologyonline.com/dictionary/polymorphism
Categories
1/3 Adhoc polymorphism – Overloading
2/3 Parametric polymorphism – Generic – Instances of a function or class are created by type parameterization
3/3 Subtyping – Instances of a subclass can be passed to a base class
Polymorphism 1/3 Adhoc – Overloading
Overloading lets you declare the same method multiple times with different argument lists.
image source: https://www.guru99.com/cpp-polymorphism.html.
// Creating a super class
abstract class Shapes {
def draw() = println("Draw the shape")
// Overload
def draw(id: Int) = println(s"Draw the shape (Id: $id)")
}
// Creating a subclass
class Rectangle extends Shapes
// Creating a subclass
class Circle extends Shapes {
// Overriding
override def draw() = println("Draw the Circle")
// Override, Overload
override def draw(id: Int) = println(s"Draw the Circle (Id: $id)")
}
val rectangle = new Rectangle
rectangle.draw()
rectangle.draw(0)
val circle = new Circle
circle.draw()
circle.draw(1)
Result:
val rectangle: Rectangle = Rectangle@3aa66b06
Draw the shape
Draw the shape (Id: 0)
val circle: Circle = Circle@6d6d7a8
Draw the Circle
Draw the Circle (Id: 1)
Overriding vs Overloading
Overloading | Overriding |
---|---|
Must have at least two methods by the same name in the same class. | Must have at least one method by the same name in both Parent and child class. |
Must have a different number of parameters. If the numbers of parameters are the same, they must have different parameters. | Must have the same number of Parameters. |
Overloading is known as compile-time polymorphism. | Types of parameters also must be the same. |
Polymorphism 2/3 Parametric – Generic
Algorithms are written in terms of types to be specified later that are then instantiated when needed for specific types provided as parameters.
i.e. Generic classes/functions would take a type as a parameter.
class Stack[A] {
private var elements: List[A] = Nil
def push(x: A): Unit =
elements = x :: elements
def peek: A = elements.head
def pop(): A = {
val currentTop = peek
elements = elements.tail
currentTop
}
}
val stack = new Stack[Int]
stack.push(1)
stack.push(2)
println(stack.pop()) // prints 2
println(stack.pop()) // prints 1
Polymorphism 3/3 Subtyping
Intro
A form of type polymorphism in which a subtype is a datatype that is related to another datatype (the supertype) by some notion of substitutability
i.e. we can operate on the supertype elements and operate on the subtype’s elements.
Suppose S
is a subtype of T
. In that case, the subtyping relation is often written S <: T
to mean that any term of type S can be safely used in a context where a term of type T is expected.
- upper bound –
S <: T
means S is a subtype of T, and - lower bound –
S >: T
means S is a supertype of T, or T is a subtype of S. - Mixed bound –
S >: TS <: T
means restrict S any type on the interval between TS and T
image source: https://livebook.manning.com/book/programming-with-types/chapter-7/
Example
// Creating a super class
class Shape
// Creating a subclass
class Rectangle extends Shape
def draw[T <: Shape](shape: T): Unit = println(s"Draw the shape ${shape}")
draw[Shape](new Shape) // Draw the shape class repl$.rs$line$1$Shape
draw[Rectangle](new Rectangle) // Draw the shape class repl$.rs$line$2$Rectangle
Variance
Relationships
Relationship between generics, subtyping, variance and polymorphism
Intro
Variance refers to how subtyping between more complex types relates to subtyping between their components, i.e. Variance tells us if a type constructor (equivalent to a generic type in Java) is a subtype of another type constructor.
class Foo[+A] // A covariant class
class Bar[-A] // A contravariant class
class Baz[A] // An invariant class
Variance Checks
class Foo[+A] – covariant type parameters can only appear in method results.
class Bar[-A] – contravariant type parameters can only appear in method parameters.
class Baz[A] – invariant type parameters can appear anywhere.
Example:
trait Function1[-T, +U]:
def apply(x: T): U // good
def apply2(x: U): T // bad
// Error msg - contravariant type T occurs in covariant position in type (x: U): T
Here,
T
is contravariant and appears only as a method parameter typeU
is covariant and appears only as a method result type
Covariance
A type parameter T
of a generic class can be made covariant by using the annotation +T
. For some class List[+T]
, making T
covariant implies that for two types A
and B
where B
is a subtype of A
, then List[B]
is a subtype of List[A]
. This allows us to make very useful and intuitive subtyping relationships using generics.
abstract class Animal {
def name: String
}
case class Cat(name: String) extends Animal
case class Dog(name: String) extends Animal
Both Cat
and Dog
are subtypes of Animal
. The Scala standard library has a generic immutable sealed abstract class List[+A]
class, where the type parameter A
is covariant. This means that a List[Cat]
is a List[Animal]
. A List[Dog]
is also a List[Animal]
. Intuitively, it makes sense that a list of cats and a list of dogs are each list of animals, and you should be able to use either of them in place of List[Animal]
.
In the following example, the method printAnimalNames
will accept a list of animals as an argument and print their names on a new line. If List[A]
were not covariant, the last two method calls would not compile, which would severely limit the usefulness of the printAnimalNames
method.
def printAnimalNames(animals: List[Animal]): Unit =
animals.foreach {
animal => println(animal.name)
}
val cats: List[Cat] = List(Cat("Whiskers"), Cat("Tom"))
val dogs: List[Dog] = List(Dog("Fido"), Dog("Rex"))
// prints: Whiskers, Tom
printAnimalNames(cats)
// prints: Fido, Rex
printAnimalNames(dogs)
Contravariance
A type parameter A
of a generic class can be made contravariant by using the annotation -A
. This creates a subtyping relationship between the class and its type parameter that is similar but opposite to what we get with covariance. That is, for some class Printer[-A]
, making A
contravariant implies that for two types A
and B
where A
is a subtype of B
, Printer[B]
is a subtype of Printer[A]
.
Consider the Cat
, Dog
, and Animal
classes defined above for the following example:
abstract class Printer[-A] {
def print(value: A): Unit
}
A Printer[A]
is a simple class that knows how to print out some type A
. Let’s define some subclasses for specific types:
class AnimalPrinter extends Printer[Animal] {
def print(animal: Animal): Unit =
println("The animal's name is: " + animal.name)
}
class CatPrinter extends Printer[Cat] {
def print(cat: Cat): Unit =
println("The cat's name is: " + cat.name)
}
If a Printer[Cat]
knows how to print any Cat
to the console, and a Printer[Animal]
knows how to print any Animal
to the console, it makes sense that a Printer[Animal]
would also know how to print any Cat
. The inverse relationship does not apply because a Printer[Cat]
does not know how to print any Animal
to the console. Therefore, we should be able to use a Printer[Animal]
in place of Printer[Cat]
, if we wish, and making Printer[A]
contravariant allows us to do exactly that.
abstract class Printer[-A] {
def print(value: A): Unit
}
class AnimalPrinter extends Printer[Animal] {
def print(animal: Animal): Unit =
println("The animal's name is: " + animal.name)
}
class CatPrinter extends Printer[Cat] {
def print(cat: Cat): Unit =
println("The cat's name is: " + cat.name)
}
def printMyCat(printer: Printer[Cat], cat: Cat): Unit =
printer.print(cat)
val catPrinter: Printer[Cat] = new CatPrinter
val animalPrinter: Printer[Animal] = new AnimalPrinter
printMyCat(catPrinter, Cat("Boots")) // The cat's name is: Boots
printMyCat(animalPrinter, Cat("Boots")) // The animal's name is: Boots
The output of this program will be:
The cat's name is: Boots.
The animal's name is: Boots.
Invariance
Generic classes in Scala are invariant by default. This means that they are neither covariant nor contravariant. In the following example, the Container
class is invariant. A Container[Cat]
is not a Container[Animal]
, nor is the reverse true.
class Container[A](value: A) {
private var _value: A = value
def getValue: A = _value
def setValue(value: A): Unit = {
_value = value
}
}
It may seem like a Container[Cat]
should naturally also be a Container[Animal]
, but allowing a mutable generic class to be covariant would not be safe. In this example, it is very important that Container
is invariant. Supposing Container
was actually covariant, something like this could happen:
abstract class Animal {
def name: String
}
case class Cat(name: String) extends Animal
case class Dog(name: String) extends Animal
class Container[A](value: A) {
private var _value: A = value
def getValue: A = _value
def setValue(value: A): Unit = {
_value = value
}
}
val catContainer: Container[Cat] = new Container(Cat("Felix"))
val cat: Cat = catContainer.getValue // val cat: Cat = Cat(Felix)
// Error
val animalContainer: Container[Animal] = catContainer
// Found: (catContainer : Container[Cat]) Required: Container[Animal]
// Won't work
animalContainer.setValue(Dog("Spot")) // Not found: animalContainer
Fortunately, the compiler won’t work and stops us long before we can get this far.
Comparison With Other Languages
Variance is supported in different ways by some languages similar to Scala. For example, variance annotations in Scala closely resemble those in C#, where the annotations are added when a class abstraction is defined (declaration-site variance).
In Java, however, variance annotations are given by clients when a class abstraction is used (use-site variance).