Sophist

Ruby Sparks Joy

Published

Marie Kondo became famous following her 2019 Netflix original appearance in which she popularized the practice of throwing away items which do not spark joy. Since then, sparking joy has become a generic description for anything that is worth doing or keeping. The stereotypically Japanese way of doing things, which is exemplified in lean manufacturing techniques popularized by Toyota, is replete with examples promoting a highly principled approach to planning and design.

Ruby, which also traces its origin to the land of the rising sun, owes its creation to Yukihiro Matsumoto, who wished to design a programming language that combined elements of functional, object-oriented, and imperative paradigms into a language that developers would enjoy writing.

Perfection is achieved, not when there is nothing more to add, but when there is nothing left to take away. - Matz

Ruby encourages good naming conventions

Most languages have a loose set of recommendations for style. Python encourages snake case for method names and pascal case for class names, JavaScript developers are encouraged to use camel case for all identifiers, meanwhile Kotlin in most IDE environments enforces camel case for identifiers to such an extent that using snake case results in build warnings. Similarly, Ruby encourages snake case for identifiers and pascal case for class names and module names.

Ruby prescribes additional recommendations for method names that make them more descriptive. Because Ruby encourages immutable constructs, methods that modify state should be labelled with an exclamation point, while methods that return booleans should be labelled with a question mark.

For example, sort sorts the array and returns it, while sort! modifies the array in-place:

numbers = [5, 2, 5, 3, 4]

numbers.sort => [2, 3, 4, 5, 5]
numbers => [5, 2, 5, 3, 4]

numbers.sort! => [2, 3, 4, 5, 5]
numbers => [2, 3, 4, 5, 5]

Similarly, include? ends with a question mark because it’s expected to return a boolean value:

numbers = [5, 2, 5, 3, 4]

numbers.include? 3 => true
numbers.include? 9 => false

Ruby’s functional constructs make bugs harder to write

Ruby discourages mutating variables in-place. Mutability and side-effects are often necessary in the real world, but there are many situations where they can (and should) be avoided. Each variable you mutate creates a ‘parallel’ state you have to mentally trace while writing or reading code.

In Ruby, every method and operation seems to return something; even assigning variables returns something:

b = (a = 3) => 3

a => 3
b => 3

This fact means that methods and assignments can be chained together to produce very expressive and beautiful code:

# Get very odd number between 1 and 100 and sum them together
(1..100).filter(&:odd?).sum => 2500

In addition to this, Ruby has implicit returns and allows for calling methods like accessing variables. Implicit returns might at first glance seem like an avenue for producing more bugs, but in practice it encourages developers to avoid assigning superfluous variables and focus on maintaining coherent control flow. You might think calling methods like accessing variables could be problematic. After all, methods are not variables; they can have side-effects! That is true, but if you’re using immutable variables like you should, this will never be a problem.

Ruby has a method for everything

Ruby has a method for everything. This seems to be a disadvantage at first, since it gives Ruby a higher perceived learning curve, but that need not be the case. Ruby’s features come to you slowly as you write it, and you’ll find yourself missing them when you work in another language.

For example, I often need to slice long lists of items into batches, usually as preparation for parallel processing. In Ruby this is remarkably simple:

words = ["list", "of", "example", "words", "to", "be", "split"]

words.each_slice(3).to_a => [["list", "of", "example"], ["words", "to", "be"], ["split"]]

Consider the same problem in Python, a language that supposedly competes with Ruby on language features. Python has no built-in method for doing this. Python’s itertools library does have an equivalent method called batched(p, n) that accomplishes the same function as each_slice, but this is only available in Python 3.12. If like most people, you’re using a Python version before that on an existing project, the only solution is to write your own with a list comprehension or write a method that calls islice() on the input as an iterator until it’s consumed.

Ruby has superior data structures

Ruby, like other languages, supports hash maps between two arbitrary types. These structures are useful for mapping one set of values to another and run quickly because they use hash buckets for fast access times. As a result, they have become ubiquitous in modern languages, especially in Python where they are called dictionaries.

Ruby calls these structures hashes, and allows for two different types of keys. The traditional strategy of using a string as a key is supported:

string_hash = {
  "key_one" => 3,
  "key_to" => 55
}

string_hash["key_one"] => 3

But Ruby also supports symbols. Symbols are Ruby’s answer to C# enumerations, which map named constants to integers. This promotes speed because the overhead of hashing strings is no longer needed. Strings and symbols can be converted between each other with ease, and hash syntax has a quick shorthand for using them as keys:

# Strings and symbols are easily converted
"string_value".to_sym => :string_value
:symbol_value.to_s => "symbol_value"

symbol_hash = {
  key_one: 3,
  key_to: 55
}

symbol_hash[:key_one] => 3

Ruby encourages good codebase organization without being too opinionated

Like Python, Ruby has the concept of modules and module scope. Unlike Python, modules are defined explicitly and aren’t necessarily coupled with file names.

In Python, directory structure produces a module hierarchy:

my_project/
--> __init__.py
    main.py
    submodule_a/
    --> __init__.py
        helper.py
        thing.py

Thus, to import something from the module hierarchy you need only use from submodule_a.helper import ClassName.

Ruby uses a similar pattern for imports, but modules are defined hierarchically as well:

module MainModule
  module SubmoduleA
    class ClassName
    end
  end
end

Instantiating the class from a file that has imported the source with require or require_relative is as simple as:

MainModule::SubmoduleA::ClassName.new

Ruby doesn’t waste time with object-oriented formalities

Although Matz often sings the praises of object-oriented programming, he isn’t a sycophant. Ruby encourages the use of inheritance, composition, and classes to solve problems that are well-suited to them. Inheritance, when used correctly, can save code through polymorphism without compromising readability.

Ruby thus supports private methods and fields by default and allows them to be selectively exposed without the asinine Java/C# rituals of writing getters and setters:

class MyClass
  attr_reader :readable
  attr_accessor :accessible
  
  def initialize(a, b)
    @readable = a
    @accessible = b
  end
end

inst = MyClass.new 'can_change', 'can_read'

inst.readable = 'cannot change'
(irb):70:in `<main>': undefined method `readable=' for #<MyClass:0x00007858455c6438 @readable="can_read", @accessible="can_change"> (NoMethodError)

inst.accessible = 'can change'
# no issues

Designating private methods is as simple as using the keyword private, which marks every method below it as private.

Null safety

Kotlin fans often assert that Kotlin is a modern and safe language because it supports null safety. Interpreted languages tend to crash when code attempts to access a method or property of a null object. In Kotlin, unless a variable is marked explicitly as nullable with ?, attempting to assign null to it will cause an immediate compilation failure.

Ruby 2.3 introduced null safety using &., which allows null safety even in the complex chains of method calls endemic to Ruby’s design. What happens if one method in the chain returns nil? Without null safety, this throws an exception, but using the &. operator, we can avoid an exception and return nil and short-circuit the entire method chain.

def mess_up
  nil
end

mess_up.sort
`<main>': undefined method `sort' for nil:NilClass (NoMethodError)

mess_up&.sort
=> nil

And many more

In addition to the features and traits I have already enumerated, Ruby has a wonderful standard library: