-
Notifications
You must be signed in to change notification settings - Fork 22k
Fix Enumerable#sum
for Enumerator#lazy
#48720
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Fix Enumerable#sum
for Enumerator#lazy
#48720
Conversation
Don't we need a changelog entry for this fix? |
Lazy is a notable example, but I guess we're just generally wrong to assume it's safe to call 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 |
31484cf
to
e9d5a19
Compare
@guilleiguaran Added a changelog entry. @matthewd If we will check for known safe enumerables, we need to also include
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?
|
My point is that custom Enumerable-implementing classes are not safe to call 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 |
e9d5a19
to
c9ea12b
Compare
@matthewd Thanks, applied the first suggestion and added you as a coauthor 👍 . |
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 |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, looks simpler.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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 nil
s 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?
There was a problem hiding this comment.
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.)
There was a problem hiding this comment.
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
:
rails/activesupport/test/core_ext/enumerable_test.rb
Lines 140 to 142 in f61bcfb
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
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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.
c9ea12b
to
82bea98
Compare
Co-authored-by: Matthew Draper <matthew@trebex.net> Co-authored-by: Jonathan Hefner <jonathan@hefner.pro>
82bea98
to
b12fe80
Compare
Thank you, @fatkodima! 1️⃣ |
Fixes #48712.
The problem was that
.first
method call, used in the condition, evaluated lazy enumerator, and thus lead to the problematic behavior.