Variance

Introduction

At some point in your haxe life, you will encounter a compiler error along the lines of:

Array<Int> should be Array<Float>

Intuitively, you might think "If Int extends Float, then surely Something<Int> extends Something<Float>". But is that really the case? The answer is yes and no, and depends interestingly enough on the semantics of Something. Let us explore that.

Exploring variance

First, let us look at a specific, simplified problem:

// Does not compile
var a1:Array<Int> = [];
var a2:Array<Float> = a1;
Trying to compile this produces above error. Now, try using an Iterable instead of an Array:
// Compiles
var a1:Iterable<Int> = [];
var a2:Iterable<Float> = a1;
So where's the difference? There are two relevant distinctions:
  • Iterable is a typedef, while Array is a class.
  • Iterable provides a read operation, but no write operation.

The first is just a limitation of the current haxe version (2.08), which might be lifted in the future. The second is actually interesting to talk about. Before that, let us quickly review the concept of references.

Regarding references and objects


To fully understand variance, you have to think in terms of references: a reference of type Base may actually reference to an object of type Child. You use this all the time, e.g. for stuff like this:
var reference:DisplayObject = new Sprite();
trace(Std.is(reference, Sprite)); // true
To the compiler, you work with a DisplayObject and it will complain if you try to apply a Sprite-specific method on that reference. The real, physical object that is referenced is actually of type Sprite. It is always the object whose constructor was called and will not change its type. Also, in the general case this type is only known at runtime, not at compile-time.

Covariance


With that out of the way, the initial Array/Iterable example suddenly makes a lot more sense. An Iterable<Float> can guarantee that all objects you extract from it are (at least) of type Float. They might be Ints, or could be other types deriving from Float, if that was legal. The point is that you can treat them as Floats, and that is really all Iterable<Float> has to worry about.

An Array<Float>, which provides the push() method among other things, can not provide that safety. Here is a simple way of creating a runtime exception:

// Don't try this at home
class Base { }
class Child1 extends Base {
    public function new() { }
}
class Child2 extends Base {
    public function new() { }
}

class Main {
    static public function main() {
        var a1:Array<Child1> = [new Child1()];
        var a2:Array<Base> = cast a1; // Needed to compile
        a2.push(new Child2());
        for (child in a1) { }; // Disaster
    }
}
Notice that we ignore the compiler by using cast to actually see the problem. With a2 being of type Array<Base> and Child2 extending Base, we can push a Child2 object onto an array of Base objects. However, when trying to access said object through the original reference of type Array<Child1>, we get a nasty type coercion error, with varying outcome depending on the target platform. This is why the compiler prohibits covariance on objects that allow write operations: It protects you from writing Child2 objects through a Base reference to something expecting Child1 objects.

Contravariance


If you multiply the above by -1, you basically get the idea behind contravariance. Here's the quick proof that the problem exists:
// Does not compile
var a1:Array<Float> = [];
var a2:Array<Int> = a1;
Falling back to an Iterable does not help either this time:
// Does not compile
var a1:Iterable<Float> = [];
var a2:Iterable<Int> = a1;
If this was legal, you could iterate over a2, expecting Int objects while the actual array might as well contain Floats. It might work in this case because Float can be coerced to Int, but in general it is a bad idea and the compiler consequentially complains.
So reading is a problem, but unsurprisingly writing is not. Of course you could write Int objects into above array, because the array holds Floats, and thus also Ints. The only problem remaining is that we need an appropriate definition for our container, because Iterable provides no write functionality. You can try this:
//Compiles and works
typedef WriteOnly<T> = {
    public function push(t:T):Int;
};

class Main {
    static public function main() {
        var a1:Array<Float> = [0.2];
        var a2:WriteOnly<Int> = a1;
        a2.push(497); // Push to Int array
        for (i in a1) trace(i); // Read from float array
    }
}
Consider the WriteOnly type as promise to the compiler to only write, but not read. The signature of push() is compatible with Array's push signature, so that works. In a sense, WriteOnly is the opposite of Iterable: One promises write-only, the other read-only operations.

Conclusion

  • Type parameters of typedefs can be covariant (more generic) if you only read them.
  • Type parameters of typedefs can be contravariant (more specific) if you only write to them.
  • You can make promises regarding write-only/read-only operations by using appropriate typedefs. For instance, Iterable promises to only read.
  • You should refrain from using unsafe cast to make the compiler "shut up" about variance related issues.
  • Classes and Interfaces are currently invariant in haxe, meaning their type parameters cannot be more generic or more specific. This might change in future haxe implementations.
version #13803, modified 2012-04-19 16:00:16 by ncannasse