Crystal: Abstract arrays and instance variables

Created on 7 Aug 2017  路  14Comments  路  Source: crystal-lang/crystal

abstract class Foo
end

class Bar < Foo
end

class Baz
  def initialize(@foo : Array(Foo))
  end
end

Baz.new([Bar.new])

# in temp.cr:8: instance variable '@foo' of Baz must be Array(Foo), not Array(Bar)

However it compiles when def initialize(foo : Array(Foo)) :thinking:

question

Most helpful comment

No, it isn't related to unions. Covariance isn't too hard concept.
Imagine we have Array(Bar). We can consider it is an Array(Foo) for purpose of reading from it, as any Bar we can get from it is a Foo. So reading from array is called covariant.

But we can't consider Array(Bar) as Array(Foo) if we add items to it. Because we can add any Foo to Array(Foo), so if Array(Bar) is Array(Foo), we would be able to add Bar2.new to Array(Bar) (like in a @kirbyfan64 example), that is wrong. Actually, it's quite opposite - we can consider any Array(Foo) as Array(Bar) for purpose of adding elements. So adding items is called contravariant.
In Crystal currently all generics are invariant - Array(Bar) and Array(Foo) are just incompatible.But even if it has proper covariant\contravariant concept, Array would be still invariant because it allows both covariant (reading) and contravariant (adding) actions.

In your case confusion is possible due to the fact that [Bar.new] looks like a valid initializer for Array(Foo). But that's not how type inference in Crystal works - first, compiler determines a type of [Bar.new] - and it is Array(Bar). After that it tries to pass Array(Bar) to def initialize(@foo : Array(Foo)), that is expecting Array(Foo) and it's not possible due to explained above contravariance.

You have also said that def initialize(foo : Array(Foo)) compiles. That's another interesting moment. To a some extent, we can even say that Crystal actually implements covariance(not contravariance), lol. If you not initialize a field, but just pass a parameter to a function, Crystal allows passing Array(Bar) instead of Array(Foo). Actual type checking will happen in a body of function, so it won't allow you to do any contravariant actions, like adding Bar2 item to Array(Bar) thinking it's a good old Array(Foo).

TL'DR: as you can see, my answer doesn't include any unions, so the problem is not related to unions.

All 14 comments

This is not a bug. Array(Foo) != Array(Bar). You should use Baz.new([Bar.new] of Foo) to get it to work: https://carc.in/#/r/2hi4

@straight-shoota is right, but to give some added context on why this would be very bad:

abstract class Foo
end

class Bar < Foo
end

class Bar2 < Foo
end

class Baz
  def initialize(@foo : Array(Foo))
    @foo.push Bar2.new
  end
end

ar = [Bar.new]  # type is Array(Bar)
Baz.new(ar)
puts ar  # type is Array(Bar), but now it also contains a Bar2

@kirbyfan64 it's related to that union everything compiler's strategy... IMHO it's the compiler's weak spot

@vladfaust ...no, it isn't. Try googling programming covariance problem and you'll see this is a problem with any language that has generics.

@kirbyfan64 too hard for me :open_mouth:
Is it related to #4775?

No, it isn't related to unions. Covariance isn't too hard concept.
Imagine we have Array(Bar). We can consider it is an Array(Foo) for purpose of reading from it, as any Bar we can get from it is a Foo. So reading from array is called covariant.

But we can't consider Array(Bar) as Array(Foo) if we add items to it. Because we can add any Foo to Array(Foo), so if Array(Bar) is Array(Foo), we would be able to add Bar2.new to Array(Bar) (like in a @kirbyfan64 example), that is wrong. Actually, it's quite opposite - we can consider any Array(Foo) as Array(Bar) for purpose of adding elements. So adding items is called contravariant.
In Crystal currently all generics are invariant - Array(Bar) and Array(Foo) are just incompatible.But even if it has proper covariant\contravariant concept, Array would be still invariant because it allows both covariant (reading) and contravariant (adding) actions.

In your case confusion is possible due to the fact that [Bar.new] looks like a valid initializer for Array(Foo). But that's not how type inference in Crystal works - first, compiler determines a type of [Bar.new] - and it is Array(Bar). After that it tries to pass Array(Bar) to def initialize(@foo : Array(Foo)), that is expecting Array(Foo) and it's not possible due to explained above contravariance.

You have also said that def initialize(foo : Array(Foo)) compiles. That's another interesting moment. To a some extent, we can even say that Crystal actually implements covariance(not contravariance), lol. If you not initialize a field, but just pass a parameter to a function, Crystal allows passing Array(Bar) instead of Array(Foo). Actual type checking will happen in a body of function, so it won't allow you to do any contravariant actions, like adding Bar2 item to Array(Bar) thinking it's a good old Array(Foo).

TL'DR: as you can see, my answer doesn't include any unions, so the problem is not related to unions.

@konovod Maybe we should put this explanation in the FAQ or language reference...

@konovod when I firstly glanced at your response I thought like "meh, later". I've tried several more times later and now I think that leaving a university was not such a good idea :roll_eyes: Anyway, this is the cause I love programming - we, developers, always help each other and this is great! Thanks for such a detailed answer. I may be not understanding everything you wrote, but you've selflessly spent your time to help others who will ever see this issue. Spasibo!

Is that code related to covariance too? We see class variables here

class A
  def name
    p @@name
  end

  macro inherited
    @@name : String = {{@type.name}}.to_s
  end
end

class B < A
end

class C < A
end

a = [] of A

a << B.new
a << C.new

a.each &.name

# Can't infer the type of class variable '@@name' of A
# 
#     p @@name
#       ^

UPD: Looks relevant to https://github.com/crystal-lang/crystal/issues/2661

Imagine you add A.new to a. You didn't that, but compiler can't know it - a is [] of A, so A.new is valid element of it, and for A class field @@name isn't declared, so compiler can't know it type.

@konovod abstract class A doesn't change the thing. Is that a BUG?!

I'm not sure about bug, but definitely an oversight\possible improvement.
What's a use case though? Can't you just add @@name = "A" to the A class? Or even @@name : String? once #4871 is merged.

@konovod take a look:

module Service
  def self.send(api_method, payload)
    p "Sent to external service: #{api_method}, #{payload}"
  end
end

abstract class Request
  def send_to_service
    Service.send(@@service_api_method, @payload)
  end

  def initialize(@payload : Hash(String, String))
  end

  macro inherited
    @@service_api_method = {{@type.name}}.to_s
  end
end

class GetUser < Request
end

class CreateUser < Request
end

a = [] of Request

a << GetUser.new({"id" => "42"})
a << CreateUser.new({"name" => "foo"})

a.each &.send_to_service

# => Can't infer the type of class variable '@@service_api_method' of Request

Request is an abstract class, it is never going to be instantiated. But the compiler insists on specifying the Request's class variable type explicitly while it's defined in inherited macro.

Yes, one can add @@service_api_method = "undefined" to the Request, but this is odd and just an workaround, because the Request itself is never meant to be instantiated.

This should probably work, in an ideal world. I'm not sure if there will be any edge cases preventing this from being fixed though. You should open a seperate issue.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

Sija picture Sija  路  3Comments

grosser picture grosser  路  3Comments

oprypin picture oprypin  路  3Comments

oprypin picture oprypin  路  3Comments

ArthurZ picture ArthurZ  路  3Comments