title: "On Ruby methods" description: > A look on writing Ruby methods that reveal their intent. categories: posts
Table of Contents
Commands (change state) return:
self
or failure (nil
for convenience methods)nil
or failureQueries (show state) return:
nil
Predicates return true
or false
.
Interjections should be handle with care.
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:
null
value from a 3rd party eg database.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.
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.
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"
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
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 failWhen 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
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:
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.
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.
From Matz himself{:rel="nofollow noreferrer noopener"}
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".