Mastering Ruby Debugging: From puts to Professional Tools
Hello, Ruby developers!
Debugging is an essential skill in software development, and in this post, we’ll be looking at how to investigate the behavior of Ruby code. As the RubyMine team, we’ve accumulated considerable expertise in creating tools for Ruby developers, and we’re excited to share our experience and knowledge with you.
Recently, at the EuRuKo 2024 conference, our team member Dmitry Pogrebnoy presented the Demystifying Debuggers talk. This blog post is the first in a series based on that presentation, aiming to provide you with valuable insights into debugging Ruby applications.
Every Ruby programmer inevitably encounters situations where their code doesn’t behave as expected. In these moments, we all wish we had an efficient way to pinpoint the problem and fix it quickly. That’s where debugging tools come into play.
In this post, we’ll explore various tools and approaches available to Ruby developers for investigating bugs. We’ll cover several classes of tools, each with its own strengths and weaknesses. Understanding the specifics of each tool will help you choose the most effective one for your particular debugging scenario.
To make our discussion more concrete, we’ll start with a real-world example of a bug we encountered in one of our internal Ruby projects. This case study will illustrate the importance of proper debugging techniques and set the stage for our exploration of debugging tools.
Whether you’re a seasoned Ruby developer or just starting out, this guide will help you sharpen your debugging skills and tackle bugs more efficiently. Let’s get started!
A real bug case from the RubyMine team
In the RubyMine team, our development efforts extend beyond the IDE itself. We’ve created several proprietary gems that enhance the IDE’s functionality. To share some insights, we’ll explore a real-world bug we encountered in one of these gems about a year ago. We’ve isolated and simplified the code sample to focus on the core issue.
Consider the following Ruby code:
def process(thing) if defined? thing.to_s || defined? thing.inspect puts "Element is Printable" else puts "Element is Not Printable" end end process(5) # -> Element is Printable process(BasicObject.new) # -> Element is Printable
At first glance, this process
method seems straightforward. It aims to check whether the given argument has either a to_s
or an inspect
method. If either method exists, process
should print “Element is Printable”; otherwise, it prints “Element is Not Printable”.
At the bottom, you can see two calls of this method with their outputs. The first call process(5)
produces the message “Element is Printable”. This is correct. But the second call process(BasicObject.new)
looks suspicious. It takes BasicObject
as an argument, but prints “Element is Printable”. This is incorrect because the BasicObject
instance does not respond to either of the methods we are looking for. So apparently this code contains a bug.
Let’s take a moment to examine the process
method. Can you spot the bug?
Spoiler – click to expand!
The bug lies in the if
condition:
defined? thing.to_s || defined? thing.inspect
Due to Ruby’s operator precedence, the interpreter actually evaluates this as:
defined?(thing.to_s || defined?(thing.inspect))
This expression always returns “expression”, regardless of whether thing
responds to to_s
or inspect
. As a result, the condition is always true, and our method incorrectly classifies every object as printable.
The fix is simple but illustrative of how small syntax errors can lead to significant logical flaws. We need to explicitly structure our conditions using parentheses:
def process(thing) if defined?(thing.to_s) || defined?(thing.inspect) puts "Element is Printable" else puts "Element is Not Printable" end end process(5) # -> Element is Printable process(BasicObject.new) # -> Element is Not Printable
With this correction, our method now accurately distinguishes between objects that implement to_s
or inspect
and those that don’t.
By sharing this real-world example, we hope to demonstrate that debugging is a crucial skill for all developers, regardless of experience level. It’s not just about fixing errors; it’s about understanding the intricacies of the language and writing more reliable code.
In more complex, production-level applications, such issues can be far more challenging to identify and resolve. This underscores the importance of robust debugging tools and techniques, which we’ll explore in the following sections.
Choosing the right tool
When it comes to debugging Ruby code, developers have several tools and approaches at their disposal. Let’s explore these options, starting with the basics and then moving on to more advanced techniques.
puts statements
The most basic debugging technique, requiring no setup or additional gems, is using puts
statements. This method involves inserting print statements directly into your code to output variable values or execution flow information. While simple, it can be surprisingly effective for quick investigations.
Let’s apply this technique to our earlier example:
def process(thing) puts "defined? thing.to_s: #{defined? thing.to_s}" puts "defined? thing.inspect: #{defined? thing.inspect}" puts "defined? thing.to_s || defined? thing.inspect: #{ defined? thing.to_s || defined? thing.inspect }" if defined? thing.to_s || defined? thing.inspect puts "Element is Printable" else puts "Element is Not Printable" end end process(5) process(BasicObject.new)
This yields the following output:
defined? thing.to_s: method defined? thing.inspect: method defined? thing.to_s || defined? thing.inspect: expression Element is Printable defined? thing.to_s: defined? thing.inspect: defined? thing.to_s || defined? thing.inspect: expression Element is Printable
The inconsistent output from these two methods calls with different arguments hints at where the problem might lie. We can see that, for BasicObject.new
, both thing.to_s
and thing.inspect
are undefined, yet the condition still evaluates to true
.
While basic puts
statements are useful, several gems can make them more informative:
1. puts_debuggerer
gem enhances puts
output with the file name, line number, and content of this line.
For example:
require 'puts_debuggerer' pd "defined? thing.to_s: #{defined? thing.to_s}"
Output:
[PD] example_puts_debuggerer.rb:5 in Object.process > pd "defined? thing.to_s: #{defined? thing.to_s}" => "Debug print 1: method"
2. awesome_print
and similar gems provide more structured and readable output, especially useful for complex objects.
Generally puts
statements are useful and might effectively help you with simple cases or when other tools don’t work for some reason. However, puts
statements are really basic. They require modifying your source code every time you need to adjust an existing message or add a new one. They are usually not convenient to use because you need to restart the program whenever you modify what you are printing.
Pros and cons of debugging using puts
Pros:
- Simple and quick to implement.
- Works in any Ruby environment.
- No additional tools or setup are required.
Cons:
- Requires modifying source code.
- Can clutter the code if overused.
- Forces you to restart the program if you want to change what you print.
- Limited information compared to more advanced tools.
While puts
statements are invaluable for quick checks, they become less efficient for complex scenarios or when frequent changes are needed. In such cases, more advanced tools like interactive consoles or full-fledged debuggers offer greater flexibility and power.
Interactive consoles
Interactive consoles represent the next level in bug investigation tools for Ruby developers. The two primary options are IRB and Pry, both offering powerful introspection capabilities.
To utilize interactive consoles for debugging, you typically need to insert binding.irb
or binding.pry
calls into your source code. When the binding command is executed, an interactive console launches, providing access to the current context and the ability to execute arbitrary expressions in this context.
Let’s use IRB in our earlier example:
def process(thing) binding.irb if defined? thing.to_s || defined? thing.inspect puts "Element is Printable" else puts "Element is Not Printable" end end process(5) # -> Element is Printable process(BasicObject.new) # -> Element is Printable
When the code hits the binding.irb
line, we’ll enter an interactive session:
From: 5_example_define_irb.rb @ line 2 : 1: def process(thing) => 2: binding.irb 3: if defined? thing.to_s || defined? thing.inspect 4: puts "Element is Printable" 5: else 6: puts "Element is Not Printable" 7: end irb(main):001> defined? thing.to_s => nil irb(main):002> defined? thing.inspect => nil irb(main):003> defined? thing.to_s || defined? thing.inspect => "expression" irb(main):004> exit Element is Printable
This interaction allows us to examine the behavior of the condition’s individual parts, helping to pinpoint the issue.
Pros and cons of debugging using interactive consoles
Pros:
- More complex and flexible than
puts
statements. - Partially allows for on-the-fly investigation.
- No need to predetermine all debugging output.
Cons:
- Still requires source code modification.
- Requires you to set predefined introspection points that cannot be changed at runtime.
- Forces you to restart the program if you want to change introspection points.
While interactive consoles offer more power than simple puts
statements, they still have limitations. For complex debugging scenarios or when fine-grained control over execution is needed, full-featured debuggers provide even more capabilities.
Debuggers
Debuggers represent the pinnacle of tools available for investigating bugs in Ruby code. They offer capabilities far beyond simple puts
statements and interactive consoles, providing full control over program execution. This powerful feature set allows developers to:
- Pause execution at a specified point using breakpoints.
- Inspect and modify variables in real time.
- Examine the call stack at every breakpoint.
- Step through code line by line.
- Evaluate expressions in the current context.
Let’s explore the three main debuggers for Ruby:
1. byebug
gem
- Default debugger for Ruby 2.5.X, Ruby 2.6.X, Rails 5, and Rails 6.
- Comes with all the essential features you’d expect from a debugger like breakpoints, stepping, context, and stack introspection.
- For Rails applications, it requires modification of the application source code. You usually need to place a special call in your code to start the debugger at a certain place.
- Has noticeable performance overheads that make it less usable for complicated applications.
2. debug
gem
- Supports only Ruby versions starting from 2.7.
- Has no performance overheads on supported Ruby versions.
- For Rails applications,
debug
, similar tobyebug
, requires modification of the application source code. - Bundled with Ruby starting from version 3.1.
- Supports Ruby versions 2.3 and later – so almost all possible versions of Ruby your application could use.
- Has no performance overheads on any of the supported versions of Ruby.
- No need to modify the code to use the debugger.
- Provides a user-friendly UI out of the box that streamlines debugging.
Despite its extensive feature set, debuggers might be difficult to use in some specific configurations. While debuggers are powerful, they’re most effective when combined with other debugging techniques. The choice of debugger often depends on your specific project and configuration requirements, Ruby version, and personal preferences.
Conclusion
Debugging in Ruby is both an art and a science, presenting challenges that can be overcome with the right tools. As we’ve explored in this post, Ruby developers have a rich toolkit at their disposal, ranging from simple puts
statements to sophisticated debuggers.
Each debugging approach we’ve discussed has its strengths:
puts
statements offer quick, straightforward insights, ideal for simple issues or when other tools are unavailable.- Interactive consoles like IRB and Pry provide a more dynamic environment, allowing for deep context introspection and complex expression evaluation.
- Full-fledged debuggers, such as the
byebug
anddebug
gems, as well as the RubyMine debugger, offer comprehensive control over program execution, enabling developers to dissect even the most intricate bugs.
The journey from encountering an unexpected bug to pinpointing its exact cause often requires a combination of these tools, along with methodical investigation and sometimes a bit of creative problem-solving. By understanding the strengths and limitations of each debugging tool, you can select the most appropriate approach for each unique situation.
As the RubyMine team, we’re particularly interested in how our debugging tools serve the Ruby community. We encourage you to explore the RubyMine debugger and share your experiences in the comments below or create an issue in the issue tracker. Your fellow developers will surely appreciate your insight.
Looking ahead, our next post will delve deeper into the inner workings of debuggers. We’ll explore their internal mechanisms and even tackle an exciting challenge: creating a basic debugger from scratch. This exploration will enhance your understanding of debugging tools and provide deeper insights into Ruby’s internals.
Meanwhile, take advantage of the advanced debugger in RubyMine. Download the latest RubyMine version from our website or via the free Toolbox App.
Remember, effective debugging is more than just finding and fixing errors – it’s about understanding your code at a fundamental level. Each debugging session is an opportunity to learn, improve, and write more robust Ruby code.
Stay curious, keep exploring, and happy debugging!
The RubyMine team