Inspecting Ruby's inspect method

If you’ve ever worked with a class in Ruby’s Core Library or Rails, you might not realize that special care was taken to print useful information when used in the context of an IRB session. Take Time for example.

Time.new
# => 2023-06-09 15:10:59.033028 -0400

Compare this output to the output of a user defined class.

class Person
  attr_reader :name, :age

  def initialize(name, age)
    @name = name
    @age = age
  end
end

Person.new("Ralph", 20)
# => #<Person:0x0000000103f70c98 @age=20, @name="Ralph">

Although we still print something to the console, it could be improved by overriding the inspect method.

class Person
  attr_reader :name, :age

  def initialize(name, age)
    @name = name
    @age = age
  end

  def inspect
    { name:, age: } # tempting, but doesn't adhere to convention...keep reading!
  end
end

Person.new("Ralph", 20)
# => {:name=>"Ralph", :age=>20}

Now instead of printing the class name and memory address, we just print a Hash with the name and age. This may seem like an improvement, but it actually violates the specification for the inspect method.

User defined classes should override this method to provide a better representation of obj. When overriding this method, it should return a string whose encoding is compatible with the default external encoding.

If we call inspect on our Person instance, we’ll see we’re not returning a String, but a Hash.

Person.new("Ralph", 20).inspect
# => {:name=>"Ralph", :age=>20}
Person.new("Ralph", 20).inspect.class
# => Hash

We can use this opportunity to modify our inspect method by not only ensuring it returns a String, but also adding back the class name to make it clear what we’re working with. This is a common convention. Here’s an example from Rails.

class Person
  attr_reader :name, :age

  def initialize(name, age)
    @name = name
    @age = age
  end

  def inspect
   "#<#{self.class.name} @name=#{name.inspect} @age=#{age.inspect}>"
  end
end

Person.new("Ralph", 20)
# => #<Person @name="Ralph" @age=20>
Person.new("Ralph", 20).inspect
# => "#<Person @name=\"Ralph\" @age=20>"

Now if we call inspect we’ll return a String in accordance to the specification.

You’ll also note that we call inspect on each attribute, which ensures the value as a whole is returned as expected.

If we did not do this, the name attribute would render without quotation marks. This is an issue because it makes it look like the name is a Ralph class, rather than a String.

class Person
  attr_reader :name, :age

  def initialize(name, age)
    @name = name
    @age = age
  end

  def inspect
   "#<#{self.class.name} @name=#{name} @age=#{age}>"
  end
end

Person.new("Ralph", 20)
# => #<Person @name="Ralph" @age=20>
Person.new("Ralph", 20).inspect
# => "#<Person @name=Ralph @age=20>" # <- Note the missing quotation marks on Ralph

Examples

With great control comes great responsibility flexibility.

Now that we know we can control what inspect displays, let’s see what we can do with it!

Add a little spice

Only seeing the class name of Person isn’t super helpful. What about adding a little to help us debug a problem we’re facing right now? Remember, what inspect does can change from day to day based on our needs – and might not even need to be committed to the repository.

def inspect
  # helpful for debugging, but not needed anywhere else
  is_gmail = email.end_with? "gmail.com"

  "#<#{self.class.name}> name: #{name} is_gmail: #{is_gmail}"
end

Too much spice

Sometimes, we need more than a couple attributes to be displayed, and we don’t want to write an inspect with all the properties that we want. We also don’t want to have to maintain this method as we add new class properties. What to do if some of the attributes make the output overwhelming?

except to the rescue!

def inspect
  attributes.except(["created_at", "updated_at", "some_long_guid", "a_giant_json"]).to_s
end

Many classes are part of has_x and belongs_to relationships. We can show some details about those relationships in inspect:

has_many :favorites

def inspect
  "[other output] favorites: #{favorites.count}"
end

or even chain into their inspect values:

has_many :favorites

def inspect
  "[other output] favorites: #{favorites.map(&:inspect)}"
end

Improving test output

Some tests display the failed object by calling inspect on it. For example, in Minitest, assert_predicate will show output like this when the assertion fails:

Person::Test#test_#named?_returns_true_when_the_person_has_a_name [/.../person_test.rb:100]:
Expected #<User id: 25, email: "email@example.com", created_at: "2023-06-09 17:32:48.000000000
+0000", updated_at: "2023-06-09 17:32:48.000000000 +0000", emergency_phone_number: nil, some_id:
"418a8ebd-a784-4e79-90ed-3b7df650d421", favorite_sandwich: nil, first_name: nil, last_name: nil,
date_of_birth: "2002-06-09", github_url: nil> to be named?.

If we define a Person#inspect

def inspect
  "#{self.class.name}: id=#{id} email=#{email}"
end

the test failure becomes much more readable:

Person::Test#test_#named?_returns_true_when_the_person_has_a_name [/.../person_test.rb:100]:
Expected User: id=25 email=email@example.com to be named?.

Formatting

Sometimes the truth is out there can be noisy. Here’s another example of simplifying the output.

module Prefixes
  module Out
    module Of
      module Control
        class SomeClass
          def inspect
            self.class.name.gsub(/Prefixes::Out::Of::Control::/, "").to_s
          end
        end
      end
    end
  end
end

# Before
Prefixes::Out::Of::Control::SomeClass.new
# <Prefixes::Out::Of::Control::SomeClass:0x000000010dbd7ca8>

# After
Prefixes::Out::Of::Control::SomeClass.new
# <SomeClass>

Providing a default for subclasses

Another helpful thing we can do is override inspect in a higher level object so we don’t have to do it everywhere.

Say we have a bunch of classes that derive from ApplicationModel:

class ApplicationModel
  def inspect
    "#{self.class.name}: attributes=#{attributes.inspect}>"
  end
end

class SomeClass < ApplicationModel
end

SomeClass.new
# => SomeClass: attributes={}

Any subclass that needs to override the implementation can, but with one tiny block of code on the superclass, we can greatly improve our developer life.