ruby software-design opinion

Ruby gems scattered on abstract source code, a big question mark, AI generated


I always had mixed feelings towards Ruby constants. First, they’re not that constant to begin with. You can reassign a constant at runtime freely and all you get is a warning:

irb(main):001:0> FOO = :bar
irb(main):002:0> FOO = :lol
(irb):2: warning: already initialized constant FOO
(irb):1: warning: previous definition of FOO was here

Contrary to popular belief, a #freeze won’t help, either. Yes, it makes the object you’re assigning immutable, but it doesn’t prevent another constant assignment:

irb(main):001:0> FOO = "bar".freeze
irb(main):002:0> FOO = "baz".freeze
(irb):2: warning: already initialized constant FOO
(irb):1: warning: previous definition of FOO was here
irb(main):003:0> # 🤷

(It would be nice to be able to change warning into an error, but there’s certainly no RUBYOPT flag that I know of.)

But most importantly: unless they’re defined in the top level object space, I consider them implementation details. If an instance of A::Nested::Class accesses Some::Other::CONST, it crosses 5-6 boundaries (depending on where you start counting). That’s not actually respecting that module’s privacy and also a violation of the Law of Demeter.

private_constant all the things!

By default, constants are public. Most software engineers are very eager to work with the private keyword to limit the public API of their instances, but it’s rarer to see that same rigor applied to class or module-level constants.

Ruby has private_constant since basically forever (MRI v1.9.3 to be precise). It accepts one or more symbols referring to defined constants in its scope:

module MyModule
  FOO = "bar".freeze
  LOL = :wat?

  private_constant :FOO, :LOL

  def self.foo
    FOO
  end
end

Any attempt to access MyModule::FOO from outside MyModule will raise a NameError (“private constant MyModule::FOO referenced”).

Please note that MyModule.foo still works (and returns the frozen string "bar") as it only accesses the private constant from within its defining scope.

(Singleton) Methods Over Constants

I mentioned I consider constants implementation details. A named identifier for a magic value, maybe something configurable and set at load time. And sometimes, you want to expose that to other components in your applications.

As shown in the code snippet above, you can always create a singleton method / module function that wraps around a private constant.

In my opinion, methods are in all cases superior to CONSTANTS:

  1. you can defer (lazy load) the assignment
  2. you can memoize (@class_var ||= ...) what’s being assigned
  3. you can delegate the method call
  4. it’s easier to stub a method than a constant in your tests
  5. it pairs well with making class/module-level value configurable on the application level, e.g. using a well-known MyModule.configure(&block) format or by using dry-config
  6. it feels much more OOP to send a message to an object than working with its constants (also, I always feel that CONSTANTS YELL AT ME in the source code)

Points 1..5 all give you a great forward compatible way to refactor how your magic value is used, all for the small price of making your constants private and adding a getter singleton method around it if you really need to expose it to the outside world right away.

Enforce Explicit Constant Visibility

Unfortunately, there is no way to make all constant private by default, but RuboCop includes a RuboCop::Cop::Style::ConstantVisibility cop to at least make the constant scope explicit.

I like that it makes you stop and think about what you’re doing.

Named Classes Are Constants

Now, constants aren’t only referred to by UPPERCASE identifiers: all class and module names are in fact constants, so you could argue that my criticism about accessing Some::CONSTANT directly must also extend to a form like Nested::Class.new.

And indeed it does, but that will be the topic of the next article. 😀


Header image credit: DALL·E 2.

comments powered by Disqus