Skip to content

Conversation

fatkodima
Copy link
Member

Fixes #48712.

The problem was that .first method call, used in the condition, evaluated lazy enumerator, and thus lead to the problematic behavior.

@guilleiguaran
Copy link
Member

Don't we need a changelog entry for this fix?

@matthewd
Copy link
Member

Lazy is a notable example, but I guess we're just generally wrong to assume it's safe to call first and then restart iteration.

Perhaps we should allow this faster check only for a specific list of known common enumerables, and otherwise always fall back to an implementation that guarantees a single invocation of each.

@fatkodima fatkodima force-pushed the fix-enumerable-sum-with-lazy-enumerator branch from 31484cf to e9d5a19 Compare July 20, 2023 11:13
@fatkodima
Copy link
Member Author

@guilleiguaran Added a changelog entry.

@matthewd If we will check for known safe enumerables, we need to also include Enumerable in this list to not to start generating deprecations for custom enumerable classes (currently these are accepted without deprecations), see

But Enumerator::Lazy is also an Enumerable, so that branch will be called for it too and this won't work. We will need a more complex solution 🤔. How about to go ahead with the current implementation, since lazy enumerator is the only one reported so far, and maybe change the implementation to more complex if more cases will be reported?

@matthewd
Copy link
Member

My point is that custom Enumerable-implementing classes are not safe to call first on. It's true that this is the first report we've seen, but I'm rather uncomfortable with the idea of noticing a general problem, yet only introducing a specific fix for the one stdlib class we know is affected.

I think I'm specifically suggesting that we need to do something like:

enum = enum_for(:each)
first_value =
  begin
    first_value = enum.next
  rescue StopIteration
    return 0
  end
unless first_value.is_a?(Numeric) || first_value.respond_to?(:coerce)
  # warn
end
Enumerator.produce(first_value) { enum.next }.inject(:+)

or maybe it's easier to just reimplement the inject:

initial = true
a = 0
each do |e|
  if initial
    initial = false
    unless e.is_a?(Numeric) || e.respond_to?(:coerce)
      # warn
    end
    a = e
  else
    a += e
  end
end
a

@fatkodima fatkodima force-pushed the fix-enumerable-sum-with-lazy-enumerator branch from e9d5a19 to c9ea12b Compare July 20, 2023 12:34
@fatkodima
Copy link
Member Author

@matthewd Thanks, applied the first suggestion and added you as a coauthor 👍 .

Comment on lines 80 to 93
enum = enum_for(:each)
first_value =
begin
first_value = enum.next
rescue StopIteration
return 0
end
unless first_value.is_a?(Numeric) || first_value.respond_to?(:coerce)
ActiveSupport::Deprecation.warn(<<-MSG.squish)
Rails 7.0 has deprecated Enumerable.sum in favor of Ruby's native implementation available since 2.4.
Sum of non-numeric elements requires an initial argument.
MSG
end
Enumerator.produce(first_value) { enum.next }.inject(:+) || 0
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just my opinion, but I feel like @matthewd's second suggestion is more straightforward (and more efficient). If we want a less for-loop-like implementation, we could also use reduce (aka inject) instead of each:

reduce(nil) do |sum, value|
  if sum.nil?
    unless value.is_a?(Numeric) || value.respond_to?(:coerce)
      # warn
    end
    value
  else
    sum + value
  end
end || 0

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, looks simpler.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That assumes the enumerable doesn't start with nil values / sum + value never returns nil.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(I agree the Enumerator version is probably overcomplicated / needlessly inefficient)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That assumes the enumerable doesn't start with nil values / sum + value never returns nil.

When we have nils at the beginning ([nil, nil, 2, 3, 4]), the branch with if sum.nil? will be executed for all of them. When we have nil in the middle somewhere ([1, 2, nil, 3]), we will get an error about not able to coerce, like in native sum. Am I missing something?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the branch with if sum.nil? will be executed for all of them

That's a behaviour change.

(More obscurely, a redefined NilClass#+ would also deviate.)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That assumes the enumerable doesn't start with nil values / sum + value never returns nil.

It does, but we want [nil].sum == 0:

assert_deprecated do
assert_equal 0, [nil].sum
end

I don't think + would purposefully return nil because nil + anything would raise (unless you override NilClass#+, which seems... unwise). But I suppose we could be masking an error here. Alternatively:

first = true

reduce(nil) do |sum, value|
  if first
    unless value.is_a?(Numeric) || value.respond_to?(:coerce)
      # warn
    end

    first = false
    value
  else
    sum + value
  end
end || 0

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we want [nil].sum == 0

Ah, fair.

unless you override NilClass#+, which seems... unwise

Agree, but even while "unwise" might be a reasonable line in main, I think we should aim for unreasonable accomodation in stable branches.

(And I can imagine a risky but loosely-plausible NilClass#+ that exclusively accepts some specific CustomClass as its argument)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated with the if first variant.

@fatkodima fatkodima force-pushed the fix-enumerable-sum-with-lazy-enumerator branch from c9ea12b to 82bea98 Compare July 20, 2023 17:00
Co-authored-by: Matthew Draper <matthew@trebex.net>
Co-authored-by: Jonathan Hefner <jonathan@hefner.pro>
@fatkodima fatkodima force-pushed the fix-enumerable-sum-with-lazy-enumerator branch from 82bea98 to b12fe80 Compare July 20, 2023 18:37
@jonathanhefner jonathanhefner merged commit 83385cc into rails:7-0-stable Jul 20, 2023
@jonathanhefner
Copy link
Member

Thank you, @fatkodima! 1️⃣

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants