On Ruby methods
Table of Contents
TL;DR
Commands (change state) return:
self
or failure (nil
for convenience methods)nil
or failure- yield the result to a block if necessary.
Queries (show state) return:
- value or default
- value or null object
- value or
nil
Predicates return true
or false
.
Interjections should be handle with care.
Wording
Procedures
Ruby can encapsulate procedures in methods, and blocks. Unfortunately, that is not enough to communicate the code’s intent. Which can lead to bugs.
A key way to show a procedure’s intent is the returned value. When procedures return nil
they could easily mean either of these:
- It has no return value.
- There’s usually a return value, but not this time.
- It replaces a returned
null
value from a 3rd party eg database. - Something unexpected happened.
We can help prevent undefined nil
(those that can’t be accounted for in the test suite) by reasoning the behaviour requested from an object.
Since methods are, by far, more common we’ll refer to them the rest of the note, even when the concepts apply to blocks too.
Messages
The procedures mentioned above get invoke through messages sent to the objects that house them.
In Ruby, the messages an object responds to are what define its duck-type, which is more closely related to interfaces and parametric polymorphism than to subtyping. Which is a gateway to interfaces, and polymorphism.
Whilst messages can trigger any kind of procedure, we can reveal our code’s intent following a few conventions in both code and tests. More on that below.
Functions
When a message triggers a process on one or more arguments (rather than in collaboration with them) we call it a function. Immutable parts help to keep functions easy to reason about, but aren’t mandatory. eg.
1 + 2 #=> 3
is a function where 1
and 2
don’t change even when combined. Instead, they produce a new numeric object (3
). We can reuse them over and over. On the other hand, functions such as <<
will always change the state of at least one of the parts involved.
a = "1"
b = "2"
a << b
a #=> "12"
b #=> "2"
Methods
When a message triggers a process that only affects the object receiving it, or it’s internals, we call it a method. Whilst the returned value may change, it’s always true about the object we are querying.
a = ""
a.empty? #=> true
a << "hi"
a.empty? #=> false
Commands
When a message, or method, triggers changes to an attribute, or state, we may call it a command. Depending on context, a command should return (in order of preference):
self
or failnil
or fail
When context requires it, we can expose the result of the process via a block. Then, we can enforce immutability to prevent further changes to the result.
def command &block
fail_with_some_reason # a private method with an exception policy
block.call @state.freeze if block_given?
self
end
Trade off: Whilst returning self
allows method chaining, it can lead to bugs if we are not clear (in code and tests) on what self
is, and what isn’t. For instance, dependencies can either collaborate in a process or get processed by it.
On the other hand, when we return self
rather than nil
we can use the latter for convenience singleton methods.
class Validation
ValidationError = Class.new StandardError
def self.validate obj
validation = new obj
validation.validate
rescue ValidationError # Only rescue exception defined on self
nil # assert_nil this
end
def initialize obj
@obj = obj
end
def validate
fail_with_some_reason # a private method with an exception policy
self
end
# more code
end
Queries
Whilst commands focus on changes, queries do so on state. Query methods retrieve the state of the object we are sending the message to. They can also retrieve it from collaborators that may get passed as arguments as long as they don’t change the collaborators’ actual state. Thus queries work better when they return:
- queried or default values
- queried value or fail
- queried value or null object
- queried value or
nil
More often than not, queries are part of the data flow. They are considered robust when they can gracefully handle mishaps. Yet depending on context failing may be better than a null object
. For instance, when handling sensitive data. Ruby hashes, can be queried like so:
hash = Hash.new
hash[:invalid] #=> nil
hash.fetch :invalid #=> KeyError (key not found: :invalid)
other_hash = Hash.new { "default" } # <- that there could be a null object
other_hash[:non_existent] #=> "default"
As we’ve seen, both commands and queries may return nil
when the ‘unhappy’ path is not exceptional. For instance when missing elements.
Even when nil
can be accounted for on tests, we may have a hard time differentiating it from those out of unexpected behavior. Returning nil
should be our last option.
Predicates
In Ruby, predicate methods are those that, by convention, end with a question mark (#empty?
). Although, ruby considers nil
, and false
falsy duck types predicates are better off returning true
or false
to avoid leaking data, in general, and to prevent bugs originated from leaked data, in particular.
Interjections
From Matz himself
The bang (!) does not mean “destructive” nor lack of it mean non-destructive either. The bang sign means “the bang version is more dangerous than its non-bang counterpart; handle with care”.