CrackedRuby logo

CrackedRuby

Hash Access and Assignment

Hash access and assignment operations in Ruby including retrieval, modification, default value handling, and advanced access patterns.

Core Built-in Classes Hash Class
2.5.2

Overview

Ruby implements hash access and assignment through several methods that handle key-value pair retrieval and storage. The Hash class provides multiple approaches for accessing values, each with distinct behavior when keys are missing or nil values are stored.

The primary access methods include [] for basic retrieval, fetch for controlled access with error handling, and dig for nested structure traversal. Assignment operations use []= for direct assignment, store as an alias, and various merge operations for batch updates.

user = { name: "Alice", age: 30, preferences: { theme: "dark" } }

# Basic access patterns
user[:name]              # => "Alice" 
user[:email]             # => nil
user.dig(:preferences, :theme)  # => "dark"

Hash access behavior depends on whether keys exist, the configured default value or block, and the specific method used. Missing keys return the default value with [] but raise KeyError with fetch unless a fallback is provided.

scores = Hash.new(0)  # Default value of 0
scores[:math]         # => 0
scores.fetch(:science)  # KeyError: key not found: :science
scores.fetch(:science, 85)  # => 85

Assignment operations modify the hash structure and return the assigned value. Ruby handles symbol-string key distinctions strictly, treating :name and "name" as separate keys unless the hash uses indifferent access patterns.

Basic Usage

The [] operator provides the most common access pattern, returning the value for existing keys or the hash's default value for missing keys. Default values are set during hash creation or through the default= method.

inventory = { apples: 15, bananas: 8, oranges: 12 }

# Standard access
inventory[:apples]        # => 15
inventory[:grapes]        # => nil

# With default values
counts = Hash.new(0)
counts[:visitors] += 1    # Creates key with default, then increments
counts[:visitors]         # => 1

The []= operator assigns values to keys, creating new entries or overwriting existing ones. Assignment always returns the assigned value, not the previous value or the hash itself.

config = {}
result = (config[:timeout] = 30)  # => 30
config[:retries] = 3
config  # => { timeout: 30, retries: 3 }

# Chained assignments
config[:database] = config[:cache] = "redis"
config  # => { timeout: 30, retries: 3, database: "redis", cache: "redis" }

The fetch method provides controlled access with explicit handling of missing keys. Without arguments, fetch raises KeyError for missing keys. With a second argument, it returns the fallback value. With a block, it yields the missing key and returns the block result.

settings = { host: "localhost", port: 3000 }

settings.fetch(:host)           # => "localhost"
settings.fetch(:password)       # KeyError
settings.fetch(:password, "")   # => ""
settings.fetch(:timeout) { 60 } # => 60

# Block receives the missing key
settings.fetch(:missing) { |key| "default_#{key}" }  # => "default_missing"

The store method functions identically to []= but provides a method-style interface that accepts chaining or functional composition patterns.

cache = {}
cache.store(:session_id, "abc123").store(:user_id, 42)  # Error: store returns value
# Correct usage:
cache.store(:session_id, "abc123")
cache.store(:user_id, 42)
cache  # => { session_id: "abc123", user_id: 42 }

Advanced Usage

The dig method traverses nested hash structures safely, returning nil if any intermediate key is missing rather than raising an error. This method accepts multiple keys as arguments, drilling down through each level.

data = {
  users: {
    "123" => { profile: { email: "alice@example.com", preferences: { theme: "dark" } } }
  },
  settings: { debug: true }
}

data.dig(:users, "123", :profile, :email)              # => "alice@example.com"
data.dig(:users, "123", :profile, :preferences, :theme) # => "dark"
data.dig(:users, "456", :profile, :email)              # => nil (user doesn't exist)
data.dig(:users, "123", :invalid, :email)              # => nil (stops at :invalid)

Hash default values can be set as static values or dynamic blocks. Default blocks receive the hash instance and the missing key, allowing context-aware default generation.

# Static default
word_counts = Hash.new(0)
word_counts[:hello] += 1  # => 1

# Dynamic default with block
auto_arrays = Hash.new { |hash, key| hash[key] = [] }
auto_arrays[:fruits] << "apple"
auto_arrays[:fruits] << "banana"
auto_arrays  # => { fruits: ["apple", "banana"] }

# Context-aware defaults
user_sessions = Hash.new do |hash, user_id|
  hash[user_id] = {
    created_at: Time.now,
    requests: 0,
    last_activity: Time.now
  }
end

session = user_sessions[123]  # Creates session automatically
session[:requests] += 1

The values_at method retrieves multiple values simultaneously, returning an array of values in the same order as the requested keys. Missing keys contribute nil values to the result array.

person = { name: "Bob", age: 25, city: "Portland", country: "USA" }

person.values_at(:name, :age)           # => ["Bob", 25]
person.values_at(:name, :missing, :city) # => ["Bob", nil, "Portland"]

# Useful for extracting specific fields
required_fields = person.values_at(:name, :email, :phone)
has_required = required_fields.all?  # => false (email and phone are nil)

The fetch_values method combines values_at functionality with fetch behavior, raising KeyError for missing keys unless blocks or defaults are provided.

config = { host: "db.example.com", port: 5432, ssl: true }

config.fetch_values(:host, :port)  # => ["db.example.com", 5432]
config.fetch_values(:host, :password)  # KeyError: key not found: :password

# With block for missing keys
config.fetch_values(:host, :password) { |key| "default_#{key}" }
# => ["db.example.com", "default_password"]

Multiple assignment operations can be performed atomically using merge! or update, which accept hashes or yield key-value pairs to blocks for conditional updates.

defaults = { timeout: 30, retries: 3, debug: false }
user_config = { timeout: 60, verbose: true }

# Merge overwrites existing keys
final_config = defaults.merge!(user_config)
# => { timeout: 60, retries: 3, debug: false, verbose: true }

# Block-based merging for custom logic
stats = { visits: 10, errors: 2 }
daily_stats = { visits: 5, errors: 1, warnings: 3 }

stats.merge!(daily_stats) { |key, old_val, new_val| old_val + new_val }
# => { visits: 15, errors: 3, warnings: 3 }

Error Handling & Debugging

KeyError represents the primary exception type for hash access operations, raised by fetch and fetch_values when keys are missing and no fallback is provided. The exception message includes the missing key for debugging.

user_data = { id: 1, name: "Alice" }

begin
  email = user_data.fetch(:email)
rescue KeyError => e
  puts "Missing required field: #{e.key}"  # => "Missing required field: email"
  puts "Available keys: #{user_data.keys}" # => "Available keys: [:id, :name]"
  email = "unknown@example.com"
end

Default value blocks can raise exceptions or perform logging when missing keys are accessed, providing visibility into unexpected access patterns.

monitored_cache = Hash.new do |hash, key|
  warn "Cache miss for key: #{key.inspect}"
  warn "Available keys: #{hash.keys.inspect}" unless hash.empty?
  
  # Could raise exception for required keys
  raise KeyError, "Required cache key missing: #{key}" if key.to_s.start_with?('required_')
  
  nil  # Return nil for optional keys
end

monitored_cache[:optional_data]    # Logs warning, returns nil
monitored_cache[:required_config]  # Raises KeyError

The key? and has_key? methods check for key existence without triggering default value generation, preventing unwanted side effects during validation.

lazy_hash = Hash.new { |h, k| h[k] = expensive_computation(k) }

# Wrong: triggers expensive computation
if lazy_hash[:data]  # Computes and stores default value
  process_data(lazy_hash[:data])
end

# Correct: checks existence without side effects
if lazy_hash.key?(:data)  # No computation triggered
  process_data(lazy_hash[:data])  # Now triggers computation
end

# Alternative validation patterns
def safe_access(hash, key, &block)
  if hash.key?(key)
    yield hash[key]
  else
    puts "Key #{key} not found in hash"
    nil
  end
end

Nested hash access failures can be debugged by checking each level independently or using custom dig implementations that provide detailed failure information.

def debug_dig(hash, *keys)
  current = hash
  path = []
  
  keys.each do |key|
    path << key
    unless current.respond_to?(:[]) && current.key?(key)
      raise KeyError, "Key #{key.inspect} not found at path #{path.inspect}. Available: #{current.keys rescue 'N/A'}"
    end
    current = current[key]
  end
  
  current
end

data = { users: { "1" => { name: "Alice" } } }
debug_dig(data, :users, "2", :name)  # Clear error showing where traversal failed

Hash modification during iteration can lead to unexpected behavior or exceptions. Ruby detects some concurrent modifications but not all edge cases.

scores = { alice: 85, bob: 92, charlie: 78 }

# Dangerous: modifying during iteration
scores.each do |name, score|
  scores.delete(name) if score < 80  # May skip elements or raise exception
end

# Safe: collect keys first
low_scorers = scores.select { |name, score| score < 80 }.keys
low_scorers.each { |name| scores.delete(name) }

Common Pitfalls

Default values are shared across all missing keys unless default blocks create new objects for each access. This sharing can lead to unintended mutations affecting multiple keys.

# Dangerous: shared default value
groups = Hash.new([])
groups[:admins] << "alice"
groups[:users] << "bob"
groups[:admins]  # => ["alice", "bob"] - unexpected!

# Safe: new array for each key
groups = Hash.new { |h, k| h[k] = [] }
groups[:admins] << "alice"
groups[:users] << "bob"
groups[:admins]  # => ["alice"]
groups[:users]   # => ["bob"]

Symbol and string keys are distinct in hashes, leading to confusion when data comes from mixed sources like JSON parsing or form parameters.

config = { timeout: 30, "retries" => 3 }

config[:timeout]    # => 30
config["timeout"]   # => nil
config[:retries]    # => nil  
config["retries"]   # => 3

# Solution: consistent key types or indifferent access
config.transform_keys(&:to_s)  # All string keys
config.transform_keys(&:to_sym) # All symbol keys

The difference between nil values and missing keys is lost with the [] operator when default values are nil, but preserved with key? and fetch.

data = { active: true, disabled: false, removed: nil }

data[:active]    # => true
data[:disabled]  # => false  
data[:removed]   # => nil
data[:missing]   # => nil - same as explicit nil!

# Distinguish missing vs nil
data.key?(:removed)  # => true
data.key?(:missing)  # => false

data.fetch(:removed)  # => nil
data.fetch(:missing)  # KeyError

Hash assignment with ||= behaves unexpectedly when values can be falsy but valid, since ||= assigns when the current value is falsy, not just when the key is missing.

settings = { debug: false, verbose: nil, timeout: 0 }

# Wrong: overwrites valid falsy values
settings[:debug] ||= true     # Changes false to true!
settings[:timeout] ||= 30     # Changes 0 to 30!

# Correct: check key existence
settings[:debug] = true unless settings.key?(:debug)
settings[:timeout] = 30 unless settings.key?(:timeout)

# Alternative: use fetch with fallback
settings[:debug] = settings.fetch(:debug, true)
settings[:timeout] = settings.fetch(:timeout, 30)

Deep copying issues arise when hash values contain mutable objects, since assignment and merging operations copy references, not objects.

template = { 
  config: { retries: 3, timeout: 30 },
  users: ["admin"]
}

# Shallow copy shares nested objects
instance1 = template.dup
instance2 = template.dup

instance1[:users] << "alice"
instance2[:users]  # => ["admin", "alice"] - unexpected!

instance1[:config][:timeout] = 60
instance2[:config][:timeout]  # => 60 - also unexpected!

# Deep copy solution
require 'json'
def deep_copy(obj)
  JSON.parse(JSON.dump(obj), symbolize_names: true)
end

instance1 = deep_copy(template)
instance2 = deep_copy(template)
# Now modifications are independent

Hash comparison with == checks both keys and values but may not behave as expected when values are arrays or hashes that have been modified in place.

original = { items: [1, 2, 3] }
modified = { items: [1, 2, 3] }

original == modified  # => true

original[:items] << 4
original == modified  # => false

# But if both reference the same array:
shared_array = [1, 2, 3]
hash1 = { items: shared_array }
hash2 = { items: shared_array }

hash1 == hash2  # => true
hash1[:items] << 4
hash1 == hash2  # => true (still equal because same array object)

Reference

Core Access Methods

Method Parameters Returns Description
#[](key) key (Object) Object Returns value for key or default value
#[]=(key, value) key (Object), value (Object) Object Assigns value to key, returns value
#fetch(key, *args, &block) key (Object), optional default, optional block Object Returns value or raises KeyError
#store(key, value) key (Object), value (Object) Object Alias for []=
#dig(*keys) *keys (Array of Objects) Object or nil Traverses nested structure safely

Batch Access Methods

Method Parameters Returns Description
#values_at(*keys) *keys (Array of Objects) Array Returns array of values for given keys
#fetch_values(*keys, &block) *keys (Array of Objects), optional block Array Like values_at but raises KeyError for missing keys
#slice(*keys) *keys (Array of Objects) Hash Returns new hash with only specified keys

Existence Check Methods

Method Parameters Returns Description
#key?(key) key (Object) Boolean Returns true if key exists
#has_key?(key) key (Object) Boolean Alias for key?
#include?(key) key (Object) Boolean Alias for key?
#member?(key) key (Object) Boolean Alias for key?

Default Value Methods

Method Parameters Returns Description
#default None Object Returns default value
#default=(obj) obj (Object) Object Sets default value
#default_proc None Proc or nil Returns default proc
#default_proc=(proc) proc (Proc) Proc Sets default proc

Modification Methods

Method Parameters Returns Description
#merge(other, &block) other (Hash), optional block Hash Returns new merged hash
#merge!(other, &block) other (Hash), optional block Hash Modifies hash in place
#update(other, &block) other (Hash), optional block Hash Alias for merge!
#replace(other) other (Hash) Hash Replaces contents with other hash

Common Hash Creation Patterns

# Empty hash with default value
Hash.new(0)
Hash.new { |h, k| h[k] = [] }

# From arrays
Hash[["a", 1, "b", 2]]                    # => {"a"=>1, "b"=>2}
Hash[["a", 1], ["b", 2]]                  # => {"a"=>1, "b"=>2}

# From other enumerables  
["a", "b"].zip([1, 2]).to_h               # => {"a"=>1, "b"=>2}
{a: 1, b: 2}.invert                       # => {1=>:a, 2=>:b}

Exception Types

Exception Raised By Description
KeyError fetch, fetch_values Missing key without default
TypeError Various Invalid key type for specialized hashes
FrozenError Modification methods Attempt to modify frozen hash

Performance Characteristics

Operation Time Complexity Notes
#[], #[]= O(1) average O(n) worst case with hash collisions
#fetch O(1) average Same as [] but with additional checks
#dig O(k) Where k is the number of keys
#merge O(n + m) Where n, m are sizes of both hashes
#key? O(1) average Faster than accessing value

Thread Safety Notes

Hash access and assignment operations are not atomic. Concurrent access requires external synchronization for writes, though multiple concurrent reads are safe if no writes occur.

require 'thread'
mutex = Mutex.new
shared_hash = {}

# Safe concurrent access pattern
mutex.synchronize do
  shared_hash[:counter] = (shared_hash[:counter] || 0) + 1
end