0:00

As you know, in previous sessions we have already covered two forms of polymorphism.

One was subtyping, which was usually associated with object oriented

programming. The other was generics, which came

originally from functional programming. Once you combine subtyping and generics,

the subtle interactions that we are going to explore in this session and the next

one. In particular, we're going to develop an

important method in this session to find out when one type can be a subtype of

another. That method is called the Liscov

Substitution Principle. In the last session we have encountered

the two principal forms of polymorphism. Subtyping, where we can pass instances of

a subtype where a base type was required. And, generics, where we can parameterize

types with other types. In this session we will look at the

interactions between the two concepts. There are essentially two main areas to

cover. The first one is bounds, where we can

subject type parameters to sub type constraints.

And the second is variance, that defines how parameterized types behave under sub

typing. So let's look at type bounds first.

As a motivating example, consider you want to write a method assertAllPos or assert

all positive. That method should take an IntSet and it

should return the IntSet it, itself, but it should check with all elements of the

IntSet are positive. If they're not then it should throw an

exception. What would be the best type you can give

to assert all pos? You might come up with this type here.

Assertallpos. Well, it would take an insert and it would

return an IntSet and well, in the case where not all elements are positive would

withdraw an exception but that's not reflected in the result type.

And that's fine for most situations. But maybe one can be more precise.

In fact, if we look at the behavior of a assertAllPos, we see that it's governed

essentially by two equations that we set assertAllPos of empty is empty.

2:49

So what we see in particular is that if assertAllPos gets an empty argument, then

it would give you back an empty result. And if it gets a non-empty argument, it

would give you back a non-empty result. And that knowledge is actually not

reflected in this type here. Well we say well it takes an IntSet and

gives you back an IntSet. So how can we capture that additional

knowledge? So one way to express it is this way.

We could say assertAllPos. It takes sum type S that must be some

subtype of IntSet either empty or non empty.

And a set of that type itself. And will return a result of the same type.

So here the part that says less that colon insert is an upper bound of the type

parameter S what it means is that we can instantiate S to any type argument as long

as the type argument conforms to the bound, conforms to insert.

4:24

So we've seen upper bounds where the type variable ranged overall subtypes of a

given type. Scala actually also has lower bounds.

So we could say a bound s is a super type of non-empty.

And that would introduce a type parameter s that can range only over the super types

of non-empty. So in our case, of the IntSet example, s

could be one of either non-empty, IntSet AnyRef or any.

You might ask where are lower bounds useful and it's not immediately apparent

but you'll see later on in this session an important use case where lower bounds are

indeed essential. Finally it's also possible to mix a lower

bound with an upper bound. So that you would write like here.

You could say s is bounded from below by non empty and from above by IntSet And

that would then restrict any actual argument for s to a type that's in the

interval between non empty and IntSet In our case, that interval actually contains

only the two types, non-empty and IntSet because we have this inheritance

relationship. But in general, there could have, of

course, be more types between the lower bound and the upper bound.

So now that we've have looked at bounds, there is still another thing to consider.

So, we know that non-empty is a sub-type of IntSet What about if we wrap both types

in a list? Should a list of non-empty also be a

sub-type of list of IntSet? Intuitively, this makes sense.

A list of nonempty sets is obviously a special case of a list of arbitrary sets.

So from a domain modeling perspective, list of nonempty should indeed be a

subtype of list of IntSets So we call types for which this relationship holds,

covariant, because the subtyping relationship varies exactly like the type

parameter. In our case then, it would make sense to

make lists into a covariant type. The question to ask then, of course, is

that a property just of list, or should all types be covariant, is covariance

something that every parameterized type should be.

7:01

So to get some perspective on it, let's look at the concept of arrays in Java, and

also in C#. Which is, in this respect, buck for buck,

comparable with Java. If you don't know Java or C#, then the

only thing you need to know here, really, is that an array of elements of type T is

written T brackets in Java. And in Scala, we actually express this

slightly differently. We would use a normal parameter as type

syntax array of T to refer to the same types.

Arrays in Java are actually covariant, just like the list type we have seen.

So one would have that an array of nonempty sets is a subtype of an array of

IntSets. But it actually turns out that this idea

of arrays being covariant causes problems. To see why, consider this Java snippet

below. We create an array of non empties A, we

assign it to an IntSet B, we assign empty into the first element of B, and we pull

out the first element of A and assign it to a non empty.

So let's visualize what goes on here. In the first step, we create a new array.

8:19

And fill it with a non-empty element, call it A.

In the second step we assign A to B and that's actually a reference assignment.

So after this step, we would have another pointer, B, pointing to the same array.

In the third step, we assign empty into the first element of the B array.

So let me erase a non-empty value here, and replace it with an empty value

instead. In the final step, we pull out the first

element of the array. That's the empty value.

And assign it into a non empty set, S. So what we would get is S, of type non

empty, equals E. Now, something's clearly gone wrong here.

Because we ended up assigning an empty set into a variable of type non-empty sets.

So, if types are supposed to prevent something, it's precisely this.

That we, that, that you can't do that. So what went wrong?

So looking at the example again. The first line would execute fine.

So would the second line, because arrays are covariant.

But the third line will actually give you something at runtime namely and array

store exception. So you would get a runtime exception.

9:43

That protects the assignment of MT into this array.

What actually happens it that, to make up for the problems caused by coherence of

arrays, Java needs to store in every array a type tag that reflects what, at what

type this array was created. So when we create a non empty array, the

type tag would read, well, it contains non empty, so let me write this here.

So the type tag would say, well it's actually a non empty array.

And now that we have signed something in to an element of the array, we run time

type of the thing we have signed gets checked against the type tag.

So in our case here it would be have an empty value but the type tag would read

non empty and that would give you a run time error.

Now, it seems that this is not a very good deal.

We have traded a compile-time error for a run-time error, and we have also paid the

price for a run-time check that we have to do.

Every array store has to undergo this, this check against the array tach.

So one could argue that it was really a mistake to make a raised covariant that

produced a hole in the type system that had to be patched by a run time check.

And you might ask why did the designers of Java do it in the end.

Well it actually turned out that they wanted to do with they wanted to be able

to write a method such as sort. That would work for any array.

So the way they would express that in the first version of Java, it would say the

sort method would take an object array. And then covariance of arrays was

necessary so that an array of strings or an array of integers could all be passed

to an object array. Of course, with Java five and later on,

you've a much better way of doing that. You would do it the same way as in

Scholar, you would use a generic type. But before, because generics were not

available in the earlier version of Java, people made do with that.

Now, can we somehow generalize what we've learned here?

When does it makes sense for a, for a type to be a subtype of another?

And when should that rather not be the case?

That's actually an important principle, first stated by Barbara Liskov that tells

us when a type can be a subtype of another.

Essentially what it says is if A is a subtype of B.

Then everything one can do with a value of type B, should one should also be able to

do with a value of type A. So we have the Type B, that's the super

type. The Type A is the subtype.

And we say, well, if we expect that we can do something with Bs, then we can, should

be able to substitute an A for a B, and we can still do the same thing with an A.

The actual definition Liskov used is actually a bit more formal, so here it is.

The definition says, let Q of X be a property that's provable about object X of

type B, then Q of Y should also be provable for objects Y of type A.

Where A is less than B. So, the original formulation coached it in

terms of what you can prove about objects, not what operations you can perform.

Take what we've seen from Java back to Scala.

Then, let's look at the problematic array example but now expressed in Scala.

Here's how you would do that. Would create an array of non empty values,

you would assign empty into the first element of the array B, so notice that

array selection is expressed with parenthesis in Scholar, not brackets, so

its really the same thing as a function call.

And the underlying reason for that is that arrays are really specializations of

functions in Scholar. If you write code like that in Scholar,

then what would you expect to observe? Would you expect to see a type error or

would you expect to see a program that compiles?

And if you expect the type error then in what line would you expect it.

If the program compiles, would you expect to it throw exception at runtime or you

would you think it should run without exception.

So, you have six choices overall. Make your choice.