Reflection, Introspection and Tracing
|
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). Reflection is a program asking questions about itself: which class is this object, which methods does it answer to, where was this method defined, who called me, which objects are alive, which line of code is running, which warnings did the interpreter raise, and how was this source turned into instructions. Ruby answers all of them from inside the language, and that is why tools such as debuggers, test frameworks, linters, profilers and documentation generators can be written in Ruby itself.
The page goes from the cheap and portable to the expensive and CRuby-specific. The first half (objects, classes, Method
objects, bindings and backtraces) is plain Ruby that works on every implementation and costs almost nothing. The second
half (ObjectSpace, TracePoint, Warning, Coverage, RubyVM::InstructionSequence, RubyVM::AbstractSyntaxTree and
Prism) looks at the running interpreter and its compiled form, is partly CRuby-only, and some of it is documented as
experimental or unstable.
It builds on Classes and Objects,
Modules and Mixins and
Method Lookup and super (the ancestor chain that the
reflection methods report), and it is the read-only companion of
Dynamic Dispatch and Method Definition
(send, define_method, method_missing) and Hooks, eval and DSLs
(inherited, method_added, instance_eval, eval), which change a program instead of looking at it.
Exceptions and Error Handling covers raise,
backtrace and cause; this page covers the Thread::Backtrace::Location objects behind them.
Object Lifecycle, Freezing and Copying and
Garbage Collection and Memory go further on finalizers and
the heap, and IRB and Debugging,
Performance, Benchmarking and Profiling,
Testing with Minitest and
YJIT, ZJIT and the VM show the tools that are built on these
interfaces. For how other languages expose the same ideas, see
Java,
Go,
PHP and
C#.
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, and
Style/OneClassPerFile off because several small classes share one example file). The output shown is the real output;
memory addresses and absolute paths are removed from it on purpose (the examples print File.basename), because they
differ from run to run. The examples that need the gem are run with ruby -Ilib, so require "shelf" finds it (see
the shelf gem source). The warnings example is run under plain ruby (it is also warning-free
under -w) so that the default warning settings are visible. The few places where an example silences a cop on purpose
carry a rubocop:disable-next comment and say why.
Reflection at a glance
| Question | Ruby API | Section |
|---|---|---|
What class is this, and what is its hierarchy? |
|
|
What state does this object hold? |
|
|
What can this object do? |
|
|
What does this class define? |
|
|
Where is this method or constant defined? |
|
|
What are its parameters? |
|
|
Which locals are visible here, and who called me? |
|
|
Which objects are alive, and how big are they? |
|
|
What is the interpreter doing right now? |
|
|
Which warnings are on, and who sees them? |
|
|
Which lines, branches and methods ran? |
|
|
How was this source compiled and parsed? |
|
Asking an object about itself
An object answers three kinds of question: what is it (its class and identity), what does it hold (instance variables) and
what can it do (methods). Every one of the methods below is defined on Object (most through Kernel), so it is available
on nearly every value; BasicObject instances have none of them.
# frozen_string_literal: true
require "shelf"
def show(label, value) = puts("#{label.ljust(38)}#{value}")
isbn = Shelf::Isbn.new("978-0-306-40615-7")
author = Shelf::Author.new("Ursula K. Le Guin")
book = Shelf::Book.new(isbn:, title: "The Dispossessed", author:)
loan = Shelf::Loan.new(book, state: :lent)
show "class", loan.class
show "is_a?(Object)", loan.is_a?(Object)
show "instance_variables", loan.instance_variables
show "instance_variable_get(:@state)", loan.instance_variable_get(:@state).inspect
show "instance_variable_defined?(:@due)", loan.instance_variable_defined?(:@due)
loan.instance_variable_set(:@due, 14)
show "after instance_variable_set", loan.instance_variables
show "remove_instance_variable", loan.remove_instance_variable(:@due)
show "after remove", loan.instance_variables
show "public_methods(false)", loan.public_methods(false).sort
show "methods - Object.instance_methods", (loan.methods - Object.instance_methods).sort
show "respond_to?(:state)", loan.respond_to?(:state)
show "respond_to?(:initialize)", loan.respond_to?(:initialize)
show "respond_to?(:initialize, true)", loan.respond_to?(:initialize, true)
show "singleton_methods", loan.singleton_methods
show "Data members", Shelf::Book.members
show "Data to_h keys", book.to_h.keys
class Shelf::Loan
is_a?(Object) true
instance_variables [:@book, :@state]
instance_variable_get(:@state) :lent
instance_variable_defined?(:@due) false
after instance_variable_set [:@book, :@state, :@due]
remove_instance_variable 14
after remove [:@book, :@state]
public_methods(false) [:book, :describe, :state]
methods - Object.instance_methods [:book, :describe, :state]
respond_to?(:state) true
respond_to?(:initialize) false
respond_to?(:initialize, true) true
singleton_methods []
Data members [:isbn, :title, :author]
Data to_h keys [:isbn, :title, :author]
Source: Object
loan.instance_variables lists the variables that have been assigned, in order of first assignment, and
instance_variable_set creates one on the fly, as the @due line shows. This is why instance variables are not
declared: an object has exactly the variables that some code has assigned to it, and reflection is the only way to see the
list without reading the class. Since Ruby 3.0, reading a variable that was never assigned no longer warns (it just
returns nil), so instance_variable_defined? is the way to tell "never set" from "set to `nil`".
Methods an object answers to
| Call | Returns |
|---|---|
|
Public and protected methods the object responds to, from its singleton class up through the whole ancestor chain. Includes everything inherited from |
|
Only the singleton methods (the same as |
|
The methods of that visibility, again through the whole chain. Pass |
|
The public and protected methods defined on the object itself, and (for a class or module) the ones it gained by |
|
Whether a public method is available; pass |
|
A |
respond_to? is the duck-typing question ("can I send you this message?"), and it is the one to ask in application code.
The methods family answers the introspection question ("what is defined?"), and it deliberately does not see methods
that exist only through method_missing: those need the respond_to_missing? hook to appear in respond_to? and
method (see method_missing and respond_to_missing?).
Reading and writing state
instance_variable_get, instance_variable_set, instance_variable_defined? and remove_instance_variable take a Symbol
or a String that must start with @; anything else raises NameError ("'state' is not allowed as an instance variable
name"). They go around the class’s public interface, and the interface is the contract. Use them where bypassing is the
point: serializers, test helpers, debuggers, Marshal, inspect and copying code. In application logic, an attr_reader
or a method is almost always the better tool, because the reflective call breaks silently when the variable is renamed.
A frozen object refuses instance_variable_set and remove_instance_variable with FrozenError; see
Object Lifecycle, Freezing and Copying.
Ruby 4.0 adds a hook that makes Kernel#inspect print only some of the variables: if the object has a private method
instance_variables_to_inspect returning an array of names, inspect shows just those. That is the supported way to keep
secrets out of logs and p output without overriding inspect (see
Classes and Objects).
Asking a class or module about itself
Classes and modules are objects too, so everything above works on them, and Module adds the questions that concern a
definition: its place in the hierarchy, the methods and constants it defines, and its name.
# frozen_string_literal: true
require "shelf"
klass = Shelf::Catalog
puts "name #{klass.name}"
puts "superclass #{klass.superclass}"
puts "ancestors #{klass.ancestors}"
puts "included_modules #{klass.included_modules.first(2)}"
puts "include?(Enumerable) #{klass.include?(Enumerable)}"
puts "instance_methods(false) #{klass.instance_methods(false)}"
puts "private (own) #{Shelf::Loan.private_instance_methods(false)}"
puts "method_defined? :map #{klass.method_defined?(:map)}"
puts "... without inherited #{klass.method_defined?(:map, false)}"
puts "public_method_defined? #{klass.public_method_defined?(:add)}"
puts "constants #{Shelf.constants.sort}"
puts "const_get #{Shelf.const_get('Store::Memory')}"
puts "const_defined? String #{Shelf.const_defined?(:String)} / #{Shelf.const_defined?(:String, false)}"
file, line = Shelf::Loan.const_source_location(:STATES)
puts "STATES defined at #{File.basename(file)}:#{line}"
puts "Data subclasses #{Data.subclasses.map(&:name).compact.grep(/\AShelf/).sort}"
# Class#subclasses is direct only; a descendants walk is a short recursion.
def descendants(klass) = klass.subclasses.flat_map { |sub| [sub, *descendants(sub)] }
shelf_errors = descendants(Exception).select { it.name&.start_with?("Shelf") }
puts "Exception descendants #{shelf_errors}"
puts "Shelf::Error < ... #{Shelf::Error.ancestors.take(3)}"
anonymous = Class.new
puts "anonymous name #{anonymous.name.inspect}"
anonymous.set_temporary_name("fake<Draft>")
puts "temporary name #{anonymous.name}"
puts "singleton attached to #{Shelf::Loan.singleton_class.attached_object}"
begin
Shelf::Loan.attached_object
rescue TypeError => e
puts "attached_object error #{e.message}"
end
name Shelf::Catalog
superclass Object
ancestors [Shelf::Catalog, Enumerable, Object, Kernel, BasicObject]
included_modules [Enumerable, Kernel]
include?(Enumerable) true
instance_methods(false) [:each, :add]
private (own) [:initialize]
method_defined? :map true
... without inherited false
public_method_defined? true
constants [:Author, :Book, :Catalog, :Config, :Error, :Isbn, :Loan, :Refinements, :Store, :VERSION]
const_get Shelf::Store::Memory
const_defined? String true / false
STATES defined at loan.rb:6
Data subclasses ["Shelf::Author", "Shelf::Book", "Shelf::Isbn"]
Exception descendants [Shelf::Error]
Shelf::Error < ... [Shelf::Error, StandardError, Exception]
anonymous name nil
temporary name fake<Draft>
singleton attached to Shelf::Loan
attached_object error 'Shelf::Loan' is not a singleton class
The hierarchy
-
Module#ancestorsis the method lookup chain, the same ordersuperfollows: the class, then the modules it prepends, includes or inherits, then the superclasses. It is the first thing to print when "the wrong method runs". For a class with aprepend, the prepended module appears before the class. The chain is explained in Method Lookup and super. -
Class#superclassis the direct parent (nilforBasicObject);Module#included_modulesandModule#include?ask about mixins. Both report the modules brought in byincludeandprepend, not those added byextend(those are on the singleton class). -
Class#subclasses(Ruby 3.1) returns the direct subclasses, in no guaranteed order and without singleton classes. There is nodescendantsin Ruby 4.0, so the example above recurses; before 3.1 the standard idiom wasObjectSpace.each_object(Class).select { |c| c < klass }, which is slower and sees singleton classes (see Looking at the heap with ObjectSpace). Theinheritedhook in Hooks, eval and DSLs is the way to be told about subclasses as they are created, rather than asking later. -
Class#attached_object(Ruby 3.2) goes the other way fromsingleton_class: given a singleton class, it returns the one object it belongs to, and raisesTypeErrorfor any other class.Module#singleton_class?tests the same thing without raising.
The methods a module defines
| Call | Returns |
|---|---|
|
Public and protected instance methods, including inherited ones. |
|
The methods of one visibility; |
|
A Boolean for one name. |
|
The names removed with |
|
An |
|
The |
Methods generated by attr_reader, Struct, Data and define_method are listed like any other. Methods that exist only
through method_missing are not listed anywhere.
Constants, names and class variables
-
Module#constantslists the constants a module defines and, for a class, the inherited ones;constants(false)keeps just its own.const_getaccepts a Symbol, a String, or a qualified"Store::Memory"String, andconst_defined?answers without raising; both take aninheritflag, which is whyShelf.const_defined?(:String)istrue(found throughObject) andShelf.const_defined?(:String, false)isfalse.const_getraisesNameError("uninitialized constant Shelf::Nope") for a missing name and ("wrong constant name lower") for a name that is not a constant. A missing constant first callsconst_missing, the hook that autoloaders such as Zeitwerk build on (see Code Loading: require and autoload). -
Module#const_source_locationreturns[file, line]for the place the constant was defined, which is how an editor jumps to a constant, and the answer for a class is where its firstclasskeyword ran. It returns[]for constants defined in C andnilwhen the constant does not exist. -
Module#nameis the constant path the module was first assigned to, ornilfor an anonymous one. Ruby 3.3’sset_temporary_namegives an anonymous class a readable label in error messages andinspectwithout making it a constant; the label must not look like a constant path ("fake<Draft>"is accepted,"Foo::Bar"and""raiseArgumentError). Once the class is assigned to a constant it has a permanent name that replaces the temporary one, andset_temporary_namethen raisesRuntimeError("can’t change permanent name"). -
Module#class_variables,class_variable_getandclass_variable_defined?read@@xvariables. Class variables are shared down the whole inheritance tree and are rarely the right design; see Variables, Constants and Scope.
Method objects
obj.method(:name) returns a Method, a method already bound to its receiver. Module#instance_method(:name) returns an
UnboundMethod, the same method not yet bound to any receiver. Both are plain objects that describe a method and can be
called; they are the reflective view of the definition, while Proc is the view of a block (see
Blocks, Procs and Lambdas).
# frozen_string_literal: true
require "shelf"
def where(method)
file, line = method.source_location
file ? "#{File.basename(file)}:#{line}" : "(C code)"
end
describe = Shelf::Loan.instance_method(:describe)
puts describe.class
puts "owner #{describe.owner}"
puts "name #{describe.name}"
puts "arity #{describe.arity}"
puts "parameters #{describe.parameters}"
puts "location #{where(describe)}"
init = Shelf::Loan.instance_method(:initialize)
puts "initialize #{init.parameters} arity #{init.arity}"
isbn = Shelf::Isbn.new("978-0-306-40615-7")
author = Shelf::Author.new("Ursula K. Le Guin")
book = Shelf::Book.new(isbn:, title: "The Dispossessed", author:)
loan = Shelf::Loan.new(book)
bound = loan.method(:describe)
puts "bound class #{bound.class}, receiver is loan: #{bound.receiver.equal?(loan)}"
puts "call #{bound.call}"
puts "unbind + bind #{bound.unbind.bind_call(Shelf::Loan.new(book, state: :returned))}"
map = Shelf::Catalog.instance_method(:map)
puts "map owner #{map.owner}, location #{where(map)}"
puts "Integer#+ #{1.method(:+).owner}, location #{where(1.method(:+))}"
puts "to_s defined #{where(Shelf::Isbn.instance_method(:to_s))}"
isbn_init = Shelf::Isbn.new("978-0-306-40615-7").method(:initialize)
puts "Isbn#initialize owner #{isbn_init.owner}, super_method owner #{isbn_init.super_method.owner}"
puts "inspect #{bound.inspect.sub(%r{ \S*/}, ' ')}"
puts "inspect (C) #{1.method(:+).inspect}"
UnboundMethod
owner Shelf::Loan
name describe
arity 0
parameters []
location loan.rb:17
initialize [[:req, :book], [:key, :state]] arity -2
bound class Method, receiver is loan: true
call The Dispossessed is on the shelf
unbind + bind The Dispossessed came back
map owner Enumerable, location (C code)
Integer#+ Integer, location (C code)
to_s defined isbn.rb:13
Isbn#initialize owner Shelf::Isbn, super_method owner Data
inspect #<Method: Shelf::Loan#describe() loan.rb:17>
inspect (C) #<Method: Integer#+(_)>
Source: Method and UnboundMethod
What a Method can tell you
| Call | Answer |
|---|---|
|
The name used to look it up, and the name it was defined with. They differ for an alias: |
|
The class or module in which the method is defined, which is not necessarily |
|
The object the method is bound to. |
|
|
|
An array of |
|
The number of required arguments, or |
|
The |
|
Detach a method from its receiver and attach it to another object of the right class (a |
|
A lambda that calls the method, a curried lambda, composition, and |
The inspect format shows the owner, the signature in terse form and the location:
#<Method: Shelf::Loan#describe() loan.rb:17>, with (_) for a C method’s single required argument. Since Ruby 3.2 an
UnboundMethod no longer shows the class it was fetched from, only the owner, and two UnboundMethod`s are `== when they
are the same underlying method, so String.instance_method(:object_id) == Array.instance_method(:object_id) is true.
The predicates Method#public?, private? and protected? that existed in 3.1 were removed again in 3.2; ask the class
(public_method_defined?) instead.
Method#source_location and Module#const_source_location are what editors and IRB’s show_source and edit commands
use to find the right file (see IRB and Debugging). Method#owner is
what to ask first when a method behaves unexpectedly: the owner tells you whether it came from your class, a mixin or
core.
Procs and blocks
A Proc answers the same questions: parameters, arity, source_location, lambda? and binding. proc { |a, (b, c), *d,
e: 1| }.parameters is : a plain proc reports its parameters as optional,
because a proc tolerates missing and extra arguments, and a destructured parameter is unnamed. Proc#parameters(lambda: true)
(Ruby 3.2) reports them as a lambda would (:req). Proc#binding returns the Binding the block closes over (see
Local variables, bindings and backtraces).
method_missing and respond_to_missing?
A method that exists only because method_missing answers for it is invisible to every query on this page. The rule that
keeps reflection honest is to define respond_to_missing? with the same condition: Ruby then reports the name from
respond_to? and, more importantly, builds a Method object for it, so obj.method(:find_by_title) works,
map(&obj.method(:find_by_title)) works, and Object#public_send and Enumerable#map(&:sym) behave as callers expect.
methods still does not list the name, because nothing is defined. RuboCop’s Style/MissingRespondToMissing enforces the
pair; the example below disables it on one class on purpose to show what the omission costs.
# frozen_string_literal: true
Book = Struct.new(:title)
# A catalogue front-end that answers find_by_<field> calls dynamically.
class Finder
def initialize(books) = @books = books
def method_missing(name, *args)
return super unless name.start_with?("find_by_")
field = name.to_s.delete_prefix("find_by_")
@books.find { |book| book.public_send(field) == args.first }
end
def respond_to_missing?(name, include_private = false) = name.start_with?("find_by_") || super
end
# The same idea without respond_to_missing?: deliberately incomplete, for the comparison.
class Plain
# rubocop:disable-next Style/MissingRespondToMissing
def method_missing(name, *) = name.start_with?("find_by_") ? :found : super
end
finder = Finder.new([Book.new("Ubik")])
puts "dynamic call #{finder.find_by_title('Ubik').title}"
puts "respond_to? #{finder.respond_to?(:find_by_title)}"
puts "method object #{finder.method(:find_by_title).call('Ubik').title}"
puts "methods listing #{finder.methods.include?(:find_by_title)}"
plain = Plain.new
puts "plain call #{plain.find_by_x}"
puts "plain respond_to? #{plain.respond_to?(:find_by_x)}"
begin
plain.method(:find_by_x)
rescue NameError => e
puts "plain method #{e.class}: #{e.message}"
end
begin
finder.other
rescue NoMethodError => e
puts "unknown name #{e.message}"
end
dynamic call Ubik
respond_to? true
method object Ubik
methods listing false
plain call found
plain respond_to? false
plain method NameError: undefined method 'find_by_x' for class 'Plain'
unknown name undefined method 'other' for an instance of Finder
Source: Object#respond_to_missing? and BasicObject#method_missing
method_missing itself (when to use it, how to write it with super and full signatures, and how it compares with
define_method) is in Dynamic Dispatch and Method Definition.
Local variables, bindings and backtraces
Binding
A Binding captures a point in the program: the local variables in scope, self, the current method and block, and the
source location. Kernel#binding returns the current one, Proc#binding the one a block closed over, and
TOPLEVEL_BINDING the one of the main script. It is the object that eval, ERB templates and binding.irb need to run
code "as if it were there" (see Hooks, eval and DSLs).
# frozen_string_literal: true
# A method that hands out its own binding.
def due_date(loan_days: 14)
start = "2026-10-01"
puts "building a binding for #{start}"
binding
end
b = due_date
puts "local_variables #{b.local_variables}"
puts "start #{b.local_variable_get(:start)}"
puts "receiver #{b.receiver}"
puts "source_location #{b.source_location.then { |file, line| "#{File.basename(file)}:#{line}" }}"
puts "eval #{b.eval('[start, loan_days]')}"
puts "defined? #{b.local_variable_defined?(:loan_days)} #{b.local_variable_defined?(:nope)}"
b.local_variable_set(:loan_days, 28)
puts "after set #{b.local_variable_get(:loan_days)}"
b.local_variable_set(:fresh, 1)
puts "new variable #{b.local_variables} (visible only through this binding)"
# Ruby 4.0: it and numbered parameters are reached through the implicit_parameter methods.
[10].each { puts "implicit (it) #{it} #{binding.implicit_parameters} #{binding.implicit_parameter_get(:it)}" }
# rubocop:disable-next Style/ItBlockParameter
[1].each { puts "implicit (_1) #{_1} #{binding.implicit_parameters} #{binding.local_variables}" }
# Kernel-level views of the current scope
name = "Ada"
puts "Kernel#local_variables #{local_variables}"
puts "global_variables #{global_variables.include?(:$stdout)}"
puts "binding of a block #{proc { name }.binding.local_variable_get(:name)}"
puts "TOPLEVEL_BINDING #{TOPLEVEL_BINDING.receiver}"
building a binding for 2026-10-01
local_variables [:loan_days, :start]
start 2026-10-01
receiver main
source_location e05_binding.rb:7
eval ["2026-10-01", 14]
defined? true false
after set 28
new variable [:fresh, :loan_days, :start] (visible only through this binding)
implicit (it) 10 [:it] 10
implicit (_1) 1 [:_1] [:b, :name]
Kernel#local_variables [:b, :name]
global_variables true
binding of a block Ada
TOPLEVEL_BINDING main
Source: Binding and Kernel#local_variables
-
local_variables,local_variable_get,local_variable_setandlocal_variable_defined?read and write locals by name.local_variable_seton a name that does not exist creates a variable that lives only inside that binding (the:freshline above); it is invisible to the code that was running. A Symbol that is not a valid local name raisesNameError. -
receiverisselfat that point, andsource_locationis[file, line]of thebindingcall. -
Binding#eval(string)evaluates code in that scope. It is as dangerous asKernel#evalwith the same string: never build the string from input you do not control (see Security). -
Ruby 4.0 changes how numbered parameters and
itinteract with bindings.Binding#local_variablesno longer includes them andlocal_variable_getrefuses them, and three new methods reach them instead:implicit_parameters(the names that exist in the scope,[:it]or[:_1]),implicit_parameter_getandimplicit_parameter_defined?. The names only exist when the block actually usesitor_1; asking for:itin a block that never mentions it raisesNameError. -
The
rubocop:disable-next Style/ItBlockParametercomment in the example keeps the_1line on purpose: RuboCop would preferit, but the point of that line is to show how_1appears inimplicit_parameters. -
A binding keeps every local variable of its scope alive, as long as the binding itself is reachable. Storing bindings in a long-lived structure is a classic memory leak.
Kernel#local_variables is the shortcut for binding.local_variables, and Kernel#global_variables lists the global
names (the special $stdout, $PROGRAM_NAME and friends, documented in
Pre-defined global variables).
Who called me: caller and caller_locations
Kernel#caller(start = 1, length = nil) returns the current call stack as strings, without the current frame (caller(0)
includes it). Kernel#caller_locations returns the same frames as Thread::Backtrace::Location objects, which is the
form to prefer: no string parsing, and cheaper because the strings are only built if asked for. Thread.each_caller_location
(Ruby 3.2) yields the frames one at a time and stops when the block breaks, so a lookup of the nearest frame does not build
the whole stack.
# frozen_string_literal: true
require "shelf"
def where(loc) = "#{File.basename(loc.path)}:#{loc.lineno} #{loc.label}"
def short(lines) = lines.map { it.sub(%r{^\S*/}, "") }
class Reporter
def report = trace_it
def trace_it
puts "caller(0) #{short(caller(0))}"
puts "caller_locations(1) #{where(caller_locations(1, 1).first)}"
Thread.each_caller_location { |loc| puts "each_caller_location #{where(loc)}" }
puts "__method__/__callee__ #{__method__.inspect} #{__callee__.inspect}"
end
alias aliased trace_it
end
Reporter.new.report
Reporter.new.aliased
begin
Shelf::Isbn.new("1234")
rescue ArgumentError => e
puts "message #{e.message}"
puts "backtrace #{short(e.backtrace)}"
puts "locations #{e.backtrace_locations.map { where(it) }}"
puts "location class #{e.backtrace_locations.first.class}"
puts "label, base_label #{e.backtrace_locations.first.label}, #{e.backtrace_locations.first.base_label}"
end
puts "backtrace limit #{Thread::Backtrace.limit}"
puts "__FILE__ and __dir__ #{File.basename(__FILE__)} #{File.basename(__dir__)}"
caller(0) ["e06_backtraces.rb:12:in 'Reporter#trace_it'", "e06_backtraces.rb:9:in 'Reporter#report'", "e06_backtraces.rb:20:in '<main>'"]
caller_locations(1) e06_backtraces.rb:9 Reporter#report
each_caller_location e06_backtraces.rb:9 Reporter#report
each_caller_location e06_backtraces.rb:20 <main>
__method__/__callee__ :trace_it :trace_it
caller(0) ["e06_backtraces.rb:12:in 'Reporter#trace_it'", "e06_backtraces.rb:21:in '<main>'"]
caller_locations(1) e06_backtraces.rb:21 <main>
each_caller_location e06_backtraces.rb:21 <main>
__method__/__callee__ :trace_it :aliased
message invalid ISBN: 1234
backtrace ["isbn.rb:8:in 'Shelf::Isbn#initialize'", "e06_backtraces.rb:23:in 'Shelf::Isbn.new'", "e06_backtraces.rb:23:in '<main>'"]
locations ["isbn.rb:8 Shelf::Isbn#initialize", "e06_backtraces.rb:23 Shelf::Isbn.new", "e06_backtraces.rb:23 <main>"]
location class Thread::Backtrace::Location
label, base_label Shelf::Isbn#initialize, initialize
backtrace limit -1
__FILE__ and __dir__ e06_backtraces.rb ex
Thread::Backtrace::Location |
Meaning |
|---|---|
|
The file as the interpreter was given it, and its absolute form ( |
|
The line of the call inside that frame. |
|
The frame’s name. Since Ruby 3.4 it carries the owner: |
|
The bare method name without the owner or the |
__method__ is the name the current method was defined with and __callee__ the name it was called by, which differ
inside an alias (the example prints :trace_it :trace_it and then :trace_it :aliased). __FILE__, __dir__,
__LINE__ and $PROGRAM_NAME ($0) locate the running source; __dir__ is the absolute directory of the file, and
$0 == __FILE__ is the idiom for "run only when executed as a script" (see
Command-Line Programs).
An exception carries its frames: Exception#backtrace is an array of strings and Exception#backtrace_locations the matching
array of locations (both nil for an exception that was never raised). Thread::Backtrace.limit returns the value of the
--backtrace-limit option (-1 when unlimited), which shortens printed traces. Since Ruby 3.4, Exception#set_backtrace,
raise and Thread#raise also accept an array of Thread::Backtrace::Location, which lets a library re-raise with the
original frames.
Backtraces in Ruby 3.4 and 4.0
The text of a backtrace changed twice, and any code or test that parses it needs to know:
-
Ruby 3.4 quotes with apostrophes instead of a backtick and a quote, adds the class or module name (
'Reporter#trace_it','Shelf::Isbn.new'), and drops the extra frames thatrescueandensureused to add. -
Ruby 4.0 stops showing interpreter-internal frames: methods implemented with
<internal:...>Ruby code (such asArray#fetch_values) now appear as if they were C methods, in the caller’s file and line. The "wrong number of arguments" error names the owner (in 'Object#add'instead ofin 'add'), andErrorHighlightprints a snippet for the caller and the definition:
x23.rb:1:in 'Object#add': wrong number of arguments (given 1, expected 2) (ArgumentError)
caller: x23.rb:2
| add(1)
^^^
callee: x23.rb:1
| def add(x, y) = x + y
^^^
from x23.rb:2:in '<main>'
Source: Ruby 4.0.0 NEWS and Ruby 3.4.0 NEWS
Looking at the heap with ObjectSpace
ObjectSpace is the interface to the object heap of a CRuby process. It answers "what is alive?" and "how much does it
cost?", which are questions about the whole process rather than about one object. Its methods are diagnostics: they are
meant for tests, debugging sessions and tools, and several of them are slow or depend on the garbage collector, so they
do not belong on a hot path.
# frozen_string_literal: true
require "objspace"
require "shelf"
isbn = Shelf::Isbn.new("978-0-306-40615-7")
author = Shelf::Author.new("Ursula K. Le Guin")
catalog = Shelf::Catalog.new
3.times { |i| catalog.add(Shelf::Book.new(isbn:, title: "Book #{i}", author:)) }
# Walking the heap
puts "live Books #{ObjectSpace.each_object(Shelf::Book).count}"
shelf_classes = ObjectSpace.each_object(Class).select { it.name&.start_with?("Shelf::") }
puts "Shelf classes #{shelf_classes.map(&:name).sort}"
names = []
count = ObjectSpace.each_object(Shelf::Author) { names << it.name }
puts "each_object block #{count} #{names}"
# Counting objects
before = ObjectSpace.count_objects
Array.new(1000) { Object.new }.size
after = ObjectSpace.count_objects
puts "count_objects keys #{before.keys.first(3)}, T_OBJECT grew #{after[:T_OBJECT] - before[:T_OBJECT] >= 1000}"
puts "memsize_of #{ObjectSpace.memsize_of('x' * 10_000) > 10_000}"
puts "memsize_of(1) #{ObjectSpace.memsize_of(1)}"
# Where was this object allocated?
ObjectSpace.trace_object_allocations do
book = Shelf::Book.new(isbn:, title: "Traced", author:)
file = ObjectSpace.allocation_sourcefile(book)
puts "allocated at #{File.basename(file)}:#{ObjectSpace.allocation_sourceline(book)}"
puts "allocating method #{ObjectSpace.allocation_method_id(book).inspect}"
end
# Weak references
weak = ObjectSpace::WeakMap.new
weak[author] = "cached value"
puts "WeakMap #{weak[author]} (size #{weak.size})"
keys = ObjectSpace::WeakKeyMap.new
keys[author.name] = :seen
puts "WeakKeyMap #{keys[author.name]} #{keys.getkey(author.name).equal?(author.name)}"
# _id2ref is deprecated in Ruby 4.0
Warning[:deprecated] = true
Warning.extend(Module.new { def warn(msg, category: nil) = puts("[#{category.inspect}] #{msg}") })
id = author.object_id
puts "_id2ref found it #{ObjectSpace._id2ref(id).equal?(author)}"
live Books 3
Shelf classes ["Shelf::Author", "Shelf::Book", "Shelf::Catalog", "Shelf::Config", "Shelf::Error", "Shelf::Isbn", "Shelf::Loan", "Shelf::Store::Memory"]
each_object block 1 ["Ursula K. Le Guin"]
count_objects keys [:TOTAL, :FREE, :T_OBJECT], T_OBJECT grew true
memsize_of true
memsize_of(1) 0
allocated at e07_objectspace.rb:29
allocating method :new
WeakMap cached value (size 1)
WeakKeyMap seen true
[:deprecated] e07_objectspace.rb:47: warning: ObjectSpace._id2ref is deprecated
_id2ref found it true
Source: ObjectSpace, ObjectSpace::WeakMap and ObjectSpace::WeakKeyMap
Walking and counting objects
-
ObjectSpace.each_object([module]) { |obj| ... }calls the block for every living, non-immediate object (optionally only instances ofmodule, which can be a class or a module) and returns how many it found; without a block it returns anEnumerator. Immediate values (small integers,nil,true, static Symbols and flonums) are not objects on the heap and are never yielded. The result depends on garbage that has not been collected yet, so counts can be higher than expected; callGC.startfirst when a test needs an exact number.each_object(Class)also yields every singleton class (dozens, even in a bare process), so filter withsingleton_class?or byname. The documentation warns that once the firstRactor.newhas run,each_objectstops yielding objects that are not shareable. -
ObjectSpace.count_objectsreturns a Hash of counts by internal type (:TOTAL,:FREE,:T_OBJECT,:T_STRING,:T_ARRAY,:T_HASH, …). The example compares two snapshots to confirm that a thousand objects were allocated. It is a cheap, coarse tool for leak hunting: take a snapshot, run the suspect code in a loop, take another and compare the types that grew. It accepts a Hash to fill, so a measurement can avoid allocating a new one. -
ObjectSpace.garbage_collectisGC.start; see Garbage Collection and Memory. -
Use
ObjectSpace.each_objectto find live instances for debugging, never for application logic such as a registry. A registry is a Hash filled byinitializeor by theinheritedhook, and it behaves the same on every run.
Weak references and finalizers
ObjectSpace::WeakMap holds both keys and values weakly and compares keys by identity. ObjectSpace::WeakKeyMap
(Ruby 3.3) holds only its keys weakly, keeps values strongly, compares keys by value with eql?, and offers getkey to fetch
the stored key object, which makes it the right structure for an interning cache keyed by a string. WeakMap#delete was
added in 3.3. Both let an entry disappear once nothing else refers to the key, so the size is only meaningful right
after you have looked at it, and their entries are not stable across a garbage collection.
ObjectSpace.define_finalizer(obj, callable) and undefine_finalizer register code to run after an object has been
collected. The callable must not hold a reference to the object, or it will never be collected, and finalizers run late
and in unspecified order; the rules are in
Object Lifecycle, Freezing and Copying.
_id2ref is deprecated
ObjectSpace._id2ref(id) turns an object_id back into the object. Ruby 4.0 deprecates it (Feature #15408): it prints
warning: ObjectSpace._id2ref is deprecated when deprecation warnings are on (-W:deprecated or -w; the example turns
the category on and routes the message to puts so you can see its category). An id says nothing about whether its object is
still alive, so the lookup could always fail or find a different object. Hold the object itself, or keep a WeakMap
when you need a cache that does not keep objects alive.
The objspace extension
require "objspace" adds a diagnostic layer to the same module:
| Method | Purpose |
|---|---|
|
Bytes used by one object (without what it references) or by all objects of a class. It returns 0 for immediates, as in the example. |
|
Byte counts and counts per internal type, for leak and bloat analysis. |
|
Records where each object was allocated while the block runs, then answers "which line created this?". It slows the program noticeably, so use it in a diagnostic run only. |
|
The object graph, to find out what keeps an object alive. |
|
JSON descriptions of one object or of the whole heap, for offline analysis with heap-analysis tools. |
The allocation example reports allocating method :new because the object was created by Class#new on the line
shown. Profilers that report allocations per line (see
Performance, Benchmarking and Profiling) are built
on exactly these calls.
Tracing with TracePoint
TracePoint is the object-oriented interface to the interpreter’s event hooks. You describe the events you want, give a
block, and Ruby calls the block each time one of them happens, with a TracePoint argument that describes the event. It
replaces the old Kernel#set_trace_func, whose callback received six positional arguments and which is only kept for
compatibility; new code should not use it. Debuggers (the debug gem), coverage tools, profilers, loggers that print "who
called what" and test frameworks that detect unexpected calls are built on it.
# frozen_string_literal: true
require "shelf"
isbn = Shelf::Isbn.new("978-0-306-40615-7")
author = Shelf::Author.new("Ursula K. Le Guin")
book = Shelf::Book.new(isbn:, title: "The Dispossessed", author:)
catalog = Shelf::Catalog.new
calls = []
trace = TracePoint.new(:call, :return) do |tp|
next unless tp.defined_class.to_s.start_with?("Shelf::")
line = "#{tp.event.to_s.ljust(6)} #{tp.defined_class}##{tp.method_id} at #{File.basename(tp.path)}:#{tp.lineno}"
line += " params #{tp.parameters}" if tp.event == :call
calls << line
end
puts "enabled before #{trace.enabled?}"
trace.enable do
catalog.add(book)
catalog.count
end
puts "enabled after #{trace.enabled?}"
puts calls
enabled before false
enabled after false
call Shelf::Catalog#add at catalog.rb:13 params [[:req, :book]]
call Shelf::Store::Memory#add at memory.rb:11 params [[:req, :book]]
return Shelf::Store::Memory#add at memory.rb:14
return Shelf::Catalog#add at catalog.rb:16
call Shelf::Catalog#each at catalog.rb:18 params [[:block, :&]]
call Shelf::Store::Memory#all at memory.rb:9 params []
return Shelf::Store::Memory#all at memory.rb:9
return Shelf::Catalog#each at catalog.rb:22
Source: TracePoint
The example listens to :call and :return, keeps only the classes in the Shelf:: namespace and records the event, the
method, the file and line, and the parameters. The Shelf::Catalog#add call, the Shelf::Store::Memory#add call it makes and
the two returns appear in call-stack order. Enumerable#count is implemented in C, so it produces no :call event, but
the Ruby method Catalog#each that it invokes does.
Events
If you do not list events, a TracePoint listens to every event, and the documentation says not to depend on that set, which can change. Always list the events you need.
| Event | Fires |
|---|---|
|
Before a new line of Ruby code runs. The highest-volume event. |
|
On entering and leaving a method written in Ruby: |
|
On entering and leaving a method written in C, such as |
|
On entering and leaving a block, proc or lambda body. |
|
Shorthand for all call events ( |
|
On entering and leaving a |
|
When an exception is raised. |
|
When a raised exception is rescued by Ruby code (Ruby 3.3). Only Ruby-level |
|
When a thread starts and finishes (fired in the new thread). |
|
When control passes to another fiber. |
|
When new code is compiled by |
# frozen_string_literal: true
def risky = raise(ArgumentError, "boom")
def guarded
risky
rescue ArgumentError => e
e.message
end
log = []
events = %i[raise rescue class end b_call c_call script_compiled]
trace = TracePoint.new(*events) do |tp|
case tp.event
when :raise then log << "raise #{tp.raised_exception.class} at line #{tp.lineno}"
when :rescue then log << "rescue #{tp.raised_exception.message.inspect} at line #{tp.lineno}"
when :c_call then log << "c_call #{tp.defined_class}##{tp.method_id}" if tp.method_id == :upcase
when :script_compiled then log << "script_compiled #{tp.eval_script.inspect}"
else log << "#{tp.event.to_s.ljust(16)} line #{tp.lineno}"
end
end
trace.enable
guarded
class Draft
VERSION = 1
end
[1].each { |n| n }
"shelf".upcase
eval("1 + 1", binding, __FILE__, __LINE__)
trace.disable
puts log
raise ArgumentError at line 3
rescue "boom" at line 7
class line 25
end line 27
b_call line 28
c_call String#upcase
script_compiled "1 + 1"
Source: TracePoint.new and TracePoint#raised_exception
The log shows the order of events for a method that raises and rescues, a class body, a block, a C method and an eval.
:class reports the line of the class keyword and :end the line of the matching end. The example uses a plain
enable and disable pair so that the events of an enable block (its own :b_call) are not recorded.
Attributes of the event
Inside the block, the argument answers questions about the event. Calling an attribute that the current event does not
define raises RuntimeError ("not supported by this event").
| Attribute | Meaning |
|---|---|
|
The event name as a Symbol. |
|
The file and line of the event. |
|
The name the method was defined with, and the name it was called by (they differ for an alias: the |
|
The class or module that owns the method, which for a singleton method is the singleton class. Use |
|
The receiver at the time of the event. |
|
The parameter list of the method or block, in the format of |
|
A |
|
The value being returned, on |
|
The exception, on |
|
On |
Two rules protect the interpreter from confusing itself. The TracePoint argument is only valid inside the block, and
touching it later raises RuntimeError ("access from outside"); copy the values you need instead of keeping the object.
And TracePoints are active per Ractor: enabling one in the main Ractor does not trace code that runs in another.
Turning it on and off, and limiting its scope
enable and disable return the previous state, so enable returns false the first time and true the second.
enable { ... } activates the hook for the duration of a block, and disable { ... } suspends it for a block while it
is on. TracePoint.trace(*events) { ... } creates and enables in one step. enabled? tells you which state it is in, and
inspect prints #<TracePoint:disabled> or #<TracePoint:enabled>, and inside the hook the event, the method and the
location.
# frozen_string_literal: true
require "shelf"
isbn = Shelf::Isbn.new("978-0-306-40615-7")
author = Shelf::Author.new("Ursula K. Le Guin")
book = Shelf::Book.new(isbn:, title: "The Dispossessed", author:)
catalog = Shelf::Catalog.new
# 1. target: limits the hook to one method's code
seen = []
only_add = TracePoint.new(:call) { |tp| seen << "#{tp.defined_class}##{tp.method_id}" }
only_add.enable(target: Shelf::Catalog.instance_method(:add)) do
catalog.add(book)
catalog.each(&:itself)
end
puts "target: add only #{seen}"
# 2. an enable block traces the current thread only (since 3.2)
events = []
thread_hook = TracePoint.new(:thread_begin, :thread_end) { |tp| events << tp.event }
thread_hook.enable { Thread.new { 1 }.join }
puts "default thread scope #{events}"
thread_hook.enable(target_thread: nil) { Thread.new { 1 }.join }
puts "target_thread: nil #{events}"
# 3. enable / disable return the previous state
hook = TracePoint.new(:c_call) { nil }
puts "enable, enable #{hook.enable} #{hook.enable}"
puts "disable, disable #{hook.disable} #{hook.disable}"
# 4. TracePoint.trace enables at once; hooks are not re-entered
own_code = ->(tp) { tp.defined_class == Object || tp.defined_class.to_s.start_with?("Shelf") }
nested = []
outer = TracePoint.trace(:call) do |tp|
nested << "#{tp.defined_class}##{tp.method_id}" if own_code.call(tp)
catalog.add(book) if tp.method_id == :first_call
end
def first_call = 1
first_call
outer.disable
puts "no re-entrance #{nested}"
reentrant = []
outer = TracePoint.trace(:call) do |tp|
reentrant << "#{tp.defined_class}##{tp.method_id}" if own_code.call(tp)
TracePoint.allow_reentry { catalog.each(&:itself) } if tp.method_id == :first_call
end
first_call
outer.disable
puts "allow_reentry #{reentrant}"
target: add only ["Shelf::Catalog#add"]
default thread scope []
target_thread: nil [:thread_begin, :thread_end]
enable, enable false true
disable, disable true false
no re-entrance ["Object#first_call"]
allow_reentry ["Object#first_call", "Shelf::Catalog#each", "Shelf::Store::Memory#all"]
Source: TracePoint#enable and TracePoint.allow_reentry
-
target:restricts the hook to one method,UnboundMethod,Procor other code object for whichRubyVM::InstructionSequence.ofreturns an instruction sequence. The example traces onlyCatalog#addeven thoughCatalog#eachis also called. Withtarget_line:it narrows further to one line, but only together with the:lineevent (otherwiseArgumentError: "target_line is specified, but line event is not specified"). Targeted tracing is the cheap form, because the interpreter instruments just that code. -
target_thread:limits the hook to one thread. Since Ruby 3.2,enablewith a block and withouttarget,target_lineortarget_threaddefaults to the current thread, which is why the firstthread_beginhook sees nothing: the event is fired in the new thread. Passtarget_thread: nilto listen on every thread. -
While a hook runs, other TracePoint hooks are not called, so a hook that calls traced methods does not recurse (the
catalog.addinside the hook is invisible to it).TracePoint.allow_reentry { ... }(Ruby 3.1) lifts that restriction inside its block; it raisesRuntimeErrorif reentrance is already allowed, and the hook must make sure it does not trigger itself endlessly. -
TracePoint.newneeds a block (ArgumentError: "must be called with a block") and known events (ArgumentError: "unknown event: nope").
What tracing costs
Every enabled event adds work to the interpreter, and :line, :call and :c_call happen constantly. Tracing a
tiny method in a loop is several times slower than running it untraced on the same machine, and enabling a
TracePoint can force the VM to throw away optimized code: Ruby 4.0 adds an invalidate_everything counter to the default YJIT
statistics that counts the times all compiled code was invalidated because of a TracePoint (and it fixed a bug where
TracePoints stopped working after a garbage collection). Practical rules:
-
Enable a hook for the narrowest scope: a block, a
target, a thread, and only the events you need. -
Filter inside the block as the first statement (
next unless ...), because the block is called for every event of the chosen kinds, including those from gems and from the standard library. -
Never leave a tracer enabled in production unless that is its purpose. Use it in tests, development and diagnostics; for steady-state observation, prefer sampling profilers (see Performance, Benchmarking and Profiling).
-
A tracer that reads
tp.bindingortp.parameterson every event is the most expensive kind; ask for them only when you report.
A reusable call tracer
The class below is the useful core of most "print every call" tools: it wraps a TracePoint, keeps only one namespace, indents by call depth and summarizes return values by type so that logs do not contain object addresses.
# frozen_string_literal: true
require "shelf"
# Records an indented call tree for the methods of one namespace.
class CallTracer
def initialize(namespace) = @namespace = namespace
def trace(&)
lines = []
depth = 0
hook = TracePoint.new(:call, :return) do |point|
next unless point.defined_class.to_s.start_with?(@namespace)
depth -= 1 if point.event == :return
lines << "#{' ' * depth}#{describe(point)}"
depth += 1 if point.event == :call
end
hook.enable(&)
lines
end
private
def describe(point)
name = "#{point.defined_class.to_s.delete_prefix('Shelf::')}##{point.method_id}"
point.event == :call ? "-> #{name}" : "<- #{name} = #{summary(point.return_value)}"
end
def summary(value)
case value
in String | Symbol | Integer | nil | true | false then value.inspect
else value.class.to_s.delete_prefix("Shelf::")
end
end
end
isbn = Shelf::Isbn.new("978-0-306-40615-7")
author = Shelf::Author.new("Ursula K. Le Guin")
book = Shelf::Book.new(isbn:, title: "The Dispossessed", author:)
catalog = Shelf::Catalog.new.add(book)
tracer = CallTracer.new("Shelf::")
first = tracer.trace { Shelf::Loan.new(book).describe }
puts first
puts
second = tracer.trace { catalog.map(&:title) }
puts second
-> Loan#initialize
<- Loan#initialize = :available
-> Loan#describe
<- Loan#describe = "The Dispossessed is on the shelf"
-> Catalog#each
-> Store::Memory#all
<- Store::Memory#all = Array
<- Catalog#each = Array
Source: TracePoint#defined_class and TracePoint#return_value
The second trace shows the limit of :call events: Enumerable#map is a C method and is not listed, only the Ruby
method Catalog#each that it calls. Add :c_call to see C methods, at a much higher cost.
Warnings
Ruby’s warnings are an introspection channel too: the interpreter reports questionable code and deprecated features through
one hook, Warning.warn, and a program can switch categories on and off, filter them and capture them. This is how test
suites turn warnings into failures and how libraries announce their own deprecations.
Verbosity and categories
Two independent settings decide what is printed.
-
$VERBOSEhas three states.false(the default,-W1) prints the important warnings.true(-wor-W2) prints every warning, including style and unused-code diagnostics (assigned but unused variable,method redefined).nil(-W0) prints none:Kernel#warndoes nothing at all. -
Warning categories are Boolean switches for groups of warnings.
Warning.categories(Ruby 3.4) lists them::deprecated,:experimental,:performanceand:strict_unused_block.
| Category | Covers | Default |
|---|---|---|
|
Features slated for removal: mutating a chilled string literal (Ruby 3.4), |
off; |
|
Features still marked experimental, such as the Ractor API in 4.0. |
on; |
|
Hints about slow patterns, such as redefining a core method the interpreter and JIT optimize ( |
off; |
|
A block passed to a method that never uses it ( |
off; |
$ ruby -W:deprecated script.rb # one category on
$ ruby -W:no-experimental script.rb # one category off
$ ruby -w script.rb # verbose: $VERBOSE = true, deprecated on
$ ruby -W0 script.rb # silent: $VERBOSE = nil
$ RUBYOPT="-W:deprecated" ruby script.rb
Source: Command-line options
The same switches exist in code: Warning[:deprecated] = true and Warning[:experimental] read and write a category, and
an unknown name raises ArgumentError ("unknown category: nope"). RUBYOPT applies the options to every Ruby process
started from that shell, which is how CI jobs raise the level of the whole test run without editing scripts.
Emitting, capturing and filtering
-
Kernel#warn(*messages, uplevel: nil, category: nil)writes to$stderrthroughWarning.warn.uplevel: nprefixes the message with the file and line of the callernframes up and the wordwarning:(the standard form for deprecation messages, so users see their line, not yours).category:(Ruby 3.0) names the category, and the message is dropped if that category is off, which is how a library emits a deprecation that stays quiet by default. -
Warning.warn(message, category: nil)is the single method every warning passes through, from the interpreter and fromKernel#warn. Overriding it redirects, filters or collects warnings. The documentation recommends extendingWarningwith a module that callssuperfor the default behaviour, and says never to redefine the instance methodWarning#warn. A hook should accept**kwargsto survive additions to the signature. -
Ruby only calls
Warning.warnfor categories that are switched on, so a capture hook sees exactly what would have been printed.
# frozen_string_literal: true
# Capture every warning instead of printing it (useful in tests).
module WarningCapture
Entry = Data.define(:category, :message)
def self.entries = @entries ||= []
def warn(message, category: nil)
WarningCapture.entries << Entry.new(category:, message: message.chomp)
nil
end
end
Warning.extend(WarningCapture)
def report(label)
before = WarningCapture.entries.size
yield
fresh = WarningCapture.entries.drop(before)
puts "#{label.ljust(26)} #{fresh.empty? ? 'nothing' : fresh.map { [it.category, it.message] }}"
end
puts "categories #{Warning.categories}"
puts "defaults #{Warning.categories.to_h { [it, Warning[it]] }}"
report("warn, no category") { warn "plain" }
report("deprecated off") { warn "old way", category: :deprecated }
Warning[:deprecated] = true
report("deprecated on") { warn "old way", category: :deprecated }
report("uplevel: 0") { warn "careful", uplevel: 0 }
report("chilled string") { eval("text = 'lit'; text << 'x'", binding, __FILE__, __LINE__) }
report("Warning.warn direct") { Warning.warn("direct\n") }
report("$VERBOSE = nil") do
$VERBOSE = nil
warn "silenced"
ensure
$VERBOSE = false
end
begin
warn "bad", category: :nope
rescue ArgumentError => e
puts "unknown category #{e.class}: #{e.message}"
end
Warning[:experimental] = true
report("experimental feature") { Ractor.new { 1 }.value }
categories [:deprecated, :experimental, :performance, :strict_unused_block]
defaults {deprecated: false, experimental: true, performance: false, strict_unused_block: false}
warn, no category [[nil, "plain"]]
deprecated off nothing
deprecated on [[:deprecated, "old way"]]
uplevel: 0 [[nil, "e11_warnings.rb:29: warning: careful"]]
chilled string [[:deprecated, "e11_warnings.rb:30: warning: literal string will be frozen in the future (run with --debug-frozen-string-literal for more information)"]]
Warning.warn direct [[nil, "direct"]]
$VERBOSE = nil nothing
unknown category ArgumentError: unknown category: nope
experimental feature [[:experimental, "e11_warnings.rb:44: warning: Ractor API is experimental and may change in future versions of Ruby."]]
Source: Warning, Kernel#warn and Command-line options
This example runs under plain ruby (not -w), so the defaults are visible: deprecated is off until the script turns it
on, and experimental is already on. It shows each point in turn:
-
a plain
warnreaches the hook with no category; -
a
category: :deprecatedwarning disappears while the category is off and arrives once it is on; -
uplevel: 0prefixes the file and line (e11_warnings.rb:29: warning: careful); -
a chilled string mutated in
evalproduces the real Ruby 3.4+ deprecation, with the:deprecatedcategory; -
$VERBOSE = nilsilenceswarn; and -
the Ractor warning (the Ractor API is experimental in Ruby 4.0, see Ractors) arrives with the
:experimentalcategory.
A test helper that raises on any captured warning is the standard way to keep a code base free of deprecations before the
next Ruby upgrade; see What’s New and Migrating Older Code
and Testing with Minitest. The community warning gem wraps this
hook with ready-made filters; it is not part of Ruby.
Code coverage
Coverage (in the standard library, require "coverage") counts how often each line, branch and method of the files loaded
while it is running was executed. It is the engine under test-coverage tools such as the community SimpleCov gem
(https://github.com/simplecov-ruby/simplecov); the documentation labels the module experimental, and
it measures the whole process, not one thread.
The one rule that surprises everyone: start Coverage before the code to measure is loaded. A file that was already
loaded when Coverage.start ran is not instrumented, and a file reloaded with load after the start is measured from then on.
# frozen_string_literal: true
require "coverage"
# Coverage must be started before the code to measure is loaded.
Coverage.start(lines: true, branches: true, methods: true)
require "shelf"
isbn = Shelf::Isbn.new("978-0-306-40615-7")
author = Shelf::Author.new("Ursula K. Le Guin")
book = Shelf::Book.new(isbn:, title: "The Dispossessed", author:)
Shelf::Loan.new(book, state: :lent).describe
result = Coverage.result
path = result.keys.find { it.end_with?("shelf/loan.rb") }
data = result.fetch(path)
puts "modes #{data.keys}"
puts "lines (number: hits)"
data[:lines].each_with_index do |hits, index|
puts " #{index + 1}: #{hits}" if hits && index.between?(9, 21)
end
puts "methods"
data[:methods].each do |(klass, name, first, _, last, _), hits|
puts " #{klass}##{name} lines #{first}-#{last} called #{hits}x"
end
puts "branches"
data[:branches].each do |(kind, _, line, *), arms|
puts " #{kind} at line #{line}"
arms.each { |(arm, _, arm_line, *), hits| puts " #{arm} (line #{arm_line}): #{hits}" }
end
puts "running? #{Coverage.running?}, supported?(:eval) #{Coverage.supported?(:eval)}"
modes [:lines, :branches, :methods]
lines (number: hits)
10: 1
11: 1
13: 1
14: 1
17: 1
18: 1
19: 0
20: 1
21: 0
methods
Shelf::Loan#describe lines 17-23 called 1x
Shelf::Loan#initialize lines 10-15 called 1x
branches
unless at line 11
else (line 11): 1
then (line 11): 0
case at line 18
in (line 19): 0
in (line 20): 1
in (line 21): 0
else (line 18): 0
running? false, supported?(:eval) true
Source: Coverage
| Mode | Result for each file |
|---|---|
|
An array indexed by line number minus one: the execution count, or |
|
A Hash from each conditional ( |
|
A Hash from |
|
An array of the line numbers that ran at least once, with no counts. Much cheaper than |
|
Also measure code compiled by |
|
|
The control methods are Coverage.start (setup followed by resume), setup (prepare without counting), resume and
suspend (Ruby 3.1) to count only part of a run, peek_result to read the numbers without stopping, result to read and
stop (result(stop: false, clear: true) keeps running and resets the counters), running?, state (one of :idle,
:suspended, :running), supported?(mode) (Ruby 3.2) and line_stub(file), which returns the all-zero lines array for a
file so that unloaded files can be reported at 0 %.
# frozen_string_literal: true
require "coverage"
require "fileutils"
require "tmpdir"
# Writes a small source file defining method `name` and returns its path.
def write_source(dir, name)
path = File.join(dir, "#{name}.rb")
File.write(path, "def #{name}(days)\n if days > 14\n :overdue\n else\n :ok\n end\nend\n")
path
end
dir = Dir.mktmpdir
# setup / resume / suspend / peek_result / result
first_path = write_source(dir, "first")
Coverage.setup(oneshot_lines: true)
load first_path
puts "state after setup #{Coverage.state}"
Coverage.resume
first(3)
Coverage.suspend
first(30)
Coverage.resume
puts "peek (not stopping) #{Coverage.peek_result.values.first}"
first(30)
puts "result #{Coverage.result.values.first}"
puts "state after result #{Coverage.state}"
# result formats and line_stub
second_path = write_source(dir, "second")
Coverage.start(lines: true)
load second_path
second(3)
puts "lines per source line #{Coverage.result[second_path][:lines]}"
puts "line_stub #{Coverage.line_stub(second_path)}"
third_path = write_source(dir, "third")
Coverage.start
load third_path
third(3)
puts "legacy array format #{Coverage.result[third_path]}"
# code run through eval is measured only with eval: true (Ruby 3.2)
Coverage.setup(lines: true, eval: true)
Coverage.resume
# rubocop:disable-next Style/EvalWithLocation
eval("a = 1\nb = a + 1\n", binding, "generated.rb", 1)
puts "eval: true #{Coverage.result}"
FileUtils.remove_entry(dir)
state after setup suspended
peek (not stopping) {oneshot_lines: [2, 5]}
result {oneshot_lines: [2, 5, 3]}
state after result idle
lines per source line [1, 1, 0, nil, 1, nil, nil]
line_stub [0, 0, 0, nil, 0, nil, nil]
legacy array format [1, 1, 0, nil, 1, nil, nil]
eval: true {"generated.rb" => {lines: [1, 1]}}
Source: Coverage.setup, Coverage.line_stub and Coverage.supported?
In the second example, first(30) ran while coverage was suspended and left no trace, so line 3 (:overdue) appears in the
result only after the second first(30) that ran after resume. The RuboCop cop Style/EvalWithLocation is silenced for
the eval line because the invented file name generated.rb is the point: a measured eval is reported under the name you
give it.
Coverage.result raises RuntimeError ("coverage measurement is not enabled") when nothing is running, and start after
setup without a result raises as well; keep calls paired. Coverage is slower than a plain run, but it keeps its counters inside the interpreter instead of calling a Ruby
block for every line the way a :line TracePoint does. A line count
of 1 does not mean "tested": it means the line ran, which is why branch coverage and good assertions matter more than the
percentage (see Testing with Minitest).
Instruction sequences, syntax trees and Prism
Between the text of a program and its execution, CRuby parses the source into a syntax tree and compiles the tree into YARV instructions (see YJIT, ZJIT and the VM). Three APIs expose those stages, and they differ greatly in how far you can rely on them.
| API | Stage | Stability |
|---|---|---|
|
Compiled YARV bytecode |
CRuby only. The documentation states that the behaviour of its methods can change in any version and that the instruction output will almost certainly change as Ruby changes. |
|
Syntax tree from the legacy |
CRuby only and documented as experimental: the order and number of child nodes are not guaranteed. It points to Prism as the stable choice. |
Prism ( |
Syntax tree from the parser CRuby itself uses (default since Ruby 3.4) |
The official, versioned API. A default gem since Ruby 3.3, portable across Ruby implementations, with a documented node set. |
Ripper ( |
Event-based lexer and parser API |
Older and still shipped. Prism provides a Ripper-compatible |
The whole RubyVM module exists only on MRI (the reference implementation), and the documentation says normal users must not use it. Use these APIs to
learn, to debug and to build tools that you control the Ruby version of, not in application code.
RubyVM::InstructionSequence
An instruction sequence (an iseq) is what a method, block or file compiles to. InstructionSequence.compile(source, file)
compiles a string, compile_file a file, of(method_or_proc) returns the iseq of an existing method or block (nil for C
methods), and disasm prints the instructions. eval runs a compiled iseq, to_a returns its contents as data and
to_binary / load_from_binary serialize it, which is the basis of bytecode caches. label, path, base_label,
first_lineno and absolute_path identify it, compile_option and compile_option= read and change the compiler
switches (specialized instructions, inline constant caches, frozen_string_literal, and so on).
# frozen_string_literal: true
source = <<~RUBY
def late_fee(days, rate: 5)
days > 14 ? (days - 14) * rate : 0
end
RUBY
iseq = RubyVM::InstructionSequence.compile(source, "late_fee.rb")
puts "label, path #{iseq.label}, #{iseq.path}"
puts "first_lineno #{iseq.first_lineno}"
puts "to_a header #{iseq.to_a.first(3)}"
puts
# Disassemble a method that already exists in this process.
class Calculator
def double(value) = value * 2
end
method_iseq = RubyVM::InstructionSequence.of(Calculator.instance_method(:double))
puts method_iseq.disasm
options = RubyVM::InstructionSequence.compile_option.slice(:inline_const_cache, :specialized_instruction)
puts "compile options #{options}"
puts "eval a fresh iseq #{RubyVM::InstructionSequence.compile('6 * 7').eval}"
puts "of a C method #{RubyVM::InstructionSequence.of(1.method(:+)).inspect}"
label, path <compiled>, late_fee.rb
first_lineno 1
to_a header ["YARVInstructionSequence/SimpleDataFormat", 4, 0]
== disasm: #<ISeq:double@e14_instruction_sequence.rb:17 (17,2)-(17,31)>
local table (size: 1, argc: 1 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
[ 1] value@0<Arg>
0000 getlocal_WC_0 value@0 ( 17)[Ca]
0002 putobject 2
0004 opt_mult <calldata!mid:*, argc:1, ARGS_SIMPLE>[CcCr]
0006 leave [Re]
compile options {inline_const_cache: true, specialized_instruction: true}
eval a fresh iseq 42
of a C method nil
Source: RubyVM::InstructionSequence and RubyVM
The disassembly of value * 2 is five instructions: load the local value, push the constant 2, call the specialized
multiplication opt_mult, and leave. The bracketed flags at the end of a line ([Ca], [CcCr], [Re]) mark call
information and trace points. You will meet the same listing from the command line, with no code at all:
$ ruby --dump=insns -e 'def double(v) = v * 2'
== disasm: #<ISeq:<main>@-e:1 (1,0)-(1,21)>
0000 definemethod :double, double ( 1)[Li]
0003 putobject :double
0005 leave
== disasm: #<ISeq:double@-e:1 (1,0)-(1,21)>
local table (size: 1, argc: 1 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
[ 1] v@0<Arg>
0000 getlocal_WC_0 v@0 ( 1)[Ca]
0002 putobject 2
0004 opt_mult <calldata!mid:*, argc:1, ARGS_SIMPLE>[CcCr]
0006 leave [Re]
Source: RubyVM::InstructionSequence
ruby --dump=parsetree prints the Prism syntax tree of a program in the same way, and adding --dump=-optimize shows the
code without the compiler’s optimizations (opt_mult becomes a plain send). Besides compile, there are compile_prism
and compile_parsey, which pick the parser explicitly; compile uses the default one.
An iseq also drives the target: option of TracePoint (Tracing with TracePoint), the :script_compiled event’s
instruction_sequence attribute, and the iseq-based Coverage line_stub described above.
RubyVM::AbstractSyntaxTree
RubyVM::AbstractSyntaxTree.parse(string), parse_file(path) and of(method_or_proc) return a tree of
RubyVM::AbstractSyntaxTree::Node objects. A node has a type (:DEFN, :OPCALL, :LVAR, …), children, and
first_lineno, first_column, last_lineno, last_column; with keep_tokens: true (Ruby 3.2) it also has tokens
and all_tokens, with error_tolerant: true (Ruby 3.2) the parser returns a partial tree for broken code, and
Node#locations (Ruby 3.4) returns RubyVM::AbstractSyntaxTree::Location objects with the positions of each token.
# frozen_string_literal: true
source = <<~RUBY
def price(days)
days * 2 + 1
end
RUBY
def dump(node, depth = 0)
indent = " " * depth
return puts("#{indent}#{node.inspect}") unless node.is_a?(RubyVM::AbstractSyntaxTree::Node)
puts "#{indent}#{node.type} #{node.first_lineno}:#{node.first_column}-#{node.last_lineno}:#{node.last_column}"
return if node.type == :ARGS # ten parameter slots, skipped here
node.children.each { dump(it, depth + 1) }
end
tree = RubyVM::AbstractSyntaxTree.parse(source)
puts "root type #{tree.type}"
dump(tree.children.last)
begin
RubyVM::AbstractSyntaxTree.of(method(:dump))
rescue RuntimeError => e
puts "of(method) #{e.message}"
end
tokens = RubyVM::AbstractSyntaxTree.parse("puts 1 + 2", keep_tokens: true).tokens
puts "tokens #{tokens.first(4).map { it[1..2] }}"
root type SCOPE
DEFN 1:0-3:3
:price
SCOPE 1:0-3:3
[:days]
ARGS 1:10-1:14
OPCALL 2:2-2:14
OPCALL 2:2-2:10
LVAR 2:2-2:6
:days
:*
LIST 2:9-2:10
INTEGER 2:9-2:10
2
nil
:+
LIST 2:13-2:14
INTEGER 2:13-2:14
1
nil
of(method) cannot get AST for ISEQ compiled by prism
tokens [[:tIDENTIFIER, "puts"], [:tSP, " "], [:tINTEGER, "1"], [:tSP, " "]]
The tree of the three-line price method shows the quirks that make the API unsuitable as a tool foundation: days * 2 + 1
is an OPCALL of + whose receiver is another OPCALL, an operator call has a LIST of arguments, and the parameter list
ARGS is a ten-slot array of mostly nil and numbers whose meaning is only in the C source.
The last lines of the output hide the most important pitfall. AbstractSyntaxTree.of(method) needs a method compiled by the legacy
parser. Since Ruby 3.4 the default parser is Prism, so the iseq of any method in your program was compiled by Prism and of
raises RuntimeError ("cannot get AST for ISEQ compiled by prism"); on Ruby 3.3 the same call returns the tree. parse and parse_file keep working because they always use
the legacy parser, and they print the legacy parser’s own diagnostics to $stderr under -w; ruby --parser=parse.y
restores the old behaviour of of. Code that needs to analyse source it finds through Method#source_location should read
the file and parse it with Prism, as in the next section.
Prism
Prism is a portable, error-tolerant recursive-descent parser for Ruby. It is both a C library, which CRuby uses internally,
and a gem with a Ruby API for any tool that needs to parse Ruby. It became the default parser of CRuby in 3.4 (selected
with --parser=prism or --parser=parse.y), and the Ruby documentation names it as the official API for parsing Ruby code
that works across implementations. Because it is error tolerant, broken code still yields a tree and a list of errors. Prism versions follow the gem, not Ruby’s version: Ruby 4.0.0 bundled Prism 1.7.0 and the 4.0.7 used here reports
Prism::VERSION 1.8.1.
The entry points are Prism.parse(source, version: ...), parse_file, parse_success?, parse_comments, lex and
dump. A Prism::ParseResult has value (the ProgramNode), errors, warnings, comments and magic_comments.
Every node is a typed class (DefNode, CallNode, LocalVariableReadNode, ItLocalVariableReadNode, …), with named
accessors instead of positional children, a location with start_line, end_line, start_column and slice, and accept
for visitors. Prism::Visitor walks the tree, and you override visit_*_node for the nodes you care about, calling super
to continue into the children.
# frozen_string_literal: true
require "prism"
require "ripper"
require "shelf"
source = <<~RUBY
# Price a loan.
def price(days, rate: 2)
days * rate + 1
end
RUBY
result = Prism.parse(source)
puts "result class #{result.class}, success? #{result.success?}"
puts "errors, warnings #{result.errors.size}, #{result.warnings.size}"
puts "comments #{result.comments.map { it.location.slice }}"
method_node = result.value.statements.body.first
puts "node #{method_node.class} #{method_node.name.inspect}"
puts "lines #{method_node.location.start_line}-#{method_node.location.end_line}"
puts "parameters #{method_node.parameters.requireds.map(&:name)} #{method_node.parameters.keywords.map(&:name)}"
puts "source slice #{method_node.location.slice.lines.first.chomp}"
puts "first tokens #{Prism.lex('a = 1').value.map { |token, _state| token.type }}"
puts "parse_success? #{Prism.parse_success?('1 + 2')} #{Prism.parse_success?('1 +')}"
broken = Prism.parse("def price(\n 1 +\nend")
broken.errors.each { puts " line #{it.location.start_line}: #{it.message} (#{it.type})" }
puts "tree still built #{broken.value.class}"
# A visitor walks the tree; override only the node types you care about.
class MethodCollector < Prism::Visitor
attr_reader :found
def initialize
super
@found = []
end
def visit_def_node(node)
@found << "#{node.name}:#{node.location.start_line}"
super
end
end
# Reflection finds the file; Prism parses it.
isbn_file = Shelf::Isbn.instance_method(:to_s).source_location.first
collector = MethodCollector.new
Prism.parse_file(isbn_file).value.accept(collector)
puts "defs in isbn.rb #{collector.found}"
# The same question under two language versions: `it` is a method call in 3.3 and a parameter in 3.4.
%w[3.3 3.4].each do |version|
call = Prism.parse("[1].map { it }", version:).value.statements.body.first
puts "it under #{version} #{call.block.body.body.first.class}"
end
# Ripper is the older, event-based API; Prism ships a drop-in translation of it.
puts "Ripper.sexp #{Ripper.sexp('1 + 2')}"
puts "Prism's Ripper #{Prism::Translation::Ripper.sexp('1 + 2') == Ripper.sexp('1 + 2')}"
puts "Prism::VERSION #{Prism::VERSION}"
result class Prism::ParseResult, success? true
errors, warnings 0, 0
comments ["# Price a loan."]
node Prism::DefNode :price
lines 2-4
parameters [:days] [:rate]
source slice def price(days, rate: 2)
first tokens [:IDENTIFIER, :EQUAL, :INTEGER, :EOF]
parse_success? true false
line 2: unexpected integer; expected a `)` to close the parameters (def_params_term_paren)
line 2: unexpected '+', expecting end-of-input (expect_eol_after_statement)
line 2: unexpected 'end', expected a receiver for unary `+` (unary_receiver)
line 3: unexpected 'end', assuming it is closing the parent method definition (unexpected_token_close_context)
tree still built Prism::ProgramNode
defs in isbn.rb ["initialize:6", "to_s:13", "valid?:17"]
it under 3.3 Prism::CallNode
it under 3.4 Prism::ItLocalVariableReadNode
Ripper.sexp [:program, [[:binary, [:@int, "1", [1, 0]], :+, [:@int, "2", [1, 4]]]]]
Prism's Ripper true
Prism::VERSION 1.8.1
Source: Prism, Ripper, Prism Ruby API and Prism: Ruby API
What the example shows, in order:
-
a successful parse (
success?, no errors) and the typedDefNodewith its name, lines, parameters and the exact source slice, plus the comment that was found; -
Prism.lexreturning tokens, andparse_success?as the quick validity check; -
a broken file producing four errors, each with a line, a message and a stable
typesymbol, and still a tree; -
a
Prism::Visitorsubclass collecting the methods ofisbn.rb, a file that was found through reflection (source_location) and parsed with Prism, the pattern behind every documentation, lint and refactoring tool; -
the
version:option:itin[1].map { it }is parsed as a method call when the target is Ruby 3.3 and as the implicit block parameter (ItLocalVariableReadNode) when it is 3.4, which is what lets a tool analyse code for an older Ruby than the one it runs on; and -
Ripper.sexpcompared with the same call onPrism::Translation::Ripper, which returns the same result for that input.
Prism::Translation::Parser and RubyParser produce the trees that the community parser and ruby_parser gems return, so
tools written for those gems (RuboCop’s cops are built on the parser AST) can run on Prism without rewriting. Ripper
(Ripper.sexp, Ripper.lex, Ripper.tokenize) is still available and is how older tools such as syntax highlighters
work; for new code, choose Prism.
Which one to use
| You want to … | Use |
|---|---|
Find where a method or constant is defined |
|
Read, search or rewrite Ruby source in a tool |
Prism |
Keep working with a tool written for the |
|
Understand why Ruby is slow or what a construct compiles to |
|
Check syntax without running a program |
|
See which line of source a backtrace frame points at |
|
Run Ruby code produced by a program |
Not these APIs: see Hooks, eval and DSLs and treat the text as untrusted |
Putting it together: an API report
The pieces combine into small tools. This one prints, for each class, the methods it defines itself with their visibility,
a signature rebuilt from Method#parameters, and the file and line from source_location. It uses only Module and
Method reflection, so it works on any Ruby.
# frozen_string_literal: true
require "shelf"
# Renders Method#parameters back into something that looks like a signature.
module Signature
FORMATS = {
req: "%<name>s", opt: "%<name>s = ...", rest: "*%<name>s", keyreq: "%<name>s:",
key: "%<name>s: ...", keyrest: "**%<name>s", block: "&%<name>s"
}.freeze
ANONYMOUS = %i[* ** &].freeze
def self.render(parameters)
parameters.map do |kind, name|
format(FORMATS.fetch(kind), name: ANONYMOUS.include?(name) ? nil : name)
end.join(", ")
end
end
# The instance methods a class defines itself: visibility, method and location.
def own_methods(klass)
%i[public private].flat_map do |visibility|
klass.public_send(:"#{visibility}_instance_methods", false).map { [visibility, klass.instance_method(it)] }
end
end
def location(method)
file, line = method.source_location
file ? "#{File.basename(file)}:#{line}" : "(generated)"
end
def api_report(klass)
puts klass
own_methods(klass).sort_by { |_, method| method.source_location&.last || 0 }.each do |visibility, method|
signature = "#{method.name}(#{Signature.render(method.parameters)})"
puts format(" %<visibility>-7s #%<signature>-34s %<where>s", visibility:, signature:, where: location(method))
end
end
[Shelf::Isbn, Shelf::Loan, Shelf::Catalog, Shelf::Store::Memory].each { api_report(it) }
Shelf::Isbn
public #digits() (generated)
private #initialize(digits:) isbn.rb:6
public #to_s() isbn.rb:13
private #valid?(value) isbn.rb:17
Shelf::Loan
public #state() loan.rb:8
public #book() loan.rb:8
private #initialize(book, state: ...) loan.rb:10
public #describe() loan.rb:17
Shelf::Catalog
private #initialize(store: ...) catalog.rb:8
public #add(book) catalog.rb:13
public #each(&) catalog.rb:18
Shelf::Store::Memory
private #initialize() memory.rb:7
public #all() memory.rb:9
public #add(book) memory.rb:11
The report shows details that a reading of the source hides: Shelf::Isbn#digits is "generated" because the reader that Data.define
creates has no Ruby source; Loan#state and Loan#book both point at line 8, the attr_reader call; the private initialize is
listed with its keyword parameter digits: for Isbn because the Data class overrides it; Catalog#each(&) shows the
anonymous block parameter. A documentation generator, an IDE outline and a "public API changed" check in CI are all
variations of this loop.
Costs, safety and portability
-
Speed.
respond_to?,instance_variable_getandMethodobjects are cheap enough for ordinary code, though aMethodallocation per call adds up in a tight loop.ObjectSpace.each_object,count_objects_size, allocation tracing and every TracePoint are diagnostic tools with real cost. Never put them in a request path. -
Safety. Reflective calls take names, and names that come from outside are input:
obj.public_send(params[:action]),Object.const_get(params[:type]),Binding#evalandinstance_variable_set(params[:name], ...)let a caller reach methods, classes and state that you never meant to expose. Allow-list the names, and read Security. -
Encapsulation. Reflection ignores
private(instance_variable_get,send,instance_methodof a private method) by design. Using it in application code ties the caller to the implementation. Tests are the usual exception, and even there prefer the public interface; see Visibility and Encapsulation. -
Portability.
RubyVMexists only on MRI, and severalObjectSpacemethods,TracePoint#instruction_sequenceand theobjspaceextension are documented as MRI specific.Method,Binding,caller_locationsand theModulequeries are core Ruby and the safe subset. The shared, versioned API for parsing is Prism. -
Stability. Everything under
RubyVMand the output ofdisasm,ObjectSpace.count_objectskeys,TracePoint.statand the text of backtrace lines and warnings can change in any release (3.4 changed backtraces and 4.0 changed them again). Do not parse them in code you ship; use the structured APIs (caller_locations,Warning.warnwithcategory:,Prism). -
Retention.
Bindingobjects,Methodobjects with a bound receiver,Proc#bindingand aTracePointargument kept after the hook returns all hold on to their scope or receiver; collect values, not the reflective objects.
Where older books and tutorials are outdated
| Older statement | Current behaviour |
|---|---|
Ruby parses and executes a program "line by line". |
The whole file is parsed first (with Prism since 3.4), compiled to YARV bytecode, and then executed, possibly by YJIT (3.2) or ZJIT (4.0, experimental). |
Use |
Use |
Find subclasses with `ObjectSpace.each_object(Class).select { |
c |
c < Base }`. |
|
|
|
|
Deprecated in 4.0 (Feature #15408); a deprecation warning appears under |
|
Added in 3.1, removed in 3.2. Use |
|
|
|
It works only for methods compiled by the legacy parser; with Prism as the default it raises |
|
Hashes print |
Warnings are all-or-nothing, controlled by |
Categories ( |
|
It has lines, branches, methods, |
|
|
Common exceptions and warnings
The messages below are the real ones from Ruby 4.0.7.
# frozen_string_literal: true
require "coverage"
require "prism"
require "shelf"
def attempt(label)
yield
puts "#{label}: no error"
rescue StandardError, ScriptError => e
puts "#{label}\n #{e.class}: #{e.message.lines.first.chomp}"
end
loan = Shelf::Loan.new(nil)
attempt("instance_variable_get without @") { loan.instance_variable_get("state") }
attempt("instance_variable_set on frozen") { Shelf::Isbn.new("978-0-306-40615-7").instance_variable_set(:@x, 1) }
attempt("method with a missing name") { loan.method(:nope) }
attempt("instance_method with a missing name") { Shelf::Loan.instance_method(:nope) }
attempt("public_instance_method on private") { Shelf::Loan.public_instance_method(:initialize) }
attempt("bind to the wrong class") { Shelf::Loan.instance_method(:describe).bind(Object.new) }
attempt("const_get missing constant") { Shelf.const_get(:Nope) }
attempt("const_get with a bad name") { Shelf.const_get("lower") }
attempt("attached_object on a class") { Shelf::Loan.attached_object }
attempt("TracePoint with a bad event") { TracePoint.new(:nope) { nil } }
attempt("TracePoint without a block") { TracePoint.new(:call) }
attempt("TracePoint attribute misuse") do
TracePoint.new(:call, &:return_value).enable { Shelf::Catalog.new }
end
attempt("TracePoint attribute outside hook") do
saved = nil
TracePoint.new(:call) { saved = it }.enable { Shelf::Catalog.new }
saved.lineno
end
attempt("Coverage.result when not running") { Coverage.result }
attempt("Warning with an unknown category") { Warning[:nope] }
attempt("AST.of a Prism-compiled method") { RubyVM::AbstractSyntaxTree.of(loan.method(:describe)) }
attempt("compile broken source") { RubyVM::InstructionSequence.compile("def (") }
attempt("Prism with an unknown version") { Prism.parse("1", version: "2.6") }
instance_variable_get without @
NameError: 'state' is not allowed as an instance variable name
instance_variable_set on frozen
FrozenError: can't modify frozen Shelf::Isbn: #<data Shelf::Isbn digits="9780306406157">
method with a missing name
NameError: undefined method 'nope' for class 'Shelf::Loan'
instance_method with a missing name
NameError: undefined method 'nope' for class 'Shelf::Loan'
public_instance_method on private
NameError: method 'initialize' for class 'Shelf::Loan' is private
bind to the wrong class
TypeError: bind argument must be an instance of Shelf::Loan
const_get missing constant
NameError: uninitialized constant Shelf::Nope
const_get with a bad name
NameError: wrong constant name lower
attached_object on a class
TypeError: 'Shelf::Loan' is not a singleton class
TracePoint with a bad event
ArgumentError: unknown event: nope
TracePoint without a block
ArgumentError: must be called with a block
TracePoint attribute misuse
RuntimeError: not supported by this event
TracePoint attribute outside hook
RuntimeError: access from outside
Coverage.result when not running
RuntimeError: coverage measurement is not enabled
Warning with an unknown category
ArgumentError: unknown category: nope
AST.of a Prism-compiled method
RuntimeError: cannot get AST for ISEQ compiled by prism
compile broken source
SyntaxError: <compiled>:1: syntax errors found
Prism with an unknown version
ArgumentError: invalid version: 2.6
Source: NameError, TracePoint.new and Coverage.result
| Exception or warning | Typical cause |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Mistakes in |
|
A TracePoint attribute that the current event does not have, such as |
|
Reading a TracePoint argument after the hook has returned. |
|
|
|
|
|
|
|
|
|
|
|
|
Pitfalls
-
obj.methods(false)is not "the methods the class defines"; it is the singleton methods. Useobj.class.instance_methods(false)for the class’s own, orpublic_methods(false)for singleton plus class. -
instance_methodshides private methods, so a class can look empty (initializeis private). Ask forprivate_instance_methods(false)as well, orrespond_to?(name, true). -
Comparing
Method#ownerwithreceiver.classis wrong for inherited and mixed-in methods:Catalog.new.method(:map).ownerisEnumerable. -
Method#source_locationisnilfor C methods andconst_source_locationis[]for constants defined in C; check before indexing. Forattr_*anddefine_methodit points at the generating call, not at adef. -
method_missingwithoutrespond_to_missing?breaksrespond_to?,methodand anything built on them. -
ObjectSpace.each_object(Class)includes singleton classes. Counting objects with it is also affected by garbage that has not yet been collected. -
A TracePoint block runs for every matching event in every file, including gems. Filter first and narrow the scope. The hook cannot trace code it calls itself unless you use
allow_reentry. -
A TracePoint attribute used on the wrong event raises; a stored TracePoint argument raises later.
bindingisnilfor C events. -
TracePoint#enable { }is scoped to the current thread only. Usetarget_thread: nilfor others. -
Coverage.startafterrequiremeasures nothing for the files already loaded. Start it first, at the top of the test helper or the script that loads the code. -
$VERBOSE = nilturns offwarnentirely, even your own deprecation messages. PreferWarning[:deprecated] = falsewhen you only want to silence one category. -
RubyVM::AbstractSyntaxTree.ofraises with Prism-compiled code (3.4+). Do not build tools on it. -
Parsing a backtrace or a warning message with a regexp breaks on the next Ruby upgrade: 3.4 changed the quoting and added owners, 4.0 changed internal frames and the
ArgumentErrortext. -
bindingandevalrun code that was never written in the file you are reading. Do not pass them input from outside.
Version notes
| Version | Change that concerns reflection and tracing |
|---|---|
3.0 |
|
3.1 |
|
3.2 |
|
3.3 |
|
3.4 |
Prism is the default parser; backtraces and error messages use apostrophes and owner names, and drop |
4.0 |
|
4.1 (preview) |
Not released as of 2026-10-18. This page makes no claim about it. |
References
-
Method, UnboundMethod, Proc and Binding
-
Thread::Backtrace::Location and Exception (
backtrace,backtrace_locations,set_backtrace) -
ObjectSpace (including the
objspaceextension), WeakMap and WeakKeyMap -
Warning and Command-line options (
-W,--dump,--parser) -
RubyVM, RubyVM::InstructionSequence and RubyVM::AbstractSyntaxTree
-
Prism documentation, the Prism repository and Prism: Ruby API
-
Ruby 3.1.0 NEWS, 3.2.0, 3.3.0, 3.4.0 and 4.0.0
-
ruby/debug (a debugger built on TracePoint) and SimpleCov (a coverage tool built on
Coverage), both community projects