Ruby Class Methods are a better design choice than Constants
Preface
Constants are a valuable lanaguage construct and this article is not intended to say they are bad. What this article aims to explain is how using a constant without first considering a classes API exposes your class to unintended coupling in the future making refactoring more difficult.
Example
I will use an example from a post by Kir Shatrov titled Methods vs constants for static values in Ruby as a basis for explaining why Class Methods are preferred over Constants in Ruby. I have modified his example to use Hashes to better explain the coupling that occurs with overusing constants in a class design.
Let's assume you are building a class responsible for managing several queues. To keep track of each queue, your implementation could look like the following:
# Option 1 - Constant
class Queue
QUEUE_NAMES = {reserved: 1, optional: 2}
end
# Option 2 - Class Method
class Queue
class << self
def get_queue(name)
self.queue_names[name]
end
private
def queue_names
{reserved: 1, optional: 2}
end
end
end
# Option 3 - Both
class Queue
QUEUE_NAMES = {reserved: 1, optional: 2}
def self.queue_names
QUEUE_NAMES
end
end
This article is to explain why Option 2, using Class Methods, is preferred.
Why Option 1 is so prevalent
As Kir Shatrov explained:
In older versions of Ruby, Option 2 would had poor performance due duplicated string allocation - however, with frozen string literals, this is no longer a thing and the performance of all options is the same.
Now in this case, we are using Hashes so this optimization does not apply. However it is good to know that is why you see this code when strings are used.
What is wrong with the constant approach?
Constants by default (see caveat section for private_constant
details) are "leaky abstractions" that expose the internal workings to the outside world. In most cases it is not the intent of the author for this data to be used anywhere but outside the class. Unfortunately, nothing prevents outside code from using the data structure. For example, let's say you wrote this Scheduler class:
class Scheduler
def schedule_job(queue_name, job)
queue = Queue::QUEUE_NAMES[queue_name.downcase]
run_on(queue, job)
end
def run_on(queue, job)
# ...
end
end
You may be wondering what is so bad about this. Well the Queue
class has lost control of it's internal data structure QUEUE_NAMES
. Let's say Queue
needs to be refactored to make use of some new paramenters:
class Queue
QUEUE_NAMES = {
urgent: {id: 1, name: 'reserved', priority: 1},
low: {id: 2, name: 'optional', priority: 99}
}
# ...
end
This change should be completely fine to make as Queue
is responsible for the queue's themselves. However the Scheduler
is now broken because its implementation is coupled on the previous data structure of Queue::QueueNames
. This is what is known as a leaky abstraction. So how do we solve it? We use Option 2, class methods.
How class methods solves this problem
By using class methods in place of our constants in order for Scheduler
to be written at all, Queue
would need to expose the data though it's API explicitly through a get_queue
method:
class Queue
class << self
def get_queue(name)
self.queue_names[name]
end
private
def queue_names
{reserved: 1, optional: 2}
end
end
end
Then Scheduler
would look like this:
class Scheduler
def schedule_job(queue_name, job)
queue = Queue.get_queue(queue_name.downcase)
run_on(queue, job)
end
def run_on(queue, job)
# ...
end
end
Now we make the same changes we made in the previous example, but notice we can now make this change without any impact to the Scheduler
class.
class Queue
class << self
def get_queue(name)
queue = self.queue_names.find do |priority, details|
details[:name] == name
end
{queue[:name] => queue[:priority]}
end
private
def self.queue_names
{
urgent: {id: 1, name: 'reserved', priority: 1},
low: {id: 2, name: 'optional', priority: 99}
}
end
end
end
As you can see Queue has completely autonomy to change its internal implementation as needed. It also can decide to update how it's external API looks and like we did above, maintain backwards-compatibility as things change.
Other Arguments in favor of Class Methods
- Constants cannot be garbage collected as they must always be present.
- Constants cannot be redefined which restricts the ability to test a class under different execution paths. -- This is often the case when you want to test an implementation against a mock value that simulates various failure scenarios.
- Care must be taken with constant naming to avoid fetching the wrong one. If one class has a constant
User::DEFAULT
and another class hasAccount::DEFAULT
, care must be taken to make sure you can never grab the wrongDEFAULT
constant during a lookup.
Ruling out Option 3
This approach is redundant as you will see, it has all the pitfalls of Option 1 with none of the solutions offered by Option 2. The tiny benefit is the code is providing a hint to the consumer that they would prefer the usage of the class method, but have no promises that request will be obeyed.
It can be helpful in a situation where Option 1 was chosen and, due to outstanding circumstances, you are unable to update all references to the constant at the same time. It should be used only as a last-resort approach even in this situation. A better approach long-term would be using private_constant
which is explained below.
Disclaimer / Caveats
Since posting this article, there have been several things I glossed over in the original article that I wanted to post here:
- Ruby offers
Module#private_constant
which when used in Option 1 will get you to the same outcome as Option 2.- You should consider which would work best in a given situation based on the data you are working with. The end result will be the same: you have protected the API from unexpected coupling to outside classes.
- Why did I not include it as Option 4? In writing Ruby for well over a decade, I can count on one hand the number of times I have seen
private_constant
used.
- The code examples used in this article are basic by design. If performance is the priority in an implementation, you must better understand what data structures you are using inside a class and what operations that class needs to make upon that data.