Let's Read - Eloquent Ruby - Ch 11

Brandon Weaver - Sep 10 - - Dev Community

Perhaps my personal favorite recommendation for learning to program Ruby like a Rubyist, Eloquent Ruby is a book I recommend frequently to this day. That said, it was released in 2011 and things have changed a bit since then.

This series will focus on reading over Eloquent Ruby, noting things that may have changed or been updated since 2011 (around Ruby 1.9.2) to today (2024 — Ruby 3.3.x).

Chapter 11. Define Operators Respectfully

Ruby, like some other languages, allows you to overload operators and a lot of them at that. Remember that every operator in Ruby is a method, meaning 1 + 2 == 1.+(2), and that includes certain prefix operators as well.

The book mentions that it's something that falls in and out of vogue. At the current time I think it's tapered off a bit and found a good niche of "use when reasonable" rather than "use whenever and wherever all the time because we can." Honestly that's probably a good policy for a lot of programming in general.

Defining Operators in Ruby

The book mentions that Ruby allows you to do a lot of things to the language, up to and including making your own idea of a Float class and all the operators that come along with it. You can do much much worse if you're particularly inclined, especially when things like TracePoint are involved, but that's a subject for a much longer article.

The book immediately jumps into a similar example to the I'd listed above:

sum = first + second

# What you are really saying is:
sum = first.+(second)

result = first + second * (third - fourth)

# Will smoothly translate into:
result = first.+(second.*(third.-(fourth)))
Enter fullscreen mode Exit fullscreen mode

Yep, operators are methods, and in Ruby we can define methods even for our own classes which the book then gets into here:

class Document
  def +(other)
    Document.new(
      title: title,
      author: author,
      content: "#{content} #{other.content}"
    )
  end
end

doc1 = Document.new(title: "Tag Line1", author: "Kirk", content: "These are the voyages")
doc2 = Document.new(title: "Tag Line2", author: "Kirk", content: "of the starship ...")

total_document = doc1 + doc2
puts total_document.content
# STDOUT: These are the voyages of the starship ..."
Enter fullscreen mode Exit fullscreen mode

The only problem here might be that for classes we should make sure they're "combine-able" such that the author and maybe the title line up, otherwise merging them loses "Tag Line2" and makes it to where the left-side document always wins out. If that's a non-issue proceed away, but it's something to watch out for, and when it is an issue typically I'd do something like this:

class Document
  def +(other)
    raise ArgumentError, "Must be same title" unless title == other.title
    raise ArgumentError, "Must be same author" unless author == other.author

    Document.new(
      title: title,
      author: author,
      content: "#{content} #{other.content}"
    )
  end
end
Enter fullscreen mode Exit fullscreen mode

...which would error out the example case, so this really all comes down to your discretion.

Note: A book only has so much space. The things I might be pedantic over or expand upon are very likely outside the scope of the book and of the basic examples meant to convey a point. It doesn't mean the book is wrong and I'm right, it just means that I enjoy expanding upon content otherwise I would not be writing this series.

A Sampling of Operators

The book mentions that you can define more than 20 operators for classes in Ruby, a number which has not moved much in recent days. The ones you're probably familiar with like arithmetic (+, -, *, /, %), less familiar with like bitwise (&, |, ^), and shifts (>> and <<.) Of course there are a few unary operators as well we'll get into for a moment.

Of course, as the book mentions, unless you're really into C and low-level chances are you're probably not doing bitwise operations in Ruby. You probably know << for a very different reason:

names = []
names << "Rob"
names << "Denise"

names
# => ["Rob", "Denise"]
Enter fullscreen mode Exit fullscreen mode

That's because in different contexts operators have different meanings. Take the same applied to functions and you have a composition operator:

add_5 = -> a { a + 5 }
mult_2 = -> a { a * 2 }
bold = -> a { "<strong>#{a}</strong>" }

# Forward composition, aka "pipe"
(add_5 >> mult_2 >> bold).call(4)
# => "<strong>18</strong>"
Enter fullscreen mode Exit fullscreen mode

Reverse composition also works with << which may be more familiar to our functionally oriented friends.

Unary Operators

Operators like + are binary, meaning they require two values, like 1.+(2) or in a prefix notation language something like + 1 2 which might make the distinction clearer. Unary operators apply to only one argument, like !:

!true
# => false
Enter fullscreen mode Exit fullscreen mode

If it were applied to a class, like Document, maybe it would do something like this that the book mentions:

class Document
  def !
    Document.new(title:, author:, content: "It is not true: #{content}")
  end
end
Enter fullscreen mode Exit fullscreen mode

Wait wait wait, what happened to the values? Why are title and author key only? Well that's called shorthand Hash syntax which is great for highlighting which values are changing and focusing on those syntactically. That, and a bit less typing, which is always welcome.

Anyways, we run that and we might get something like this:

favorite = Document.new(title: "Favorite", author: "Russ", content: "Chocolate is best")
!favorite
# Document(..., content: "It is not true: Chocolate is best")
Enter fullscreen mode Exit fullscreen mode

Now the book mentions there are operators you can't overwrite like the english operators (not, and, or) and the logical booleans (&&, ||) as their behaviors are fixed. Now there are other reasons to avoid the english operators, but that's something for another article.

Notice how it didn't mention things like -4, which is also an operator. Now how does that work if subtraction already takes this?:

def -(other)
  # implementation
end
Enter fullscreen mode Exit fullscreen mode

Well in a slightly sneaky way:

# Please don't actually do this:
class Numeric
  def +@ = abs
  def -@ = self * -1
end
Enter fullscreen mode Exit fullscreen mode

...though if I'm being very honest the number of times that I've needed to or wanted to use these prefix operators has been exceptionally rare, probably because of how confusing they might be. In the case of numbers you already have abs for absolute value and * -1 for making something negative. You could probably even justify a positive or negative method to do it too if you really wanted to. Point being those names are clearer, especially for types where seeing a + or - in front of them may be odd.

Anyways, those prefix operators are rare because they're likely to be confusing, and I have yet to see them outside of very rare financial libraries.

Array Brackets?

Yep, those are methods too:

class Document
  def [](index)
    words[index]
  end
end
Enter fullscreen mode Exit fullscreen mode

The book suggests probably adding a size method as well, but there was an interesting paradigm which evolved since then in which it's used as a constructor alternative:

class Point
  attr_reader :x, :y

  def initialize(x, y)
    @x = x
    @y = y
  end

  def self.[](x, y)
    new(x, y)
  end
end

Point[1, 2]
# => #<Point:0x0000000106f50198 @x=1, @y=2>
Enter fullscreen mode Exit fullscreen mode

...which has a lot of precedence around pattern matching, Struct, Data, and general constructors considering folks became wary of monkey-patching Kernel for every top-level initializer shorthand.

Operating Across Classes

The book mentions that it's nice as long as both the classes line up, but once they're different... Well, what do you figure would happen if someone did this?:

doc = Document.new(title: "hi", author: "russ", content: "hello")
new_doc = doc + "out there!"
Enter fullscreen mode Exit fullscreen mode

Well with the current method we have it'd break:

class Document
  def +(other)
    Document.new(title:, author:, content: "#{content} #{other.content}")
  end
end

# String does not respond to content!
Enter fullscreen mode Exit fullscreen mode

...but we could also fix it, like the book mentions:

class Document
  def +(other)
    if other.kind_of?(String)
      Document.new(title:, author:, content: "#{content} #{other}")
    else
      Document.new(title:, author:, content: "#{content} #{other.content}")
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Though I would almost use a case statement for this:

class Document
  def +(other)
    case other
    when String
      Document.new(title:, author:, content: "#{content} #{other}")
    when Document
      Document.new(title:, author:, content: "#{content} #{other.content}")
    else
      raise ArgumentError, "Can't combine Document and #{other.class}"
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

...just to be explicit about what we do and do not support.

Staying Out of Trouble

The book then asks when you should define your own operators, and answers very similarly to what I would say of most things in programming: "It depends."

Types like Matrix, Vector, Set, and other similar concepts have a very clear defined usecase for each operator but others are likely not as clear. Really the further you are away from concrete idioms the more I would likely push for using a named method instead. Speaking of push:

items = []
items.push("Something")
Enter fullscreen mode Exit fullscreen mode

...if you came from a different language would push or << be more immediately clear to you? Food for thought.

Even given this simple example in the book:

a + b
Enter fullscreen mode Exit fullscreen mode

You could probably think of several things that could be doing. One benefit we have are boundaries, or rules, that some of these operators tend to follow. In the case of addition we have a few properties which are interesting rules:

# Any order: Order does not matter, same result
a + b == b + a

# Grouping execution: Group however you want if the order stays the same
a + b + c == (a + b) + c
(a + b) + c == a + (b + c)

# Same Types: Combine two of the same type, get back the same type
1.is_a?(Integer) # true
2.is_a?(Integer) # true
(1 + 2).is_a?(Integer) # true

# Empty: There's an element in the type, which when combined with any other
# returns the same thing
1 + 0 == 1
0 + 1 == 1

# Empty is different if you change the operator
1 * 1 == 1
1 * 10 == 10
Enter fullscreen mode Exit fullscreen mode

Ah right, sorry, that was category theory. Congrats, you accidentally learned the basics of what are called Monoids there (mono - one, oid - in the manner of = in the manner of one thing, or combinable.) Have fun with that!

Note: Yes yes, fine, I can hear you Haskell types typing away in my comments. Commutative is not a requirement for Monoids, it forms an Abelian Group instead if you add inversion as well. This was a basic offhand quippy example. Also any order is the commutative property, grouping is associativity, same types are closure, and empty is identity.

In the Wild

Now a lot of those above are left-associative, sure, but there are some types that are not. Consider Time like the book does:

now = Time.now
one_minute_from_now = now + 60
Enter fullscreen mode Exit fullscreen mode

You can add time to the right, sure, but if you added it to the left?

60 + now # Error! Time can't be coerced into Integer
Enter fullscreen mode Exit fullscreen mode

That's because + on Time knows how to deal with Integers but not the other way around.

Wrap Up

As with all things the name of the game here is discretion. You could have an operator for everything, but would it add clarity to your program or take away from it? If the latter you should put it down and make a clearer named method. Just because you can do something in Ruby does not mean you should, unless of course you're in a code golfing competition in which case all bets are off, go wild.

You might notice the book deliberately avoids == and inequalities, and that's because that's the next chapter!

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Terabox Video Player