Question about Covariance and Contravariance in Crystal

Hi! I’m running into something a bit confusing related to covariance and contravariance in Crystal.

According to the docs (Inheritance - Crystal), arrays are invariant. So assigning an Array(Bar) to a variable of type Array(Foo) should fail if Bar < Foo—and indeed, that’s what happens:

[nix-shell:~/rnd]$ cat compile_error.cr 
class Foo
end

class Bar < Foo
end

arr : Array(Foo) = [Bar.new]
[nix-shell:~/rnd]$ crystal compile_error.cr 
Showing last frame. Use --error-trace for full trace.

In compile_error.cr:7:1

 7 | arr : Array(Foo) = [Bar.new]
     ^--
Error: type must be Array(Foo), not Array(Bar)

However, passing an Array(Bar) to a method that expects Array(Foo) compiles without issue:

[nix-shell:~/rnd]$ cat runtime_issue.cr 
class Foo
end

class Bar < Foo
end

def foo(arr : Array(Foo))
  puts arr.class
end

foo([Bar.new])
[nix-shell:~/rnd]$ crystal runtime_issue.cr 
Array(Bar)

Why does this compile? I would expect a type error here too, given the invariance of arrays.

1 Like

As it is said in bottom line, " in general Covariance and Contravariance is not fully supported."

Generics in function arguments are covariant (so if function is declared to accept Something<Foo> it will happily accept Something<Bar>), but if you try to break things like this:

class Foo
end

class Bar < Foo
end

def foo(arr : Array(Foo))
  arr << Foo.new
end

foo([Bar.new])

there will be compile-time error because actual types are checked, not argument restrictions.

2 Likes

Kipar, could you explain how to make this kind of code work properly in a Crystal-canonical way?

Looks like arguments are covariant. But “case-when” construction is invariant.

We know in compile-time that we’re passing covariant argument into a code block where this argument is being used in invariant control expression.

I guess this should lead to a compiler warning about variance difference and possible unexpected behavior.

class Parent
end
 
class Child1 < Parent
end
 
class Child2 < Parent
end
 
def method(arr : Array(Parent) | Parent | Nil)
  pp "method called with argument #{typeof(arr)}"
  
  case arr
    when Array(Parent) #expecting here Array(Child) will match with Array(Parent)
    	pp "matched with parent array type"
    when Parent
    	pp "matched with parent type"
    else
    	pp "no match"
  end
end
 
 
#case1 passing covariant array as argument
#expected compile error here, but since arguments are covariant everything is ok
method(arr: [Child1.new])
 
#case2 passing strictly matching type array as argument
method(arr: [Child1.new,Child2.new])

Output:

“method called with argument Array(Child1)”
“no match”
“method called with argument Array(Parent)”
“matched with parent array type”

Yes, case .. when internally calls Class#=== that calls is_a? and is_a? construction is invariant. There is an open issue about it:

I’m not sure what should be a correct solution. Covariance in argument restrictions is a useful in many cases, so current state of things looks ok for me, but of course there are corner cases.

Ideal solution (checking which generics are covariant\contravariant\invariant) require complex logic and\or sematic changes.

1 Like

Yep, that’s why I would expect at least a warning from the compiler about this.
It is super easy to get unexpected behavior just by removing a single array element, thus causing different type of the whole array :)

1 Like