1. Introduction

In this tutorial, we’ll discuss the types of polymorphism in Scala and by extension in the functional programming sphere.

It’s important to mention that there are capabilities in functional programming languages like Scala that aren’t available in object-oriented languages like Java.

These unique capabilities increase the possibilities of polymorphism we can apply in our programs. Feel free to check out our tutorial on polymorphism in Java to make a comparison.

2. What Is Polymorphism

In the simplest of terms, polymorphism means one object having many forms or an object’s ability to exhibit multiple behaviors. In computer terms, this would show up as handling different data types using the same interface or method. The more formal definition is:

polymorphism is the provision of a single interface to entities of different types or the use of a single symbol to represent multiple different types.

In the coming sections, we’ll explore the different types of polymorphism.

3. Parametric Polymorphism

If we’re to take only one statement from this section, it’s that parametric polymorphism is just generics as used in languages such as Java, C#, and Scala.

We can easily recognize parametrically polymorphic functions in Scala by the presence of one or more type parameters delimited by square brackets in the method signature — they enable us to apply the same logic to different data types.

Let’s look at an example to illustrate this. Imagine we’re writing a method to pair-wise reverse a List. We want to be able to input List(1,2,3,4,5,6) and get output List(2,1,4,3,6,5). If the length is odd, then the last element should stay in its place such that List(1,2,3,4,5) becomes List(2,1,4,3,5).

3.1. The Naive Solution

def pairWiseReverseInt(xs: List[Int]): List[Int] = xs.grouped(2).flatMap(_.reverse).toList

Let’s create a test for the above scenario:

it should "reverse an even length int list" in {
    val input = List(1,2,3,4,5,6)
    val expected = List(2,1,4,3,5,6)
    val actual = pairWiseReverseInt(input)
    assert(actual == expected)
}

The method we’ve written works well for integer arrays but is not reusable for other types. As an example, if we’d like to pair-wise reverse a list of strings or doubles, we’d have to create additional methods and pointlessly duplicate the logic:

def pairWiseReverseString(xs: List[String]): List[String] = arr.grouped(2).flatMap(_.reverse).toList

And a quick test for it:

it should "pair-wise reverse a string list" in {
    val original = List("a","b","c","d","e")
    val expected = List("b","a","d","c","e")
    val actual = pairWiseReverseString(original)
    assertResult(expected)(actual)
}

3.2. The DRY Solution

Parametric polymorphism helps us eliminate this unnecessary duplication of code by introducing a second formal parameter list for type parameters. With parametric polymorphism, the logic remains the same for all the different types. To illustrate, we’ll refactor the above pair-wise reverse methods into one that works for all types:

def pairWiseReverse[A](xs:List[A]): List[A] = xs.grouped(2).flatMap(_.reverse).toList

We’ve represented the type parameter by letter A, but we could just as well have used K, T, or any other letter — it doesn’t matter. As with all formal parameters, we expect to substitute A during a method call with a concrete type such as Int, String, or any other concrete type in our case:

it should "pair-wise reverse lists of any type" in {
    val originalInts = List(1,2,3,4,5)
    val expectedInts = List(2,1,4,3,5)
    val originalStrings = List("a","b","c","d","e")
    val expectedStrings = List("b","a","d","c","e")
    val originalDoubles = List(1.2,2.7,3.4,4.3,5.0)
    val expectedDoubles = List(2.7,1.2,4.3,3.4,5.0)

    assertResult(expectedInts)(pairWiseReverse[Int](originalInts))
    assertResult(expectedStrings)(pairWiseReverse[String](originalStrings))
    assertResult(expectedDoubles)(pairWiseReverse[Double](originalDoubles))
}

Note that the Scala compiler can infer the type from the function call argument we pass in. It’s not mandatory that we explicitly pass in the type argument.

4. Subtype Polymorphism

The key concept in subtype polymorphism is substitutability as defined in the Liskov substitution principle. We can recognize a Scala function that exhibits subtype polymorphism when at least one of its parameter types has subtypes — that is, when it’s a supertype of at least one type.

This type relation is sometimes written as S <: *T,* which means *S* is a subtype of T or *T* :> S, meaning T is a supertype of S. We sometimes call this inclusion polymorphism. Unlike parametric polymorphism, we restrict the range of types a function can be applied to with this subtype relationship.

In the following example, we make Circle and Square subtypes of Shape. The method printArea() accepts a Shape but will also work correctly if any of the subtypes is passed to it:

trait Shape {
    def getArea: Double
}
case class Square(side: Double) extends Shape {
    override def getArea: Double = side * side
}
case class Circle(radius: Double) extends Shape {
    override def getArea: Double = Math.PI * radius * radius
}

def printArea[T <: Shape](shape: T): Double = (math.floor(shape.getArea) * 100)/100

Let’s add a test to capture the expected behavior:

"Shapes" should "compute correct area" in {
    val square = Square(10.0)
    val circle = Circle(12.0)

    assertResult(expected = 100.00)(printArea(square))
    assertResult(expected = 452.39)(printArea(circle))
}

In the above example, the subtyping relation is written as T <: Shape, to mean that any argument of type T can be safely used in a context where a term of type Shape is expected. We constrain T to only variables that are subtypes of Shape. So, we say that function printArea() is subtype polymorphic over subtypes of Shape.

5. Ad-Hoc Polymorphism

One of the easiest ways to think about ad-hoc polymorphism is in terms of a switch statement that’s happening in the background but without a default case. In this case, the compiler switches between different code implementations depending on the type of input a method receives.

There’s a similarity between parametric and ad-hoc polymorphism: they both use generics to allow code to work on different types. The main difference is that in the former, the same code runs for every type. In the latter, possibly different logic is executed based on the type, hence the switch-statement analogy in the previous paragraph.

There are three outstanding concepts required to understand ways Scala supports ad-hoc polymorphism: function overloading, operator overloading, and implicits.

Checkout our gentle introduction to implicit classes. We’ll cover the other two concepts next.

5.1. Function Overloading

Let’s look at a built-in example of where ad-hoc polymorphism is applied in Scala. Assume we have a list of integers that we want to sort:

it should "sort integers correctly" in {
    val intList = List(3,5,2,1,4)
    val sortedIntList = intList.sorted
    assertResult(expected = List(1,2,3,4,5))(actual = sortedIntList)
}

We just called the method sorted on the List, and it knew exactly how to sort integers since they are a primitive type. What if we also want List to know how to sort another type that we have, say student ID’s wrapped in a custom class:

case class StudentId(id: Int)

Much as the underlying type is an Int, the Scala compiler does not know how to sort the type StudentId. Going by our switch-statement analogy, it has encountered a case without implementation, and remember, we don’t have a default case.

As a matter of fact, let’s attempt to do that:

it should "sort custom types correctly" in {
    val studentIds = List(StudentId(5), StudentId(1),StudentId(4), StudentId(3), StudentId(2))
    val sortedStudentIds = studentIds.sorted
    assertResult(
      expected = List(
        StudentId(1), 
        StudentId(2),
        StudentId(3), 
        StudentId(4), 
        StudentId(5)
      )
    )(actual = sortedStudentIds)
}

This test will fail to compile with the following error:

No implicit arguments of type: Ordering[StudentId]

It so happens that the method sorted expects some help with sorting types it doesn’t know about. The type Ordering is a trait whose job is very similar to Java’s Comparator interface, in that it also declares a compareTo method that provides a sorting strategy for a type.

The idiomatic way to fix this error is to create an implicit class or value of type Ordering that is within the scope of our code.

Since this is not an article about implicits, let’s fix the error by providing an implementation of Ordering type for StudentId just within the test. We’ll do this by defining the ordering strategy and passing it to the sorted method as an argument:

    ...
    val ord: Ordering[StudentId] = (x, y) => x.id.compareTo(y.id) 
    val sortedStudentIds = studentIds.sorted(ord)
    ...

5.2. Operator Overloading

Operator overloading is not so different from function overloading. This is where different operators have different implementations depending on the types of their arguments.

We know the semantics of common operators, especially the arithmetic ones like the addition (+) and subtraction (-) operators. Operator overloading is the reason all the following snippets of code compile and work:

val intSum = 1990 + 10
val dblSum = 13.37 + 15.81
val strConcat = "FirstName " + "LastName"

The addition operator is able to work for integers, doubles, and strings alike because it’s overloaded for each of those types. The compiler can infer the right implementation of the operator from the types of the operands.

We can take this same idea and overload existing Scala operators to take care of our unique needs. Assuming we are building software to solve math problems, would we rather write our operations like this:

a + b * c

Or:

Add(a, Multiply(b, c))

Both are equivalent, but the first one just looks more expressive and commonplace in the mathematics domain. So, we can say operator overloading is just syntactic sugar to allow us to program using notations that are closer to our target domain.

5.3. How Scala Supports Operator Overloading

In actual technical terms, operator overloading is really made possible by Scala’s permissiveness with method names and flexibility in how we invoke methods on objects.

More specifically; Scala allows us to use some operator names in method naming. + and are valid method names for any class we create in Scala, unlike Java. Let’s create an abstraction for handling complex numbers as a demo:

case class Complex(re: Double, im: Double) {
    def + (op: Complex): Complex = Complex(re + op.re, imaginary + op.im)
    def - (op: Complex): Complex = Complex(re - op.re, imaginary - op.im)
    override def toString: String = s"$re + ${im}i"
}

Notice that we can use operators as method names, which actually communicates our intention more clearly, given the context. Secondly, there’s also flexibility in method invocation syntax:

it should "add and subtract complex numbers successfully" in {
    val cmpl1 = Complex(8.0, 3.0)
    val cmpl2 = Complex(6.0, 2.0)

    val cmplSum = cmpl1.+(cmpl2)
    val cmplDiff = cmpl1 - cmpl2

    assertResult(expected = "14.0 + 5.0i")(actual = cmplSum.toString)
    assertResult(expected = "2.0 + 1.0i")(actual = cmplDiff.toString)
}

Notice that we can use postfix notation as in the addition invocation, but Scala also allows us to separate the object, method name, and the argument as in the subtraction invocation where we call (-) as an infix operator. Both are valid syntax in Scala.

6. Conclusion

In this article, we’ve covered the three types of polymorphism in Scala. The full source code is available over on GitHub.


« 上一篇: Scala数据类型指南
» 下一篇: Scala中的伴生对象