Generics: Variance and Constraints of Parametric Types

The desire to reuse code shouldn’t come at a cost of compromising type safety. Generics bring a nice balance to this issue. With generics you can create code that can be reused for different types. At the same time, the compiler will verify that a generic class or a function isn’t used with unintended types. We’ve enjoyed a fairly good amount of type safety with generics in Java. So, you may wonder, what could Kotlin possibly provide to improve? It turns out, quite a bit.

By default, in Java, generics impose type invariance—that is, if a generic function expects a parametric type T, you’re not allowed to substitute a base type of T or a derived type of T; the type has to be exactly the expected type. That’s a good thing, as we’ll discuss further in this section. But what good are rules if there are no exceptions?—and it’s in the area of exceptions that Kotlin comes out ahead.

We’ll first look at type invariance and how Kotlin, just like Java, nicely supports that. Then we’ll dig into ways to change the default behavior.

Sometimes you want the compiler to permit covariance—that is, tell the compiler to permit the use of a derived class of a parametric type T—in addition to allowing the type T. In Java you use the syntax <? extends T> to convey covariance, but there’s a catch. You can use that syntax when you use a generic class, which is called use-site variance, but not when you define a class, which is called declaration-site variance. In Kotlin you can do both, as we’ll see soon.

Other times you want to tell the compiler to allow contravariance—that is, permit a super class of a parametric type T where type T is expected. Once again, Java permits contravariance, with the syntax <? super T> but only at use-site and not declaration-site. Kotlin permits contravariance both at declaration-site and use-site.

In this section we’ll first review type variance, which is available in Java. Going over it here will help set the context for the much deeper discussions to follow. Then, you’ll learn the syntax for covariance both for declaration-site and use-site. After that, we’ll dive into contravariance and, finally, wrap up with how to mix multiple constraints for variance.

Type Invariance

When a method receives an object of a class T, you may pass an object of any derived class of T. For example, if you may pass an instance of Animal, then you may also pass an instance of Dog, which is a subclass of Animal. However, if a method receives a generic object of type T, for example, List<T>, then you may not pass an object of a generic object of derived type of T. For example, if you may pass List<Animal>, you can’t pass List<Dog> where Dog extends Animal. That’s type invariance—you can’t vary on the type.

Let’s use an example to illustrate type invariance first, and then we’ll build on that example to learn about type variance. Suppose we have a Fruit class and two classes that inherit from it:

 open​ ​class​ Fruit
 class​ Banana : Fruit()
 class​ Orange: Fruit()

Now suppose a basket of Fruits is represented by Array<Fruit> and we have a method that receives and works with it.

 fun​ ​receiveFruits​(fruits: Array<Fruit>) {
  println(​"Number of fruits: ${fruits.size}"​)
 }

Right now, the receiveFruits() method is merely printing the size of the array given to it. But, in the future, it may change to get or set an object from or into the Array<Fruit>. Now if we have a basket of Bananas, that is Array<Banana>, then Kotlin, like Java, won’t permit us to pass it to the receiveFruits() method:

 val​ bananas: Array<Banana> = arrayOf()
 receiveFruits(bananas) ​//ERROR: type mismatch

This restriction is due to Kotlin’s type invariance with generic types—a basket of Bananas doesn’t inherit from a basket of Fruits. There’s a really good reason for this. Inheritance means substitutability—that is, an instance of derived may be passed to any method that expects an instance of base. If an instance of Array<Banana> were allowed to be passed as argument where an instance of Array<Fruit> was expected, we may be in trouble if the receiveFruits() method were to add an Orange to the Array<Fruit>. In this case, when processing Array<Banana>, we’ll run into a casting exception later when we try to treat that instance of Orange as Banana—no orange ever likes that kind of treatment. Alternatively, we may attempt to implement the receiveFruits() method so that it adds an Orange only if the given parameter isn’t an Array<Banana>, but such a type check will result in the violation of the Liskov Substitution Principle—see Agile Software Development, Principles, Patterns, and Practices [Mar02].

Even though Banana inherits from Fruit, by disallowing the Array<Banana> to be passed in where Array<Fruit> is expected, Kotlin makes the use of generics type safe.

Before we move forward, let’s make a slight change to the code to understand what at first appears like a quirk but is actually the sign of a sound type system.

 fun​ ​receiveFruits​(fruits: List<Fruit>) {
  println(​"Number of fruits: ${fruits.size}"​)
 }

We changed the parameter type from Array<Fruit> to List<Fruit>. Now, let’s pass an instance of List<Banana> to this function:

 val​ bananas: List<Banana> = listOf()
 receiveFruits(bananas) ​//OK

After this change, the Kotlin compiler doesn’t complain. That seems unfair—the language seems to restrict Arrays but not List. But the reason for this difference in behavior is a good one. Array<T> is mutable, but List<T> is immutable. You may add an Orange to Array<Fruit>, but you can’t add anything to List<Fruit>. That makes sense, but you may be curious how Kotlin knows how to tell the difference. The answer to that lies in how the two types are defined: class Array<T> vs. interface List<out E>. That out is the secret sauce. Let’s dig into that next.

Using Covariance

Kotlin protects us from passing an Array<Banana> where Array<Fruit> is expected and thus prevents us from inadvertently adding some arbitrary fruit into an array of Banana. That’s great, but sometimes we want to tell Kotlin to relax the rules a bit, but without compromising type safety, of course. In other words, we want the Kotlin compiler to allow covariance—accept a generic of derived type where a generic of base type is expected. This is where type-projections come in.

Let’s create an example where Kotlin will block our approach and then find ways to make progress, but without lowering type safety.

As you code along the examples in the rest of this chapter, anywhere the classes Fruit, Banana, and Orange are needed, bring them along from the examples we created in the previous section. Here’s a copyFromTo() function that uses two arrays of Fruit:

 fun​ ​copyFromTo​(from: Array<Fruit>, to: Array<Fruit>) {
 for​ (i ​in​ 0 until from.size) {
  to[i] = from[i]
  }
 }

The copyFromTo() function iterates over the objects in the from parameter and copies them into the to array. It assumes that the size of the two arrays are equal, since that detail isn’t relevant to what we’re interested in here. Now, let’s create two arrays of Fruits and copy the contents of one into the other:

 val​ fruitsBasket1 = Array<Fruit>(3) { _ -> Fruit() }
 val​ fruitsBasket2 = Array<Fruit>(3) { _ -> Fruit() }
 
 copyFromTo(fruitsBasket1, fruitsBasket2)

The copyFromTo() method expects two Array<Fruit>, and we’re passing exactly those types. No complaints.

Now, let’s change the parameter that we pass to the copyFromTo() function:

 val​ fruitsBasket = Array<Fruit>(3) { _ -> Fruit() }
 val​ bananaBasket = Array<Banana>(3) { _ -> Banana() }
 
 copyFromTo(bananaBasket, fruitsBasket) ​//ERROR: type mismatch

Kotlin blocks us from passing an Array<Banana> where Array<Fruit> is expected since it’s worried that the method copyFromTo() may potentially add a Fruit that’s not a Banana to the Array<Banana>, which should be a no-no; as we discussed earlier, Array<T> is type invariant.

We can tell Kotlin that we intend to only read out of the Array passed to the from parameter; there’s no risk of passing any Array<T> where T is either of type Fruit or a derived class of Fruit. This intent is called covariance on the parametric type—to accept a type itself or any of its derived types in its place.

The syntax from: Array<out Fruit> is used to convey covariance on the Fruit parametric type. Kotlin will assert that no method call is made on the from reference that would allow data to be passed in. Kotlin will determine this by examining the signature of the methods being called.

Let’s fix the code by using a covariant parametric type:

 fun​ ​copyFromTo​(from: Array<out Fruit>, to: Array<Fruit>) {
 for​ (i ​in​ 0 until from.size) {
  to[i] = from[i]
  }
 }

Kotlin will now verify that within the copyFromTo() function, no call to send in an argument of the parametric type Fruit is made on the parameter with covariance. In other words, the following two calls, if present within the loop in copyFromTo(), will fail compilation:

 from[i] = Fruit() ​//ERROR
 from.​set​(i, to[i]) ​//ERROR

With only code to read from the from parameter and code to set into the to parameter, we’ll have no trouble passing Array<Banana>, Array<Orange> or Array<Fruit> where Array<Fruit> is expected for the from parameter:

 copyFromTo(bananaBasket, fruitsBasket) ​//OK

The Array<T> class has methods to both read out and set in an object of type T. Any function that uses Array<T> may call either of those two types of methods. But using covariance, we’re promising to the Kotlin compiler that we’ll not call any method that sends in any value with the given parametric type on Array<T>. This act of using covariance at the point of using a generic class is called use-site variance or type projection.

Use-site variance is useful for the user of a generic class to convey the intent of covariance. But, on a broader level, the author of a generic class can make the intent of covariance—for all users of that class—that any user can only read out and not write into the generic class. Specifying covariance in the declaration of a generic type, rather than at the time of its use, is called declaration-site variance. A good example of declaration-site variance can be found in the declaration of the List interface—which is declared as List<out T>. That use of declared-site variance is what permitted passing List<Banana> to receiveFruits(), whereas passing Array<Banana> was disallowed.

In other words, List<out T> by definition guarantees to Kotlin that receiveFruits(), or any method for that matter, won’t write into the parameter of type List<T>. On the other hand, Array<out T> guarantees to Kotlin that the receiver of that covariant parameter won’t write into that parameter. Declaration-site variance achieves universally what use-site variance achieves tactically for just the parameter it’s applied to.

Using covariance, you tell the compiler to accept a derived parametric type in place of a parametric type. You can ask the compiler to take base types as well. That’s contravariance, which we’ll explore next.

Using Contravariance

Looking at the copyFromTo() method, it makes sense to copy objects from any Array<T> where T is of type Fruit or one of Fruit’s derived classes. Covariance helped to convince Kotlin that it’s safe to be flexible with the from parameter. Now let’s turn our attention to the to parameter. The type of the parameter is the invariant Array<Fruit>.

No issue arises if we pass an Array<Fruit> to the to parameter. But what if we want to pass a Fruit or one of the derived classes of Fruit into a collection of Fruit or any class that is collection of a base of Fruit. If we desire that kind of flexibility, we can’t simply pass an instance of Array<Any> as an argument to the to parameter. We have to explicitly ask the compiler to permit contravariance—that is, to accept a parametric type of base where an instance of a parametric type is expected.

Without using contravariance, let’s first try passing an instance of Array<Any> to the parameter to and see what Kotlin says:

 val​ things = Array<Any>(3) { _ -> Fruit() }
 val​ bananaBasket = Array<Banana>(3) { _ -> Banana() }
 
 copyFromTo(bananaBasket, things) ​//ERROR: type mismatch

That’s a no go, again due to the default type invariant behavior of Kotlin to protect us. We can once again ask Kotlin to relax, but this time to permit the parameteric type to be a type or a base type—contravariant—of the parameteric type.

 fun​ ​copyFromTo​(from: Array<out Fruit>, to: Array<in Fruit>) {
 for​ (i ​in​ 0 until from.size) {
  to[i] = from[i]
  }
 }

The only change was to the second parameter, what was to: Array<Fruit> is now to: Array<in Fruit>. The in specification tells Kotlin to permit method calls that set in values on that parameter and not permit methods that read out.

Now, let’s retry the call we tried before:

 copyFromTo(bananaBasket, things) ​//OK

That’s no problem. This again is a use-site variance, but this time for contravariance (in) instead of covariance (out). Just like declaration-site variance for covariance, classes may be defined with parametric type <in T> to universally specify contravariance—that is, that type can only receive parametric types and can’t return or pass out parametric types.

Designing generic functions and classes isn’t an easy task; we have to take the time to think about types, variances, and consequences. One more thing we have to consider when working with parametric types is to constrain the type that can be passed. We’ll see that next.

Parametric Type Constraints Using where

Generics offer the flexibility to use different types in place of parametric types, but sometimes that much flexibility isn’t the right option. We may want to use different types but within some constraint.

For instance, in the following code the type T is expected to support a close() method:

 fun​ <T> ​useAndClose​(input: T) {
  input.close() ​//ERROR: unresolved reference: close
 }

But arbitrary types don’t have a close() method and so the compiler fails on the call to close(). We can, however, tell Kotlin to constraint the parametric type to only types that have that method, via an interface with a close() method—for example, the AutoCloseable interface. Let’s rework the function declaration to use a constraint:

 fun​ <T​:​ AutoCloseable> ​useAndClose​(input: T) {
  input.close() ​//OK
 }

The function useAndClose() expects a parameteric type T as parameter, but only one that conforms to being AutoCloseable. Now we can pass any object that can satisfy the AutoCloseable constraint, as here for example:

 val​ writer = java.io.StringWriter()
 writer.append(​"hello "​)
 useAndClose(writer)
 println(writer) ​// hello

To place a single constraint, modify the parametric type specification to place the constraint after colon. But if there are multiple constraints, that technique won’t work. We need to use where in that case.

In addition to saying that the parametric type should conform to AutoCloseable, let’s also ask it to conform to Appendable, so we can call the append() method:

 fun​ <T> ​useAndClose​(input: T)
 where​ T: AutoCloseable,
  T: Appendable {
  input.append(​"there"​)
  input.close()
 }

At the end of the method declaration, place a where clause and list all the constraints, comma separated. Now, we can use both the close() and the append() method on the parameter. Let’s exercise this modified version of useAndClose() function:

 val​ writer = java.io.StringWriter()
 writer.append(​"hello "​)
 useAndClose(writer)
 println(writer) ​//hello there

We passed an instance of StringWriter which implements both AutoCloseable and Appendable, but we may pass any instance as long as it conforms to both of those constraints.

Star Projection

Kotlin’s declaration-site variance isn’t the only difference from Java’s support for generics. In Java you may create raw types, like ArrayList, but that’s generally not type safe and should be avoided. Also, in Java we may use the ? to specify that a function may receive a generic object of any type, but with a read-only constraint. Star projection, which is defining the parametric type with <*>, is the Kotlin equivalent of both specifying generic read-only type and raw type.

Use star projection when you want to convey that you simply don’t know enough about the type but nevertheless want type safety. The star projection will permit only read-out and not write-in. Here’s a piece of code to use star projection:

 fun​ ​printValues​(values: Array<​*​>) {
 for​ (value ​in​ values) {
  println(value)
  }
 
 //values[0] = values[1] //ERROR
 }
 
 printValues(arrayOf(1, 2)) ​//1 2

The function printValues() takes an Array<*> as its parameter, and any change to the array isn’t permitted within the function. Had we written the parameter as Array<T>, then the commented out line, marked as ERROR, would have compiled if uncommented. That would result in potentially modifying the collection, when the intent is to iterate over it. The star projection protects us from such inadvertent errors. The star projection here, <*>, is equivalent to out T but is more concise to write. If star projection is used for a contravariant parameter, which is defined as <in T> in the declaration-site variance, then it’s equivalent to in Nothing, to emphasize that writing anything will result in a compilation error. Thus, star projection prevents any writes and provides safety.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.144.189.177