Bug #22174
closedSet operations (&, ^, collect!, flatten, classify, divide) do not preserve compare_by_identity
Description
Since Set was transitioned to a C-level core class in Ruby 4.0, several operations that allocate new sets or partition subsets (&, ^, collect!, flatten, classify, and divide) lost the compare_by_identity behavior for the receiver set or its subsets.
- For
&,collect!,flatten,classify, anddivide, the resulting sets/subsets are allocated without propagating thecompare_by_identityflag (returningfalsefor#compare_by_identity?). - For
^(XOR) with a genericEnumerable, the returned set has the flag set (viadup), but it allocates a temporary settmpto hold the RHS elements without propagating thecompare_by_identityflag. This causes the RHS elements to be deduplicated incorrectly using value equality instead of identity equality.
A pull request has been opened with the fix and tests: https://github.com/ruby/ruby/pull/17633
Reproduction¶
require 'set'
# 1. Intersection &
s1 = Set.new.compare_by_identity
s2 = Set.new
s1 << "a"
s2 << "a"
puts "Intersection preserves compare_by_identity: #{(s1 & s2).compare_by_identity?}"
# Expected: true
# Actual: false
# 2. XOR ^ (with duplicate objects by value on RHS)
s_xor = Set.new.compare_by_identity
x1 = +"x"
x2 = +"x"
result = s_xor ^ [x1, x2]
puts "XOR with Enumerable preserves identity comparison: #{result.size == 2}"
# Expected: true (size should be 2, because x1 and x2 are distinct objects)
# Actual: false (size is 1)
# 3. collect! / map!
s_collect = Set.new(["a", "b"]).compare_by_identity
s_collect.collect! { |x| x }
puts "collect! preserves compare_by_identity: #{s_collect.compare_by_identity?}"
# Expected: true
# Actual: false
# 4. flatten
s_flat = Set.new([Set.new([1])]).compare_by_identity
puts "flatten preserves compare_by_identity: #{s_flat.flatten.compare_by_identity?}"
# Expected: true
# Actual: false
# 5. classify
s_classify = Set.new(["a", "b"]).compare_by_identity
classified = s_classify.classify { |x| x }
puts "classify subsets preserve compare_by_identity: #{classified.values.all?(&:compare_by_identity?)}"
# Expected: true
# Actual: false
# 6. divide
s_divide = Set.new(["a", "b"]).compare_by_identity
divided = s_divide.divide { |x| x }
puts "divide subsets preserve compare_by_identity: #{divided.all?(&:compare_by_identity?)}"
# Expected: true
# Actual: false
Updated by jeremyevans0 (Jeremy Evans) 3 months ago
- Backport changed from 3.3: UNKNOWN, 3.4: UNKNOWN, 4.0: UNKNOWN to 3.3: DONTNEED, 3.4: DONTNEED, 4.0: REQUIRED
Updated by jeremyevans0 (Jeremy Evans) 13 days ago
- Backport changed from 3.3: DONTNEED, 3.4: DONTNEED, 4.0: REQUIRED to 3.3: UNKNOWN, 3.4: UNKNOWN, 4.0: UNKNOWN
From my testing with the example given, Set has returned false for all of these operations since Set#compare_by_identity was added in Ruby 2.4:
$ ruby24 -v t.rb
ruby 2.4.9p362 (2019-10-02 revision 67824) [x86_64-openbsd]
Intersection preserves compare_by_identity: false
XOR with Enumerable preserves identity comparison: false
collect! preserves compare_by_identity: false
flatten preserves compare_by_identity: false
classify subsets preserve compare_by_identity: false
divide subsets preserve compare_by_identity: false
$ ruby34 -v t.rb
ruby 3.4.10 (2026-06-30 revision 2b0b7728dc) +PRISM [x86_64-openbsd]
Intersection preserves compare_by_identity: false
XOR with Enumerable preserves identity comparison: false
collect! preserves compare_by_identity: false
flatten preserves compare_by_identity: false
classify subsets preserve compare_by_identity: false
divide subsets preserve compare_by_identity: false
$ ruby40 -v t.rb
ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [x86_64-openbsd]
Intersection preserves compare_by_identity: false
XOR with Enumerable preserves identity comparison: false
collect! preserves compare_by_identity: false
flatten preserves compare_by_identity: false
classify subsets preserve compare_by_identity: false
divide subsets preserve compare_by_identity: false
So the core Set implementation returning false for these operations is backwards compatible. It would be backwards incompatible to change them to return true. Maybe we should change them, but we should realize that doing so is breaking backwards compatibility and not restoring backwards compatibility.
@knu (Akinori MUSHA) What are your thoughts on this? Should we break backwards compatibility here? If so, should we backport it to both Ruby 4.0 (core C implementation) and/or Ruby 3.4 (stdlib Ruby implementation)? ruby/set has been archived, so I assume we would need to unarchive it to fix the issue in Ruby 3.4.
Personally, I think we should handle this on a case by case basis:
collect!/map!: Unsetting thecompare_by_identityflag does not make sense, so I think we should change these.divide/classify: It seems reasonable to keepcompare_by_identityin the returned sets, but I'm not sure it's worth the backwards compatibility breakage.&/^: If both receiver and argument havecompare_by_identity, it seems best that the resulting set usescompare_by_identity. However, if the receiver hascompare_by_identityand the argument does not (or vice versa), I'm not sure we should necessarily use the receiver's setting. I think the behavior here should be thatb & aanda & breturn a consistent value in regards tocompare_by_identityifahascompare_by_identityandbdoes not.flatten: This is a transformation of some kind, and Ruby doesn't necessarily keepcompare_by_identityacross transformations. For example,Hash#transform_valueskeepscompare_by_identity, butHash#transform_keysdoes not, and sets and hash keys are closely related (set is basically a hash with keys and no values). Not sure it's worth the backwards compatibility breakage to change this.
Updated by knu (Akinori MUSHA) 13 days ago
I'd leave Ruby 3.4 and the pure Ruby implementation just as they are.
As for Ruby 4.x I'd expect:
collect!/map!retain the identity flag of the receiver.divide/classifygenerate subsets inheriting the flag of the receiver.&/^return a set inheriting the flag of the LHS set.flattenreturns a set inheriting the flag of the receiver.
And on these points, I would prefer consistency with what users who explicitly choose to use the flag would expect from this feature over backward compatibility.
Updated by jeremyevans0 (Jeremy Evans) 11 days ago
OK. I submitted https://github.com/ruby/ruby/pull/18848 to fix this in master (same as https://github.com/ruby/ruby/pull/17633 but squashed and with fixed specs). It leaves Ruby 4.0 behavior unspecified. After it is merged, I'll submit a backport PR for Ruby 4.0. After that is merged, I'll update the specs to specify the Ruby 4.0 behavior matches the Ruby 4.1 behavior.
Updated by jeremyevans0 (Jeremy Evans) 4 days ago
- Status changed from Open to Closed
- Backport changed from 3.3: UNKNOWN, 3.4: UNKNOWN, 4.0: UNKNOWN to 3.3: DONTNEED, 3.4: DONTNEED, 4.0: REQUIRED
Updated by jeremyevans0 (Jeremy Evans) 4 days ago
4.0 backport PR: https://github.com/ruby/ruby/pull/18992