Object Lifecycle, Freezing and Copying
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Ruby 4.0 (4.0.7, checked on 2026-10-18); features newer than Ruby 3.0 name the version that
introduced them. Every Ruby object has a life: it is created by a literal, by new or by one of a few lower-level
routes, it is initialised, it is used (and perhaps copied), it may be frozen so that it can no longer change, it
eventually becomes unreachable, and the garbage collector reclaims it, running any finalizer and clearing any weak
reference on the way. This page follows that sequence: how objects are allocated and initialised, how dup and clone
copy them and how a class customises copying, the difference between shallow and deep copies, what freeze does and
does not do, the frozen string literal magic comment and the chilled strings of Ruby 3.4, deep freezing and
Ractor-shareable constants, finalizers, weak maps and weak references, and object_id with the deprecation of
ObjectSpace._id2ref in Ruby 4.0.
It builds on Classes and Objects (new and initialize),
Variables, Constants and Scope (references versus
objects, constants) and Literals (which literals are frozen). The
equality side of identity, equal?, eql? and hash, is in
Equality, Hashing and Comparison; immutable value
objects built with Data are in
Struct, Data and Value Objects; string mutability
in depth is in Strings; hashes (whose string keys are copied and frozen)
are in Hashes. Freezing is what makes data safe to share between threads
and Ractors: see Threads and the GVL and
Ractors. The collector itself, GC.stat and allocation tracing are in
Garbage Collection and Memory; Marshal is covered
with the other formats in
Serialization: JSON, YAML, CSV and Marshal
and its risks in Security. The errors raised here are part of
Exceptions and Error Handling, and the traps that
catch newcomers are collected in
Common Mistakes and Gotchas. For other languages'
treatment of the same ideas, see the Rust reference (ownership, Clone and
Drop) and the Go reference (values, copies and no destructors).
Every example was syntax-checked with ruby -wc, run with ruby -w on Ruby 4.0.7 and linted with RuboCop 1.92 using
the configuration of the running shelf gem (double-quoted strings, NewCops: enable, Style/Documentation off). The
output shown in # => comments and in the output blocks is the real output. The examples use the shelf gem (a
small library catalogue, see the shelf gem source) and are run with
ruby -Ilib, so require "shelf" finds it. Where an address such as 0x0000000120348520 would appear in an object’s
default inspect, the examples print only the stable part of the message, because the address changes on every run.
The life of an object
Each stage has its own set of tools, and the sections below take them in order:
| Stage | What Ruby offers | Section |
|---|---|---|
Creation |
Literals, |
|
Copying |
|
|
Freezing |
|
Freezing objects, Frozen string literals and chilled strings, Deep freezing and shareable objects |
Identity |
|
|
Death |
|
Allocation and initialisation
Class#new is two steps: it asks the class to allocate an empty instance with Class#allocate, then calls the private
instance method initialize on it with the arguments it received (and the block). Allocation never calls
initialize, so an allocated object has no instance variables and none of the invariants the constructor would
establish. The value of initialize is ignored: new returns the allocated object, whatever initialize returned.
# frozen_string_literal: true
require "shelf"
isbn = Shelf::Isbn.new("978-0-306-40615-7")
book = Shelf::Book.new(isbn:, title: "Ruby", author: Shelf::Author.new("Ada"))
# new = allocate + initialize
loan = Shelf::Loan.new(book, state: :lent)
blank = Shelf::Loan.allocate
p loan.instance_variables # => [:@book, :@state]
p blank.instance_variables # => []
p blank.state # => nil
p Shelf::Loan.private_instance_methods(false) # => [:initialize]
p Shelf::Loan.respond_to?(:allocate) # => true
begin
Integer.allocate
rescue TypeError => e
puts e.message
end
begin
Object.new(1)
rescue ArgumentError => e
puts e.message
end
# Marshal.load builds objects with allocate, so initialize does not run.
class Counted
def initialize = puts("initialize ran")
end
Counted.new
Marshal.load(Marshal.dump(Counted.new))
[:@book, :@state]
[]
nil
[:initialize]
true
allocator undefined for Integer
wrong number of arguments (given 1, expected 0)
initialize ran
initialize ran
Source: Class#allocate
The two initialize ran lines come from the two calls to Counted.new; the Marshal.load that follows prints nothing.
Things to take from the example:
-
initializeis private (Loan.private_instance_methods(false)lists it), so it cannot be called on an existing object, and a second call is impossible by accident.BasicObject#initializetakes no arguments, which is whyObject.new(1)raisesArgumentErrorwhen no class in the chain definesinitialize. -
Classes whose instances are immediate values or special objects have no allocator.
Integer.allocateraisesTypeError("allocator undefined for Integer"), andInteger.newandSymbol.newdo not exist (NoMethodError).Thread.current.dupfails for the same reason. -
Marshal.loadrebuilds objects withallocateand sets their instance variables directly, soinitializeis not called. Validation done only ininitializeis therefore skipped for data that comes fromMarshal; this is one of the reasons to never load untrustedMarshaldata.dupandclonealso skipinitialize(they call the copy hooks instead, see below).Data#withis different: it builds the new object throughinitializewith the merged members, so a validatinginitializesuch as the one inShelf::Isbnruns again. -
A class can make
newprivate (private_class_method :new, the basis of the singleton and factory patterns) or remove it (singleton_class.send(:undef_method, :new)). Core classes such asIntegerandSymbolgo further and remove the allocator, so neithernewnorallocatecan build a half-formed instance.
Ways in which an object comes into existence, and what runs:
| Route | What runs |
|---|---|
A literal ( |
The interpreter builds the object. No |
|
|
|
|
|
Only the allocator: no instance variables, no |
|
An allocation of the same class, then the copy hooks (next section). |
|
An allocation, then the stored instance variables are set; |
|
A new class object, which is itself an object with a lifecycle. |
A conventional initialize stores its arguments, validates them and does no input or output: an object that needs a
file, a socket or a database connection receives it as an argument, or opens it lazily. That keeps dup,
Marshal and tests simple. A constructor that must never leave a half-initialised object can finish with freeze
(see Building immutable objects).
Copying objects
Ruby variables hold references, so b = a copies the reference and both names point at one object. To get a new
object you call dup or clone. Both produce a shallow copy: a new object of the same class whose instance
variables refer to the same objects as the original’s. What differs is how much "extra" state is carried across:
| State of the original | dup |
clone |
|---|---|---|
Instance variables (references) |
copied |
copied |
Frozen state |
not copied: the copy is unfrozen |
copied: a frozen original gives a frozen copy |
Singleton class (singleton methods) |
not copied |
copied |
Modules added with |
not copied |
copied |
Hook called |
|
|
Keyword |
none |
|
Think of dup as "make me another one like this" and clone as "make an exact replica, including how it was
specialised". The documentation for Object#dup states the rule that modules added with extend are not copied by
dup, and Kernel#clone that it "copies the frozen value state of obj, unless the :freeze keyword argument is given".
# frozen_string_literal: true
require "shelf"
module Auditable
def audit = "audited #{self.class}"
end
store = Shelf::Store::Memory.new
def store.name = "main store"
store.extend(Auditable)
store.freeze
copy = store.dup
twin = store.clone
thawed = store.clone(freeze: false)
[["dup", copy], ["clone", twin], ["clone(freeze: false)", thawed]].each do |label, obj|
puts "#{label.ljust(22)} frozen=#{obj.frozen?} " \
"singleton=#{obj.singleton_methods.sort.inspect} " \
"audit=#{obj.respond_to?(:audit)}"
end
p copy.singleton_class.include?(Auditable) # => false
p twin.singleton_class.include?(Auditable) # => true
dup frozen=false singleton=[] audit=false
clone frozen=true singleton=[:audit, :name] audit=true
clone(freeze: false) frozen=false singleton=[:audit, :name] audit=true
false
true
Source: Object#dup and Kernel#clone
The singleton method name and the module Auditable survive clone (including clone(freeze: false)), and neither
survives dup. The frozen state of the original survives clone unless freeze: false is passed.
The freeze: keyword of clone
clone accepts a freeze: keyword (freeze: false since Ruby 2.4, freeze: true since 3.0):
| Argument | Result |
|---|---|
|
The copy is frozen if and only if the original is. |
|
The copy is always frozen, even when the original is not (3.0). |
|
The copy is never frozen, even when the original is. It is the supported way to get an unfrozen replica that keeps singleton methods and extended modules. |
Any other value |
|
Immediate values and other objects that cannot change are their own copies: 1.dup and nil.dup return the receiver,
1.clone returns it too, and 1.clone(freeze: false) raises ArgumentError ("can’t unfreeze Integer"), as does
:a.clone(freeze: false). Some objects cannot be copied at all: Thread.current.dup raises TypeError ("allocator
undefined for Thread") and ENV.dup and ENV.clone raise TypeError (the latter since Ruby 3.2), telling you to use
ENV.to_h.
The copy hooks
Neither dup nor clone copies instance variables itself in a way a class can intercept. Instead each allocates the
copy, copies the instance variables, and calls hook methods on the copy, passing the original:
| Hook | Role |
|---|---|
|
The one to override in almost every case. Called on the new object after its instance variables were copied. Called by both |
|
Called by |
|
Called by |
The three hooks are private. Overriding initialize_dup or initialize_clone is only needed when dup and clone
must behave differently (for example, a class that shares a connection when cloned but opens a new one when
duplicated). Whatever you override, call super, so that the chain of defaults, and the copy logic of the core class
you may inherit from, still runs.
# frozen_string_literal: true
require "shelf"
# A store that reports every step of copying.
class AuditedStore < Shelf::Store::Memory
def initialize_dup(source)
puts "initialize_dup"
super
end
def initialize_clone(source, freeze: nil)
puts "initialize_clone(freeze: #{freeze.inspect})"
super
end
def initialize_copy(source)
puts "initialize_copy from #{source.class}"
super
end
end
store = AuditedStore.new
puts "-- dup"
store.dup
puts "-- clone"
store.clone
puts "-- clone(freeze: true)"
p store.clone(freeze: true).frozen?
puts "-- clone(freeze: false) of a frozen store"
p store.freeze.clone(freeze: false).frozen?
-- dup
initialize_dup
initialize_copy from AuditedStore
-- clone
initialize_clone(freeze: nil)
initialize_copy from AuditedStore
-- clone(freeze: true)
initialize_clone(freeze: true)
initialize_copy from AuditedStore
true
-- clone(freeze: false) of a frozen store
initialize_clone(freeze: false)
initialize_copy from AuditedStore
false
Source: Object#dup
Both dup and clone end in initialize_copy, dup through initialize_dup and clone through initialize_clone.
In the last step the original was frozen (freeze returned the store), freeze: false was passed down to
initialize_clone, and the copy came out unfrozen. Proc#dup and Proc#clone call these hooks too (since 3.3), and
Proc#dup returns an instance of the subclass (since 3.2).
Writing initialize_copy
initialize_copy is where a class copies the parts of its state that must not be shared. The rules:
-
It runs on the new object; the argument is the original. Reassign the instance variables that need their own copy (
@books = @books.dup), do not mutate the original’s. -
Call
superfirst, so that the superclass’s copying runs. ForObjectthe default does nothing visible, but a class that inherits fromHash,Array,StringorSetrelies onsuperto copy the contents. -
The return value is ignored, so
@books.dupas the last expression achieves nothing. -
Calling it directly on an existing object is an error in practice: it raises
TypeError("initialize_copy should take same class object") if the argument has a different class, andFrozenErrorif the receiver is frozen. -
Do not use it to validate; use
initialize. Do not do input or output in it.
# frozen_string_literal: true
require "shelf"
isbn = Shelf::Isbn.new("978-0-306-40615-7")
book = Shelf::Book.new(isbn:, title: "Ruby", author: Shelf::Author.new("Ada"))
# Object#dup copies the instance variable references, not the array they point to.
original = Shelf::Store::Memory.new
copy = original.dup
copy.add(book)
p original.all.size # => 1: both stores share one @books array
p copy.equal?(original) # => false
# A subclass that gives every copy its own array.
class IndependentStore < Shelf::Store::Memory
def initialize_copy(source)
super
@books = @books.dup
end
end
store = IndependentStore.new
twin = store.dup
twin.add(book)
p store.all.size # => 0
p twin.all.size # => 1
1
false
0
1
Source: Hash#initialize_copy (one of many core implementations)
Shelf::Store::Memory#all returns @books.dup for the same reason: handing out the internal array would let callers
modify the store.
How core classes copy
The core classes implement initialize_copy and follow the same shallow rule, with a few details worth knowing:
-
Array#dup,Hash#dupandSet#dupcopy the container, not the elements. The copy has the same class as the original, so a subclass ofArrayorHashduplicates to the same subclass. -
Hash#dupkeeps the default value and the default proc, andcompare_by_identitymode. A frozen hash duplicates to an unfrozen one. -
String#dupproduces a plain, unfrozen, unchilled string even when the original is a frozen literal or chilled (Frozen string literals and chilled strings), which is why"literal".dupis the portable way to get a mutable string. -
Struct#dupis shallow: the copy shares the member values. -
Datainstances are always frozen:dupandcloneboth return frozen copies, andclone(freeze: false)on aDataobject still returns a frozen one, becauseDatafreezes its instances. UseData#withto derive a modified copy. -
Rangeobjects are frozen when created from a literal or withRange.new(3.0), butdupreturns an unfrozen copy, as for every class. -
Class#dupandModule#dupcopy the methods and constants; the copy is a new class or module with its own identity. -
Method#dup,Proc#dup,Binding#dup,IO#dup(a new file descriptor) andThread::Mutex#dupwork;ThreadandENVrefuse to be duplicated.
Shallow and deep copies
A shallow copy is cheap and usually what you want: the copied collection can have elements added and removed
independently, and the elements are values or immutable objects anyway. It becomes a bug when the shared elements are
mutable and one holder changes them. Ruby has no built-in deep_dup (Rails adds one); the options are:
| Technique | Notes |
|---|---|
|
The right place for a class’s own state (see above). It copies exactly what the class knows must not be shared. |
|
Deep copy of plain data, preserving shared references and cycles. Fails ( |
A recursive helper |
Fastest and most explicit when you know the data shapes, as in the example below. Handle cycles yourself or avoid them. |
|
Deep-copies, then deep-freezes the copy; the original is untouched (see Deep freezing and shareable objects). |
# frozen_string_literal: true
require "shelf"
shelf = { "novels" => [+"Dune", +"Emma"], "poetry" => [+"Odes"] }
shallow = shelf.dup
shallow["novels"] << +"Ulysses"
shallow["essays"] = []
p shelf.keys # => ["novels", "poetry"]
p shelf["novels"].size # => 3: the arrays are shared
# 1. Marshal round trip: deep, but only for plain data.
deep = Marshal.load(Marshal.dump(shelf))
deep["novels"].first << "!"
p shelf["novels"].first # => "Dune"
begin
Marshal.dump(proc { 1 })
rescue TypeError => e
puts e.message
end
# 2. A recursive helper for the shapes you know.
def deep_dup(value)
case value
when Hash then value.to_h { |k, v| [deep_dup(k), deep_dup(v)] }
when Array then value.map { |v| deep_dup(v) }
when String then value.dup
else value
end
end
copy = deep_dup(shelf)
copy["poetry"] << +"Songs"
p shelf["poetry"] # => ["Odes"]
# 3. Ractor.make_shareable(copy: true) deep-freezes a deep copy.
frozen_copy = Ractor.make_shareable(shelf, copy: true)
p frozen_copy.frozen?, frozen_copy["novels"].first.frozen?
p shelf.frozen?, shelf["novels"].first.frozen?
["novels", "poetry"]
3
"Dune"
no _dump_data is defined for class Proc
["Odes"]
true
true
false
false
Source: Marshal and Ractor.make_shareable
The dup of the outer hash shares the arrays, so "Ulysses" appears in the original’s list while the new key essays
does not. The Marshal copy and the helper copy are independent, and the Ractor.make_shareable(..., copy: true) result
is frozen all the way down while the original is untouched. Security reminder: Marshal.load runs arbitrary
constructors for data it did not create, so use it for copying your own objects, never for input from outside the
process; RuboCop’s Security/MarshalLoad cop deliberately allows the exact Marshal.load(Marshal.dump(x)) idiom for
that reason.
Freezing objects
freeze marks an object as immutable and returns the object itself. After that, every operation that would change the
object’s own state raises FrozenError (a subclass of RuntimeError, since Ruby 2.5), and frozen? returns true.
There is no unfreeze: the only way back is a copy (dup, or clone(freeze: false)). Freezing is a flag on one
object. It has three limits that explain most surprises:
-
It is shallow. The elements of a frozen
Arrayand the values of a frozenHashare separate objects with their own frozen state.[+"a"].freeze.first << "b"works. -
It protects the object’s state, not its name. A variable that refers to a frozen object can still be reassigned to another object. Constants can be reassigned too (with a warning);
freezehas nothing to do with that. -
It is not reversible, and it applies to the receiver only. A frozen object’s copies are not frozen (except through
clone), and freezing a class does not freeze its instances.
# frozen_string_literal: true
require "shelf"
isbn = Shelf::Isbn.new("978-0-306-40615-7")
suffix = 1
literals = {
"Integer" => 1, "Symbol" => :a, "nil" => nil, "true" => true,
"Float" => 1.5, "Range" => (1..3), "Regexp" => /a/,
"literal String" => "abc", "interpolated" => "a#{suffix}",
"Array" => [1], "Data" => isbn, "Struct" => Shelf::Config.new(nil, 14)
}
literals.each { |name, value| puts "#{name.ljust(15)} #{value.frozen?}" }
def attempt(label)
yield
rescue FrozenError => e
puts "#{label}: #{e.message}"
p e.receiver.class
end
attempt("string") { "abc" << "d" }
attempt("array") { Shelf::Loan::STATES << :lost }
attempt("ivar") { isbn.instance_variable_set(:@digits, "0") }
attempt("data") { isbn.instance_eval { @digits = "0" } }
list = %w[a b].freeze
p list.equal?(list.freeze) # => true: freeze returns self
p list.dup.frozen? # => false
p list.map(&:upcase).frozen? # => false
p list.first.frozen? # => true (the literal elements are frozen strings)
Integer true
Symbol true
nil true
true true
Float true
Range true
Regexp true
literal String true
interpolated false
Array false
Data true
Struct false
string: can't modify frozen String: "abc"
String
array: can't modify frozen Array: [:available, :lent, :overdue, :returned]
Array
ivar: can't modify frozen Shelf::Isbn: #<data Shelf::Isbn digits="9780306406157">
Shelf::Isbn
data: can't modify frozen Shelf::Isbn: #<data Shelf::Isbn digits="9780306406157">
Shelf::Isbn
true
false
false
true
Source: Object#freeze, Kernel#frozen? and FrozenError
Which objects are frozen without you asking:
| Object | Frozen? |
|---|---|
|
Always: they are immediate values or immutable objects. |
|
Always since 3.0, including |
|
Since 3.0. |
|
Only with |
Interpolated |
Never, even with the magic comment (since 3.0). |
|
Frozen (3.0). |
|
Frozen, shared strings. |
|
Always frozen, even after |
|
Never, unless you freeze them. |
|
Not frozen by default. |
|
A string key that is not frozen is copied and the frozen copy is used as the key, so changing the original later cannot corrupt the table. |
Notes on the behaviour of frozen objects:
-
Reading is always fine.
instance_variable_get,inspect,==, iteration anddupwork on frozen objects. -
Even a no-op mutation raises.
[].freeze.clearand"AAA".freeze.upcase!(which would change nothing) raiseFrozenError; the check happens before the operation. -
Instance variables and singleton methods count as state. Assigning an instance variable,
define_singleton_methodandextendon a frozen object all raise. Memoization with@x ||= ...therefore fails on a frozen object, and a frozen object’s singleton class is frozen too. -
FrozenError#receiverreturns the object that refused to change, which is what a rescue handler or a test asserts on. -
You can hook freezing.
freezeis an ordinary method; a class can override it (callingsuper) to freeze its collaborators, which is a legitimate way to get "freeze my parts too".Thread::Queue#freezeandThread::SizedQueue#freezeraiseTypeError(3.3) because a frozen queue would be useless. -
Frozen state is per object.
Isbn.new(...)called twice yields two distinct frozen objects; freezing is not interning.String#-@(aliasdedup, 3.2) returns a frozen, deduplicated string, which is what to use for many repeated strings.
Freezing configuration and classes
# frozen_string_literal: true
require "shelf"
Shelf.configure { |config| config.loan_days = 21 }
Shelf.config.freeze
begin
Shelf.configure { |config| config.loan_days = 7 }
rescue FrozenError => e
puts e.message.split(": ", 2).first
end
p Shelf.config.store.frozen? # => false: freeze is shallow
# Freezing a class stops reopening it, which also seals its constants.
class Plugin
VERSION = 1
end
Plugin.freeze
begin
Plugin.class_eval { def run = :ok }
rescue FrozenError => e
puts e.message
end
begin
Plugin.const_set(:LIMIT, 3)
rescue FrozenError => e
puts e.message
end
p Plugin.new.frozen? # => false: instances are not frozen with their class
can't modify frozen Shelf::Config
false
can't modify frozen Class: Plugin
can't modify frozen Class: Plugin
false
Source: Module#freeze
Shelf.config is a Struct, so freeze stops further assignments to its members, but the store it holds is a
separate object and stays mutable: shallow again. Freezing a class (or module) forbids adding methods, constants and
class-level instance variables to it, but its instances are ordinary objects. Freezing a library’s own classes at the
end of loading, to prevent late monkey patching, is rare but legal; refinements and monkey patching are covered in
Refinements and Monkey Patching.
Building immutable objects
There are three levels of "immutable" in Ruby code, and Data is the one the language gives you ready-made:
-
A frozen collection of frozen elements (
%w[a b].freezewith the magic comment, ordeep_freeze). -
A class that freezes itself at the end of
initialize: copy and freeze each mutable argument, thenfreeze. No method may assign an instance variable afterwards, including memoization. -
Data.define(3.2): instances are frozen on creation,withderives a modified copy,==/eql?/hashare value-based. See Struct, Data and Value Objects.
# frozen_string_literal: true
require "shelf"
# A mutable class made immutable by freezing at the end of initialize.
class Reservation
attr_reader :title, :tags
def initialize(title, tags)
@title = title.dup.freeze
@tags = tags.map(&:freeze).freeze
freeze
end
# Memoizing writes an instance variable, so it fails once frozen.
def label
@label ||= "#{title} [#{tags.join(', ')}]"
end
# The functional update: build a new frozen object.
def with_tags(*more) = self.class.new(title, tags + more)
end
res = Reservation.new("Dune", %w[novel])
begin
res.label
rescue FrozenError => e
puts e.message[/\A[^:]+/]
end
p res.with_tags("classic").tags # => ["novel", "classic"]
p res.tags # => ["novel"]
# Data does the same for you, and #with is the functional update.
isbn = Shelf::Isbn.new("978-0-306-40615-7")
book = Shelf::Book.new(isbn:, title: "Dune", author: Shelf::Author.new("Herbert"))
renamed = book.with(title: "Dune Messiah")
p book.title, renamed.title
p renamed.author.equal?(book.author) # => true: with is a shallow copy
can't modify frozen Reservation
["novel", "classic"]
["novel"]
"Dune"
"Dune Messiah"
true
Source: Data#with
The memoization failure is the typical cost of freezing at the end of initialize: compute the value eagerly, in
initialize, or keep the cache outside the object (a WeakKeyMap, see below). Note that Data#with is shallow: the
renamed book shares its author with the original, which is safe only because Author is itself a Data.
Reasons to prefer immutable objects: they can be shared freely between threads and Ractors without locks, they are safe
to use as hash keys, copies can be avoided (a function that receives a frozen argument cannot be surprised by later
changes), and an invariant established in initialize stays true. The cost is allocation when you "change" them.
Frozen string literals and chilled strings
Strings are the one core type whose literals can be mutable or frozen, and the rules changed in Ruby 3.4.
| Mode | Behaviour |
|---|---|
|
Every non-interpolated literal in that file is frozen and deduplicated. Mutating it raises |
|
Literals are ordinary mutable strings, with no warning. |
No comment (Ruby 3.4 and later, including 4.0) |
Literals are chilled: |
|
Command-line switch: freeze literals in files without the comment (the comment in a file takes precedence over the switch). |
|
Makes literals plain mutable strings, with no warning (3.4). |
|
With a warning or |
The warnings appear only when the :deprecated warning category is on: -w, -W:deprecated or
Warning[:deprecated] = true. A program run without them behaves as before, which is why chilled strings can
be introduced without breaking anything, and why a test suite should turn the warnings on (or run once with
RUBYOPT=-W:deprecated) to find the mutations before the default changes.
# rubocop:disable Style/FrozenStringLiteralComment
# No frozen_string_literal comment: literals are "chilled" in Ruby 3.4+.
title = "Dune"
p title.frozen? # => false
title << " Messiah" # warns under -W:deprecated, still works
p title
copy = "Emma".dup
copy << "!" # no warning: dup returns a plain String
owned = +"Odes"
owned << "!" # no warning: +@ duplicates a chilled string
p copy, owned
name = :dune.to_s
name << "!" # Symbol#to_s is chilled as well
p name
# rubocop:enable Style/FrozenStringLiteralComment
The chilled literal is flagged on its first mutation; dup, the unary plus +@ (which returns the string itself if it is
mutable and a copy if it is frozen or chilled, since 3.4), String.new and a string with interpolation never are.
Symbol#to_s is chilled in the same way since 3.4. Running the script:
$ ruby chilled.rb
false
"Dune Messiah"
"Emma!"
"Odes!"
"dune!"
$ ruby -W:deprecated chilled.rb
false
chilled.rb:5: warning: literal string will be frozen in the future (run with --debug-frozen-string-literal for more information)
"Dune Messiah"
"Emma!"
"Odes!"
chilled.rb:15: warning: string returned by :dune.to_s will be frozen in the future
"dune!"
Source: NEWS for 3.4
$ ruby -W:deprecated --debug-frozen-string-literal chilled.rb
false
chilled.rb:5: warning: literal string will be frozen in the future
chilled.rb:3: info: the string was created here
"Dune Messiah"
"Emma!"
"Odes!"
chilled.rb:15: warning: string returned by :dune.to_s will be frozen in the future
"dune!"
$ ruby --enable=frozen-string-literal chilled.rb
true
chilled.rb:5:in '<main>': can't modify frozen String: "Dune" (FrozenError)
Source: Magic comments: frozen_string_literal, NEWS for 3.4 and String#+@
The rubocop:disable comment above is the deliberate exception that lets the file stay without the magic comment, as
Style/FrozenStringLiteralComment would otherwise require it. The magic comment in a file wins over the command-line
switch: with # frozen_string_literal: false the literals stay mutable even under --enable=frozen-string-literal.
How to write code that works in all modes:
-
Put
# frozen_string_literal: truein every file and build mutable strings with""`, `String.new` or `dup` (`+String.new("text")and"text".dupgive an unfrozen string that is not chilled).<<onto a string built by interpolation works because interpolated strings are never frozen. -
Use
+@(+str) when a method must mutate an argument that may be frozen: it copies only when needed, which replaces the oldstr.dup if str.frozen?pattern. -
Use
-str(String#-@, aliasdedup) to intern a long-lived string such as a hash key or a constant. -
"lit".freezeis compiled to a shared frozen string; RuboCop’sStyle/RedundantFreezeflags afreezethat the magic comment already makes redundant, andStyle/MutableConstantasks that constants assigned a mutable literal be frozen.
In 4.0 the string returned by Symbol#to_s is chilled (a frozen result is announced for a later release); use
Symbol#name, which returns a frozen string, when a read-only name is enough.
Deep freezing and shareable objects
Because freeze is shallow, a constant such as GENRES = %w[novel poetry].freeze is only half protected unless the
literals inside are frozen too (the magic comment takes care of that case). For nested structures there are three
tools:
-
A recursive helper walks the structure and freezes everything, as in the example below. It is explicit and easy to adapt, but it must handle every type you use (and cycles).
-
Ractor.make_shareable(obj)freezesobjand everything reachable from it (elements, instance variables, hash keys and values,Structmembers), and returnsobj. Withcopy: trueit first makes a deep copy, so the original objects stay unfrozen.Ractor.shareable?(obj)answers whether an object is already deeply frozen. The Ractor API is documented as experimental ("the specification and implementation of this method are not mature and may be changed"), butmake_shareableis widely used as the standard deep freeze, and does not by itself start a Ractor or print the experimental warning thatRactor.newprints. -
# shareable_constant_value:is a magic comment that makes the constants of a file deeply frozen (3.0).
# frozen_string_literal: true
require "shelf"
# Object#freeze is shallow: the elements stay mutable.
genres = [+"novel", +"poetry"].freeze
p genres.frozen?, genres.first.frozen? # => true, false
genres.first << "s"
p genres # => ["novels", "poetry"]
# A recursive deep freeze for plain data.
def deep_freeze(value)
case value
when Hash then value.each { |k, v| deep_freeze(k) && deep_freeze(v) }
when Array, Set then value.each { |v| deep_freeze(v) }
end
value.freeze
end
config = { "loan_days" => 14, "branches" => [+"north", +"south"] }
deep_freeze(config)
p config["branches"].first.frozen? # => true
# Ractor.make_shareable freezes everything reachable (and follows ivars).
Branch = Struct.new(:name, :tags)
branch = Branch.new(+"north", [+"main"])
p Ractor.shareable?(branch) # => false
Ractor.make_shareable(branch)
p Ractor.shareable?(branch), branch.tags.first.frozen? # => true, true
# A proc is shareable only if it captures nothing from its surroundings,
# and its self must be shareable too (it defaults to nil).
counter = 0
begin
Ractor.shareable_lambda { counter += 1 }
rescue ArgumentError => e
puts e.message
end
double = Ractor.shareable_lambda { |x| x * 2 }
p Ractor.shareable?(double), double.call(21)
true
false
["novels", "poetry"]
true
false
true
true
can not make a Proc shareable because it accesses outer variables (counter).
true
42
A Proc can be made shareable only if it does not capture local variables from its surroundings (it reads nothing it
did not receive), and its self must be shareable. Ractor.shareable_proc and Ractor.shareable_lambda, new in Ruby
4.0, build such procs: self is replaced by nil unless self: is given. Ractors and the rules for sharing objects
between them are explained in Ractors.
The shareable_constant_value magic comment
# shareable_constant_value: MODE (Ruby 3.0, documented as experimental for some modes) controls what happens to the
value assigned to every constant that follows it in the file, until the comment appears again:
| Mode | Effect on CONSTANT = value |
Result of assigning a hash that holds an unfrozen local variable |
|---|---|---|
|
Nothing: no freezing, no check. |
not frozen; |
|
Literals directly assigned (and literals nested in them) are deeply frozen; any other value must already be shareable, or |
|
|
Every value is made shareable with |
frozen, |
|
Every value is deeply copied and made shareable (as |
frozen copy, |
Module#const_set is not affected by the comment. The results in the last column were produced by running a small
script with each mode (CATALOG.frozen?, CATALOG["north"].frozen? and source.frozen? print false false false for
none, true true true for experimental_everything and true true false for experimental_copy; literal
fails on that assignment with the message below):
$ ruby mode_literal.rb
mode_literal.rb:5:in 'RubyVM::FrozenCore.ensure_shareable': cannot assign unshareable object to CATALOG (Ractor::IsolationError)
from mode_literal.rb:5:in '<main>'
The literal mode is the safe, readable choice for constants made of literals, and it makes the explicit .freeze
calls (and RuboCop’s Style/MutableConstant complaints) unnecessary:
# frozen_string_literal: true
# shareable_constant_value: literal
module Shelf
GENRES = %w[novel poetry essay]
LIMITS = { loan_days: 14, renewals: 2 }
end
p Shelf::GENRES.frozen?, Shelf::GENRES.first.frozen?
p Shelf::LIMITS.frozen?
true
true
true
experimental_everything is dangerous with constants that come from other libraries (it would deep-freeze the
library’s own object), and both experimental modes may change; prefer literal in new code. Deep freezing has a
cost on data that really has to change: freeze what is configuration or reference data, not working state.
Object identity and object ids
Every object has an identity, obj.equal?(other), and an integer object_id that is unique among live objects
and stable for the lifetime of an object. In current Ruby the id is allocated from a counter the first time it is requested, not
derived from the memory address, so successive new ids are small multiples of eight (16, 24, 32) and the
value says nothing about where the object lives. Equal objects that are
distinct (two Shelf::Author values with the same name) have different ids; immediate values have fixed ones (nil
is 4 and true is 20 in 4.0, and n.object_id is 2 * n + 1 for small integers). equal? compares identity;
==, eql? and hash compare values and are covered in
Equality, Hashing and Comparison.
ObjectSpace._id2ref(id) converts an id back to the object. It is deprecated in Ruby 4.0 (Feature #15408): it prints
ObjectSpace._id2ref is deprecated when deprecation warnings are enabled, and the garbage collector no longer
maintains the id-to-object table until the method is first used, which makes object_id allocation and sweeping
faster. It cannot work for objects that can be collected: an id whose object is gone raises RangeError ("16 is a recycled
object"), and a number that was never an id raises it too ("is not an id value"). Replace it by keeping the object in your own registry,
ideally a weak one:
# frozen_string_literal: true
require "shelf"
author = Shelf::Author.new("Ada")
id = author.object_id
p id.class # => Integer
p author.object_id == id # => true: stable for the object's lifetime
p Shelf::Author.new("Ada").object_id == id # => false: equal but a different object
# Deprecated in 4.0: turning an id back into an object.
p ObjectSpace._id2ref(id).equal?(author) # => true, with a warning under -w
# Keep your own registry instead; its values are held weakly.
registry = ObjectSpace::WeakMap.new
registry[id] = author
p registry[id].equal?(author) # => true
Integer
true
false
id2ref.rb:12: warning: ObjectSpace._id2ref is deprecated
true
true
Source: Object#object_id and NEWS for 4.0
Without -w the deprecated call prints nothing and returns true, so the warning is the only visible change in 4.0;
code that still calls it will break when the method is removed.
Finalizers
A finalizer is a callable that Ruby invokes some time after an object has become garbage. It is a last resort for
releasing a resource that the program failed to release explicitly (a file descriptor held by a C extension, a
temporary file). ObjectSpace.define_finalizer(obj, callable) registers it; ObjectSpace.undefine_finalizer(obj)
removes all finalizers of obj. The callable receives the object’s object_id, never the object (which is already
gone). A block can be given instead of the callable. The call returns [0, callable].
# frozen_string_literal: true
# A resource whose cleanup must not depend on the object itself.
class Lease
# The finalizer is built in a class method, so its proc cannot capture self.
def self.releaser(name)
proc { |id| puts "released #{name} (object_id #{id.class})" }
end
def initialize(name)
@name = name
ObjectSpace.define_finalizer(self, self.class.releaser(name))
end
end
lease = Lease.new("room-1")
other = Lease.new("room-2")
ObjectSpace.undefine_finalizer(other)
puts "holding #{lease.class} and #{other.class}"
at_exit { puts "at_exit handler" }
puts "end of script"
begin
ObjectSpace.define_finalizer("text".dup, :not_callable)
rescue ArgumentError => e
puts e.message
end
begin
ObjectSpace.define_finalizer(42, proc {})
rescue ArgumentError => e
puts e.message
end
begin
ObjectSpace.define_finalizer(Object.new.freeze, proc {})
rescue FrozenError => e
puts e.message[/\A[^:]+/]
end
holding Lease and Lease
end of script
wrong type argument Symbol (should be callable)
cannot define finalizer for Integer
can't modify frozen Object
at_exit handler
released room-1 (object_id Integer)
The rules that make finalizers hard to use correctly:
-
The callable must not refer to the object. If it captures
self(for example aproccreated in an instance method that uses an instance variable), the object is reachable from the finalizer table and can never be collected; it is then finalized only at exit. Ruby warns "finalizer references object to be finalized" when the callable’s receiver is the object. Build the proc in a class method (asLease.releaserdoes), or use a small callable class that stores the needed data (the two patterns recommended by the documentation). -
Timing is unspecified. A finalizer may run long after the object became unreachable, on any thread, between any two operations, or never before the process ends. Programs must not depend on it for correctness. The one guarantee is that remaining finalizers run when the process exits, after the
at_exithandlers, as the output shows. -
Do not rely on state. The finalizer runs when other objects may already have been finalized, and it runs with the VM in a delicate state: avoid locks and long operations, and do not allocate large structures.
-
Errors are not propagated. An exception in a finalizer is not raised in your code; since Ruby 3.1 it is printed to standard error unless
$VERBOSEisnil. -
What cannot have a finalizer. Immediate values (
cannot define finalizer for Integer), frozen objects (FrozenError), and a callable that does not respond tocall(wrong type argument Symbol (should be callable)). -
ObjectSpace.garbage_collectandGC.startrequest a collection but guarantee nothing about when finalizers run.
The better tools for deterministic cleanup are the block forms: File.open(path) { |file| ... } closes the file when
the block ends, Mutex#synchronize unlocks, and your own methods can do the same with ensure:
# frozen_string_literal: true
# Deterministic cleanup: acquire, yield, release in ensure.
def with_lease(name)
lease = acquire(name)
yield lease
ensure
release(lease) if lease
end
Source: Exceptions: ensure
Use a finalizer only as a safety net behind an explicit close or release, and keep the explicit path as the one
every caller is expected to use. Frameworks that manage native memory (C extensions, FFI pointers) are the typical
users; see C Extensions, FFI and WebAssembly.
Weak references
A strong reference keeps an object alive; a weak reference does not. Ruby provides three classes for them:
| Class | What it holds weakly | Comparison and notes |
|---|---|---|
|
Both keys and values. An entry disappears when either is collected. |
Keys compare by identity ( |
|
Only keys; values are held strongly. |
Keys compare with |
|
One referent, wrapped in a proxy |
|
# frozen_string_literal: true
require "shelf"
# WeakMap: keys and values are weak, and keys compare by identity.
ids = ObjectSpace::WeakMap.new
key = +"978-0-306-40615-7"
ids[key] = Shelf::Author.new("Ada")
p ids.key?(key) # => true
p ids.key?(+"978-0-306-40615-7") # => false: another object
p ids[key] # => #<data Shelf::Author name="Ada">
# WeakKeyMap: keys compare with eql? and hash, values are strong.
canon = ObjectSpace::WeakKeyMap.new
intern = lambda do |isbn|
existing = canon.getkey(isbn)
next existing if existing
canon[isbn] = true
isbn
end
first = intern.call(Shelf::Isbn.new("978-0-306-40615-7"))
again = intern.call(Shelf::Isbn.new("9780306406157"))
p first.equal?(again) # => true: one canonical instance while it is alive
begin
canon[:symbol] = true
rescue ArgumentError => e
puts e.message
end
require "weakref"
ref = WeakRef.new(+"a string held weakly")
p ref.weakref_alive?
p ref.upcase
true
false
#<data Shelf::Author name="Ada">
true
WeakKeyMap keys must be garbage collectable
true
"A STRING HELD WEAKLY"
Source: ObjectSpace::WeakMap, WeakKeyMap#getkey and WeakRef
The WeakKeyMap is a canonicaliser (interning table): getkey returns the instance that is stored as the key, which is
how the example makes two Isbn values with the same digits share one object while any caller still holds it. The
lambda returns the stored key if there is one and registers the argument otherwise.
Typical uses and cautions:
-
Caches that must not leak: associate computed data with an object (a parsed form, a thumbnail) without keeping the object alive:
WeakMap[obj] = data. The data lives as long asobjdoes, provideddatadoes not itself refer toobj(a value that references its key keeps the entry alive in aWeakKeyMap). -
Instance registries and interning:
WeakKeyMapfor canonical instances of value objects, as above. -
Observers and listeners: hold subscribers weakly so that forgetting to unsubscribe does not leak.
-
Never use a weak structure for data you cannot recompute. An entry can disappear at the next collection, so every lookup must handle a miss and rebuild the value.
-
Output from
size,keysandinspectof a weak map depends on the collector and is not reproducible; test the behaviour you control (presence while a strong reference is held) and not collection itself.
Where older books and tutorials are outdated
| Older teaching | Current behaviour |
|---|---|
"String literals are frozen by default since Ruby 3.4" |
They are not. Ruby 3.4 made literals chilled (mutation warns only under |
|
There is one |
|
Deprecated in 4.0. Use a registry (a |
"Always call |
Prefer |
|
|
|
Removed in 3.2. There is no taint flag to copy or clear any more. |
|
True, and since 3.0 |
|
Prefer |
|
A bundled gem in 4.0 and slow; use |
A |
|
Common exceptions and warnings
| Exception or warning | Cause |
|---|---|
|
Mutating a frozen object (string, array, hash, instance variable, constant table of a frozen class). Subclass of |
|
|
|
|
|
Calling |
|
|
|
A |
|
|
|
A symbol, integer or other immediate used as a |
|
A constant assigned a non-shareable value in |
|
A proc passed to |
|
Using a |
|
Mutating a chilled literal under |
|
Mutating the result of |
|
The finalizer’s receiver is the object; it can never be collected. |
|
Using |
Pitfalls
-
dupandcloneare shallow. A copy of a container shares its elements; copy them too or freeze them. -
initialize_copythat mutates the original. The receiver is the copy and the argument is the original; assign the receiver’s variables, and callsuper. -
Forgetting that
dupdrops singleton state (extended modules,define_singleton_method), and thatclonekeeps it and the frozen flag. -
Freezing is shallow and not undoable.
freezea collection and its elements, or useRactor.make_shareable. -
Memoizing in a frozen object fails with
FrozenError; compute ininitialize, or store the cache elsewhere. -
Mutating a string literal in a file with the magic comment raises; build strings with
+""orString.new, and use<<on those. -
Assuming chilled strings are frozen. They are not:
"x".frozen?isfalsewithout the comment, and writing to them works (for now). -
Finalizers that capture
selfkeep the object alive forever; they also cannot be relied on for timing. -
Using
object_idas a key when you mean identity: useHash#compare_by_identity(the keys are held strongly), or aWeakMapwhen the map must not keep the objects alive. -
Marshal.loadas a copy tool for untrusted data, and as a copy for objects with procs, IO, mutexes or singleton methods. -
Constants are not frozen by being constants.
Shelf::Loan::STATESis frozen because the source callsfreeze; without it any caller could append.
RuboCop cops for this page
| Cop | What it checks |
|---|---|
|
Requires the |
|
A constant assigned a mutable literal (array, hash, string) must be frozen; works with the magic comment for strings. Unsafe autocorrect (adds |
|
Flags |
|
Flags |
Version notes
| Version | Change |
|---|---|
2.4 |
|
2.5 |
|
3.0 |
|
3.1 |
|
3.2 |
|
3.3 |
|
3.4 |
Chilled string literals (and |
4.0 |
|
References
-
Object#dup, Kernel#clone, Object#freeze, Kernel#frozen?, Object#object_id and Class#allocate
-
FrozenError, Module#freeze, Data, Marshal and Hash#initialize_copy
-
Magic comments (frozen_string_literal, shareable_constant_value) and Ruby command-line options
-
ObjectSpace, ObjectSpace::WeakMap, ObjectSpace::WeakKeyMap, WeakRef and GC