Instances, Classes, and Modules, Oh My!
One of the biggest challenges of object oriented programming in Ruby is defining the interface of your objects. In other languages, such as Java, there is an explicit way to define an interface that you must conform to. But in Ruby, it’s up to you.
Compounding with this difficulty is the problem of deciding which object should own a method that you want to write. Trying to choose between modules, class methods, instance methods, structs, and lambdas can be overwhelming.
In this post, we’ll look at several ways to solve the Exercism Leap Year problem, exploring different levels of method visiblitiy and scope level along the way.
Leap Year
If you sign up for Exercism.io and start solving Ruby problems, one of the first problems you will look at is called Leap Year. The tests guide you to write a solution that has an interface that works like this:
Year.leap?(1984)
#=> true
A leap year is defined as a year that is divisible by four, unless it’s also divisible by 100. Apparently, centuries aren’t leap years. That is, unless they are centuries that are divisible by 400. So there are three rules for leap years:
- If it’s divisible by 400, it’s an exceptional century and is a leap year.
- If it’s divisible by 100, it’s a mundane century and in not a leap year.
- Otherwise, if it’s divisible by 4, it’s a leap year, otherwise it’s not.
The First Approach: Using Class Methods
Here’s one simple solution to this problem. You can rearrange the booleans in a couple of different ways and it will still work. This is the version I came up with:
class Year
def self.leap?(year)
year % 4 == 0 && !(year % 100 == 0) || year % 400 == 0
end
end
This is nice and compact, but not entirely easy to understand. I’m a pretty big fan of self-documenting code, so I used the extract method refactoring pattern to name these three rules.
class Year
def self.leap?(year)
mundane_leap?(year) || exceptional_century?(year)
end
def self.mundane_leap?(year)
year % 4 == 0 && !century?
end
def self.century?(year)
year % 100 == 0
end
def self.exceptional_century?(year)
year % 400 == 0
end
end
Class Methods and Privacy
This is a lot more understandable, in my mind. But there’s a problem with this. mundane_leap?
, century?
, and exceptional_century?
are all publicly exposed methods. They really only exist in support of leap?
, and I’m not sure how reusable they are, with the possible exception of century?
. If I write this test, it will pass:
def test_exceptional_century
assert Year.exceptional_century?(2400)
end
I would like to make exceptional_century?
private, so that it can’t be accessed outside of the Year
class. I can try something like this:
class Year
...
private
def self.mundane_leap?(year)
year % 4 == 0 && !century?
end
def self.century?(year)
year % 100 == 0
end
def self.exceptional_century?(year)
year % 400 == 0
end
end
But, unfortunately, this won’t work. The test still passes, becuase the private
keyword doesn’t work on class methods. Instead, I would have to use private_class_method
after my method definition.
private_class_method :mundane_leap?, :century?, :exceptional_century?
Now if I run that last test, it will raise an error.
All About the Eigenclass
In my mind, what we’ve now done is somewhat better, but there’s still a smell here. I’ll get to exactly what that is in just a moment, but for now I’ll say that it’s due to the fact that we’ve defined all of these methods on the singleton class, or eigenclass, of Year
. If you don’t know about eigenclasses, you can read about them here.
We can define methods on an eigenclass by prepending each method definition with self.
, or we can use class << self
or class << Year
and nesting our method definitions inside of that block. Doing it this way makes it possible to use the private
keyword, because we’re now working at the instance level of scope. If we were to introduce class << self
, then, we could do away with our private_class_method
call.
class Year
class << self
def leap?(year)
mundane_leap?(year) || exceptional_century?(year)
end
private
def mundane_leap?(year)
year % 4 == 0 && !century?(year)
end
def century?(year)
year % 100 == 0
end
def exceptional_century?(year)
year % 400 == 0
end
end
end
But we haven’t really changed much here. In my mind, there’s still a big smell, which is this. According to Wikipedia, a class is (emphasis mine) “ an extensible program-code-template for creating objects “. Our Year
class never creates a single instance. It’s just an eigenclass that happens to be able to create (mostly) useless Year
instances.
So where should we be putting class-level methods that are not associated with any instance object?
Second Approach: Module Functions
In Ruby, the Class
object inherits from Module
. A module is basically a collection of methods, constants, and classes. Their primary feature is that they can be mixed into other modules and classes to extend their functionality. They’re also frequently used for namespacing (for example, the ActiveRecord
constant is a module).
We can put our Year
implementation into a module without changing much: just swap the word class
for module
. Instead of using class << self
as we did for the class version, we can use extend self
in our module, and get the same effect, allowing us to use the private
keyword. extend
takes the methods defined in a module and makes them into class-level methods on the target module (or class). This is in contrast with include
, which mixes them in as instance-level methods. Thus, if we extend the module into itself, it gets all of it’s own methods at the class level.
module Year
extend self
def leap?(year)
mundane_leap?(year) || exceptional_century?(year)
end
private
def mundane_leap?(year)
year % 4 == 0 && !century?(year)
end
def century?(year)
year % 100 == 0
end
def exceptional_century?(year)
year % 400 == 0
end
end
If we wanted to define some other methods in a Year
class that had nothing to do with leap years, we could change the name of our module to Leap
and mix it into a class called Year
.
If we want to make the three leap year rule methods private, we now have another choice of how to do it. We can use module_function
. module_function
will make these methods available to the module, but when they get mixed into the class, they will be private. Module functions allow you to be selective with what can be called by the module itself, while still defining methods that can be mixed into other modules and classes.
module Leap
def leap?(year)
mundane_leap?(year) || exceptional_century?(year)
end
def mundane_leap?(year)
year % 4 == 0 && !century?(year)
end
def century?(year)
year % 100 == 0
end
def exceptional_century?(year)
year % 400 == 0
end
module_function :mundane_leap?, :century?, :exceptional_century?
end
class Year; extend Leap; end
Now, if we run the tests, they will all still pass, with the exception of the one we wrote that tries to call exceptional_century?
Third Approach: Instance Methods
I want to stay with the idea that we might want a Year
class that can do other things unrelated to determining whether we are talking about leap year or not. Really, there are a bunch of different years, but they all have certain things in common (which era they fall in, whether they are leap, etc.). In my mind, this would be a good use case for instances instead of class level methods.
What it look like if we brought the responsibility for knowing whether a year is leap back into a Year
class, but passing that behavior onto instances of year, instead of putting it on the eigenclass?
It’s a little weird to be able to do something like Year.new(2015).year
, so I’ll name the state we’re going to store reckoning
instead.
class Year
def self.leap?(year)
self.new(year).leap?
end
attr_reader :reckoning
def initialize(reckoning)
@reckoning = reckoning
end
def leap?
mundane_leap? || exceptional_century?
end
private
def mundane_leap?
reckoning % 4 == 0 && !century?
end
def century?
reckoning % 100 == 0
end
def exceptional_century?
reckoning % 400 == 0
end
end
The instance-based approach has certain advantages. We don’t have to use anything exotic like private_class_method
, class << self
, or module_function
, to make our methods private. This might make things easier to understand for future maintainers of our code.
We also gained access to the reckoning
, that could be used to calculate other properites in the future. For example, if we wanted to write to_julian
or to_hebrew
methods, we’d already have the number we’d need to use to calculate that available to us.
Finally, we can now use a protected
section as well. Methods in this section will only be visible to other instances of Year
. We might use it to do something like this:
class Year
...
protected
def ==(other)
self.recoking == other.reckoning
end
...
end
Conclusion
We’ve looked at several different ways to write a program that can tell us whether a given year is a leap year. Each has its advantages and disadvantages.
We started with a class method that was a single-liner. There’s a lot of value in the compactness of ` year % 4 == 0 && !(year % 100 == 0) | year % 400 == 0. But it's also fairly hard to understand. If we decide to split some of that logic into separate methods, now we're faced with the problem of how to keep these new methods out of the interface of Year`. |
We used an eigenclass-based approach, simply hiding the implementation methods with private_class_method
. We solved it using a module, treating the leap?
method both as a mix-in and as a module_function
. Finally, we pushed the functionality down to the instance level, which could help us in dealing with multiple Year
objects down the road.
Which of these approaches is best?
It depends on lots of things: how you feel about privacy, how you expect the program to change in the future, how comfortable you are with scope and the more arcane methods in Ruby. At the very least, it’s nice to know what’s available to you when thinking about what methods you want to expose, and what level of scope you should use it on. I hope this post will factor into your thoughts when making these kind of decisions in the future.
P.S. What do you think? Should I have used Structs or Lambdas? Throw us a comment below!
Share your thoughts with @engineyard on Twitter
OR
Talk about it on reddit