Data structures
The principal Ruby data structures: Array (ordered, indexed, mutable sequence), Hash (unordered key-value collection — though insertion-ordered in iteration since 1.9), Symbol (interned identifier), Range (span of values, lazy or eager), Set (unique-value collection from the standard library), and Struct/Data (lightweight value classes). Every value is an object — including the literals — and admits the conventional methods through method dispatch. The combination — first-class collections with substantial method surfaces, the conventional literal syntax, the Enumerable mixin for substantial iteration, the Hash with symbol keys as the conventional configuration form — is the substance of Ruby’s data-structure surface.
Arrays
Ordered, indexed, mutable sequences:
arr = [1, 2, 3, 4, 5] # literal
arr = Array.new(5) # [nil, nil, nil, nil, nil]
arr = Array.new(5, 0) # [0, 0, 0, 0, 0]
arr = Array.new(5) { |i| i * i } # [0, 1, 4, 9, 16]
arr = %w[apple banana cherry] # ["apple", "banana", "cherry"]
arr = %i[one two three] # [:one, :two, :three]
Arrays admit any mix of types:
mixed = [1, "two", :three, 4.0, nil, [5], {6 => 7}]
Indexed access
arr = [10, 20, 30, 40, 50]
arr[0] # 10 (first)
arr[-1] # 50 (last)
arr[100] # nil (out of bounds)
arr.first # 10
arr.last # 50
arr.first(2) # [10, 20]
arr.last(2) # [40, 50]
# Slicing:
arr[1, 3] # [20, 30, 40] (start, length)
arr[1..3] # [20, 30, 40] (range)
arr[1...3] # [20, 30] (exclusive)
arr[1..] # [20, 30, 40, 50] (endless)
# Update:
arr[0] = 99 # arr = [99, 20, 30, 40, 50]
arr[1, 2] = [200, 300] # replace 2 elements starting at index 1
Mutation
arr.push(6) # append
arr << 7 # operator form
arr.pop # remove and return last
arr.shift # remove and return first
arr.unshift(0) # prepend
arr.insert(2, 99) # insert at index 2
arr.delete(99) # remove all instances
arr.delete_at(0) # remove at index
arr.compact! # remove nil
arr.uniq! # remove duplicates
arr.flatten! # flatten nested arrays
The ! variants mutate; the unsuffixed forms return new arrays.
Array operations
[1, 2, 3] + [4, 5, 6] # [1, 2, 3, 4, 5, 6]
[1, 2, 3, 1] - [1] # [2, 3]
[1, 2, 3] & [2, 3, 4] # [2, 3] (intersection)
[1, 2, 3] | [3, 4, 5] # [1, 2, 3, 4, 5] (union)
[1, 2] * 3 # [1, 2, 1, 2, 1, 2] (repeat)
[1, 2, 3] * ", " # "1, 2, 3" (join)
arr.reverse # new reversed array
arr.sort # new sorted array
arr.flatten # new flat array
arr.uniq # new array without duplicates
arr.compact # remove nil
Iteration
arr.each { |x| puts x }
arr.each_with_index { |x, i| puts "#{i}: #{x}" }
arr.map { |x| x * 2 }
arr.select { |x| x > 2 }
arr.reject { |x| x.even? }
arr.reduce(0) { |sum, x| sum + x }
arr.find { |x| x > 5 }
arr.count { |x| x > 0 }
arr.any? { |x| x.negative? }
arr.all?(&:positive?)
arr.zip([4, 5, 6]) # [[1,4], [2,5], [3,6]]
arr.partition { |x| x.even? } # [evens, odds]
arr.group_by { |x| x % 3 } # {0=>[3,6], 1=>[1,4], 2=>[2,5]}
arr.chunk_while { |a, b| a + 1 == b } # consecutive groups
arr.each_slice(2) # [[1,2], [3,4], [5]]
arr.each_cons(2) # [[1,2], [2,3], [3,4], [4,5]]
Treated in Enumerable.
Hashes
Key-value collections:
h = { "name" => "Alice", "age" => 30 } # string keys
h = { name: "Alice", age: 30 } # symbol keys (preferred)
h = Hash.new # empty hash
h = Hash.new(0) # default value 0 for missing keys
h = Hash.new { |h, k| h[k] = [] } # default block
The symbol-key shorthand { name: "Alice" } is conventional in modern Ruby; the older { :name => "Alice" } is admitted but verbose. The => (hashrocket) form is required for non-symbol keys.
Access
h = { name: "Alice", age: 30 }
h[:name] # "Alice"
h[:missing] # nil
h.fetch(:name) # "Alice"
h.fetch(:missing) # KeyError
h.fetch(:missing, "default") # "default"
h.fetch(:missing) { |k| "no #{k}" } # "no missing"
h.dig(:user, :profile, :name) # safe nested access; nil if any link missing
h.key?(:name) # true
h.has_key?(:name) # alias
h.include?(:name) # alias
h.value?("Alice") # true
Mutation
h[:email] = "a@b.c" # add or update
h.store(:email, "a@b.c") # alternative
h.delete(:email) # remove
h.merge!(other_hash) # in-place merge
h.update(other_hash) # alias
h.clear # remove all
Iteration
h.each { |k, v| puts "#{k}: #{v}" }
h.each_pair { |k, v| puts "#{k}: #{v}" }
h.each_key { |k| puts k }
h.each_value { |v| puts v }
h.keys # array of keys
h.values # array of values
h.to_a # array of [key, value] pairs
h.map { |k, v| [k, v * 2] }.to_h # transform to new hash
h.transform_values { |v| v * 2 } # only values
h.transform_keys(&:to_s) # only keys
h.select { |k, v| v > 0 }
h.reject { |k, v| v.nil? }
h.partition { |k, v| v.positive? }
Default values
counts = Hash.new(0) # missing keys → 0
words.each { |w| counts[w] += 1 }
# {"a"=>3, "b"=>1, ...}
groups = Hash.new { |h, k| h[k] = [] } # missing keys → empty array
items.each { |i| groups[i.category] << i }
# {fruit: [apple, banana], veg: [...]}
The Hash.new(default) admits “if missing, return this”; Hash.new { |h, k| ... } admits “if missing, run this block AND assign”. The block form is conventional for mutable defaults (otherwise all missing keys share the same array).
Combining hashes
{ a: 1 }.merge({ b: 2 }) # { a: 1, b: 2 }
{ a: 1 }.merge({ a: 2 }) # { a: 2 } (right wins)
{ a: 1 }.merge({ a: 2 }) { |k, v1, v2| v1 + v2 } # { a: 3 } (with merger block)
# Spread (Ruby 2.0+):
defaults = { host: "localhost", port: 8080 }
config = { **defaults, port: 9000 }
# { host: "localhost", port: 9000 }
Symbols
Interned identifiers:
:name # symbol literal
:"with spaces"
:[:complex] # admitted
:name.class # Symbol
:name == :name # true (same object)
:name.object_id == :name.object_id # true (always)
# Convert:
:name.to_s # "name"
"name".to_sym # :name
"name".intern # alias for to_sym
Symbols are immutable and interned — the same literal always refers to the same object. The conventional uses are hash keys, method-name references in metaprogramming, and tagged enumerations.
For dynamic symbol creation from user input, the conventional defence is to use a fixed set of symbols (avoid memory growth from interning user data):
ALLOWED_STATUSES = [:active, :inactive, :banned]
status = ALLOWED_STATUSES.find { |s| s.to_s == user_input }
The to_sym is conventional but should be paired with input validation.
Ranges
Spans of values:
(1..10) # inclusive
(1...10) # exclusive (no 10)
("a".."z") # character range
(1..Float::INFINITY) # endless
(...10) # beginless (Ruby 2.7+)
(10..) # endless
r = (1..5)
r.to_a # [1, 2, 3, 4, 5]
r.size # 5
r.include?(3) # true
r.cover?(2.5) # true (within bounds)
r.sum # 15
r.min # 1
r.max # 5
r.first # 1
r.first(3) # [1, 2, 3]
r.last(2) # [4, 5]
r.step(2).to_a # [1, 3, 5]
Ranges admit substantial flexibility for iteration and slicing. Treated in Types.
Sets
The Set class (from the standard library) admits unique-value collections:
require "set"
s = Set.new # empty set
s = Set.new([1, 2, 3]) # from array
s = Set[1, 2, 3] # alternative
s.add(4) # add element
s << 5 # alias for add
s.delete(2) # remove
s.include?(3) # true
s.size
a = Set[1, 2, 3]
b = Set[2, 3, 4]
a & b # intersection: Set[2, 3]
a | b # union: Set[1, 2, 3, 4]
a - b # difference: Set[1]
a ^ b # symmetric difference: Set[1, 4]
Sets are conventional for membership tests and deduplication; for “convert array to unique array”, arr.uniq is conventionally the simplest form.
Struct
The Struct class admits creating value-typed classes:
Person = Struct.new(:name, :age) do
def greet
"Hello, #{name}"
end
def adult?
age >= 18
end
end
p = Person.new("Alice", 30)
p.name # "Alice"
p.greet # "Hello, Alice"
p.adult? # true
p == Person.new("Alice", 30) # true (value equality)
p.to_a # ["Alice", 30]
p.members # [:name, :age]
p.values # ["Alice", 30]
The Struct provides:
- Constructor — accepts the listed attributes.
- Accessors — readers and writers.
- Equality — value-based.
- Iteration — over the values.
The struct is mutable by default; Struct.new(:name, :age, keyword_init: true) admits keyword-style construction (Ruby 2.5+).
Data (Ruby 3.2+)
The Data.define admits immutable value objects:
Point = Data.define(:x, :y)
p = Point.new(x: 1, y: 2)
p.x # 1
# p.x = 10 # NoMethodError (immutable)
p2 = p.with(x: 10) # produces new Point with x=10
The Data is the conventional contemporary form for value objects — admits substantial conciseness and immutability.
OpenStruct
The OpenStruct (from the standard library) admits dynamic attribute objects:
require "ostruct"
o = OpenStruct.new(name: "Alice", age: 30)
o.name # "Alice"
o.email = "a@b.c" # admits setting any attribute
o.email # "a@b.c"
o.unknown # nil (no NoMethodError)
The mechanism is conventionally avoided in performance-sensitive code (uses method_missing internally); Struct or Hash is conventionally preferred.
Common patterns
Counting
counts = Hash.new(0)
words.each { |w| counts[w] += 1 }
# Or:
counts = words.tally # since Ruby 2.7
The tally is the conventional contemporary form for counting.
Group by
groups = items.group_by(&:category)
# Equivalent:
groups = items.each_with_object({}) do |item, h|
(h[item.category] ||= []) << item
end
Hash from pairs
[[:a, 1], [:b, 2], [:c, 3]].to_h # {a: 1, b: 2, c: 3}
[[:a, 1], [:b, 2]].to_h { |k, v| [k.to_s, v] } # {"a"=>1, "b"=>2}
# From keys and values:
%w[a b c].zip([1, 2, 3]).to_h # {"a"=>1, "b"=>2, "c"=>3}
Hash methods on keys/values
h = { name: "Alice", age: 30, role: "admin" }
# Filter values by predicate on keys:
h.select { |k, _| k != :role }
h.except(:role) # since Ruby 3.0
# Filter keys to specific set:
h.slice(:name, :age)
Safely accessing nested hashes
data = { user: { profile: { name: "Alice" } } }
# Manual:
data[:user] && data[:user][:profile] && data[:user][:profile][:name]
# With dig:
data.dig(:user, :profile, :name) # "Alice"
data.dig(:user, :missing, :name) # nil
# With safe navigation (limited; not for nested hashes):
data[:user]&.dig(:profile, :name)
Frozen literal
STATUSES = [:active, :inactive, :banned].freeze
DEFAULTS = { host: "localhost", port: 8080 }.freeze
The .freeze admits immutable constants.
Set operations on arrays
required = [:name, :email, :password]
provided = params.keys
missing = required - provided
extra = provided - required
common = required & provided
Building a hash from another collection
users = [User.new("Alice"), User.new("Bob"), User.new("Charlie")]
by_name = users.each_with_object({}) do |u, h|
h[u.name] = u
end
# Or:
by_name = users.to_h { |u| [u.name, u] } # since Ruby 2.6
Lazy collections
result = (1..Float::INFINITY).lazy
.map { |n| n * n }
.select { |n| n.even? }
.first(5)
# [4, 16, 36, 64, 100]
The lazy admits substantial efficiency for substantial or infinite collections.
Hash with default block
graph = Hash.new { |h, k| h[k] = [] }
graph[:a] << :b # graph = {a: [:b]}
graph[:a] << :c # graph = {a: [:b, :c]}
graph[:b] << :d # graph = {a: [...], b: [:d]}
Each access to a missing key produces and stores a fresh empty array.
Tally
[1, 1, 2, 2, 2, 3].tally # {1=>2, 2=>3, 3=>1}
words.tally # {"hello"=>3, "world"=>2}
The tally returns a hash of counts.
Frozen string literals
# frozen_string_literal: true
"hello" # frozen
str = "hello".dup # mutable copy
str << " world" # OK
The magic comment admits substantial efficiency through immutable string literals.
A note on the conventional discipline
The contemporary Ruby data-structure advice:
- Use
Arrayfor ordered sequences. - Use
Hashwith symbol keys for configuration and structured data. - Use
Symbolfor identifier-like values. - Use
Rangefor numeric and character spans. - Use
Setfor unique-value collections (orarr.uniqfor arrays). - Use
Structfor mutable value classes. - Use
Data.define(Ruby 3.2+) for immutable value classes. - Use
Hash.new { ... }for collections with default values. - Use
digfor safe nested access. - Use
tally(Ruby 2.7+) for counting. - Use
each_with_objectfor accumulator patterns. - Use
to_hwith a block for conversions. - Use
lazyfor substantial or infinite enumerables. - Use
frozen_string_literal: true— substantial efficiency.
The combination — Array/Hash/Symbol/Range/Set/Struct/Data as the foundational types, the substantial method surfaces (especially Enumerable-derived), the literal syntax, the default-value mechanisms — is the substance of Ruby’s data-structure surface. The discipline produces concise, expressive code with substantial flexibility for substantial collection manipulation.