If Ruby Had Imports…

Sometimes it’s bonkers how much you have to import in other languages in every file before you get to the actual code. Thankfully Ruby provides a better way.

Jared White by Jared White on November 25, 2020

Here is some example code from a Rails controller in the widely-used Discourse forum software:

class BadgesController < ApplicationController
  skip_before_action :check_xhr, only: [:index, :show]
  after_action :add_noindex_header

  def index
    raise Discourse::NotFound unless SiteSetting.enable_badges

    badges = Badge.all

    if search = params[:search]
      search = search.to_s
      badges = badges.where("name ILIKE ?", "%#{search}%")
    end

    if (params[:only_listable] == "true") || !request.xhr?
      # NOTE: this is sorted client side if needed
      badges = badges.includes(:badge_grouping)
        .includes(:badge_type)
        .where(enabled: true, listable: true)
    end

    badges = badges.to_a

    user_badges = nil
    if current_user
      user_badges = Set.new(current_user.user_badges.select('distinct badge_id').pluck(:badge_id))
    end
    serialized = MultiJson.dump(serialize_data(badges, BadgeIndexSerializer, root: "badges", user_badges: user_badges, include_long_description: true))
    respond_to do |format|
      format.html do
        store_preloaded "badges", serialized
        render "default/empty"
      end
      format.json { render json: serialized }
    end
  end

  # and more actions here...
end

Now, if you’re looking at this code coming from a JavaScript/TypeScript background—or a number of other programming languages—the first thing you might immediately think is:

Where are all the import statements??

That’s right, there’s nary an import statement to be found! Where does ApplicationController come from? SiteSetting? Badge? Heck, even MultiJson? How is this all just accessible without requiring it somehow?!

Ah my friend—welcome to the wonderful world of Ruby autoloading.

How to Acquire an Instinctual Hatred of Explicit Import Statements

Step 1: write Rails apps full-time for several years.

Step 2: go peek at the top of a file written for virtually any large NodeJS framework.

Step 3: 🤢

Look, I don’t mean to pick on poor JavaScript. When you’re trying to write performant code for eventual download to a browser where you need to keep the bundle sizes lean and mean, you want to import and export and tree-shake and chunk-split and do everything you can do to avoid megabytes of unnecessary code clogging up the wires.

But riddle me this: why do you need 20 import statements at the top of a file…in a server environment??

Excuse me, what does NodeJS need with a small bundle size?

If you would indulge me for a moment, let’s imagine a world where you had to import all of the objects and functions needed in each file in your Rails application. Revisiting the example above, it might look something like this:

import ApplicationController from "./application_controller"
import { skip_before_action, after_action, params, respond_to, format } from "@rails/actionpack"
import Discourse from "../lib/global/discourse"
import SiteSetting from "../models/site_setting"
import Badge from "../models/badge"
import MultiJson from "@intridea/multi_json"

class BadgesController < ApplicationController
  # etc...
end

And that’s just for a single controller action! 🤪

This leaves us with only one question: since your Ruby on Rails code obviously doesn’t have to import/require anything for it to work, how does it do that? How does it know how to simply autoload all these objects?

Introducing Zeitwerk

Actually, before we dive into Zeitwerk, let’s quickly review built-in Ruby autoloading.

Ruby comes out of the box with a form of autoloading attached to Module. You can use this in any Ruby program you write:

# my_class.rb
module MyModule
  class MyClass
  end
end

# main.rb
module MyModule
  autoload :MyClass, "my_class.rb"
end

MyModule::MyClass.new # this triggers the autoload

This is handy in a pinch, but for larger applications or gems and particularly for Rails, you need something that’s broader-reaching and more easily configurable—plus supports concepts like “eager loading” and “reloading” (in development).

That’s where Zeitwerk comes in.

With Zeitwerk, you can define one or more source trees, and within that tree, as long as your Ruby constants (modules and classs) and hierarchy thereof match the file names and folder structure via a particular convention, it all just works. Magic!

Here’s an example from the readme:

lib/my_gem.rb         -> MyGem
lib/my_gem/foo.rb     -> MyGem::Foo
lib/my_gem/bar_baz.rb -> MyGem::BarBaz
lib/my_gem/woo/zoo.rb -> MyGem::Woo::Zoo

And here’s how you instantiate a Zeitwerk loader. It’s incredibly easy!

loader = Zeitwerk::Loader.new
loader.push_dir("lib")
loader.setup # ready!

Once you’ve instantiated a Zeitwerk loader, at any point in the execution of your Ruby program after that setup is complete you can call upon any class/module defined within that loader’s source tree and Zeitwerk will automatically load the class/module.

In addition, if you use the loader.eager_load method, you can load all of the code into memory at once. This is preferred in production for performance reasons: once your app first boots, it doesn’t have to load anything else later. On the other hand, in development you want to be able to reload code if it’s changed and run it without having to terminate your app and boot it up again. With the loader.reload method, Zeitwerk supports that too!

You may be surprised to hear that Zeitwerk is somewhat new to the Ruby scene (Rails used a different autoloader before it and there have been other techniques in that vein over time). What makes Zeitwerk so cool is how easy it is to integrate into any Ruby app or gem. I myself am starting to integrate it into Bridgetown now. The only caveat is you do need to be a little strict with how you structure your source files and folders and what you name within those files. But once you do that, it’s a cinch.

Still a Use for require Though

Even with Zeitwerk on the loose, you’ll still need to use a require statement now and then to load Ruby code from a gem or some other random file you’ve pulled into your project. But the nice thing is that Ruby’s require doesn’t work the way that import does in JavaScript. It simply adds the requested file/gem to the current execution scope of your program and then it’s available everywhere proceeding from that point. So if you add require to a main or top-level file within your application codebase, there’s no need to then “import Foo from "bar"” later on in file B and “import Foo from "bar"” in file C all over again.

This does mean that you may have to fish a bit to find out where MyObscureClassName.what_the_heck_is_this actually comes from. This is likely how some of the “argh, Ruby is too magical!” sentiment out there arises. But given a choice between Ruby magic, and JS import statement soup at the top of Every. Single. Darn. File. In. The. Entire. Codebase…

…well, I believe in magic. Do you?

“Ruby is simple in appearance, but is very complex inside, just like our human body.”

matz

Subscribe to receive a timely tip you can apply directly to your Ruby site or application each week:

Banner image by Maksym Kaharlytskyi on Unsplash


Other Recent Articles

Teaching Ruby to Beginners? Trying New Gems or Techniques? Use Bridgetown!

The next big release of Bridgetown provides an intriguing new environment for teaching and learning Ruby and trying out new tools in the Ruby ecosystem.

Continue Reading

Better OOP Through Lazily-Instantiated Memoized Dependencies

There are various schools of thought around how best to define dependencies in your object graph. Let’s learn about the one I prefer to use the majority of the time. It takes advantage of three techniques Ruby provides for us: variable-like method calls, lazy instantiation, and memoization.

Continue Reading

More This Way