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.

flowchart TD A["Which collection?"] --> B{"Do you need to find a value by a key?"} B -->|"yes"| H["Hash"] B -->|"no"| C{"Must every value occur at most once?"} C -->|"no"| R{"Is the position meaningful?"} R -->|"yes"| AR["Array"] R -->|"no, only counts"| TL["Hash of counts via tally"] C -->|"yes"| D{"Do you mostly test membership or combine groups?"} D -->|"yes"| S["Set"] D -->|"no, you mostly iterate in order"| U["Array with uniq"]
Array Hash Set

Duplicates

Allowed

Keys unique

Elements unique

Order

Positional, [i] is meaningful

Insertion order is kept and documented

Not promised

include?

Linear: compares with each element

Constant time on keys (key?)

Constant time

Combine with

|, &, -, + (and intersect? since 3.1)

merge, select, reject

|, &, -, ^, subset?, disjoint?

Element equality

== for include?, eql? and hash for uniq and |

eql? and hash on keys

eql? and hash

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

Set is added to the standard library (require "set" needed).

2.5

Set#=== is an alias of include?, so a set can be used in case/when and grep.

3.0

Set#<=> is added. SortedSet is removed from the library and moved into the sorted_set gem; the constant stays as a stub that raises when sorted_set is not installed.

3.2

Set is autoloaded: the first reference loads it, no require needed.

4.0

Set is a core class implemented in C; inspect prints Set[1, 2]; passing arguments to Set#to_set and Enumerable#to_set is deprecated; set/sorted_set.rb is gone and SortedSet is no longer an autoloaded constant; a C API (rb_set_new, rb_set_foreach and others) is added; classes that inherit from Set get a compatibility module (see Subclassing Set).

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

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? and member? (and ===) are the membership tests. === is what case/when and grep call, which makes a set a compact "one of these" pattern. RuboCop’s Style/CaseEquality objects to writing === by hand, which is why the example silences it for that single line; the case and grep forms are the idiomatic ones.

  • Set["fiction"] does not contain the symbol :fiction, and Set[1, 1.0] holds both numbers: 1.eql?(1.0) is false although 1 == 1.0 is true. Never rely on == to reason about membership.

  • Equal strings count as one element, and so do equal Data and Struct values, because they define eql? and hash by value. A plain object that does not override them is its own identity, so two Object.new are 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

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(x), <<

Add x

The set itself, so calls chain

add?(x)

Add x only if it is new

The set if it was added, otherwise nil

delete(x)

Remove x if present

The set itself

delete?(x)

Remove x only if present

The set if it was removed, otherwise nil

merge(*enums)

Add every element of each enumerable

The set itself (merge takes any number of enumerables)

subtract(enum)

Remove every element of the enumerable

The set itself

replace(enum)

Replace the contents

The set itself

clear

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

|

union, +

Elements in either

&

intersection

Elements in both

-

difference

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

<=

subset?

Every element of the receiver is in the argument

<

proper_subset?

A subset that is not equal

>=

superset?

The receiver contains every element of the argument

>

proper_superset?

A superset that is not equal

<=>

none

-1 subset, 0 equal, 1 superset, nil if neither (or the argument is not a set)

==

none

Same size and same elements (eql?); never equal to an array

intersect?

none

They have at least one element in common

disjoint?

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

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_set pays off after a few lookups; for a single lookup in a small array, include? is simpler and as fast.

  • Array#uniq is 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’s Style/MutableConstant expects the freeze).

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

Lint/RedundantRequireStatement

Reports require "set" once the target Ruby is 3.2 or later.

Lint/DuplicateSetElement

Reports duplicates in a Set[...] literal (a likely typo; the example in this page silences it on purpose).

Style/MapToSet

Prefers to_set { ... } over map { ... }.to_set.

Style/ArrayIntersect

Prefers intersect? over (a & b).empty? and (a & b).any?.

Style/CaseEquality

Avoids calling === directly (set === x should be set.include?(x)).

Lint/AmbiguousRange

Wraps complex range bounds, such as a - (1..2).

Style/MutableConstant

Wants constants holding sets to be frozen.

Where older material is outdated

Older material says In Ruby 4.0

Collections are Array and Hash only; Set is not mentioned.

Set is a first-class core collection, documented in the core reference.

require "set" first.

Not needed since 3.2 (autoload) and in 4.0 (core); the require is harmless and redundant.

#<Set: {1, 2, 3}> is the printed form.

Set[1, 2, 3] since 4.0 (subclasses of Set keep the old form through Set::SubclassCompatible).

SortedSet for ordered unique data.

Removed; use the sorted_set gem or sort an array.

to_set(MySetClass, args).

Deprecated in 4.0; construct with MySetClass.new(enum).

Set is implemented in Ruby on top of Hash (monkey patching @hash in subclasses).

Implemented in C in 4.0; do not touch internals.