0:00

In this session, we're going to continue our exploration of subtyping and generics.

In particular, we're going to discuss the important and somewhat tricky concept of

variance, Which means how subtyping relates to

genericity. The material in this session might be a

little harder than what you've seen previously.

If you want to go fast, and you don't need to know things to the last detail.

Then it's perfectly fine if you just browse this session, or skip it

completely. On the other hand, if you want to go to

the roots, then this session is for you. As a bonus, we're going to develop a

completely satisfactory model of cons lists that doesn't have any of the

shortcomings that we have to deal with before.

0:45

You've seen in the previous session that some types such as lists should be

covariant. Whereas other types such as arrays should

be not covariant. What's the difference between the two?

Well roughly speaking the type list is immutable where as the type array is

mutable because we can update its elements and immutable types can typically be

covariant if some conditions or methods are met whereas mutable types can not.

In this session we will find out precisely what the conditions are so when types that

can be covariant and when they cannot. In fact, it's not just a binary choice

between variant or not. There are actually three possible

relationships between type instance c, of c and a, and c and b, where c of t is a

parameterized type. And a and b are types such that a is a

sub-type of b. So we could either determine that c of a

is a sub-type of c of b. Or we could determine the opposite

relationship, which would say that c of b is a subtype of c of a.

Or, we could have the relationship where neither of the three was true.

So neither c of a is a subtype of c of b, nor is c of b a subtype of the other.

So if c of a is a subtype of c of b, we say c is covariant.

If the opposite is true, so c of a is a super type of c of b, then we say c in

contravariant. And if neither of the two are true, then

we say c is nonvariant. Scala lets you declare the variance of a

type by annotating the type parameter. So you can determine that a class should

be covariant by writing a + in front of the type parameter, the a here.

Or you could determine that it's contravariant by writing a minus.

Or finally, if you write neither + or -, then the default is that C is non-variant.

So c of a and c of b are unrelated. So here's some food for thought.

Say you have two function types. The Type A is a function that takes IntSet

to NonEmpty set, and Type B is a type that takes non-empty sets to general int sets.

According to the Livka substitution principle that you've seen in the last

session, which of the following should be true?

Is A, a subtype of B, or B a subtype of A? Or are A and B unrelated?

3:51

So I will argue that type A is a subtype of type B Why?

Well, let's look. What can you do with a type b function

from non-empty to IntSet? While the thing you can do is you is you

park, can pass a non-empty IntSet, and receive an IntSet back.

Can you do the same thing with type A? Well, of course, if you pass a nonempty

IntSet to the type A, it expects just an IntSet, so a nonempty will do just fine,

and you will get back a nonempty IntSet, which of course is a special case for our

IntSet. So type A satisfies the same contract as type B.

If you give it a nonempty set, it will give you back an IntSet, but it actually

will satisfy more than B. So that's why A is a true subtype of B...

So let's generalize this to arbitrary function types.

So the rule we have is if we have two function types, A1 to B1 and A2 to B2, and

the first is a subtype of the second, if. First the result types, B1 and B2, are

subtypes, so they go in the same direction, but the argument types go in

the other direction. So A2 must be a subtype of A1.

So let's express that visually. So we have the supertype A2.

To B2 here. And the presumed subtype, A1 to B1.

So, what we say is that the A2 type is a subtype.

5:41

Of a one, and B1 is a subtype Of b too, and that would mean that this is a.

Especially the case of the a2 to b2 function.

Why would that be? Let's see.

Lets look at the first functions, so all we could do is pass an argument of A2 into

this function. Because of this sub-typing relationship we

can pass the same argument also to A1 because A2 is a sub-type of A1.

Then we apply the. Function a-1 to b-1, that gives us a b-1

and again because of sub-typing that actually qualifies as a b-2.

So we could easily instead of having used this function here, we could supply the

lower function. Over there, and the same argument types

would map to the same, result types. So what we've learned is that functions

are actually contravariant in their argument types, and covariant in their

result types. We can then express that code in Scholar,

and that leads us to the revised definition of the function one-trade that

you've seen before. So now I've added two variance annotations

to the parameters. The argument type t is contravariant.

It has a minus and the result type u is covariant.

It has a plus. Now, can we just sprinkle minuses and

pluses over classes as we please, to make them call a countervariant.

Well obviously not because otherwise, we could've done the same thing with array.

Make array for instance co-variant and run into to the problem the Java did.

So there are rules when we, when can we annotate a type parameter with plus and

with minus. So, to find out what the rules are, let's

look at ganity array example. So, I, now assume that array is a class

written like this one, array and update the updating functionality.

In array is, is captured as a method called update.

7:49

So the problematic combination was that the class was covariant, so the type

parameter T will have a plus here. Whereas update took a parameter of the

same type T. So a covariant type parameter T together

with a, a method that takes a parameter of that type will lead to problems or did

lead to problems in the array case. So the Scala compiler will actually check

that there are no problematic combinations when compiling a class with variant

annotations. Roughly, what it will do is, it will let

covariant type parameters only appear in method results.

Contravariant type parameters can only appear in method parameters, and

nonvariant, or invariant, that they are used as aliases, type parameters can

actually appear anywhere. The precise rules are a bit more involved.

But fortunately this Scala compiler will perform them for us, so we don't need to

memorize them or. If we go back to the function example, the

checks that this Scala compiler performer will perform here is that the T parameter

which is contravariant, is only used as a function parameter.

That's correct. And the U parameter, which is co-variant,

is only used as a function result, which is also correct.

So, function one checks out okay, according to these checks.

So now that we've seen variants, let's get back to the previous implementation that

we did of lists. One shortcoming there that was that nil

was modeled as a class. Whereas we would prefer it to be an

object, after all there's only one empty list.

Can we change that? Well, let's have a look at the.

9:39

Example. So I've brought up the, list hierarchy

that we've seen in previous sessions. What we would like to do is turn class nil

into an object. So let's just go right ahead and do that.

So objects can't have type parameters, because there's only a single instance of

them. So I will delete the type parameter for

the object. Then we get an error which says that the

Type T here is not found. That's true because T is not longer bound

as a type parameter. So we have to find another type In which

we, which we want to use as the argument type of typelist.

What will be a good type? Well, of course, we could write list of

string or list of object. But all of that would make nil only a

subtype of a partic-, specific kind of list.

So, what we will try instead is make it a list of nothing.

10:34

That's promising because, as we know, nothing is the bottom type, which is a

subtype of every other type, so it's in a sense, universal.

It can express everything else. But we're not done yet.

To see why, let's make a little test object.

And say, here I want to have a simple assignment, where I say Valex of type list

of string, and that should be the empty list.

11:05

We get an error which says it's a long error message.

Which says, well, it found a nil, and it required a list of string.

So obviously, nil is not a subtype of list of string.

And then it goes on, to say, you may wish to define the type parameter t of list as

+t instead. So what the compiler suggests is that,

indeed, we should make list covariant. That we make by having a pipe parameter

here. If we do that, then everything will type

check correctly. So let's have a look at what happened

here. So List of string = nil type checks,

because nil is a list of nothing. Nothing is a subtype of string, and lists

are covariants. So that's why list of nothing is a subtype

of list of string. Another thing that works out very well

here, is that we have seen that in the object little head and tail already return

nothing, because they can't terminate. And that matches precisely the template of

list. Because we said for a list of t, a head

should return a t and tail should return a list of t.

Now head indeed does return the element type here, nothing.

Tail doesn't return a list of nothing but directly a nothing.

But that's actually something that's even a subtype of list of nothing.

So both types are very, very precise in what they express.

To complete this session, which was quite a bit harder than previous sessions, I

want to introduce one more thing. Sometimes we actually have to put in a bit

of work to make a class covariant. So to see an example is, let's say we want

to add a method prepend to our list class, which prepends a given element and yields

a new list. So prepend would be defined like this.

It would say, take an element of type T, the element type of the list, give me back

a list of T, and it would have the obvious implementation, would create a new count

cell with the given element, followed by the current list itself.

But that doesn't work. Why does it not work?

So. I leave this for you as an exercise.

Why does the following code not type check?

The Prepend method as before in a covarant list.

Possible answers are; Prepan turns list into a mutable class, Prepend fails

variance checking, or Prepend's right hand side contains a type error.

13:44

What do you think? One way to solve this is try it out.

Put Prepend, let's put Prepend into our worksheet and see what happens.

So what we get is an error, which says covariant type T occurs in contravariant

position in type T of value elem. What that says is that our co-variant type

parameter T appeared as a, the type of a method parameter.

And we've seen that, that actually violates the variant checking rule.

So the variance checking rule, which was actually invented to prevent mutable,

operations in covariant classes, also rules out something like this.

Which doesn't involve any mutability at all.

All we do is create a new list. Do you think that's a mistake of the, of

the rules or is there some deep wisdom to the rules nevertheless?

In fact, the Scholar Compiler is right to throw out lists that prepend because it

does violate the lists of substitution principle.

Why? Well here's something one can do with a

list of type, list of IntSet, called the list excess.

We can do excess dot prepend empty. Because empty of course is an IntSet.

15:16

But the same operation on a list wires of type list of non-empty would lead to a

type error. So if you did that wires.prependempty and

you would see a type error message like here which says well we required a

non-empty because that was the element type of the list but we found an empty and

the two are not compatible. So here's something you can do with a list

of IntSet that it cannot do with a list of non-empty set.

And because we found such a thing it follows that according to the Liskov

Substitution Principle, list of non-empty cannot be a sub-type of list of IntSet.

15:59

Okay. So now we now why prepend is illegal, but

Still, there's an unsatisfactory feeling here, because prepend is an actual method,

after all, to have on immutable lists. So the question is, can we somehow make it

variance correct? And, in fact, we can.

And for that, we will make use of a lower bound.

So, here is a reformulation of prepent which uses a lower bound for the type

parameter U. We say prepent it takes a type parameter U

which must be a super type of the list element type T.

It takes a U element of the type U and it returns a list of U.

And the body of prepent is as it was before.

17:08

So, you can find out yourself how this works in detail by solving the following

exercise. Implement prepend as shown in trait list.

The question is, let's say we have a function that takes a list of non empty as

first parameter and empty element as second parameter, and prepends the empty

list set to the list of non empty sets. What would happen?

18:29

And if we look at function F, its type, and we hover over F with say.

It takes a list of non-empty and an empty parameters as given and it returns a list

of IntSet. So a list of IntSet is the correct answer.

That still begs the explanation, why? So let's look first at the subtype

hierarchy. As always we have that IntSet.

Is a super type of both non-empty and empty.

19:34

And one thing that must happen with the supertype is that LM is an instance of the

supertype. So LM the type U that is empty because

that's the type of the X that we pass to pre-def.

Is empty a subtype of non-empty? No, of course not.

So that's why the empty value that we pass in here, it doesn't conform to the

non-empty type. So what the type inferencer would choose

instead is the next higher type up here. So that would be IntSet.

Maybe you can be an Intset, and indeed, that would work out.

The list xs, which is a list of non empty, is, of course, also a list of IntSet,

because of covariance of lists. And empty is itself an IntSet.

So the type inference that would determine that the correct type parameter for U,

indeed, must be IntSet and the type that gets returned from Prepend a list of

IntSet. And that's, then also the result type of

f. So, so one thing we've seen here is that

intuitively F does exactly the right thing.

If we take a list of non-empties and we add an empty, the best thing that we could

get out of that would be a list of IntSets because that's the smallest type in a

manner of speaking that contains both the XS, the list of non-empties, as well as

the additional element X which was of type empty.

So we see that the machinery. With the subtype bound here, it is a

little bit complicated. But, on the other hand, it does the

exactly the right thing. It will lead to exactly the right invert

types for data structures that contain elements of various different types.