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.
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.
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.
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 :)