Skip to content

Instantly share code, notes, and snippets.

@dcuadraq
Last active January 4, 2021 12:27
Show Gist options
  • Save dcuadraq/a62649414b5b04329a243578b8220802 to your computer and use it in GitHub Desktop.
Save dcuadraq/a62649414b5b04329a243578b8220802 to your computer and use it in GitHub Desktop.
Effective Ruby notes

Accustoming Yourself to Ruby

ITEM 1: UNDERSTAND WHAT RUBY CONSIDERS TO BE TRUE

nil and false are the only objects evaluated as false.

!!nil == false # true
!!0 == false # false
false.nil? # false

ITEM 2: TREAT ALL OBJECTS AS IF THEY COULD BE NIL

Use String#to_s and Integer#to_i method to avoid calling methods on nil objects.

def format_string(string)
  string.to_s.upcase
end

Array#compact method removes all nil from arrays

[1, nil, 3, nil].compact # [1, 3]

ITEM 3: AVOID RUBY’S CRYPTIC PERLISMS

Unlike with local variables, instance variables, or even constants, you’re allowed to use all sorts of characters as variable names, including numbers. Global variables vegin with a $.

def extract_error (message)
  if message =~ /^ERROR:\s+(.+)$/
    $1
  else
    "no error"
  end
end

The first perlisms in that code is the =~ operator from String class. When the regular expression matches, several global variables will be set. Global variable $1 has the content of the first capture group.

Variables created by the =~ operator are called special global variables because they’re scoped locally to the current thread and method. Essentially, they’re local values with global names. Outside of the extract_error method from the previous example, the $1 “global” variable is nil, even after using the =~ operator.

def extract_error (message)
  if m = message.match(/^ERROR:\s+(.+)$/)
    m[1]
  else
    "no error"
  end
end

String#match is more idiomatic and doesn't use any of the special global variables. It returns a MatchData object with all relevant information.

$: is an array of strings representing the directories where Ruby will search for the libraries loaded with the require method. The alias $LOAD_PATH is prefered. All cryptic global variable have descriptive alias, but some require the library require('English').

while readline
  print if ~ /^ERROR:/
end

Here the Kernel#readline method reads a line from standard input and returns it. It also stores that line of input in the $_ variable. If Kernel#print is called without arguments, it will print the content of $_ to standard output.
Regexp#~ operator tries to match the content of $_ against the regular expression to its right. If there's a match, it returns the position of the match, otherwise it returns nil.

ITEM 4: BE AWARE THAT CONSTANTS ARE MUTABLE

A constant is any identifier which begins with an uppercase letter. The names of classes and modules are actually constants in Ruby.

Constants are more like global variables than unchanging values.

module Defaults
  NETWORKS = ["192.168.1", "192.168.2"].freeze
end

Always freeze constants to prevent them from being mutated. But consider this:

def host_addresses (host, networks=Defaults::NETWORKS)
  networks.map { |net| net << ".#{host}" }
end

While the NETWORKS array itself is frozen, its elements are still mutable. You might not be able to add or remove elements from the array, but you can surely make changes to the existing elements.

If a constant references a collection object such as an array or hash, freeze the collection and its elements:

module Defaults
  NETWORKS = [
    "192.168.1",
    "192.168.2",
  ].map!(&:freeze).freeze
end

To prevent assigning new values to existing constants, freeze the class or module they’re defined in. You may even want to structure your code so that all constants are defined in their own module, isolating the affects of the freeze method:

module Defaults
  TIMEOUT = 5
end
Defaults.freeze

So, freeze the constant, freeze its module and in case of a collection freeze its elements.

ITEM 5: PAY ATTENTION TO RUNTIME WARNINGS

Classes Objects and Models

ITEM 6: KNOW HOW RUBY BUILDS INHERITANCE HIERARCHIES

ITEM 7: BE AWARE OF THE DIFFERENT BEHAVIORS OF SUPER

ITEM 8: INVOKE SUPER WHEN INITIALIZING SUB-CLASSES

ITEM 9: BE ALERT FOR RUBY’S MOST VEXING PARSE

ITEM 10: PREFER STRUCT TO HASH FOR STRUCTURED DATA

ITEM 11: CREATE NAMESPACES BY NESTING CODE IN MODULES

ITEM 12: UNDERSTAND THE DIFFERENT FLAVORS OF EQUALITY

equal? # compares by object id, it is not to be overrided.
== # Behaves as `equal?`, should be overriden, expected to represent same value eg. 1 == 1.0. Check ITEM 13
eql? # Behaves as `equal?`, should be overriden. It is the most loosly defined of the three.
=== # case equality operator, used on case expresions

About hashes

eql? is used by hashes to determine the hash's key, in case of collision, another comparison is made using the hash method defined on the instance.

ITEM 13: IMPLEMENT COMPARISON VIA "<=>" AND THE COMPARABLE MODULE

<=> operator, informally referred to as the "spaceship" operator. When writing a comparison operator it’s common practice to name the argument “other” since it will be the other object you’re comparing the receiver with.

  • When it doesn’t make sense to compare the receiver with the argument then the comparison operator should return nil.
  • If the receiver is less than the argument, return -1.
  • If the receiver is greater than the argument, return 1.
  • If the receiver is equal to the argument, return 0.

Example

Class to compare software versions, eg. 2.1.1, 2.10.3

class Version
  include(Comparable) # “<”, “<=”, “==”, “>”, and “>=”

  attr_reader(:major, :minor, :patch)
  
  def initialize (version)
    @major, @minor, @patch = version.split('.').map(&:to_i)
  end
  
  def <=> (other)
    return nil unless other.is_a?(Version)

    [ major <=> other.major,
      minor <=> other.minor,
      patch <=> other.patch,
    ].detect {|n| !n.zero?} || 0
  end
end

Consider that the Comparable module includes a version of "==". You can set its behaviour by changing the conditions <=> returns 0 or overriding it. To make instances usable as Hash keys:

  • eql? should be an alias for ==.
  • A hash method that returns Fixnum
class Version
  ...
  alias_method?(:eql?,:==)
  
  def hash
    [major, minor, patch].hash
  end
end

ITEM 14: SHARE PRIVATE STATE THROUGH PROTECTED METHODS

protected methods were designed for sharing private information between related classes. The caller and receiver don’t necessarily have to be instances of the same class, but they both have to share the superclass where the method is defined.

ITEM 15: PREFER CLASS INSTANCE VARIABLES TO CLASS VARIABLES

@ instance variables

@@ class variables (know as static on other OO languages) Class variables in a superclass are shared between it and all of its subclasses.

Singleton class manual implementation (instead of Ruby module)

class Singleton
  private_class_method(:new, :dup, :clone) # can't be called from the outside

  def self.instance
    @single ||= new # new will be called only the first time and is stored on a class' instance variable
  end
end

Classes are objects, so they have instance variables too.
“class methods” are actually instance methods for the class object.

Class variables and class instance variables have all of the same problems that global variables do. If your application has multiple threads of control then altering any of these variables without using a mutex isn’t safe. Thankfully, the Singleton module which is part of the Ruby standard library correctly implements the singleton pattern in a thread-safe way. Using it is simple:

require('singleton')

class Configuration
  include(Singleton)
end

Collections

ITEM: 16: DUPLICATE COLLECTIONS PASSED AS ARGUMENTS BEFORE MUTATING THEM

When inserting objects into collections or passing as parameters to methods, most are passed as reference, Fixnum are passed as value.
Consider adding a ! to methods that will change the parameters. Consider using Array#reject instea of Array#delete_if.

class Tuner
  def initialize (presets)
    @presets = presets
    clean
  end

  private

  def clean
    # Valid frequencies end in odd digits.
    @presets.delete_if {|f| f[-1].to_i.even?} # Will modify the object passed as param outside the class
  end
end
class Tuner
  def initialize (presets)
    @presets = clean(presets)
  end

  private

  def clean (presets)
    presets.reject {|f| f[-1].to_i.even?} # Will make a copy omitting odd numbers
  end
end

Copying objects

Object#clone and Object#dup produce a shallow copy of obj—the instance variables of obj are copied, but not the objects they reference. clone copies the frozen and tainted state of obj.

clone, unlike dup, will make the copy frozen if the original is frozen.
If obj has singleton methods, clone will also duplicate any singleton class, unlike dup.

On Ruby 2.4+ clone(freeze: true) can recieve the freeze param as false to make an unfrozen copy.

In general, clone and dup may have different semantics in descendant classes. While clone is used to duplicate an object, including its internal state, dup typically uses the class of the descendant object to create the new instance.

class Tuner
  def initialize (presets)
    @presets = presets.dup
    clean # Modifies the duplicate.
  end
  ...
end

When copying collections, clone and dup don't make copies of the referenced objects in the colleciton.
When writing your own class you can override the initialize_copy method to control the depth of the duplication process.

Marshall class can serialize and deserialize the collection and its elements:

irb> a = ["Monkey", "Brains"]
irb> b = Marshal.load(Marshal.dump(a))
irb> b.each(&:upcase!); b.first # "MONKEY"
irb> a.last # "Brains"

Marshall limitations:

  • Memory heavy (would need to have in memory the original, the copy and the serialized version)
  • Not all objects can be serialized and will rise TypeError exceptions
    • Objs with clousures or singleton methods can't
    • Some Ruby core classes (eg. IO, File)

ITEM 17: USE THE ARRAY METHOD TO CONVERT NIL AND SCALAR OBJECTS INTO ARRAYS

The Array method converts its arguments into an array

Array(1..5)   #=> [1, 2, 3, 4, 5]
Array(['Nadroj', 'Retep'])   #=> ["Nadroj", "Retep"]
Array(nil)   #=> []

h = {pepperoni: 20, jalapenos: 2}
Array(h)   #=> [[:pepperoni, 20], [:jalapenos, 2]]

ITEM 18: CONSIDER SET FOR EFFICIENT ELEMENT INCLUSION CHECKING

Set is a class from the standard library, it has to be required.
Is a container that skips duplicates.

require('set')

class Role
  def initialize(name, permissions)
    @nae, @persissoins = name, Set.new(permissions)
  end
  
  def can?(permission)
    @permissoins.include?(permission)
  end

Set characteristics:

  • Needs to be required.
  • Uses obj's eql? to check for identity. Therfore the stored objs should be able to be hash keys.
  • Unordered container.
  • Can't index individual elements.

In case of wanting an order, consider SortedSet which is also part of the standard library.

ITEM 19: KNOW HOW TO FOLD COLLECTIONS WITH REDUCE

def sum(enum)
  enum.reduce(0) do |accumulator, element|
    accumulator + element
  end
end

The block doesn’t make any assignments.
After each iteration, reduce throws away the previous accumulator and keeps the return value of the block as the new accumulator. If you mutate the accumulator and return nil from the block then the accumulator for the next iteration will be nil. The argument given to reduce is the starting value for the accumulator, if omitted, the first element will be used and start the iteration cycle on the second element.

A symbol can be passed which will be called as a method on the accumulator and passing the current element as the argument:

def sum(enum)
  enum.reduce(0, :+)
end

To transform an Array to a Hash

array.reduce({}) do |hash, element|
  hash.update(element => true)
end

Another optimization using reduce could be when filtering and extracting information from a collection.

users.reduce([]) do |names, user|
  names << user.name if user.age >= 21
  names
end

ITEM 20: CONSIDER USING A DEFAULT HASH VALUE

With Hash.new(param) you can set param as the default value to be returned on non existing keys, instead of nil

h = Hash.new(0)
a = h[:missing_key] # 0
h.keys # []
h[:missing_key] += 1 # 43
h.keys # [:missing_key]

But the default value is mutable.

h = Has.new([])
h[:missing_key] << "Hi"
h # []
h.keys # []
h[:missing_key2] # ["Hi"]

And beware of unintended beahaviour.

h = Has.new([])
h[:day] = h[:day] << "Sunday"
h[:month] = h[:month] << "January"
h[:month] # ["Sunday", "January"]
h.default # ["Sunday". "January"]

^ This can be avoided by passing Hash::new a block, that will be invoked when needs a default value.

h = Hash.new { [] }
h[:day] = h[:day] << "Sunday"
h[:month] = h[:month] << "January"
h[:day] # ["Sunday"]

The given block can receive 2 arguments, the hash itself and the accessed key.

h = Hash.new {|hash, key| hash[key] = [] }
h[:day] << "Sunday"
h[:holidays] # []
h.keys # [:weekdays, :holidays]

^ With this solution, every time a missing key is accessed, the block create a new entry in the hash and it’ll create a new array.
The correct way to check for a key/value existance is using has_key? method.

if hash.has_key?(:day)
  ...
end

Hash#fetch can be used to establish a default value in case of a non existing key, which would be the second parameter. Or if the second parameter is ommited, it will raise an exception on nonexistent key.

  h = {}
  h[:day] = h.fetch(:day, []) << "Sunday" # ["Sunday"]
  h.fetch(:missing_key)
  KeyError: key not found: :missing_key

ITEM 21: PREFER DELEGATION TO INHERITING FROM COLLECTION CLASSES

A class that inherence from Array, some of its methods will return a Array instead of the inhereted class.

class LikeArray < Array; end

x = LikeArray.new([1,2,3]) # [1,2,3]
y = x.reverse # [3,2,1]
y.class #=> Array # Is an Array instead of a LikeArray

LikeArray.new([1,2,3]) == [1,2,3] #=> true # maybe not the expected behaviour 

A solution can be found on Set's implementation, it has internally a Hash but never exposes it.
Delegation allows you to declare methods which should be forwarded along to a specific instance variable.

require('forwardable')

# A Hash clone that will rise exception when accessing nonexistent key
class RaisingHash
  extend(Forwardable)
  include(Enumerable)

  def_delegators(:@hash, :[], :[]=, :delete, :each,
                         :keys, :values, :length,
                         :empty?, :has_key?)
end

It can also change the access name of the method.

def_delegator(:@hash, :delete, :erase!) # Forward self.erase! to @hash.delete

To raise an error on nonexisting key access, you can pass a block to #new

def initialize
  @hash = Hash.new do |hash, key|
    raise(KeyError, "invalid key `#{key}'!") # raising an error will be the default value
  end
end

To make a invert method that will not return a hash but an instance of RaisingHash

def invert
  other = self.class.new
  other.replace!(@hash.invert)
  other
end

protected

def replace! (hash)
  # passes the block given to #new to ensure rising error on nonexisting key
  hash.default_proc = @hash.default_proc 
  @hash = hash
end

To make the class behave as any collection class: For cloning:

def initialize_copy (other)
  @hash = @hash.dup
end

For freezing and tainting:

def freeze # same for taint and untaint
  @hash.freeze
  super
end

Exceptions

Exceptions can be thought of as two different language features rolled into one, error descriptions and control flow.

ITEM 22: PREFER CUSTOM EXCEPTIONS TO RAISING STRINGS

raise("coffee machine low on water")

Is equivalent to

raise(RuntimeError, "coffee machine low on water")

The first argument is the class name to create and raise an exception object from the class. The second is the string to be used as the error message.
A RuntimeError is nothing more than a generic “Oops, something went wrong” error.
Since exceptions are handled based on their type, creating a new class is the standard way to differentiate it.
Rules to create exception classess:

  • New exception classes must inherit from one of the standard exception classes.
  • The Exception class and several of its sub-classes are considered low-level errors which should generally crash the program. The majority of the standard exception classes inherit instead from StandardError and you should follow suit.
  • It’s common practice to give exception class names the “Error” suffix.

Inheriting from StandardError comes from the default behavior of the rescue clause. You can omit the class name when handling exceptions with rescue. In this case it will intercept any exception whose class (or superclass) is StandardError. (The same is true when you use rescue as a statement modifier.) To create the most basic exception:

class CoffeeTooWeakError < StandardError; end

And to raise that exception:

raise(CoffeeTooWeakError)
# or
raise(CoffeeTooWeakError, "coffee to water ratio too low") # with a more descriptive message

To output a variable on the error message:

class TemperatureError < StandardError
  attr_reader(:temperature)

  def initialize (temperature)
    @temperature = temperature
    super("invalid temperature: #@temperature")
  end
end

And to call it

raise(TemperatureError.new(180))

The raise method sends the exception message to the first argument. This method is suppose to return an object which can then be raised.
Both exception classes and exception objects have their own exception method courtesy of the Exception class. The class method version is simply an alias for new.

The instance method version of exception is a bit weird though, depending on how many arguments are given to raise:

  • 1 argument, an exception object: returns self
  • 2 arguments, an exception object and a message: A copy of the exception will be made with the msg as its own overriding internal message, and raising this copy.

If you are going to create several exception classes for a project, consider creating a base class that inherits from StandardError, and inherit from it.

ITEM 23: RESCUE THE MOST SPECIFIC EXCEPTION POSSIBLE

To catch the exceptions:

begin
  task.perform
rescue => e
  logger.error("task failed: #{e}")
  # Swallow exception, abort task.
end

Its better to be specific and avoid catching coding, e.g. ArgumentError and NoMethodError

Blacklisting

The blacklist approach would be to list first, all the exceptions that you don't want to handle by re-raising them.

begin
  task.perform
rescue ArgumentError, LocalJumpError,
       NoMethodError, ZeroDivisionError
  # Don't actually handle these.
  raise
rescue => e
  logger.error("task failed: #{e}")
  # Swallow exception, abort task.
end

Ruby evaluates the rescue clauses in order, from top to bottom, first match wins.
This style is too brittle and error-prone.

Whitelisting

A better approach would be to rescue only the ones you know how to handle and let the rest propagate up the stack.

begin
  task.perform
rescue NetworkConnectionError => e
  # Retry logic...
rescue InvalidRecordError => e
  # Send record to support staff...
end

To be able to do some 'cleaning' before exiting the current scope, you can use ensure which will be executed for both normal and exceptional situations.

begin
  task.perform
rescue NetworkConnectionError => e
  # Retry logic...
rescue InvalidRecordError => e
  # Send record to support staff...
rescue => e
  service.record(e)
  raise
ensure
  ...
end

Exceptions raised while a rescue clause is executing replace the original exception, exit the scope of the rescue clause, and start exception processing with the new exception. So when handling exceptions try to not create another exception, e.g. trying to connect to a log server and having a connection exception. To avoid this, you could create a method that receives the exception

def send_to_support_staff(e)
  ...
  rescue
  raise(e)
end

ITEM 24: MANAGE RESOURCES WITH BLOCKS AND ENSURE

ITEM 25: EXIT ENSURE CLAUSES BY FLOWING OFF THE END

ITEM 26: BOUND RETRY ATTEMPTS, VARY THEIR FREQUENCY, AND KEEP AN AUDIT TRAIL

ITEM 27: PREFER THROW TO RAISE FOR JUMPING OUT OF SCOPE

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment