Tithe.ly Engineering

Greater readability using Ruby’s Variable-like Method Syntax

by Adam Dumas on February 12, 2024

Prior to joining Tithe.ly, PHP had been my bread and butter for 14 years. Having solved a variety of problems with it, I came to see it as a capable language. Though it was capable, however, I never found it to be particularly readable. I strove to write readable code, but the language didn’t seem to be doing much to help.

Coming to Ruby from PHP, I noticed there was a much larger emphasis on readability. Ruby code I wrote in the style of PHP would come up in Code Review with suggestions that greatly clarified its intent. Time and again, I have discovered Ruby lets me express ideas in code moreso than many other languages I have used.

I would like to share a couple of examples which show how Ruby’s method-invocation syntax facilitates readability.

Using variable assignment syntax for method calls

In PHP, there’s a syntactic distinction between variables and methods.

// Setting an instance variable
$foo->bar = 42;

// Calling a method
$foo->baz();

This is simple enough, but directly setting instance variables breaks encapsulation, so in PHP it’s common to define getters and setters for instance variables.

class Foo {
    private $bar;

    public function getBar() {
        return $this->bar;
    }

    public function setBar($value) {
        $this->bar = $value;
    }
}

$foo = new Foo();

$foo->setBar(42);    // assign 42 to the bar property

This is a good practice, but it means we have to use method call syntax for what is essentially an assignment.

One thing it buys us is the ability to modify the behavior of setting the bar property without needing to change the call sites. This is a win for maintainability, but it’s a loss for readability owing to the extra verbosity of method call syntax, especially with the get/set prefixes.

Ruby, on the other hand, supports a method call syntax that looks like variable assignment.

class Foo
    attr_reader :bar

    # explicitly defined for pedagogical purposes
    def bar=(value)
        @bar = value
    end
end

foo = Foo.new

foo.bar = 42       # assign 42 to the bar property

What looks like an instance variable assignment is actually a method call. The foo.bar = 42 syntax is syntactic sugar for foo.bar=(42). We are sending the bar= message to the foo object. This is useful because it allows us to use the simpler syntax of variable assignment while being able to easily add behavior later on to the bar= method without needing to change the call sites. This is a win for both readability and maintainability.

Using methods instead of local variables

When writing a method, we often need to get some pieces of data to process, and the getting of that data can make the method harder to read. For example, consider the log method:

def log
    author = current_user || "Anonymous"
    date = backdate || postdate
    commit("Processed entry #{id} with date #{date} by #{author}", date, author)
end

The heart of the method is the call to commit, but the lines where we set the author and date variables obscures this. To make it clearer, we might add a blank line before the call to commit; however, let’s explore an alternative. Notice that we are referencing the current_user, backdate, postdate and id methods as if they were variables that we didn’t have to set. What happens if we take this further and do the same thing with author and date?

def log
    commit("Processed entry #{id} with date #{date} by #{author}", date, author)
end

def date
    backdate || postdate
end

def author
    current_user || "Anonymous"
end

This is a small change, but it makes the main logic of the log method more prominent.

Since Ruby does not require parentheses for method calls, calls to date and author look like reading the value of a variable. And since methods can have any arbitrary logic in them, we can hide a lot of the complexity involved in obtaining the value, keeping it outside of methods that need the value but would become harder to read if they had to calculate it themselves.

With this approach, there are a couple of caveats related to the potential of making multiple calls to the same method.

  1. getting the data might be expensive
  2. getting the data might return different values in between calls

As an example of the second caveat, consider the following code:

def log
    commit("Processed entry #{id} with date #{date} by #{author}", date, author)
end

def date
    Date.today # <=== ERROR: date could change in between calls
end

def author
    current_user || "Anonymous"
end

Here, the call to date could return different values. We are calling it twice in the log method, so if the first call happens before midnight and the second call happens after midnight, we would get different values for date in the arguments being sent to commit.

In this case, we would want to use a cache: either a regular variable or through memoizing the method.

A note on value In most cases, the identity of the object returned does not need to be the same in between calls. It just needs to always respond in the same way to the messages we send to it. For example, if we call date and it returns a Date object, we don’t care if it’s the same Date object every time, as long as it represents the same date. An exception is if your code checks for object identity with object_id or equal?.

The upshot is that this technique works best when it’s cheap to get the data and the data doesn’t change in between calls.

Reflecting on this a bit, I am starting to see regular variables as just caches: you save time by not having to recalculate the value every time you need it, and you can trust that the value won’t change in between calls (at least not due to making repeated calls to get it). The drawback is that adding these caches can obscure the main logic of the method, so it’s a trade-off.

Conclusion

Ruby has some nice syntactic sugar that makes it easier to write and read code. The ability to use variable assignment syntax for method calls and to use methods instead of local variables can make our code more readable and maintainable.