Let's Read – Eloquent Ruby – Ch 8

baweaver

Brandon Weaver

Posted on September 5, 2024

Let's Read – Eloquent Ruby – Ch 8

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 8. Embrace Dynamic Typing

This chapter, in particular, is very amusing given the more recent zeitgeist around static typing in every language, Ruby included. It leads in with common questions folks ask like how in the world you can write reliable programs without types, and why you even want to try.

I have my own thoughts on that I might expand upon later in another post, but for this one I'll stay closer to the content of the book and the content that it's covering to scope the conversation down.

The short version of my opinion, as well as that of the book, is that there's no such thing as a perfect solution without flaws and that includes static typing. Your job as a programmer is to weigh the bad against the good and make a reasoned decision on the tradeoffs.

Shorter Programs, But Not the Way You Think

The book starts into this topic by mentioning an often repeated phrase that dynamic typing allows for more compact code. I'm sure those coming from Java might have a few misgivings with how verbose some of their code might be.

Sure, omitting typing can save a bit of time here and there, but the book wants to draw focus instead on all the savings you get from the code you never have to write in the first place.

Lazy Documents

The book then gets into the idea of a base document class as a precursor to an implementation of a "lazy" document which won't load content until absolutely necessary:

class BaseDocument
  # The book uses the string "Not Implemented", prefer the class
  def title
    raise NotImplementedError
  end

  # The book also omits the param here, personally I prefer it to
  # make the "interface" clear on what should be expected in inheriting
  # classes
  def title=(v)
    raise NotImplementedError
  end

  # Repeat for author, author=, content, words, word_count,
  # and other methods
end

# Then we recase Document as a subclass of BaseDocument
class Document < BaseDocument
  attr_accessor :title, :author, :content

  def initialize(title, author, content)
    @title = title
    @author = author
    @content = content
  end

  def words
    @content.split
  end

  def word_count
    words.size
  end
end

# Then we write our lazy class
class LazyDocument < BaseDocument
  attr_writer :title, :author, :content

  def initialize(path)
    @path = path
    @document_read = false
  end

  def read_document
    return if @document_read

    File.open(@path) do |f|
      @title = f.readline.chomp
      @author = f.readline.chomp
      @content = f.readline.chomp
    end

    @document_read = true
  end

  def title
    read_document
    @title
  end

  def title=(new_title)
    read_document
    @title
  end

  # and so on and so forth
end 
Enter fullscreen mode Exit fullscreen mode

The book mentions that this is an explicitly simple implementation for the sake of example.

Note: While this is a simplified example for the sake of brevity in the book for the sake of more production-oriented code make sure not to conflate IO with state representation as it will be incredibly difficult to test and maintain later. I've written on this before in "Functional Programming in Ruby - State."

Namely if these classes keep the same general interface we can make some assumptions in the code that uses them like being able to still call the same document methods:

doc = get_some_kind_of_document

puts "Title: #{doc.title}"
puts "Author: #{doc.author}"
puts "Content: #{doc.content}"
Enter fullscreen mode Exit fullscreen mode

The book does mention that while these do work they're not really good Ruby. The BaseDocument does nothing and takes 30+ lines to do so. The important part here is that it doesn't really matter what doc is here as long as it responds to those methods, now does it?

Duck Typing

That concept is called "duck typing" in which if it looks like a duck and quacks like one it's probably a duck. Using this idea the book gets rid of the BaseDocument class and focuses instead on implementing two distinct types of documents:

class Document
  # Body remains unchanged
end

class LazyDocument
  # Body remains unchanged
end
Enter fullscreen mode Exit fullscreen mode

As long as the signature is the same you can use them interchangeably.

If you really miss abstract classes in Ruby you can always use them from https://sorbet.org/docs/abstract, but some practitioners of Sorbet can get a tad overzealous with them by which point you've already looped most of the way back to Java.

The difference, for me, is that abstract classes and interfaces here achieve something the duck typing interface does not: programatic compliance. If you're missing methods in your implementation Sorbet will warn you of it, which is much more powerful than what Ruby had back when the book was written with the NotImplementedError and base class techniques.

So when do you use one versus another? Well if your interface has several methods required for compliance perhaps it's a better idea, but if it's 1-2 of them? Perhaps the interface is overdoing it.

Generic Lazy Document

That all said we could favor duck typing even more here by having our lazy document instead use an IO compliant object:

class LazyDocument
  attr_writer :title, :author, :content

  def initialize(source)
    @source = source
    @source_read = false
  end

  def read_source
    return if @source_read

    @title, @author, @content = @source.readlines(chomp: true)

    @document_read = true
  end

  def title
    read_source
    @title
  end

  def title=(new_title)
    read_source
    @title
  end

  # and so on and so forth
end
Enter fullscreen mode Exit fullscreen mode

IO is nice in that it supports things like Files, Streams, SDOUT, STDIN, and other common Ruby classes. You could also consider using StringIO here. The point being you can decouple from File by using anything that happens to respond to readlines instead.

Granted this could be more cleaned up later with potentially a series of parsers for CSVs vs JSON vs YAML vs who knows what type of format the information is in. Maybe you could even have it use a formatter and go further, which is an exercise left to the reader here.

Extreme Decoupling

The book then gives us an example of a few more classes that might be related to a book system:

class Title
  attr_reader :long_name, :short_name
  attr_reader :isbn

  def initialize(long_name, short_name, isbn)
    @long_name = long_name
    @short_name = short_name
    @isbn = isbn
  end
end

class Author
  attr_reader :first_name, :last_name

  def initialize(first_name, last_name)
    @first_name = first_name
    @last_name = last_name
  end
end

two_cities = Title.new("A Tale of Two Cities", "2 Cities", "0-999-9999-9")
dickens = Author.new("Charles", "Dickens")
doc = Document.new(two_cities, dickens, "It was the best...")
Enter fullscreen mode Exit fullscreen mode

Aside: Keyword Arguments

But one moment, as there's a particular thing that came to prominence in more recent Ruby versions: Keyword arguments. In these examples we're giving a lot of arguments to these class constructors in a specific order, but what would make more sense is giving them by a specific name such that:

class Title
  attr_reader :long_name, :short_name
  attr_reader :isbn

  def initialize(long_name:, short_name:, isbn:)
    @long_name = long_name
    @short_name = short_name
    @isbn = isbn
  end
end

class Author
  attr_reader :first_name, :last_name

  def initialize(first_name:, last_name:)
    @first_name = first_name
    @last_name = last_name
  end
end

two_cities = Title.new(
  long_name: "A Tale of Two Cities",
  short_name: "2 Cities",
  isbn: "0-999-9999-9"
)

dickens = Author.new(first_name: "Charles", last_name: "Dickens")
doc = Document.new(
  title: two_cities,
  author: dickens,
  content: "It was the best..."
 )
Enter fullscreen mode Exit fullscreen mode

...it becomes much clearer to read and understand what each argument is doing, especially if you're far removed from the underlying class or method those arguments belong to. In any case you might notice that I explicitly use keyword arguments from this point on, and I would likewise encourage you to do the same as they're far more intention revealing. The only exceptions are when order matters, or makes sense in a domain, such as Point.new(x, y).

Back on Topic

So back to the new classes. The request in the book was to be able to support those new instances in the underlying Document class. This works, as the book mentions, because Ruby doesn't particularly care what type things are as much as they have the methods you want to use later.

The next example highlights what a description might look like with the new Title and Author objects:

class Document
  def description
    "#{@title.long_name} by #{@author.last_name}"
  end
end
Enter fullscreen mode Exit fullscreen mode

...but notes that this conflates the classes together, and will be harder to potentially refactor them later. The book doesn't take things this far but personally there's a beautiful example of duck typing right here waiting for us:

class Author
  def to_s
    "#{@first_name} #{@last_name}"
  end
end

class Title
  def to_s
    "#{@long_name}"
  end
end

class Document
  def description
    "#{@title} by #{@author}"
  end
end
Enter fullscreen mode Exit fullscreen mode

The to_s method is our prodigious duck this round, and it allows us to rely on the fact that Author, Title, and plain old String would all work in these slots as they all support a method which lets them be represented as a String. To me this is a good example of the flexibility of Ruby's standard idioms.

Pseudo Typing

The book then mentions that some newer programmers might try something like this to approximate static typing:

class Document
  def initialize(title:, author:, content:)
    raise ArgumentError, "`title` is not a String" unless title.is_a?(String)
    raise ArgumentError, "`author` is not a String" unless author.is_a?(String)
    raise ArgumentError, "`content` is not a String" unless content.is_a?(String)

    @title = title
    @author = author
    @content = content
  end
end
Enter fullscreen mode Exit fullscreen mode

Granted I used ArgumentError here as it's more specific as well as backticks around the argument names to make it a tinge clearer what caused the errors.

The book mentions here that you end up getting the worst of both worlds in that you're now tightly coupled to types and doesn't really improve readability. If you were to do this in a full static typing system like Sorbet it might look more like this instead:

class Document
  extend T::Sig

  sig { params(title: String, author: String, content: String).void }
  def initialize(title:, author:, content:)
    @title = title
    @author = author
    @content = content
  end
end
Enter fullscreen mode Exit fullscreen mode

...but what I really wish it would do is something like interface testing without requiring interfaces like:

sig do
  params(
    title: respond_to?(:to_s),
    author: respond_to?(:to_s),
    content: respond_to?(:to_s)
  ).void
}
Enter fullscreen mode Exit fullscreen mode

...which goes beyond static typing into declaring explicitly what something needs to support to be used by the method. Granted this is more complicated than it sounds and has a number of drawbacks, as you can see in this discussion.

Required Ceremony versus Programmer-Driven Clarity

The book goes on to give a pseudo-code implementation of what static typing might look like:

def initialize(String title, String author, String content)
Enter fullscreen mode Exit fullscreen mode

...and says perhaps this gives some clarity, but asks if it would be necessary for more obvious methods like this one:

def is_longer_than?(number_of_characters)
  @content.length > number_of_characters
end
Enter fullscreen mode Exit fullscreen mode

It argues that even without type declarations you can reason that it might take a Number in and return a Boolean out the other end. It seeks to be "painfully obvious" and eschews types and documentation. The author uses this to say that we get to pick the level of detail we want, rather than it being a hard and fast requirement, and it's up to us to make that call.

That said I myself am of two minds about it. On one hand it would be nice if everything were reasonable and we could make reasonable guarantees about it, but on the other when dealing with user input and types from a myriad of directions who could make such a guarantee?

The Walled Garden

Personally I prefer a hybrid that I tend to call the "Walled Garden" approach. The outside world is scary, so we want to be much harsher with type checking and condition checking on our boundaries to ensure that anything that gets inside of our code cannot represent an invalid state.

That means that boundaries between you and the outside world, or in the case of a large Rails application using packwerk between you and another team's pack, are where you want the most effort expended on type checking. Those are the areas you have the least certainty of, the least domain context, and need the most clarity to be provided to those people on whatever outside you might be considering in this case.

Remember when I mentioned earlier to use private liberally? All of your private code can likely omit some amount of typing and documentation, but the more public it is the more that's going to become very necessary in progressively larger codebases for your public APIs.

Staying Out of Trouble

The book mentions some of this by going into what might happen if we were to not update callers of Document to know that it now takes something for title that responds to long_name instead of it being a plain String.

Its solution is to relax and write things carefully, as type errors are rare. In some cases I might agree, but in financial and healthcare systems there are certain luxuries not afforded to us, and just being careful might not cut it in some areas. The book does mention it's all about tradeoffs, and if you're in a sensitive domain with tight requirements you really should err on the side of caution.

Compare that to whatever mad science project I have going on the weekend and I can tell you my code is an order of magnitude less rigorous about typing.

The other issue is that when it's one or two developers it's easy to keep things straight, but once you have hundreds it becomes much more challenging. Does that mean we should fully embrace static typing then? Not really, you can do just as much damage that way. I've seen some companies use thousands of Sorbet T::Structs to disastrous result, and that code is nearly impossible to navigate and refactor without a well oiled chainsaw. As with all things it's about balance and Ruby gives you the choice in that matter.

The book also mentions not to be unnecessarily terse and omit information with short names. My general rule here is to make things as readable as possible to where the you in two or three weeks can still understand what and why something was written the way it was because I near guarantee you'll be back in that code.

In the Wild

Amusingly the book gets to StringIO at this bottom section when discussing alternatives to File. StringIO, IO, File, all of them have very similar interfaces of methods albeit implicit alignment to that interface rather than explicit.

In another example the book highlights that being too restrictive can make code far less flexible, like the case of Set it required something to be Enumerable rather than just requiring it to have an each method. Eventually that was updated to the latter, and now only requires something which has an each method.

Wrapping Up

Personally I could go on for a while on static versus dynamic typing, but the key point the book mentions is that above all you should be writing tests to verify behavior. Static typing is not some grand panacea, it only verifies the types but not the content inside of them for its behaviors, and far more often the latter is what ends up tripping up code I've worked on. The type is only 20% of the work, the other 80% of the work is deep domain logic and rules engines that need to be understood and well oiled.

The single thing to really keep in mind here is to do what makes sense for you in your codebase with your constraints. I can't pretend to know what you work on, the same as you for me, so we each have very distinct ways of viewing code that very well may be contradictory. Personally I get very worried though if everyone nods along with everything I say, it means I've done something horribly wrong, so please do have opinions and please do consider things no matter who it is saying them, especially if they're of some considerable power and authority.

💖 💪 🙅 🚩
baweaver
Brandon Weaver

Posted on September 5, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related