Polyglot
Languages Ruby data structures
Ruby § data-structures

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 Array for ordered sequences.
  • Use Hash with symbol keys for configuration and structured data.
  • Use Symbol for identifier-like values.
  • Use Range for numeric and character spans.
  • Use Set for unique-value collections (or arr.uniq for arrays).
  • Use Struct for mutable value classes.
  • Use Data.define (Ruby 3.2+) for immutable value classes.
  • Use Hash.new { ... } for collections with default values.
  • Use dig for safe nested access.
  • Use tally (Ruby 2.7+) for counting.
  • Use each_with_object for accumulator patterns.
  • Use to_h with a block for conversions.
  • Use lazy for 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.