In Progress
Unit 1, Lesson 21
In Progress

Method Dependencies

Video transcript & code

Let's talk about dependencies. A lot of times when we discuss dependencies in software, we're looking at dependencies between libraries, files, or classes. But today I thought dial down the microscope a little, and talk about dependencies between individual methods in a class.

Here's a class I worked on recently. Well, really this is just a tiny fraction of the class. As you can see from the comments, I've elided most of the code to focus on just a few methods.

module Quarto
  class Orgmode < Plugin
    # ---- 74 lines snipped ----

    def version
      "8.0.7"
    end

    # --- 8 lines snipped ----

    def orgmode_lisp_dir
      ENV.fetch("QUARTO_ORGMODE_VENDOR_LISP_DIR") {
        "#{vendor_dir}/lisp"
      }
    end

    # ---- 15 lines snipped ----

    def vendor_dir
      Pathname(orgmode_lisp_dir).parent.to_s
    end

    # ---- 70 lines snipped
  end
end

This is a class that I wrote, and that I later broke without realizing it. When I came back to this code, it was generating stack overflows caused by infinite recursion.

With most of the unrelated code stripped away, it isn't too difficult to see why these errors were occurring. If we look at this line here, we can see that in the absence of a user-configured environment variable, it falls back on a method called vendor_dir.

If we look at the definition of that method, it depends on a method called… orgmode_lisp_dir, which is the method we just came from.

This is a classic circular dependency.

In an upcoming episode, we'll dive into how to go about breaking a circular dependency like this one. But I'm not ready to jump to that point quite yet.

Sometimes, when I realize I've committed a seemingly obvious mistake such as this one, I like to step back for a few minutes and reflect on how I could have let it happen. How did I get myself into this mess, and what could I do differently to avoid it in the future?

When I meditated on this code for a little while, I realized that in writing it I had broken a basic rule or guideline that I usually try to observe. The rule is this: dependencies in a given class should always point downward. That is, the first method in a class should depend on methods further down in the class definition, and those methods should depend on methods even further down, and so on.

Simply observing this guideline is often enough to avoid any circular dependency problems.

How had I missed this violation? Well, one potential reason is the overall bulk of the class. As I mentioned earlier, I've removed most of the code for the purpose of readability. With nearly 200 lines in the overall class definition, and with 15 unrelated lines separating one of the offending methods from the other, it was harder to notice that I was introducing a circular dependency.

This brings me to the second guideline I violated: keep class definitions small. Sandi Metz suggests keeping them under 100 lines. I think this is a good, easy-to-remember rule of thumb. I'm even happier when I can fit a whole class in a single screen on my editor, without tweaking font sizes.

(Although I should note for full disclosure that I use a smaller font size and a larger screen when I'm coding than I do for these videos.)

What else might I have missed in the lead-up to this bug? Well, there's a technique I like to practice when I'm refactoring. If I extract code into a new method, I often like to situate the new method immediately underneath the method I pulled the code out of. For instance, consider this possible earlier version of the class in question.

module Quarto
  class Orgmode < Plugin
    # ---- 82 lines snipped ----

    def orgmode_lisp_dir
      ENV.fetch("QUARTO_ORGMODE_VENDOR_LISP_DIR") {
        "#{main.vendor_dir}/org-8.0.7/lisp"
      }
    end
    # ---- 70 lines snipped
  end
end

Let's say I had decided to start extracting elements of the path being generated. I could have first extracted out the path of the orgmode-specific vendor directory.

module Quarto
  class Orgmode < Plugin
    # ---- 82 lines snipped ----

    def orgmode_lisp_dir
      ENV.fetch("QUARTO_ORGMODE_VENDOR_LISP_DIR") {
        "#{vendor_dir}/lisp"
      }
    end

    def vendor_dir
      "#{main.vendor_dir}/org-8.0.7"
    end

    # ---- 70 lines snipped
  end
end

Then I could extract out the version number into its own method.

module Quarto
  class Orgmode < Plugin
    # ---- 82 lines snipped ----

    def orgmode_lisp_dir
      ENV.fetch("QUARTO_ORGMODE_VENDOR_LISP_DIR") {
        "#{vendor_dir}/lisp"
      }
    end

    def vendor_dir
      "#{main.vendor_dir}/org-#{version}"
    end

    def version
      "8.0.7"
    end

    # ---- 70 lines snipped
  end
end

By always "extracting downwards", I've maintained my downwards dependency direction. And I've kept the related methods close to each other. If I had done this the first time around, the proximity might have made it easier for me to see my mistake before I committed it.

So these are three guidelines I violated, in the lead-up to introducing a defect to my code.

  1. Method dependencies should point downwards.
  2. Keep class definitions small.
  3. Extract methods underneath their origin.

They aren't hard-and-fast rules, but I find that they work pretty well when I have the presence of mind and discipline to observe them.

I'm not going to say you necessarily ought to follow these guidelines too. You should think about it and decide what works best for you. But I do recommend that, the next time you commit what feels like a "silly mistake" in your code, you pause before fixing it. Take a few minutes to think about what sort of rules or practices might have prevented you from making the error in the first place. And then after you've reflected, proceed with the fix. Happy hacking!

Responses