Sets
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Ruby 4.0 (4.0.7, checked on 2026-10-18). A Set is an unordered-by-contract collection of
distinct values with fast membership tests, the mathematical operations of union, intersection, difference and symmetric
difference, and the comparisons "is a subset of" and "is disjoint from". It is a hybrid of the two collections you already
know: it takes the intuitive combining operators of Array and the constant-time lookup of Hash, because it stores its
elements the way a Hash stores its keys. Ruby 4.0 changes where Set lives (it is now a core class that needs no
require), how it prints (Set[1, 2] instead of #<Set: {1, 2}>) and how it is implemented (in C instead of in Ruby on
top of a Hash), and it deprecates the arguments of to_set. Older tutorials, and both of the books this reference
was cross-checked against, say nothing about Set at all, or show the old require "set" and the old output, so this page
spells out what changed and when.
It builds on Arrays and Hashes,
the two collections Set borrows from, on Enumerable (every method that
Set does not define itself comes from there) and on
Equality, Hashing and Comparison, because what counts
as "the same element" in a set is decided by eql? and hash. Value objects that make good set elements are the subject of
Struct, Data and Value Objects; frozen copies and the
dup and clone differences used below are covered in
Object Lifecycle, Freezing and Copying. For the
equivalent collections in other languages, see Java (HashSet),
Python (set and frozenset),
Rust (HashSet and BTreeSet) and
Go (maps used as sets).
Every example was syntax-checked with ruby -wc, run with ruby -w on Ruby 4.0.7 and linted with RuboCop 1.92 using the
configuration of the running shelf gem (double-quoted strings, NewCops: enable, Style/Documentation off). The output shown is the real
output; p prints the inspect form and returns its argument, so p x # => y means that y is what was printed, and
puts prints to_s. Where an example deliberately does something a RuboCop cop dislikes (a literal with duplicate
elements to show that duplicates vanish, a redundant require, the case-equality operator), the file silences that cop on
that line or block and says so; the section on RuboCop cops explains each one. The examples
that use the shelf gem (a small library catalogue, see the shelf gem source)
are run with ruby -Ilib.
What a set is, and when to use one
A set answers one question quickly: "have I seen this value?". It stores each value at most once, decides "the same value"
with eql? and hash (exactly as a Hash decides whether two keys are the same key), and does not promise any order.
The current implementation happens to remember insertion order (see Ordering), but the documentation says
"unordered" and code must not depend on it.
Array |
Hash |
Set |
|
|---|---|---|---|
Duplicates |
Allowed |
Keys unique |
Elements unique |
Order |
Positional, |
Insertion order is kept and documented |
Not promised |
|
Linear: compares with each element |
Constant time on keys ( |
Constant time |
Combine with |
|
|
|
Element equality |
|
|
|
Typical use |
Ordered data, indexing |
Lookup by key |
De-duplication, visited markers, permissions, tags, "is this allowed?" tests |
Use a Set when the data is a bag of distinct things and the operations are "is it in there", "add it if it is new",
"what do these two groups share" or "what is in one but not the other". Use an Array (with uniq once) when you only
iterate, and a Hash when each value has something attached to it. The cost of a membership test is the practical
reason to pick a set: an Array compares the argument with its elements one at a time, a Set hashes the argument once.
Performance and memory measures that with a counter instead of a clock.
A core class in Ruby 4.0
For twenty years Set was a standard library class: you wrote require "set" before using it. Ruby 3.2 made Set an
autoloaded constant (the first reference to Set loads the library, so the require became unnecessary), and Ruby 4.0
made it a core class that is always present and was reimplemented in C. The first example confirms what a fresh Ruby
reports.
# frozen_string_literal: true
p Set.instance_method(:add).owner # => Set
p Set.ancestors # => [Set, Enumerable, Object, Kernel, BasicObject]
p Set.include?(Enumerable) # => true
p Set.new.frozen? # => false
# rubocop:disable-next Lint/RedundantRequireStatement
p require("set") # => false
Source: Set
require "set" is still accepted and returns false (the feature counts as already loaded), so code that supports Ruby
versions before 3.2 keeps working; code that targets 4.0 only should not have it, and RuboCop’s
Lint/RedundantRequireStatement removes it when TargetRubyVersion is 3.2 or higher. The same cop explains why
Ruby code written for Ruby 4.0 never starts with require "set".
| Version | Change |
|---|---|
1.8 |
|
2.5 |
|
3.0 |
|
3.2 |
|
4.0 |
|
Source: NEWS for Ruby 4.0
SortedSet is gone
SortedSet kept its elements ordered. It was removed from the library in
Ruby 3.0 and moved to the sorted_set gem. In 4.0 the leftover autoload is gone as well, so referencing it is a plain NameError:
# frozen_string_literal: true
begin
SortedSet
rescue NameError => e
puts "#{e.class}: #{e.message}" # => NameError: uninitialized constant SortedSet
end
If you need an ordered set, install the sorted_set gem and require "sorted_set", or do not keep the elements sorted:
a Set converts to a sorted array whenever you need one (set.sort, set.min, set.sort_by), which is what most code
wanted. The standard library has no replacement; the gem itself is the answer and is third-party code outside this
reference.
Creating sets
There are four ways to build a set: the Set[] shortcut, Set.new with an optional enumerable and an optional block,
Enumerable#to_set, and Set#dup or an operator on an existing set. Ruby has no set literal (nothing like %s[...] or a
#{...} form), so Set[...] is the literal-like spelling. It takes the elements as arguments, like Array[] does.
# frozen_string_literal: true
# rubocop:disable Lint/DuplicateSetElement
p Set[3, 1, 3, 2] # => Set[3, 1, 2]
p Set.new # => Set[]
p Set.new([3, 1, 3, 2]) # => Set[3, 1, 2]
p Set.new(1..4) # => Set[1, 2, 3, 4]
p Set.new([1, 2]) { |n| n * 10 } # => Set[10, 20]
p Set.new(nil) # => Set[]
p Set.new({ a: 1, b: 2 }) # => Set[[:a, 1], [:b, 2]]
p [1, 2, 2].to_set # => Set[1, 2]
p (1..3).to_set # => Set[1, 2, 3]
p %w[ico ico dune].to_set(&:upcase) # => Set["ICO", "DUNE"]
puts Set[1, 2] # => Set[1, 2]
puts Set[1, 2].inspect # => Set[1, 2]
puts "catalogue: #{Set[:fiction, :poetry]}" # => catalogue: Set[:fiction, :poetry]
begin
Set.new(5)
rescue ArgumentError => e
puts "#{e.class}: #{e.message}" # => ArgumentError: value must be enumerable
end
# rubocop:enable Lint/DuplicateSetElement
Source: Set.new
Reading the output: the duplicates in Set[3, 1, 3, 2] vanish and the first occurrence keeps its position.
Set.new accepts any object that responds to each (an array, a range, a hash, another set, an Enumerator) or
nil, which means "no elements". Any other argument raises ArgumentError with the message value must be enumerable.
A hash is enumerated as [key, value] pairs. If a block is given, every element is passed through it first, so
Set.new(list) { |x| f(x) } is a de-duplicating map; to_set with a block does the same.
Enumerable#to_set is the conversion you write most often. It exists on every enumerable, so array.to_set,
range.to_set and hash.keys.to_set all work. Ruby 4.0 deprecates passing arguments to it (the old signature accepted a
set class and extra arguments for its constructor). Calling it with an argument prints a warning when warnings are
enabled:
# frozen_string_literal: true
require "stringio"
$stderr = StringIO.new
Warning[:deprecated] = true
p [1, 2].to_set(Set) # => Set[1, 2]
warning = $stderr.string
$stderr = STDERR
puts warning.sub(/\A[^:]+:\d+: /, "") # => warning: passing arguments to Enumerable#to_set is deprecated
Source: Enumerable#to_set
The warning is only shown when deprecation warnings are on (ruby -w or Warning[:deprecated] = true), and the example captures standard error to print it next to the result. The new inspect is meant to be pasted back into code: the
output Set[1, 2] is itself a valid expression, in the same spirit as the Ruby 3.4 change to Hash#inspect. to_s is the
same text, so string interpolation prints Set[:fiction, :poetry] too.
Membership and equality
Whether a value is "already in" the set is decided by the element’s eql? and hash methods, as for a Hash key. The
documentation of Set says exactly that: equality of elements is determined according to Object#eql? and Object#hash.
That has four consequences, shown together in the next example.
# frozen_string_literal: true
genres = Set[:fiction, :poetry]
p genres.include?(:fiction) # => true
p genres.member?(:essay) # => false
# rubocop:disable-next Style/CaseEquality
p genres === :poetry # => true
p genres.include?("fiction") # => false
p Set[1, 1.0].size # => 2
p Set[1, 1.0].include?(1.0) # => true
p Set["a", "a".dup, :a].size # => 2
kind = :poetry
label = case kind
when Set[:fiction, :poetry] then "creative"
else "other"
end
p label # => "creative"
p %i[poetry essay dune].grep(genres) # => [:poetry]
Source: Set#include?
-
include?andmember?(and===) are the membership tests.===is whatcase/whenandgrepcall, which makes a set a compact "one of these" pattern. RuboCop’sStyle/CaseEqualityobjects to writing===by hand, which is why the example silences it for that single line; thecaseandgrepforms are the idiomatic ones. -
Set["fiction"]does not contain the symbol:fiction, andSet[1, 1.0]holds both numbers:1.eql?(1.0)isfalsealthough1 == 1.0istrue. Never rely on==to reason about membership. -
Equal strings count as one element, and so do equal
DataandStructvalues, because they defineeql?andhashby value. A plain object that does not override them is its own identity, so twoObject.neware two elements.
Strings are stored as frozen copies
Mutating a string that is a hash key would silently corrupt the hash. Hash avoids that by storing a frozen copy of an
unfrozen string key, and Set does the same: when an unfrozen string is added, the set stores a frozen copy, and an
already frozen string (a literal under # frozen_string_literal: true, or the result of -"text") is stored as is:
# frozen_string_literal: true
title = +"Dune"
titles = Set[title]
p titles.first.frozen? # => true
p titles.first.equal?(title) # => false
title << "!"
p titles # => Set["Dune"]
p title # => "Dune!"
shared = "Ico"
p Set[shared].first.equal?(shared) # => true
by_identity = Set.new.compare_by_identity
by_identity << title
p by_identity.first.equal?(title) # => true
Source: Set
The last lines show the exceptions: an already frozen string is stored as is, and a set built with compare_by_identity
stores the object itself, because identity is all it compares. That is also why a mutated string can show up in an identity set. Other mutable elements are not
protected, which is the next pitfall.
Mutating an element after adding it
The documentation states the rule: a set assumes that the identity of each element does not change while it is stored,
and modifying an element makes the set unreliable. The element’s hash decided its bucket when it was added; if it
changes afterwards, lookups go to the wrong bucket and fail. Set#reset rebuilds the internal table and repairs the set,
exactly like Hash#rehash.
# frozen_string_literal: true
pair = [1]
s = Set[pair]
p s.include?([1]) # => true
pair << 2
p s # => Set[[1, 2]]
p s.include?([1, 2]) # => false
p s.include?(pair) # => false
s.reset
p s.include?([1, 2]) # => true
key = { Set[1] => :found }
p key[Set[1]] # => :found
inner = Set[1]
outer = Set[inner]
inner << 2
p outer.include?(Set[1, 2]) # => false
outer.reset
p outer.include?(Set[1, 2]) # => true
Source: Set#reset
Reading the output: after pair << 2 the set still prints as containing [1, 2] and iterating finds it, but
include?([1, 2]) is false, then true after reset. The same happens when a set is an element of another set (the
second half), and when a set is used as a Hash key (Set#hash depends on the contents). The rule that follows from it:
only put frozen or value-like objects into a set, such as symbols, integers, frozen strings, Data instances and frozen
Struct instances. Isbn and Author in the shelf gem are Data objects for exactly this reason.
Identity comparison
compare_by_identity switches a set to identity semantics (equal? and object_id) and compare_by_identity? asks. Call
it on an empty set before adding elements. It is the tool for "have I already
visited this very object?", for example in a graph walk over objects that define a value ==:
# frozen_string_literal: true
names = Set["ico"]
p names.compare_by_identity? # => false
copy = names.dup
copy << "dune"
p names # => Set["ico"]
p copy # => Set["ico", "dune"]
frozen = Set[1, 2].freeze
p frozen.frozen? # => true
p frozen.dup.frozen? # => false
p frozen.clone.frozen? # => true
begin
frozen << 3
rescue FrozenError => e
puts "#{e.class}: #{e.message}" # => FrozenError: can't modify frozen Set: Set[1, 2]
end
p frozen | [3] # => Set[1, 2, 3]
p (frozen | [3]).frozen? # => false
by_id = Set.new.compare_by_identity
first = +"ico"
second = +"ico"
by_id << first << second
p by_id.size # => 2
p by_id.include?(first) # => true
p by_id.include?(+"ico") # => false
p by_id.compare_by_identity? # => true
p Set[first, second].size # => 1
Source: Set#compare_by_identity
The identity set holds two elements whose text is the same, because they are two different objects, while an ordinary set
treats them as one. dup copies the contents and the identity mode, freeze makes the set read-only, and clone
preserves frozen state while dup does not (the same rules as for every object, see
Object Lifecycle, Freezing and Copying).
Modifying a frozen set raises FrozenError with the set printed in the message.
Adding and removing elements
Adding and removing come in pairs that differ in the value they return, and the return value is the useful part.
# frozen_string_literal: true
tags = Set[:fiction]
p tags.add(:poetry) # => Set[:fiction, :poetry]
p tags << :poetry # => Set[:fiction, :poetry]
p tags.add?(:poetry) # => nil
p tags.add?(:essay) # => Set[:fiction, :poetry, :essay]
p tags.delete(:fiction) # => Set[:poetry, :essay]
p tags.delete(:missing) # => Set[:poetry, :essay]
p tags.delete?(:missing) # => nil
p tags.delete?(:essay) # => Set[:poetry]
p tags.merge(%i[a b], %i[c]) # => Set[:poetry, :a, :b, :c]
p tags.subtract(%i[a]) # => Set[:poetry, :b, :c]
p tags.replace(%i[x y]) # => Set[:x, :y]
p tags.size # => 2
p tags.length # => 2
p tags.empty? # => false
p tags.clear # => Set[]
p tags.empty? # => true
Source: Set#add?
| Method | Effect | Returns |
|---|---|---|
|
Add |
The set itself, so calls chain |
|
Add |
The set if it was added, otherwise |
|
Remove |
The set itself |
|
Remove |
The set if it was removed, otherwise |
|
Add every element of each enumerable |
The set itself ( |
|
Remove every element of the enumerable |
The set itself |
|
Replace the contents |
The set itself |
|
Remove everything |
The set itself |
add? is the building block of "remember what I have seen" loops: seen.add?(x) is truthy exactly the first time x
appears, and is used in the catalogue example below. The bang methods
select!, reject! and filter! mimic the Array ones and share their pitfall: they return nil when nothing changed,
which breaks chaining and is the reason to prefer delete_if and keep_if, which always return the set.
# frozen_string_literal: true
nums = Set[1, 2, 3, 4, 5]
p nums.select!(&:positive?) # => nil
p nums.reject!(&:negative?) # => nil
p nums.delete_if(&:even?) # => Set[1, 3, 5]
p(nums.keep_if { |n| n > 1 }) # => Set[3, 5]
p(nums.map! { |n| n * 10 }) # => Set[30, 50]
p nums.filter!(&:positive?) # => nil
p(nums.select { |n| n > 30 }) # => [50]
p(nums.reject { |n| n > 30 }) # => [30]
p(nums.map { |n| n + 1 }) # => [31, 51]
p(nums.filter_map { |n| n * 2 if n > 30 }) # => [100]
p(nums.partition { |n| n > 30 }) # => [[50], [30]]
p(nums.group_by { |n| n > 30 }) # => {false => [30], true => [50]}
Source: Set#keep_if
Reading the output: select! and filter! printed nil because nothing was removed, while delete_if and keep_if
printed the set. The non-bang select, reject, map, filter_map, partition and group_by are not Set methods;
they are inherited from Enumerable, so they return Array (and Hash for group_by), not a set. Wrap the result in
to_set when you want a set again (RuboCop’s Style/MapToSet asks for the block form, to_set { ... }, over
map { ... }.to_set). Only the mutating map!/collect! (and the other bang methods) keep the set a set.
Do not change the set while iterating over it
Adding an element while each is running would require growing the underlying table in the middle of the walk, so Ruby
forbids it and raises. Deleting is allowed and safe.
# frozen_string_literal: true
s = Set[1, 2, 3]
begin
s.each { |n| s << (n + 10) }
rescue RuntimeError => e
puts "#{e.class}: #{e.message}" # => RuntimeError: can't add a new item into set during iteration
end
s.each { |n| s.delete(n) if n.odd? }
p s # => Set[2]
p s.each # => #<Enumerator: Set[2]:each>
p s.each(&:itself).equal?(s) # => true
p s.each_slice(2).to_a # => [[2]]
p s.first # => 2
p s.first(5) # => [2]
p s.min # => 2
p s.sum # => 2
p s.sort # => [2]
p s.sort.reverse # => [2]
p s.to_a # => [2]
p s.each_with_index.to_a # => [[2, 0]]
p s.zip(%i[a b c]) # => [[2, :a]]
Source: Set#each
The error is RuntimeError with can't add a new item into set during iteration. Collect the new elements in a separate
array or set and merge them after the loop. delete_if and reject! exist for the common case of removing while walking.
Set operations
The four set operators take the other operand as any enumerable and return a new set. Each has a named alias, which reads
better in long chains and is easier to pass to reduce.
| Operator | Alias | Result |
|---|---|---|
|
|
Elements in either |
|
|
Elements in both |
|
|
Elements of the receiver that are not in the argument |
|
none |
Elements in exactly one of them (symmetric difference) |
# frozen_string_literal: true
a = Set[1, 2, 3]
b = Set[3, 4]
p a | b # => Set[1, 2, 3, 4]
p a.union(b) # => Set[1, 2, 3, 4]
p a + b # => Set[1, 2, 3, 4]
p a & b # => Set[3]
p a.intersection(b) # => Set[3]
p a - b # => Set[1, 2]
p a.difference(b) # => Set[1, 2]
p a ^ b # => Set[1, 2, 4]
p a | [9, 9] # => Set[1, 2, 3, 9]
p a & [2, 3, 7] # => Set[2, 3]
p a - (1..2) # => Set[3]
p a ^ [3, 4] # => Set[1, 2, 4]
begin
a | 3
rescue ArgumentError => e
puts "#{e.class}: #{e.message}" # => ArgumentError: value must be enumerable
end
p a # => Set[1, 2, 3]
Source: Set#|
Reading the output: the operands may be arrays or ranges (a | [9, 9]), the result of a | [9, 9] has the nine once, and
an argument that is not enumerable raises ArgumentError with value must be enumerable. The operators never modify the
receiver (the last line proves it); merge, subtract and replace are the mutating forms. Set#- and Array#- look
alike but the Set version is linear in the size of the argument and not quadratic, because it probes the hash instead of
scanning. The result keeps the class of the receiver (a subclass of Set gives a subclass), see
Subclassing Set.
Two precedence reminders from Operators and Precedence:
& binds tighter than ^ and |, which share one level, and - binds tighter than all of them. Parenthesise mixed
chains. a - 1..2 parses as (a - 1)..2, so write a - (1..2) (RuboCop’s Lint/AmbiguousRange flags the first form).
Comparing sets
The comparison operators mean subset and superset, which is the one place where < on a collection means something other
than ordering: they define a partial order, so two sets can be neither bigger nor smaller than each other.
# frozen_string_literal: true
small = Set[1, 2]
twin = Set[2, 1]
big = Set[1, 2, 3]
p small.subset?(big) # => true
p small <= big # => true
p small.proper_subset?(big) # => true
p small < twin # => false
p small <= twin # => true
p big.superset?(small) # => true
p big >= small # => true
p big.proper_superset?(small) # => true
p small <=> big # => -1
p big <=> small # => 1
p small <=> twin # => 0
p small <=> Set[7] # => nil
p small <=> [1, 2] # => nil
p small.disjoint?(Set[8, 9]) # => true
p small.intersect?(Set[2, 9]) # => true
p small.intersect?([2, 9]) # => true
p small.disjoint?(8..9) # => true
p small == twin # => true
p small.eql?(twin) # => true
p small.hash == twin.hash # => true
p small == [1, 2] # => false
begin
small.subset?([1, 2])
rescue ArgumentError => e
puts "#{e.class}: #{e.message}" # => ArgumentError: value must be a set
end
Source: Set#<⇒
| Operator | Named method | Meaning |
|---|---|---|
|
|
Every element of the receiver is in the argument |
|
|
A subset that is not equal |
|
|
The receiver contains every element of the argument |
|
|
A superset that is not equal |
|
none |
|
|
none |
Same size and same elements ( |
|
none |
They have at least one element in common |
|
none |
They have no element in common |
Reading the output: small <=> big is -1, big <=> small is 1, and small <=> Set[7] is nil because neither
contains the other; that is why a Set is not Comparable and sorting an array of sets raises. The subset and superset
methods and the operators built on them require a Set argument and raise ArgumentError with value must be a set
for an array; intersect? and disjoint? accept any enumerable in 4.0, so small.intersect?([2, 9]) and
small.disjoint?(8..9) work. Keep that asymmetry in mind: convert with to_set before subset?.
== compares sizes and then membership with eql? semantics, so Set[1, 2] == Set[2, 1] is true regardless of
insertion order, and Set#hash is order independent, which lets sets be Hash keys and elements of other sets. The class
of the receiver does not matter: an instance of a subclass equals a plain Set with the same elements.
Array#intersect? (Ruby 3.1) answers the same question for arrays without allocating the intersection; RuboCop’s
Style/ArrayIntersect rewrites (a & b).empty? into !a.intersect?(b).
Iterating and converting
Set defines each and includes Enumerable, so map, select, sum, min, sort, sort_by, each_slice,
group_by, zip, first and the rest work, as do lazy enumerators (see
Enumerators and Lazy Evaluation). Without a block,
each returns an Enumerator; with a block it returns the set. There is no index: first returns the first element in
the current iteration order and to_a returns an array in that order.
Ordering
The set documentation describes the collection as unordered. In practice a set remembers the order in which elements
were first added (as a Hash does), so Set[3, 1, 2].to_a is [3, 1, 2] and the output above is stable from run to run.
Treat that as an implementation detail, not a contract: it is not documented for Set, Set#== ignores it, and
operations such as ^ do not specify it. When the order matters, sort explicitly (set.sort) or keep an Array.
classify, divide, flatten and join
classify groups the elements by the block’s value and returns a hash of sets; divide partitions them into a set of sets.
divide with a block of arity 2 uses the block as an equivalence relation and builds the connected components, so it
groups elements that are transitively related.
# frozen_string_literal: true
p Set[1, 2, 3, 4].classify(&:even?) # => {false => Set[1, 3], true => Set[2, 4]}
p(Set[1, 2, 4, 5, 7].divide { |a, b| (a - b).abs == 1 }) # => Set[Set[1, 2], Set[4, 5], Set[7]]
p(Set[1, 2, 4, 5, 7].divide { |n| n % 3 }) # => Set[Set[1, 4, 7], Set[2, 5]]
p Set[Set[1, 2], Set[3, Set[4]]].flatten # => Set[1, 2, 3, 4]
p Set[[1, 2], [3]].flatten # => Set[[1, 2], [3]]
recursive = Set[1]
recursive << recursive
begin
recursive.flatten
rescue ArgumentError => e
puts "#{e.class}: #{e.message}" # => ArgumentError: tried to flatten recursive Set
end
p Set[1, 2].join("-") # => "1-2"
p Set[1, 2].join # => "12"
p Set[1, 2].to_a # => [1, 2]
p Set["b", "a"].to_a.sort # => ["a", "b"]
Source: Set#divide
Reading the output: Set[1, 2, 4, 5, 7].divide { |a, b| (a - b).abs == 1 } returns Set[Set[1, 2], Set[4, 5], Set[7]],
the groups of consecutive numbers, and the arity-1 form returns the sets split by remainder (the arity-1 form is what
classify does, but it returns the sets without the keys). flatten merges nested sets into one set recursively and
leaves other nested collections (arrays, hashes) untouched; flatten! is the in-place version and returns nil when no
set was nested. A set that contains itself raises ArgumentError with tried to flatten recursive Set. join(sep) joins
the elements like Array#join.
Serialization
A set is an ordinary object, so Marshal round-trips it. JSON and YAML do not treat it as a collection by default;
convert it to an array first, which is the portable shape.
# frozen_string_literal: true
require "json"
require "yaml"
p Marshal.load(Marshal.dump(Set[1, 2, 3])) # => Set[1, 2, 3]
puts Set[1, 2].to_json # => "Set[1, 2]"
puts Set[1, 2].to_a.to_json # => [1,2]
puts JSON.generate(Set[1, 2].to_a) # => [1,2]
puts YAML.dump(Set[1, 2])
# --- !ruby/object:Set
# hash:
# 1: true
# 2: true
p YAML.unsafe_load(YAML.dump(Set[1, 2])) # => Set[1, 2]
begin
YAML.safe_load(YAML.dump(Set[1, 2]))
rescue Psych::DisallowedClass => e
puts "#{e.class}: #{e.message}" # => Psych::DisallowedClass: Tried to load unspecified class: Set
end
puts YAML.dump(Set[1, 2].to_a)
# ---
# - 1
# - 2
Source: Marshal
Reading the output: Set[1, 2].to_json without the optional json/add/set extension produces the JSON string
"Set[1, 2]" (from to_s), not an array, which is a silent data bug in an API response; to_a.to_json is the array. YAML
dumps a set as a tagged object, which the safe loader refuses to build (Psych::DisallowedClass), so use to_a for
data that crosses a trust boundary. See
Serialization: JSON, YAML, CSV and Marshal
and Security.
Subclassing Set
Ruby 4.0 reimplemented Set in C. To keep subclasses written for the Ruby implementation working, a class that inherits
from Set automatically gets the module Set::SubclassCompatible, which restores the pre-4.0 behaviour and internal
method names, notably the old #<Tags: {:a, :b}> form of inspect. New code that does not need that compatibility
should inherit from Set::CoreSet, which has the 4.0 behaviour (Shelf::CoreTags[:a, :b] prints with the new format).
# frozen_string_literal: true
module Shelf
# Subclassing Set keeps the pre-4.0 behaviour through a compatibility module.
class Tags < Set
def to_s = "tags(#{to_a.join(', ')})"
end
# Inheriting from Set::CoreSet keeps the 4.0 behaviour.
class CoreTags < Set::CoreSet
def to_s = "core(#{to_a.join(', ')})"
end
end
p Shelf::Tags.ancestors.first(3) # => [Shelf::Tags, Set::SubclassCompatible, Set]
p Shelf::CoreTags.ancestors.first(3) # => [Shelf::CoreTags, Set::CoreSet, Set]
p Shelf::Tags[:a, :b] # => #<Shelf::Tags: {:a, :b}>
p Shelf::CoreTags[:a, :b] # => Shelf::CoreTags[:a, :b]
puts Shelf::Tags[:a, :b] # => tags(a, b)
puts Shelf::CoreTags[:a, :b] # => core(a, b)
p((Shelf::Tags[:a] | %i[c]).class) # => Shelf::Tags
p((Shelf::CoreTags[:a] | %i[c]).class) # => Shelf::CoreTags
p Shelf::Tags[:a].dup.class # => Shelf::Tags
p Shelf::Tags[:a] == Set[:a] # => true
p Shelf::Tags.new(%i[z]).to_set.class # => Set
Source: Set, Inheriting from Set
Reading the output: both subclasses keep their class through operators (| returns a Shelf::Tags or a
Shelf::CoreTags), and to_set on a subclass instance returns a plain Set, because to_set without arguments returns
the receiver only when it is an exact Set. SubclassCompatible is a private constant and appears only in ancestors.
Prefer composition (a class that holds a set in an instance variable and exposes the operations it needs) to subclassing:
a set exposes clear, replace and every mutator, which is rarely the interface you want to promise.
Thread safety
Set is not thread safe. Under the global VM lock two threads can still interleave a "check then add" sequence, and
mutating a set while another thread iterates it raises the same "during iteration" error. Guard shared sets with a
Thread::Mutex, as the shelf Catalog guards its store, or share a frozen set (a frozen set is never modified, so any number of threads can read it). See
Threads and the GVL.
Performance and memory
A membership test on an Array is linear: Array#include? calls == on elements until one matches. On a Set it hashes
the argument and compares with eql? only within the matching bucket. The next example counts comparisons instead of
timing them, so the result does not depend on the machine.
# frozen_string_literal: true
# A key that counts how often it is compared.
class Probe
@comparisons = 0
class << self
attr_accessor :comparisons
end
attr_reader :id
def initialize(id) = @id = id
def ==(other)
self.class.comparisons += 1
other.is_a?(Probe) && id == other.id
end
alias eql? ==
def hash = id.hash
end
probes = Array.new(1_000) { |i| Probe.new(i) }
set = probes.to_set
target = Probe.new(999)
Probe.comparisons = 0
p probes.include?(target) # => true
p Probe.comparisons # => 1000
Probe.comparisons = 0
p set.include?(target) # => true
p Probe.comparisons # => 1
Source: Set#include?
The array needed 1000 comparisons to find the last element, the set needed one. Three practical rules follow:
-
Convert once, test many times. Building a set from an array costs one pass, so
to_setpays off after a few lookups; for a single lookup in a small array,include?is simpler and as fast. -
Array#uniqis the right tool when you only want the distinct elements in order; a set is for repeated tests and for the algebra. -
Large sets cost memory (a hash-like table with room to grow). Freeze a finished set to document that it is a constant lookup table, for example a whitelist:
ALLOWED = Set[...].freeze(RuboCop’sStyle/MutableConstantexpects thefreeze).
A duplicate finder for the catalogue
The shelf gem identifies a book by its Isbn, a Data value object, so books with the same ISBN are the same element.
The example reopens Shelf to add a small collaborator that reports the books of a feed that were already seen, using the
add? return value.
# frozen_string_literal: true
require "shelf"
module Shelf
# Keeps the identifiers already seen in a feed and reports duplicates.
class DuplicateFinder
def initialize = @seen = Set.new
def duplicates(books)
books.reject { |book| @seen.add?(book.isbn) }
end
end
end
author = Shelf::Author.new(name: "Umberto Eco")
isbn = Shelf::Isbn.new(digits: "978-0-15-144647-6")
other = Shelf::Isbn.new(digits: "9780156001311")
feed = [
Shelf::Book.new(isbn:, title: "The Name of the Rose", author:),
Shelf::Book.new(isbn: other, title: "Foucault's Pendulum", author:),
Shelf::Book.new(isbn: Shelf::Isbn.new(digits: "9780151446476"), title: "The Name of the Rose", author:)
]
dups = Shelf::DuplicateFinder.new.duplicates(feed)
p dups.map(&:title) # => ["The Name of the Rose"]
p feed.to_set(&:isbn).size # => 2
p feed.to_set(&:author).size # => 1
p Shelf::Loan::STATES.to_set.include?(:lent) # => true
Source: Set#add?
Reading the output: the first and third books spell the same ISBN once with hyphens and once without, yet the
Isbn constructor normalises it, so the two values are eql? and the third book is reported as the duplicate. The feed
has two distinct ISBNs and one author. Loan::STATES.to_set.include?(:lent) is the same test the Loan class performs with
Array#include?; with four values the difference is irrelevant, which is the point of the performance section.
RuboCop cops for sets
| Cop | What it checks |
|---|---|
|
Reports |
|
Reports duplicates in a |
|
Prefers |
|
Prefers |
|
Avoids calling |
|
Wraps complex range bounds, such as |
|
Wants constants holding sets to be frozen. |
Source: RuboCop Lint cops
Where older material is outdated
| Older material says | In Ruby 4.0 |
|---|---|
Collections are |
|
|
Not needed since 3.2 (autoload) and in 4.0 (core); the require is harmless and redundant. |
|
|
|
Removed; use the |
|
Deprecated in 4.0; construct with |
|
Implemented in C in 4.0; do not touch internals. |
References
-
Set: Inheriting from Set (
Set::SubclassCompatibleandSet::CoreSet) -
Release notes: Ruby 4.0