2018-07-27-on-ruby-methods.md 5.8 KB


title: "On Ruby methods" description: > A look on writing Ruby methods that reveal their intent. categories: posts

tags: [ruby]

Table of Contents
  • TOC {:toc}

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 fail
  • nil 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{: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".