Hi,
I’m encountering a curious anomaly in the execution of the code below:
x = (3.14..4.21).step(0.2).to_a
data = [3.14, 4.21]
# data = [1, 2, 3]
puts data
class Bar(T)
def initialize(@bar : T)
end
def to_s(io : IO)
io << @bar
end
end
class Foo(T)
include Enumerable(Bar(T))
def initialize(@foo : Enumerable(T))
end
def each(&)
@foo.each do |e|
yield Bar.new(e)
end
end
end
t = Foo.new(data)
t.each do |e|
puts e.to_s
end
which outputs:
There was a problem expanding macro ‘macro_136875762028880’
Code in /usr/lib/crystal/range.cr:120:5
120 | {% if E == Nil %}
^
Called macro defined in /usr/lib/crystal/range.cr:120:5
120 | {% if E == Nil %}
Which expanded to:
3 | while end_value.nil? || current < end_value
4 | yield current
5 | current = current.succ
^—
Error: undefined method ‘succ’ for Float64
However, if I comment the first line, the code runs fine.
if I keep the first line uncommented, and uncomment the third (the array of int32), the code runs fine.
What’s more, if I comment out the last 4 lines, the code executes normally, whether the first line is commented out or not.
The expression in the first line instantiates Range(Float64, Float64) which includes Enumerable(Float64) (the line can be reduced to 3.14..4.21 by the way; the rest is irrelevant).
In Foo#each there’s a call to a method #each on an instance variable of type Enumerable(Float64). This call instantiates all implementations of Enumerable(Float64)#each, including that of Range.
The problem with Range is that it does not actually always implement Enumerable. It depends on the type of the range elements. If the element type defines #succ, it can enumerate. Otherwise it cannot.
Unfortunately, it’s currently not possible to express such a conditional implementation in the type system. Thus Range always includes Enumerable because most common use cases are enumerable, but in some cases it cannot fullfil that interface.
I think this is a fairly clear minimal-ish example/explanation of the issue:
class Printer(T)
getter values : Enumerable(T)
def initialize(@values : Enumerable(T))
end
# this "triggers" the compilation error when T is Float64
def print
self.values.each do |value|
puts value
end
end
# this does not "trigger" the compilation error, even when T is Float64
def self.print(values : Enumerable(T))
values.each do |value|
puts value
end
end
end
x = 3.14..4.21
data = [3.14, 4.21] of Float64
## when the line below is uncommented, the later variable
## `printer` changes from `Printer(Float64)` to `Printer(Int32)`,
## avoiding the compilation error
# data = [1, 2, 3]
Printer.print(data)
printer = Printer.new(data)
printer.print
You don’t need to go through an instance variable in order to get a type cast to Enumerable. An explicit as(Enumerable(Float64)) works as well. See the example in the issue I mentioned.
Ah, I see. That makes sense. I do think the code above is useful, though, since the non-obvious part of it for me is what causes the compiler to instantiate the implementation of Enumerable(Float64)#each, even when that implementation is never called. My understanding from this conversation and making the above example is that the Enumerable(T)instance variable is an important aspect of this specific example (maybe because instance variables can be modified in other threads?).