One of the many things I’ve learnt from the Clean Code book is about the evils of output variables.
Functions can take arguments and can return values. An output variable is one of those arguments that the function uses to give a result by changing its value instead of, or in addition to, the function’s normal return value. Although this sounds really handy, it comes with some issues.
The Devil is in the Details
As mentioned before, output variables may seem innocent, and even good, but there are a number of reasons why they should be avoided. Here’s why.
Hide details that should be visible
In some of my previous articles I’ve mentioned something that is not always recognised: software developers spend most of our time reading rather than writing code.
When we use output variables, we are hiding information from whoever needs to read our code. Following the code becomes harder, as we force people to see how functions are implemented in order to understand the code (or check the documentation if this is a library) to find out that we’re using the argument to return data. And, on top of that, we have to remember all that while we read the rest of the code.
Attempts at addressing this issue by using clear variable names (for example, outValue
) are welcome, but are not really a solution. When reading the code, we may easily miss this if we’re not expecting an output variable, even if the name is clear. And it’s not unheard of to have changed a function and forgotten to change some variable names that are no longer used as output variables.
Can introduce bugs that are hard to detect
Another problem is when two or more variables point to the same object. And, in fact, I find that this is one of the worst types of bugs to have to deal with, as the code seems perfectly fine even though it’s not. Changing a value this way can lead to unexpected results. Take this code, for example:
def apply_discount(out_cart)
out_cart[:total] *= 0.9 # changes the argument
out_cart[:total] # also returns a value
end
def apply_tax(out_cart)
out_cart[:total] *= 1.1 # changes the argument
out_cart[:total] # also returns a value
end
cart = { total: 100 }
# returns 110, cart[:total] now 110
total_after_tax = apply_tax(cart)
# returns 99, cart[:total] now 99
total_after_discount = apply_discount(cart)
Here, both functions do their job correctly, but the interaction produces an invalid value. Taxes should be applied to the original price, before the discount, and the discount should be applied to the original price, without taxes. However, these functions update the value that the other uses to make their calculation, so the final result is incorrect.
If you are still wondering what the customer should be really charged, I’ll show the answer at the end of this article 🙂
Make Testing Harder
Testing a function that returns a value is simple: we call it, compare the return value to what we expect, and voilà:
def double(x)
x * 2
end
puts double(4) == 8 # test in one line
To be fair, tests tend to be more complex in real life. But that said, testing a function’s output variable response adds even more work, as we need to set up the argument, then call the function, to finally check if it changed the way we wanted:
def double!(out_var)
out_var[:value] *= 2
end
var = { value: 4 }
double!(var)
puts var[:value] == 8 # need to check the updated state
That’s more steps and more chances to mess up, and it becomes worse if the function also returns data in the standard way.
Prevents Chain Calls
Another issue is that functions with output variables don’t work well together. Consider the functions in the example below. If these functions used output variables, we’d have to break the code into multiple steps and keep track of extra variables:
data = nil
read_data(data) # sets 'data'
cleaned = nil
clean(cleaned, data) # sets 'cleaned' from data
result = nil
transform(result, cleaned) # sets 'result' from cleaned
Here, we have several lines and temporary variables to be aware of when working with this code. We also have to remember which arguments are being read, which are being written, and in what order.
However, if we had a bunch of functions that just return values, we could chain them easily:
result = transform(clean(read_data))
Here, each function returns its result, and we simply pass it to the next function.
Dancing With the Devil
Even though output variables can cause problems, they can be very useful sometimes, for example, when performance is critical, and creating new objects or passing pointers is the difference between success and failure.
Many programming languages use output variables in their built-in functions/libraries, like encoding and I/O operations. This is not by chance, but to allow building performance-critical applications if a system requires it. Sometimes, these built-in features are so idiomatic that we could even argue that using them is not particularly problematic.
A word of caution though: I’ve witnessed over and over how most developers try to prematurely optimise their code at the expense of readability and maintainability, without even knowing if that was even needed.
Please, if you “think” your code needs to be optimised, do yourself a favour and make sure before making any changes. I recommend reading this other article for further thoughts on this topic:
Beating the Devil
When we use standard returns instead of output variables, we make our functions easier to read, easier to chain, and easier to test. And if we need to return more than one thing, many languages let us return multiple values or a small object with named fields.
I would suggest wrapping code that uses output variables whenever possible with equivalent code that does not, or at least reducing the places where output variables are used. That way, only a small part of the code will deal with output variables, protecting the rest of the system from the risks I mentioned before. And if we really have to use output variables, at least let’s make it obvious in our code.
Finally, and as promised, I’ll share the expected price to pay for the customer after applying the discount and the taxes. In that example, we charged ten dollars in taxes (ten per cent of one hundred dollars), and we deducted ten per cent from the original price, which reduced the price from one hundred to ninety dollars. So, ninety dollars (the price with discount) plus ten dollars (taxes) equals one hundred dollars, which is the price the customer should really be charged.
Cheers,
José Miguel
Share if you find this content useful, and Follow me on LinkedIn to be notified of new articles.