CrackedRuby logo

CrackedRuby

Hash Default Values

Managing default values for Hash instances when accessing non-existent keys through various Ruby mechanisms.

Core Built-in Classes Hash Class
2.5.6

Overview

Hash default values determine what Ruby returns when accessing a key that does not exist in a hash. Ruby provides multiple approaches for handling missing keys: returning nil, returning a specific default value, or executing a block to compute the default value.

The Hash class supports default values through three primary mechanisms: the default value accessed via Hash#default, the default block accessed via Hash#default_proc, and the Hash#fetch method with fallback values. These mechanisms control hash behavior when Hash#[] encounters a missing key.

hash = Hash.new("missing")
hash[:nonexistent]
# => "missing"

hash_with_block = Hash.new { |h, k| h[k] = [] }
hash_with_block[:items] << "first"
hash_with_block[:items]
# => ["first"]

Ruby evaluates default values differently depending on the mechanism used. Static default values return the same object for all missing keys, while default blocks execute each time a missing key is accessed, allowing for computed or unique default values per key.

Basic Usage

Creating a hash with a static default value uses Hash.new with a single argument. This default value returns for any missing key access through Hash#[].

scores = Hash.new(0)
scores[:alice] = 85
scores[:bob]
# => 0

scores[:charlie] += 10
scores[:charlie]
# => 10

The Hash#default= method changes the default value after hash creation. Setting a new default value affects all subsequent missing key accesses but does not modify existing key-value pairs.

config = {}
config.default = "not configured"
config[:timeout]
# => "not configured"

config[:retries] = 3
config.default = "unknown"
config[:timeout]
# => "unknown"
config[:retries]
# => 3

Default blocks provide computed default values using Hash.new with a block. The block receives the hash and the missing key as parameters, allowing conditional logic for default value generation.

counters = Hash.new { |hash, key| hash[key] = 0 }
counters[:page_views] += 1
counters[:clicks] += 5
counters
# => {:page_views=>1, :clicks=>5}

grouped_items = Hash.new { |hash, key| hash[key] = [] }
grouped_items[:fruits] << "apple"
grouped_items[:vegetables] << "carrot"
grouped_items
# => {:fruits=>["apple"], :vegetables=>["carrot"]}

The Hash#fetch method provides another approach for handling missing keys with fallback values or blocks. Unlike default values, fetch does not modify the hash's default behavior for Hash#[] operations.

settings = { timeout: 30, retries: 3 }
settings.fetch(:timeout)
# => 30

settings.fetch(:max_connections, 100)
# => 100

settings.fetch(:database_url) { ENV['DATABASE_URL'] || 'localhost' }
# => "localhost"

Advanced Usage

Nested hash structures benefit from recursive default block patterns that create multi-level defaults automatically. This pattern eliminates the need for explicit hash initialization at each level.

nested_hash = Hash.new { |h, k| h[k] = Hash.new { |h2, k2| h2[k2] = [] } }
nested_hash[:users][:active] << "alice"
nested_hash[:users][:inactive] << "bob"
nested_hash[:groups][:admin] << "charlie"
nested_hash
# => {:users=>{:active=>["alice"], :inactive=>["bob"]}, :groups=>{:admin=>["charlie"]}}

# Alternative with deeper nesting
deep_nested = Hash.new do |h, k|
  h[k] = Hash.new do |h2, k2|
    h2[k2] = Hash.new { |h3, k3| h3[k3] = 0 }
  end
end
deep_nested[:stats][:daily][:views] += 100
deep_nested[:stats][:monthly][:clicks] += 50

Proc objects stored as default values create reusable default behavior that can be shared across multiple hashes or modified dynamically.

timestamp_default = proc { |hash, key| hash[key] = Time.now }
log_entries = Hash.new(&timestamp_default)
error_tracking = Hash.new(&timestamp_default)

log_entries[:first_access]
# => 2024-01-15 10:30:45 UTC
error_tracking[:database_error]
# => 2024-01-15 10:30:46 UTC

# Modifying default behavior
factory_proc = proc do |hash, key|
  case key.to_s
  when /count/
    hash[key] = 0
  when /list/
    hash[key] = []
  when /time/
    hash[key] = Time.now
  else
    hash[key] = nil
  end
end

smart_hash = Hash.new(&factory_proc)
smart_hash[:view_count] += 10
smart_hash[:item_list] << "first"
smart_hash[:created_time]
# => 2024-01-15 10:30:47 UTC

Method chaining with default blocks enables sophisticated initialization patterns for complex data structures. These patterns reduce boilerplate code for hash manipulation.

class StatCollector
  def initialize
    @data = Hash.new { |h, k| h[k] = Hash.new(0) }
  end
  
  def increment(category, metric, amount = 1)
    @data[category][metric] += amount
    self
  end
  
  def get_stats
    @data.transform_values(&:to_h)
  end
end

collector = StatCollector.new
collector.increment(:web, :page_views, 100)
         .increment(:web, :unique_visitors, 25)
         .increment(:api, :requests, 500)
         .increment(:api, :errors, 5)
collector.get_stats
# => {:web=>{:page_views=>100, :unique_visitors=>25}, :api=>{:requests=>500, :errors=>5}}

Custom default value logic can implement caching, lazy loading, or computed values based on key characteristics or external state.

class ConfigurationHash < Hash
  def initialize(config_source)
    @config_source = config_source
    super() do |hash, key|
      value = @config_source.fetch_setting(key.to_s)
      hash[key] = value if value
      value
    end
  end
end

class MockConfigSource
  def fetch_setting(key)
    case key
    when "timeout" then 30
    when "retries" then 3
    when "debug" then false
    end
  end
end

config = ConfigurationHash.new(MockConfigSource.new)
config[:timeout]
# => 30
config.keys
# => [:timeout]

Common Pitfalls

Mutable default values create shared references across all missing key accesses, leading to unexpected modifications affecting multiple keys. This occurs when using objects like arrays, hashes, or custom objects as static defaults.

# PROBLEMATIC: Shared mutable default
groups = Hash.new([])
groups[:admins] << "alice"
groups[:users] << "bob"
groups[:guests]
# => ["alice", "bob"]

# All keys share the same array object
groups[:admins].object_id == groups[:users].object_id
# => true

# CORRECT: Use block for unique defaults
groups = Hash.new { |h, k| h[k] = [] }
groups[:admins] << "alice"
groups[:users] << "bob"
groups[:guests]
# => []

Default blocks that modify the hash during execution can create infinite recursion when the block access triggers itself. This pattern occurs when the default block reads from the same hash with missing keys.

# PROBLEMATIC: Recursive default block
recursive_hash = Hash.new do |h, k|
  h[k] = h[:default_value] + "_" + k.to_s
end
recursive_hash[:test]
# => SystemStackError: stack level too deep

# CORRECT: Avoid self-referential access
safe_hash = Hash.new do |h, k|
  base_value = "default"
  h[k] = base_value + "_" + k.to_s
end
safe_hash[:test]
# => "default_test"

Modifying hash structure during default block execution can cause unexpected behavior, particularly when iterating over the hash or when multiple threads access the hash concurrently.

modification_hash = Hash.new do |h, k|
  # Modifying during iteration can cause issues
  h.each { |existing_k, v| h.delete(existing_k) if existing_k.to_s.start_with?("temp_") }
  h[k] = "value_#{k}"
end

modification_hash[:temp_1] = "temporary"
modification_hash[:permanent] = "keep"
modification_hash[:temp_2]
# => "value_temp_2"
# Side effect: temp_1 was deleted
modification_hash.keys
# => [:permanent, :temp_2]

Default value persistence differs between Hash#[] access and Hash#fetch usage. Default blocks only execute for Hash#[] operations, while fetch with blocks executes the fetch block instead of the hash's default block.

hash_with_default = Hash.new { |h, k| h[k] = "from_default_block" }
hash_with_default[:missing_key]
# => "from_default_block"
hash_with_default.has_key?(:missing_key)
# => true

hash_with_default.fetch(:another_missing) { "from_fetch_block" }
# => "from_fetch_block"
hash_with_default.has_key?(:another_missing)
# => false

Serialization libraries often ignore default values and default blocks when converting hashes to JSON, YAML, or other formats. This can cause data loss when deserializing hashes that depend on default behavior.

require 'json'

original = Hash.new { |h, k| h[k] = [] }
original[:items] << "test"

# JSON serialization loses default block
json_string = original.to_json
# => "{\"items\":[\"test\"]}"

deserialized = JSON.parse(json_string)
deserialized[:missing]
# => nil (not an empty array)

# Manual restoration required
restored = Hash.new { |h, k| h[k] = [] }
restored.merge!(deserialized)
restored[:missing]
# => []

Reference

Core Methods

Method Parameters Returns Description
Hash.new None Hash Creates hash with nil default
Hash.new(default) default (Object) Hash Creates hash with static default value
Hash.new(&block) Block `{ h,k ... }`
#default None Object Returns current default value
#default= value (Object) Object Sets default value
#default_proc None Proc or nil Returns default block
#default_proc= proc (Proc) or nil Proc or nil Sets default block
#fetch(key) key (Object) Object Returns value or raises KeyError
#fetch(key, default) key (Object), default (Object) Object Returns value or default
#fetch(key, &block) key (Object), Block Object Returns value or block result

Default Block Signatures

Pattern Block Parameters Usage
`{ hash, key ... }`
`{ h, k h[k] = value }`
`{ h, k compute(k) }`

Behavior Matrix

Access Method Default Value Default Block No Default
hash[:key] Returns default value Executes block Returns nil
hash.fetch(:key) Raises KeyError Raises KeyError Raises KeyError
hash.fetch(:key, fallback) Returns fallback Returns fallback Returns fallback
hash.fetch(:key) { block } Executes fetch block Executes fetch block Executes fetch block

Common Default Patterns

# Static defaults
Hash.new(0)           # Numeric counters
Hash.new("")          # String accumulation (dangerous with mutation)
Hash.new(false)       # Boolean flags

# Block defaults for unique objects
Hash.new { |h, k| h[k] = [] }           # Arrays per key
Hash.new { |h, k| h[k] = {} }           # Nested hashes
Hash.new { |h, k| h[k] = Set.new }      # Sets per key
Hash.new { |h, k| h[k] = 0 }            # Counters with storage

# Computed defaults
Hash.new { |h, k| h[k] = Time.now }     # Timestamps
Hash.new { |h, k| h[k] = k.to_s.upcase } # Key transformations
Hash.new { |h, k| h[k] = yield_value(k) } # External computation

Error Conditions

Error Condition Example
KeyError fetch without default or block on missing key {}.fetch(:missing)
ArgumentError Invalid arguments to Hash.new Hash.new(1, 2, 3)
SystemStackError Recursive default block Block calls hash[missing_key]
FrozenError Modifying frozen hash in default block Default block tries to store on frozen hash