Range of Floats and Enumerable strange behaviour

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.

Any explanation ?

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.

As a workaround, you could change the type restriction of the instance variable to something else, for example Indexable(T).

Ah, ok, thanks for this explanation.

Another workaround I can think of would be to define a default succ method for the Float64 type, for example:

struct Float64
  def succ(inc = Float64::EPSILON)
    self + inc
  end
end

which seems harmless enough

I’m proposing to change this:

1 Like

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
1 Like

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