Range of Floats and Enumerable strange behaviour

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)

  def to_s(io : IO)
    io << @bar

class Foo(T)
  include Enumerable(Bar(T))

  def initialize(@foo : Enumerable(T))

  def each(&)
    @foo.each do |e|
      yield Bar.new(e)

t = Foo.new(data)
t.each do |e|
  puts e.to_s

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

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

  # this "triggers" the compilation error when T is Float64
  def print
    self.values.each do |value|
      puts value

  # this does not "trigger" the compilation error, even when T is Float64
  def self.print(values : Enumerable(T))
    values.each do |value|
      puts value

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 = Printer.new(data)

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