CrackedRuby logo

CrackedRuby

String Freezing and Mutability

Guide to Ruby's string freezing mechanism, mutability control, and performance optimization through immutable string objects.

Core Built-in Classes String Class
2.2.11

Overview

Ruby strings are mutable objects by default, meaning their content can be modified after creation. String freezing converts mutable strings into immutable objects that raise FrozenError exceptions when modification is attempted. Ruby provides the freeze method to make strings immutable and the frozen? method to check immutability status.

Frozen strings offer significant memory optimization through string deduplication. When multiple frozen strings contain identical content, Ruby stores only one copy in memory. The String class automatically freezes string literals when the frozen_string_literal: true pragma is active, making this optimization transparent to developers.

Ruby implements string freezing at the object level rather than the class level. Individual string instances can be frozen while others remain mutable. Once frozen, a string cannot be unfrozen, making immutability permanent for that object.

str = "example"
str.frozen?        # => false
str.freeze
str.frozen?        # => true
str << " text"     # => FrozenError

String freezing interacts with Ruby's automatic string deduplication system. Identical frozen strings reference the same memory location, reducing memory usage in applications with repeated string values.

a = "test".freeze
b = "test".freeze
a.object_id == b.object_id  # => true (same object)

Basic Usage

The freeze method converts any string into an immutable object. Frozen strings cannot be modified through destructive methods like <<, []=, sub!, gsub!, or any method ending with ! that modifies the receiver.

message = "Hello"
message.freeze

# These operations raise FrozenError
message[0] = "h"           # => FrozenError
message << " World"        # => FrozenError
message.upcase!            # => FrozenError
message.strip!             # => FrozenError

Non-destructive methods work normally on frozen strings, returning new string objects rather than modifying the original:

frozen_str = "Hello World".freeze
upper_str = frozen_str.upcase     # => "HELLO WORLD"
sub_str = frozen_str.sub("Hello", "Hi")  # => "Hi World"

frozen_str.frozen?                # => true
upper_str.frozen?                 # => false
sub_str.frozen?                   # => false

The frozen_string_literal pragma makes all string literals frozen by default within a file. This pragma affects only string literals, not strings created through interpolation or method calls:

# frozen_string_literal: true

literal = "frozen by default"
literal.frozen?                   # => true

interpolated = "not #{literal}"   # => false
constructed = String.new("text")  # => false

String interpolation creates mutable strings regardless of the pragma setting. Each interpolation produces a new mutable string object:

# frozen_string_literal: true

name = "Alice"
greeting = "Hello, #{name}!"
greeting.frozen?                  # => false
greeting.freeze
greeting.frozen?                  # => true

Advanced Usage

String freezing integrates with metaprogramming patterns through conditional freezing based on runtime conditions. Methods can accept mutable strings and return frozen versions for caching or optimization:

class StringProcessor
  def self.process_and_cache(input)
    result = input.dup.strip.downcase
    @cache ||= {}
    @cache[result] ||= result.freeze
  end
end

processed = StringProcessor.process_and_cache("  INPUT  ")
processed.frozen?  # => true

Module and class constant strings should typically be frozen to prevent accidental modification. Ruby automatically freezes some constant strings, but explicit freezing ensures consistency:

module Constants
  DEFAULT_MESSAGE = "System ready".freeze
  ERROR_MESSAGES = {
    not_found: "Record not found".freeze,
    invalid: "Invalid input".freeze
  }.freeze
  
  def self.get_message(key)
    ERROR_MESSAGES[key] || DEFAULT_MESSAGE
  end
end

Method chaining with frozen strings requires careful handling since destructive methods raise exceptions. Builder patterns can use intermediate mutable strings and freeze the final result:

class TextBuilder
  def initialize(text = "")
    @parts = [text.dup]
  end
  
  def append(text)
    @parts << text.to_s
    self
  end
  
  def prepend(text)
    @parts.unshift(text.to_s)
    self
  end
  
  def build(frozen: true)
    result = @parts.join
    frozen ? result.freeze : result
  end
end

builder = TextBuilder.new("Start")
              .append(" middle")
              .prepend("Before ")
              .build(frozen: true)

Dynamic method creation can leverage frozen strings for method names and reduce memory allocation when defining multiple similar methods:

class AttributeAccessor
  GETTER_TEMPLATE = "def %s; @%s; end".freeze
  SETTER_TEMPLATE = "def %s=(val); @%s = val; end".freeze
  
  def self.attr_accessor(*names)
    names.each do |name|
      name_str = name.to_s.freeze
      class_eval(GETTER_TEMPLATE % [name_str, name_str])
      class_eval(SETTER_TEMPLATE % [name_str, name_str])
    end
  end
end

Performance & Memory

String freezing provides substantial memory savings through automatic deduplication. Ruby maintains an internal table of frozen strings and reuses identical content across multiple string objects:

# Without freezing - separate objects
strings = Array.new(1000) { "duplicate content" }
strings.map(&:object_id).uniq.size  # => 1000

# With freezing - shared objects  
frozen_strings = Array.new(1000) { "duplicate content".freeze }
frozen_strings.map(&:object_id).uniq.size  # => 1

Memory usage decreases significantly when applications contain repeated string values. Configuration strings, error messages, and constant values benefit most from freezing:

# High memory usage
def generate_responses(count)
  Array.new(count) { "Success" }
end

# Optimized memory usage
SUCCESS_MESSAGE = "Success".freeze
def generate_responses(count)
  Array.new(count) { SUCCESS_MESSAGE }
end

String concatenation performance differs between mutable and frozen strings. Frozen strings cannot use destructive concatenation, requiring alternative approaches for building large strings:

# Inefficient with frozen strings
def build_slow(parts)
  result = "".freeze
  parts.each { |part| result = result + part }  # Creates many objects
  result
end

# Efficient approach
def build_fast(parts)
  parts.join.freeze  # Single concatenation operation
end

The frozen_string_literal pragma improves application startup time by reducing string object allocation. Applications with many string literals see measurable improvements:

# frozen_string_literal: true

# These share memory automatically
ERROR_404 = "Page not found"
DEFAULT_404 = "Page not found" 
FALLBACK_404 = "Page not found"

ERROR_404.object_id == DEFAULT_404.object_id  # => true

String freezing interacts with garbage collection by making string objects eligible for sharing. Long-running applications accumulate fewer string objects when using frozen string literals consistently.

Common Pitfalls

The most frequent error occurs when attempting to modify frozen strings through destructive methods. This typically happens when code expects mutable strings but receives frozen ones:

def process_input(text)
  text.strip!      # Assumes mutable string
  text.downcase!   # May raise FrozenError
  text
end

# Safe approach
def process_input(text)
  text.strip.downcase  # Always returns new string
end

String interpolation creates mutable strings even with frozen string literals, causing confusion when developers expect all strings to be frozen:

# frozen_string_literal: true

base = "template"     # frozen
name = "value"        # frozen
result = "#{base}: #{name}"  # mutable!

result.frozen?        # => false
result << " more"     # Works, no error

Frozen strings cannot be used as mutable buffers for string building operations. Code that accumulates content in a single string object fails with frozen strings:

# Problematic pattern
def collect_data(items)
  buffer = ""
  items.each { |item| buffer << item.to_s }
  buffer
end

# With frozen_string_literal: true, buffer starts frozen
# First << operation raises FrozenError

Method parameters that modify strings in-place cause issues when frozen strings are passed. Libraries expecting mutable strings may fail unexpectedly:

def normalize_text!(text)
  text.gsub!(/\s+/, " ")    # Modifies in place
  text.strip!               # May raise FrozenError
end

# Caller passes frozen string
input = "messy   text".freeze
normalize_text!(input)      # => FrozenError

String assignment and freezing timing creates subtle bugs when objects are frozen after references are created:

original = "mutable"
reference = original
original.freeze

# reference points to now-frozen string
reference.frozen?           # => true
reference << " text"        # => FrozenError

Array and hash values containing frozen strings require careful handling during bulk operations:

data = ["item1".freeze, "item2".freeze, "item3".freeze]

# This fails if any string is frozen
data.each { |item| item.upcase! }  # => FrozenError

# Safe alternative
data.map!(&:upcase)  # Creates new strings

Reference

Core Methods

Method Parameters Returns Description
#freeze None self Makes string immutable, returns self
#frozen? None Boolean Returns true if string is frozen
#+@ None String Returns frozen copy of string
#-@ None String Returns unfrozen copy of string

Destructive Methods That Raise FrozenError

Method Description
#[]= Assigns character or substring at index
#<< Appends string or character to end
#concat Appends string content
#sub! Substitutes first pattern match in place
#gsub! Substitutes all pattern matches in place
#strip! Removes leading/trailing whitespace in place
#chomp! Removes trailing record separator in place
#chop! Removes last character in place
#upcase! Converts to uppercase in place
#downcase! Converts to lowercase in place
#swapcase! Swaps character case in place
#capitalize! Capitalizes first character in place
#reverse! Reverses character order in place
#squeeze! Removes duplicate characters in place
#tr! Translates characters in place
#delete! Deletes specified characters in place
#insert Inserts string at specified position
#replace Replaces entire string content
#clear Removes all characters

Non-Destructive Alternatives

Destructive Non-Destructive Returns
#strip! #strip New string without whitespace
#sub! #sub New string with substitution
#gsub! #gsub New string with all substitutions
#upcase! #upcase New uppercase string
#downcase! #downcase New lowercase string
#reverse! #reverse New reversed string
#squeeze! #squeeze New string without duplicates

String Literal Pragma

# frozen_string_literal: true

Effects:

  • All string literals in file become frozen
  • Interpolated strings remain mutable
  • String.new() creates mutable strings
  • Affects only current file scope

Memory Optimization Patterns

Pattern Memory Usage Performance
Repeated mutable strings High (separate objects) Fast creation
Repeated frozen strings Low (shared objects) Slower first creation
String interpolation Medium (new objects) Fast
String concatenation (+) High (temporary objects) Slow for many operations
Array#join Low (single operation) Fast for building

Error Handling

begin
  frozen_string.method_that_modifies!
rescue FrozenError => e
  # Handle immutable string error
  mutable_copy = frozen_string.dup
  mutable_copy.method_that_modifies!
end

Thread Safety

  • Frozen strings are thread-safe by default
  • Multiple threads can read frozen strings simultaneously
  • No synchronization needed for frozen string access
  • Mutable strings require synchronization for concurrent modification