
Ruby object inspection is inherent to all objects — except BasicObject — for displaying information about the object you are working with. As per the official documentation, Object#inspect is meant for:
[Answering] a string containing a human-readable representation of [the object]. The default
inspect
shows the object’s class name, an encoding of its memory address, and a list of the instance variables and their values (by callinginspect
on each of them). User defined classes should override this method to provide a better representation of [object]. When overriding this method, it should return a string whose encoding is compatible with the default external encoding.
Put another way, this means:
-
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).
-
The ability to override default inspection with custom inspection as best appropriate for your object. There are some guidelines to be aware of that I’ll touch upon soon.
-
String encoding must match default encoding which means if UTF-8 is the default encoding then the string answered back when inspecting an object must be UTF-8 too.
This article will take a closer look at each bullet point since you use object inspection every day whether you are conscious of it or not. For example, working in an IRB console, debugging, reading logs, reading stack dumps, and so much more.
Basics
Let’s set the stage by looking at 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.5) # 0.15e1
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.define(:name, :handle).new name: "Mork", handle: "mork"
#<data name="Mork", handle="mork">
Struct.new(:name, :handle).new name: "Mindy", handle: "mindy"
#<struct name="Mindy", handle="mindy">
class Person
attr_reader :name, :handle
def initialize name:, handle:
@name = name
@handle = handle
end
end
Person.new name: "Mork", handle: "mork"
#<Person:0x000000010e7b2960 @handle="mork", @name="Mork">
There are a few key takeaways to point out in the above code:
-
The IRB console, by default, implicitly messages
#inspect
on each of the above examples. This is an excellent quality of life improvement since you don’t have to manually type.inspect
each 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
, andClass
objects, you begin to see type information because, without, you could easily confuseData
,Struct
, andHash
as the same type when they definitely are not. -
Only with a
Class
do you finally see type and memory address information because an instance of aClass
is 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 explore and 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::Arrays
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 that is perfect for error messages because knowing the difference between a string and symbol can speed up debugging when knowing the input type is wrong. 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=value
doesn’t fit or match the aesthetic of object being inspected so customization can provide clarity. -
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, I’ll only show each modified implementation with comments and corresponding result:
# Default behavior as a starting reference.
Person.new.inspect
# "#<Person:0x00000001160919f8 @name=\"Mork\", @handle=\"mork\">"
# Equivalent to the above using Object ID.
# (memory address isn't available via Ruby but is available via C).
def inspect = "#<#{self.class}:#{object_id} @name=#{name.inspect} @handle=#{handle.inspect}>"
# "#<Person:1540 @name=\"Mork\" @handle=\"mork\">"
# Equivalent to the above using metaprogramming.
def inspect
attributes = instance_variables.reduce [] do |info, attribute|
info.append "#{attribute}=#{instance_variable_get(attribute).inspect}"
end
%(#<#{self.class}:#{object_id} #{attributes.join ", "}>)
end
# "#<Person:1540 @name=\"Mork\", @handle=\"mork\">"
# No ID.
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}>"
s# "#<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\""
As you can see, 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_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
There are a few key takeaways with the above:
-
No type or ID information is used because the
XDG
key prefix identifies the information as an XDG object. -
The 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 object for situations where the object you are inspecting is composed of several dependencies. Those dependencies will each be inspected when you inspect the primary object as noted in the Object#inspect
specification shown above. As you can imagine, this can quickly become too 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 @context=#<Sod::Context:0x00000001178e2b60> aliases=["--demo"], argument=nil, type=nil, allow=nil, default=nil, description="A demo.", ancillary=[]>
Notice how there is an embedded context
along with various and 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} @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, 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 last bullet point of the Object#inspect
specification mentions encoding compatibility. 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.
Conclusion
Hopefully this has been helpful in learning more about object inspection and provides you with practical examples of how to not only use inspection but customize as make sense for your application. Enjoy!