Crystal: type inference weirdness

Created on 27 Apr 2020  路  8Comments  路  Source: crystal-lang/crystal

Sorry for the very generic issue title, I've bumped into this rather confusing situation, and I have no idea what causes it so I couldn't really come up with a better one. Let the snippets do the talking:

abstract class Animal; end

class Dog < Animal; end

class Cat < Animal; end

class House
  @animals : Array(Animal)

  def initialize(*animals : Animal)
    @animals = animals.to_a
  end
end

p House.new(Cat.new, Cat.new)
$ crystal run animals.cr
Showing last frame. Use --error-trace for full trace.

In animals.cr:11:5

 11 | @animals = animals.to_a
      ^-------
Error: instance variable '@animals' of House must be Array(Animal), not Array(Cat)



md5-12922884b33661a9297b612b009178f1



Crystal 0.34.0 [4401e90f0] (2020-04-06)

LLVM: 8.0.0
Default target: x86_64-unknown-linux-gnu
(Running in WSL)

Most helpful comment

I suppose #to_a(type) could actually be generally useful for any Enumerable, not just Tuple.

module Enumerable
  def to_a(type : U.class = T) forall U
    ary = [] of U
    each { |e| ary << e }
    ary
  end
end

class Foo
end

class Bar < Foo
end

class Baz < Foo
end

typeof([Bar.new, Bar.new].to_a(Foo)) # => Array(Foo)
typeof([Bar.new, Bar.new].to_a)      # => Array(Bar)

All 8 comments

I read the entry about variance and covariance in the handbook. It says the type of the array needs to be set explicitly. However, in my case the type of the input splat is defined as Animal.

So what's happening is that you're effectively passing an Tuple(Cat, Cat). This passes the argument restriction of Tuple(*Animal), but performs no casting on it. Then Tuple#to_a does Array(Union(*T)).build, so Array(Cat).build effectively. This however is not assignable to Array(Animal). Yes the language is a bit inconsistent there, see #3803. Adding a Dog to the mix, constructs a Union(Cat, Dog) which the compiler simplifies to the parent type, Animal+, hence the observed effect of it working then.

Anyways, for now you can do @animals = Array(Animal).new(animals.size) {|i| animals[i] } to workaround this.

@jhass Thanks for the the explanation, it makes sense. At least in a "now I understand what's happening" way. Shall I keep the issue open?

I'm not sure, let's see what others think. It is indeed not very actionable and mostly covered by #3803 and all the related issues we already have open I think.

I don't think this can be changed in a fundamental way. Maybe when we get to revisit generics and improve covariance.
But we could make the workaround easier by adding an optional argument to #to_a, so you could call animals.to_a(Animal) instead of having to re-implement the method.

animals.to_a(Animal) would be great, it communicates the intent a lot better than the workaround.

Additionally, if the inconsistency can not be resolved easily, perhaps a warning could be printed if someone attempts something similar? That way at least I would see the same thing on the console regardless of what parameters I pass into the splat (instead of compilation error in one case and works fine in the other)

Tuple#map returns a tuple, and Tuple#to_a doesn't accept a block for transforming it. I wonder if there's a space for #to_a taking a block, or whether animals.map(&.as(Animal)).to_a is fast enough (on the stack) that there doesn't need to be a new method.

I suppose #to_a(type) could actually be generally useful for any Enumerable, not just Tuple.

module Enumerable
  def to_a(type : U.class = T) forall U
    ary = [] of U
    each { |e| ary << e }
    ary
  end
end

class Foo
end

class Bar < Foo
end

class Baz < Foo
end

typeof([Bar.new, Bar.new].to_a(Foo)) # => Array(Foo)
typeof([Bar.new, Bar.new].to_a)      # => Array(Bar)
Was this page helpful?
0 / 5 - 0 ratings

Related issues

jhass picture jhass  路  3Comments

TechMagister picture TechMagister  路  3Comments

nabeelomer picture nabeelomer  路  3Comments

Papierkorb picture Papierkorb  路  3Comments

asterite picture asterite  路  3Comments