Stdout is Forever

Ryan James Spencer

Debuggers are worth their weight in gold but stdout is the diamond in the rough. All the tools we have to pinpoint problems such as REPLs, automatic tracing, stacktraces, and even printing to stdout wind up being about two things: poking and prodding.

A useful macro or two

Rust has the dbg! macro and I love it. It's short enough to type and it shows you what file you are in, line you are on, and how the code looks plus its value after evaluation. e.g. dbg!(dbg!(12) == dbg!(1 + 11)) will print

[src/] 12 = 12
[src/] 1 + 11 = 12
[src/] dbg!(12) == dbg!(1 + 11) = true

Two important quirks with this are,

  1. No arguments passed means you just get the file and line number
  2. The code still behaves the way it used to except now you have tracing

This gives us just enough information to be lethal. This is possible because this expands at compile time and can be replicated in other languages that have macro support. This is a source transformation and we can't easily use a function because our line number will always be the line number of the function, not the calling site. As such, one option is to write it as some repeated action in your editor of choice. Imagine you have the following go code in front of you,

func AddOne(x Int) Int {
  return x + 1

and you want to lay down some tracing so you highlight the x + 1 and hit a keyboard shortcut which transforms the code into the following,

func AddOne(x Int) Int {
  fmt.Printf("[src/main.go:8] x + 1: %#v", x + 1)
  return x + 1

We could have also used the runtime.Caller function to get filename and line number but we can get that spliced in via our editor to avoid an import. If you are curious what the runtime.Caller code looks like here it is (and, yes, I'm ignoring error handling here since this is intentionally throwaway code):

func AddOne(x Int) Int {
  _, file, line, _ := runtime.Caller(0)
  fmt.Printf("[%v:%v] x + 1: %#v\n", file, line, x+1)
  return x + 1

The advantage with the above is now we can take our print lines and move them around at will and we won't have to tweak the filename/lineno combo.


Sometimes the fastest way to get at a problem is by writing test cases that flex assertions about the functionality in question. Other times that's not as fast because the logic might rely on other systems, e.g. integration tests. In those cases, if you have stacktrace support you might find it useful to panic/throw if particular assertions aren't met. When that fails you are probably interfacing with code that is covering up exceptions or panics, say a piece of library code that takes your code as a callback. You could try stubbing in your own forked version of the code (scripting languages tend to make this easy) or you could turn to building your own stacktrace. You iteratively apply print statements in the following fashion,

fn foo() {
  dbg!() # beginning
  dbg!() # middle
  dbg!() # end

With dbg! this is really easy because I don't have to think about what to pass to the printing function since dbg!() simply emits the filename and line number. In languages that may not have this I've done printf(X) where X = "A", "B", "C", and so on.

With this format in place you can use binary search to figure out where you need to apply more printing statements on each subsequent run. If, however, your tests or program take a long while to run it can pay to do upfront work but perhaps limiting yourself to an arbitrary depth to avoid spending too much time on tracing that won't pay off.


You can load your core dumps into gdb and explore the call stack after a segfault among all sorts of other cool things that debuggers allow you to do, or you can rig up systems to automatically provide tracing, such as in erlang or elixir but hopefully this article has shown that stdout gives you powerful debugging functionality since we already have access to executing the program and manipulating its source. We can print assertions to see if they hold up or mess around with alternative solutions that may work if the problem is clear. Stdout isn't always the fastest but it's lightweight which makes it invaluable as it can circumvent a lot of preparatory work. You can pair this approach into a feedback loop, too, to reduce duplicated work such as running the tests or program over and over again. In a future article I'll discuss ways to do this in a range of languages and environments but at least we've set the tone for some thinking about how to improve what we spit out while you hack to give you a better understanding of what's going on under the hood.


The name of this post is inspired from Bodil Stokke when responding to what "What are everyone's fave debugging tools for languages you write code in?"