50.054 - Parametric Polymorphism and Adhoc Polymorphism¶
Learning Outcomes¶
By this end of this lesson, you should be able to
- develop parametrically polymorphic Scala code using Generic, Algebraic Datatype
- safely mix parametric polymoprhism with adhoc polymoprhism (overloading) using type classes
- develop generic programming style code using
Functor
type class. - make use of
Option
andEither
to handle and manipulate errors and exceptions.
Currying¶
In functional programming, we could rewrite a function with multiple arguments into a function that takes the first argument and returns another function that takes the remaining arguments.
For example,
can be rewritten into
These two functions are equivalent except that
- Their invocations are different, e.g.
- It is easier to reuse the curried version to define other function, e.g.
Function Composition¶
Every function and method in Scala is an object with a .compose()
method. It works like the mathmethical composition.
In math, let \(g\) and \(f\) be functions, then
Let g
and f
be Scala functions (or methods), then
For example
Generics¶
Generics is also known as type variables. It enables a language to support parametric polymoprhism.
Polymorphic functions¶
Recall that the reverse
function introduced in the last lesson
We argue that the same implementation should work for all lists regardless of their elements' type. Thus, we would replace Int
by a type variable A
.
Polymorphic Algebraic Datatype¶
Recall that the following Algebraic Datatype from the last lesson.
Same observation applies. MyList
could have a generic element type A
instead of Int
and mapML
should remains unchanged.
The caveat here is that the Scala compiler would complain about the Nil
case above
To understand that error, we need to understand how Scala desugar the enum datatype. The above MyList
datatype is desugared as
Nil
. It can't be declared as a subtype of MyList[A]
since type variable A
is not mentioned in its definition, unlike Cons(x:A, xs:MyList[A])
. The best we can get is MyList[Nothing]
where Nothing
is the subtype of all other types in Scala. (As the dual, Any
is the supertype of all other types in Scala). We are getting very close. Now we know that Nil extends MyList[Nothing]
. If we can argue that MyList[Nothing] extends MyList[A]
then we are all good. For MyList[Nothing] extends MyList[A]
to hold,
A
must be covariant type parameter.
In type system with subtyping,
-
a type is covariant if it preserves the subtyping order when it is applied a type constructor. In the above situation,
MyList[_]
is a type constructor. The type parameterA
is covarient because we noteNothing <: A
for allA
, thusMyList[Nothing] <: MyList[A]
. -
a type is contravariant if it reverses the subtyping order when it is applied to a type constructor. For instance, given function type
A => Boolean
, the type parameterA
is contravariant, because forA <: B
, we haveB => Boolean <: A => Boolean
. (We can use functions of typeB => Boolean
in the context where a functionA => Boolean
is expected, but not the other way round.) -
a type is invariant if it does not preserve nor reverse the subtyping order when it is applied to a type constructor.
Hence to fix the above type error with the MyList[A]
datatype, we declared that A
is covariant, +A
.
mapML
into currying style.
Recall that we could make mapML
function as a method of MyList
Type class¶
Suppose we would like to convert some Scala values to JSON strings.
We could rely on overloading.
Given v
is a Scala string value, s"some_prefix ${v} some_suffix"
denotes a Scala string interpolation, which inserts v
's content into the "place holder" in the string "some_prefix ${v} some_suffix"
where the
${v}
is the place holder.
However this becomes hard to manage as we consider complex datatype.
When we try to define the toJS
function for Contact
datatype, we can't make use of the toJS
function for string value because the compiler is confused that we are trying to make recursive calls. This is the first issue we faced.
Let's pretend that the first issue has been addressed. There's still another issue.
Consider
A case class
is like a normal class we have seen earlier except that we can apply pattern matching to its values.
Let's continue to overload toJS
to handle Person
and Team
.
The second issue is that the toJS(cs:List[Contact])
and toJS(ps:List[Person])
are the identical modulo the variable names. Can we combine two into one?
toJS[A](v:A)
used in the .map()
.
It seems that we need to give some extra information to the compiler so that it knows that when we use the above generic toJS
we are referring to either Person
or Contact
, or whatever type that has a toJS
defined.
One solution to address the two above issues is to use type class. In Scala 3, a type class is defined by a polymoprhic trait and a set of type class instances.
given
defines a type class instance. An instance consists of a name and the context parameters (those with using
) and instance type. In the body of the type class instance, we instantiate an anonymous object that extends type class with the specific type and provide the defintion. We can refer to the particular type class instance by the instance's name. For instance
toJSTeam.toJS(myTeam)
yields
We can also refer to the type class instance by the instace's type. For example, recall the last two instances. In the context of the toJSTeam
, we refer to another instance of type JS[List[Person]]
. Note that none of the defined instances has the required type. Scala is smart enought to synthesize it from the instances of toJSList
and toJSPerson
. Given the required type class instance is JS[List[Person]]
, the type class resolver finds the instance toJSList
having type JS[List[A]]
, and it unifies both and find that A=Person
. In the context of the instance toJSList
, JS[A]
is demanded. We can refine the required instance's type as JS[Person]
, which is toJSPerson
.
Note that when we call a function that requires a type class context, we do not need to provide the argument for the type class instance.
Type class enables us to develop modular and resusable codes. It is related to a topic of Generic Programming. In computer programming, generic programming refers to the coding approach which an instance of code is written once and used for many different types/instances of values/objects.
In the next few sections, we consider some common patterns in FP that are promoting generic programming.
Functor¶
Recall that we have a map
method for list datatype.
Can we make map
to work for other data type? For example
It turns out that extending map
to different datatypes is similar to toJS
function that we implemented earlier. We consider introducing a type class for this purpose.
T[_]
denotes a polymorphic type that of kind * => *
. A kind is a type of types. In the above, it means Functor
takes any type constructors T
. When T
is instantiated, it could be List[_]
or BTree[_]
and etc. (C.f. In the type class JS[A]
, the type argument has kind *
.)
Some example
Functor Laws¶
All instances of functor must obey a set of mathematic laws for their computation to be predictable.
Let i
be a functor instance
1. Identity: i => map(i)(x => x)
\(\equiv\) x => x
. When performing the mapping operation, if the values in the functor are mapped to themselves, the result will be an unmodified functor.
2. Composition Morphism: i=> map(i)(f.compose(g))
\(\equiv\) (i => map(i)(f)).compose(j => map(j)(g))
. If two sequential mapping operations are performed one after the other using two functions, the result should be the same as a single mapping operation with one function that is equivalent to applying the first function to the result of the second.
Foldable¶
Similarly we can define a Foldable
type class for generic and overloaded foldLeft
( and foldRight
).
Option and Either¶
Recall in the earlier lesson, we encountered the following example.
An error occurs when we try to evalue a MathExp
which contains a division by zero sub-expression. Executing
Like other main stream languages, we could use try-catch
statement to handle the exception.
One downside of this approach is that at compile type it is hard to track the unhandled exceptions, (in particular with the presence of Java unchecked exceptions.)
A more fine-grained approach is to use algebraic datatype to "inform" the compiler (and other programmers who use this function and datatypes).
Consider the following builtin Scala datatype Option
When we execute eval(Div(Const(1), Minus(Const(2), Const(2))))
,
we get None
as the result instead of the exception. One advantage of this is that whoever is using eval
function has to respect that its return type is Option[Int]
instead of just Int
therefore, a match
must be applied before using the result to look out for potential None
value.
There are still two drawbacks. Firstly, the updated version of the eval
function is much more verbose compared to the original unsafe version. We will address this issue in the next lesson. Secondly, we lose the chance of reporting where the division by zero has occured. Let's address the second issue.
We could instead of using Option
, use the Either
datatype
Executing eval(Div(Const(1), Minus(Const(2), Const(2))))
will yield
Summary¶
In this lesson, we have discussed
- how to develop parametrically polymorphic Scala code using Generic, Algebraic Datatype
- how to safely mix parametric polymoprhism with adhoc polymoprhism (overloading) using type classes
- how to develop generic programming style code using
Functor
type class. - how to make use of
Option
andEither
to handle and manipulate errors and exceptions.
Appendix¶
Generalized Algebraic Data Type¶
Generalized Algebraic Data Type is an extension to Algebraic Data Type, in which each case extends a more specific version of the top level algebraic data type. Consider the following example.
Firstly, we need some type acrobatics to encode nature numbers on the level of type.
Next we define our GADTSList[S,A]
which is a generic list of elements A
and with size S
.
Nil
, it is declared with the type of SList[Zero, Nothing]
which indicates on type level that the list is empty. In the second case Cons
, we define it to have the type SList[Succ[N],A]
for some natural number N
. This indicates on the type level that the list is non-empty.
Having these information lifted to the type level allows us to define a type safe head
function.
Compiling head(Nil)
yields a type error.
Similarly we can define a size-aware function snoc
which add an element at the tail of a list.