Ruby object inspection is inherent to all objects — except BasicObject — via the humble Object#inspect method. This allows you to see a human readable representation of the object you are working with along with the instance variables and/or injected dependencies that your object consists of. Even better, you can override any of your objects' #inspect method to best display relevent information for inspection and/or debugging purposes.
Let’s learn how you can make use of inspection within your own objects.
Basics
Native object inspection is handled via the following methods:
-
Object#inspect: A public method available to all objects exceptBasicObject. -
Kernel#instance_variables_to_inspect: A private — introduced in Ruby 4.0.0 — which allows you to specify what instance variables you want to include.
You want to use one of the above methods, but not both. Let’s look at both next.
Inspect
Implementing your own #inspect method is the most common way to customize object inspection. For example, consider the following object:
class Person
def initialize name:, handle:
@name = name
@handle = handle
end
end
Person.new(name: "Mork", handle: "mork").inspect
#<Person:0x000000010e7b2960 @handle="mork", @name="Mork">
Notice how Ruby gives us object inspection by default. To modify the implementation, we’d only have to override the #inspect method as follows:
class Person
def initialize name:, handle:
@name = name
@handle = handle
end
def inspect = "#<Person name=#{@name} handle=#{@handle}>"
end
Person.new(name: "Mork", handle: "mork").inspect
#<Person name=Mork handle=mork>
As you can see, we have customized the output of the inspect method. While useful for demonstration purposes, you wouldn’t want to use this in practice. This’ll be explained in more detail shortly.
Instance Variables To Inspect
What if you don’t want to override the #inspect method but only include specific attributes? The answer is to use the #instance_variables_to_inspect method as follows:
class Person
def initialize name:, handle:
@name = name
@handle = handle
end
private
def instance_variables_to_inspect = [:@handle]
end
Person.new(name: "Mork", handle: "mork").inspect
#<Person:0x000000012cdfdd88 @handle="mork">
Notice only the handle variable is included when inspecting. This easier than overriding the #inspect. You could also exclude all attributes by using an empty array (not recommended):
class Person
# Truncated for brevity...
def instance_variables_to_inspect = []
end
puts Person.new(name: "Mork", handle: "mork").inspect
#<Person:0x000000012eb6e2a0>
Use of #instance_variables_to_inspect is great for simple filtering but there are several issues with this approach:
To solve the above limitations, use the Inspectable gem which provides advanced customization with minimal effort. We’ll take a closer look at the Inspectable gem later but, first, let’s look at more a few more examples of object inspection to paint a broader picture.
Examples
To set the stage, we can study the behavior of a few primitives via the IRB console:
require "bigdecimal"
require "json"
1 # "1"
1.5 # "1.5"
Rational 1, 5 # "(1/5)""
BigDecimal 1 # "0.1e1"
Time.now.utc # "2023-07-25 12:30:56.892912 UTC"
"Mork" # "Mork"
%w[Mork Mindy] # ["Mork", "Mindy"]
{name: "Mork", handle: "mork"} # {name: "Mork", handle: "mork"}
JSON name: "Mindy", handle: "mindy"
# "{\"name\":\"Mindy\",\"handle\":\"mindy\"}"
# Data (named)
User = Data.define :name, :handle
User[name: "Mork", handle: "mork"]
#<data User name="Mork", handle="mork">
# Data (anonymous)
Data.define(:name, :handle).new name: "Mork", handle: "mork"
#<data name="Mork", handle="mork">
# Struct (named)
Contact = Struct.new(:name, :handle)
Contact[name: "Mindy", handle: "mindy"]
#<struct Contact name="Mindy", handle="mindy">
# Struct (anonymous)
Struct.new(:name, :handle).new name: "Mindy", handle: "mindy"
#<struct name="Mindy", handle="mindy">
class Person
def initialize name:, handle:
@name = name
@handle = handle
end
end
Person.new name: "Mork", handle: "mork"
#<Person:0x000000010e7b2960 @handle="mork", @name="Mork">
The key takeaways from the above code are:
-
IRB, by default, implicitly messages
#inspecton each of the above examples. This is an excellent quality of life improvement since you don’t have to manually type.inspecteach time you interact with an object. -
Each primitive provides a nice, human readable string representation of an
Integer,Float,Rational,BigDecimal,Time,String,Array,Hash,JSON,Data,Struct, andClass. -
For most primitives, no additional type information is included because the string representation implicitly includes those details. With
Data,Struct, andClassobjects, you begin to see type information because, without, you could easily confuseData,Struct, andHashas the same type when they definitely are not. -
Only with a
Classdo you finally see type and memory address information because an instance of aClassis not a whole value object so equality is determined by the object ID in memory which is important to know when you have multiple instances of the same class in use.
Implicit versus Explicit
Implicit versus explicit inspection varies depending on context. With IRB, we get implicit object inspection by default. This makes sense since because IRB provides an environment to experiment with Ruby code with minimal hassle. This is not always true with other methods. Consider the following:
require "amazing_print"
require "json"
json = JSON name: "Mindy", handle: "mindy"
p json # "{\"name\":\"Mindy\",\"handle\":\"mindy\"}"
pp json # "{\"name\":\"Mindy\",\"handle\":\"mindy\"}"
ap json # "{\"name\":\"Mindy\",\"handle\":\"mindy\"}"
puts json # {"name":"Mindy","handle":"mindy"}
The pretty print methods, p, pp, and ap (short for Amazing Print) make implicit use of #inspect while puts is meant for printing to standard output so #to_s is messaged instead of #inspect. Taken a step further, this dance between #inspect and #to_s plays nicely when using the Refinements gem. Example:
require "refinements"
using Refinements::Array
demo = ["apple", :blueberry]
puts demo.to_sentence # apple and blueberry
puts demo.to_usage # "apple" and :blueberry
With the Array#to_sentence refinement, we get a string representation (i.e. #to_s) of apple and blueberry which is perfect for sentence construction where type information isn’t necessary and more akin for display within documentation or a user interface.
On the flip side, the Array#to_usage uses inspection (i.e. #inspect) which gives you a sentence with type information which is perfect for error messages because knowing the difference between a string and symbol speeds up the debugging process. Without inspection, you’d not be able to intuit the wrong argument was used since only seeing apple or blueberry in the output misses the subtle difference between using a String or Symbol depending on your input requirements. Little touches like this can make a world of difference.
Customization
As powerful as Object#inspect is there are times where you might need to customize and manually override default behavior. Here are a few use cases:
-
The default format of
@key=valuedoesn’t fit the aesthetic of the object being inspected so customization provides clarity. -
The default behavior is too verbose so you need to simplify and reduce the amount of information provided to the bare essentials.
Let’s dive into each of the above. We can experiment with our Person class from earlier:
class Person
attr_reader :name, :handle
def initialize name: "Mork", handle: "mork"
@name = name
@handle = handle
end
end
For simplicity, only each modified implementation with comments and corresponding result is shown below:
# Default behavior as a starting reference.
Person.new.inspect
# "#<Person:0x00000001160919f8 @name=\"Mork\", @handle=\"mork\">"
# Equivalent to the above by using Object ID with same formatting.
def inspect
format "#<%<class>s:%<id>#018x @name=%<name>s @handle=%<handle>s>",
class: self.class,
id: object_id << 1,
name: name.inspect,
handle: handle.inspect
end
# "#<Person:0x0000000000000f60 @name=\"Mork\" @handle=\"mork\">"
💡 The bitwise left shift (i.e. object_id << 1) simulates type tagging because Ruby, internally, uses the least significant bit.
# Equivalent to the above using metaprogramming.
def inspect
pattern = +""
values = []
instance_variables.each do |name|
pattern << "#{name}=%s "
values.push instance_variable_get(name).inspect
end
format "#<%s:%#018x #{pattern.strip}>", self.class, object_id << 1, *values
end
# "#<Person:0x00000000000012a0 @name=\"Mork\" @handle=\"mork\">"
# Equivalent to the above by only using Object ID (format is inconsistent, though).
def inspect = "#<#{self.class}:#{object_id} @name=#{name.inspect} @handle=#{handle.inspect}>"
# "#<Person:1896 @name=\"Mork\" @handle=\"mork\">"
# No ID. Discouraged.
def inspect = "#<#{self.class} @name=#{name.inspect}, @handle=#{handle.inspect}>"
# "#<Person @name=\"Mork\", @handle=\"mork\">"
# No type. Strongly discouraged.
def inspect = "#<@name=#{name.inspect}, @handle=#{handle.inspect}>"
# "#<@name=\"Mork\", @handle=\"mork\">"
# No `@` symbols or commas. Discouraged but acceptable depending on context.
def inspect = "#<#{self.class} name=#{name.inspect} handle=#{handle.inspect}>"
# "#<Person name=\"Mork\" handle=\"mork\">"
# Different format but has distinct and parsable structure.
def inspect = "#{self.class}: name=#{name.inspect} handle=#{handle.inspect}"
# "Person: name=\"Mork\" handle=\"mork\""
The above illustrates a few best practices and antipatterns. We can take this a step further by looking at the XDG gem which breaks the mold in a useful way that is appropriate for the environment XDG operates in.
XDG, for the unfamiliar, is a powerful directory specification for organizing your Dotfiles as well as general cache, configuration, state, data, and runtime information. The key concept to be aware of is XDG is purely environment focused which means XDG can represent itself as such. Example:
require "xdg"
xdg = XDG.new
xdg.inspect
# "#<XDG::Environment:7220
# XDG_CACHE_HOME=/Users/demo/.cache
# XDG_CONFIG_HOME=/Users/demo/.config
# XDG_CONFIG_DIRS=/etc/xdg
# XDG_DATA_HOME=/Users/demo/.local/share
# XDG_DATA_DIRS=/usr/local/share:/usr/share
# XDG_STATE_HOME=/Users/demo/.local/state
# >"
The key takeaways are:
-
Attribute keys are represented in upcase and snake case to match environment syntax.
-
Each key/value pair is delimited by an equals sign to match environment syntax.
-
Each key/value pair is separated by a space to match environment syntax. This also means you can more easily copy and paste for direct use in your own environment if desired.
A final example has to do with nested objects in situations where the object you are inspecting is composed of several dependencies. Those dependencies will each be inspected when you inspect the primary object so we need to be careful we are not overly verbose. If we use the Sod gem --- a Domain Specific Language (DSL) for Command Line Interfaces (CLIs) --- we can see how this plays out:
require "sod"
class Demo < Sod::Action
description "A demo."
on "--demo"
def call(*) = "For demonstration purposes only."
end
puts Demo.new.inspect
# <Demo:1200
# @context=#<Sod::Context:0x00000001178e2b60>
# aliases=["--demo"],
# argument=nil,
# type=nil,
# allow=nil,
# default=nil,
# description="A demo.", ancillary=[]
# >
Notice there is an embedded context along with various attributes that define the CLI. The implementation looks like this:
def inspect
attributes = record.to_h.map { |key, value| "#{key}=#{value.inspect}" }
%(#<#{self.class}:#{object_id} @context=#{context.inspect} #{attributes.join ", "}>)
end
The reason the custom #inspect method is provided is because without it, you’d get the following output instead:
# <Demo:0x000000012e0562a0
# @context=#<Sod::Context:0x000000012e053640>,
# @record=#<data Sod::Models::Action
# aliases=["--demo"],
# argument=nil,
# type=nil,
# allow=nil,
# default=nil,
# description="A demo.",
# ancillary=[]>
# >
Notice the extra verbosity of the record Data object. Despite using an internal Data object within the Sod::Action instance, this detail is not relevant for inspection purposes. Only the attributes that make up the record are, albeit minor this detail may seem. By only focusing on the attributes, not the record itself, this slims down what you have to sift through when looking at multiple actions at once. The reduced verbosity makes a big difference while not losing relevant details.
Encodings
The string output of your inspect object must always have compatibility encoding. All this means is that the encoding needs to match the default encoding. Example:
# Yes
Encoding.default_external # #<Encoding:UTF-8>
"demo".encoding # #<Encoding:UTF-8>
# No
Encoding.default_external # #<Encoding:UTF-8>
demo = "A test: 1021."
demo.encoding # #<Encoding:UTF-8>
demo.valid_encoding? # false
demo.inspect # ""A test: \x88\x91.""
Generally, this shouldn’t be a problem but can crop up when dealing with different languages or input sources to your application.
Inspectable
In situations where you need customization beyond what Object#inspect provides, like specific instance variable inclusion or transformation, then use the Inspectable gem. Inspectable provides an elegant solution which, in most cases, only requires a line of code to get the behavior you need without having to write custom code yourself. Here’s a quick example:
class Demo
include Inspectable[token: :redact]
def initialize token: "secret", uri: "https://demo.io"
@token = token
@uri = uri
end
end
Demo.new.inspect
"#<Demo:0x0000000000000ea0 @token=\"[REDACTED]\", @uri=\"https://demo.io\">"
Inspectable not only handles the #inspect method but also takes care of the #instance_variables_to_inspect as well. See the gem documentation to learn more.
Guidelines
Before wrapping up, the following guidelines are important to adhere to when implementing object inspection:
-
Must be a string representation of the object’s class name, memory address, and internal attributes (this can vary depending on how verbose or concise the details need to be).
-
Must have the ability to override default inspection with custom inspection as best appropriate for your object.
-
Must use string encoding that matches default encoding. This means if UTF-8 is the default encoding then the string answered back, when inspecting your object, must be UTF-8 too.
-
Never use required parameters because that violates the Liskov Substitution Principle since the superclass (
Object) requires no parameters.
Please adhere to the above in order to avoid surprising behavior when inspecting your objects.
Conclusion
No matter which path you take — native, custom, or using a gem like Inspectable — you have the ability to tailor object inspection to your needs, reduce verbosity, and/or protect sensitive information. Object inspection is powerful when applied judiciously. Otherwise, you can use default behavior which Ruby provides for free!