Avoiding "Call Super" with Callbacks
Updated 2015-12-21. The example I originally used in this article was not very good, and apparently gave the false impression that I was encouraging the use of callbacks as a general replacement for super
. I’ve updated the example with something closer to my original use case.
Call Super is situation in which a superclass requires that subclasses invoke super
when overriding a method. Martin Fowler is, I believe, among the first to have labelled this an anti-pattern (“minor code smell”, in his words), and he’s careful to point out the problem is not with the super
call, but with the framework’s requirement that you must remember to call super
. (If a super
call is optional, it’s not a code smell.)
Here’s a concrete example of where “Call Super” is a problem. Let’s imagine we’ve got a library system that tracks grace periods and late fees. We have a “rules” class that tells us what the grace period for a particular book is, and what the corresponding late fee is.
class LendingRules
def initialize(book, options={})
@book = book
@options = options
_initialize_defaults
_configure_settings
end
def _initialize_defaults
@default_grace_period = 2 #days
@default_late_fee = 1 # dollars
end
def _configure_settings
@grace_period =
@options.fetch(:grace_period, @default_grace_period)
@late_fee =
@options.fetch(:late_fee, @default_late_fee)
end
end
We’ve got the base class set up with a default grace period and default late fee, and we’ve got a method where the concrete values for those settings are pulled out of the options hash.
Now, let’s say we have a special case when dealing with fiction, so we subclass our rules and override things. We want the subclass to have a different default grace period and late fee, and we want the late fee to double if the book has a waiting list.
class FictionRules < LendingRules
def _initialize_defaults
super # <-- DON'T FORGET THIS!!!
@default_grace_period = 4 # days
@default_late_fee = 2 # dollars
end
def _configure_settings
super # <-- DON'T FORGET THIS!!!
@late_fee *= 2 if @book.waiting_list?
end
end
The anti-pattern is manifested here because we must remember to call super
. If we don’t, some of our rules won’t get initialized.
So what to do instead? Fowler recommends the use of a Template Method, where the superclass defines a (possibly empty) method, and then calls it at the point of extension. Subclasses can then override that template method, if needed.
class LendingRules
# ...
def _initialize_defaults
@default_grace_period = 2 #days
@default_late_fee = 1 # dollars
_customize_defaults
end
def _customize_defaults
end
def _configure_settings
@grace_period =
@options.fetch(:grace_period, @default_grace_period)
@late_fee =
@options.fetch(:late_fee, @default_late_fee)
_customize_settings
end
def _customize_settings
end
end
class FictionRules < LendingRules
def _customize_defaults
@default_grace_period = 4 # days
@default_late_fee = 2 # dollars
end
def _customize_settings
@late_fee *= 2 if @book.waiting_list?
end
end
This works pretty well. I’ve been trying to use this pattern more recently, and I’m really happy with what it’s done to my code. However… it falls down a bit when you’re dealing with a more complex hierarchy of classes. For example, what happens when our rules change for a particular type of fiction? Let’s say the grace period is actually just a single day for vampire romance novels.
class VampireRomanceRules < FictionRules
def _customize_defaults
super # <-- GOTTA REMEMBER THIS AGAIN?
@default_grace_period = 1 # days
end
end
If we want to change a default for our VampireRomanceRules
objects, we have to override the template method, and remember to call super! So what do we do? Well, we could add a new template method for this second layer of inheritance:
class FictionRules < LendingRules
# ...
def _customize_defaults
# ...
_customize_fiction_defaults
end
def _customize_fiction_defaults
end
end
class VampireRomanceRules < FictionRules
def _customize_fiction_defaults
# ...
end
end
But this quickly becomes a mess. The programmer using this framework has to keep track of which initialization method to implement based on what the superclass is, and that’s not a happy situation.
This is where callbacks have been really helpful for me. Specifically, ActiveSupport has a robust callback implementation that you can drop into any class. Then, your subclasses can define initialize
callbacks (or any others that you need), which the superclass will call before or after the appropriate extension points.
Like this:
require 'active_support/callbacks'
class LendingRules
include ActiveSupport::Callbacks
define_callbacks :initialize, :configure
def initialize(book, options={})
@book = book
@options = options
_initialize_defaults
_configure_settings
end
def _initialize_defaults
run_callbacks :initialize do
@default_grace_period = 2 #days
@default_late_fee = 1 # dollars
end
end
def _configure_settings
run_callbacks :configure do
@grace_period =
@options.fetch(:grace_period, @default_grace_period)
@late_fee =
@options.fetch(:late_fee, @default_late_fee)
end
end
end
class FictionRules < LendingRules
set_callback :initialize, :after, :initialize_fiction_defaults
set_callback :configure, :after, :configure_fiction_settings
def initialize_fiction_defaults
@default_grace_period = 4 # days
@default_late_fee = 2 # dollars
end
def configure_fiction_settings
@late_fee *= 2 if @book.waiting_list?
end
end
class VampireRomanceRules < FictionRules
set_callback :initialize, :after, :initialize_vampire_romance
def initialize_vampire_romance
@default_grace_period = 1 # days
end
end
Every class can have it’s own unique initialization code, without worrying about overriding a superclass’ implementation, or even knowing what method is used to initialize the superclass. It’s super clean, very self-documenting, and easy to use, too.
The only wart, in my opinion, is how ugly that set_callback
invocation looks. In practice, I usually wrap that in a helper method on the superclass:
class LendingRules
# ...
def self.initialize_with(*callbacks)
set_callback :initialize, :after, *callbacks
end
def self.configure_with(*callbacks)
set_callback :configure, :after, *callbacks
end
# ...
end
class FictionRules < LendingRules
initialize_with :initialize_fiction_defaults
configure_with :configure_fiction_settings
# ...
end
class VampireRomanceRules < FictionRules
initialize_with :initialize_vampire_romance
# ...
end
Lastly, when using ActiveSupport::Callbacks
, it’s good to understand a few things:
- Callbacks registered with
:before
are called in reverse declaration order. That is to say, the last one registered is called first. Sometimes this is good, if you want a subclass to declare something that an earlier callback needs to consume. If you want a subclass to override something, though, don’t usebefore
callbacks. - Callbacks registered with
:after
are called in declaration order. The last one registered is called last. This is ideal when a subclass needs to override or alter something that an earlier callback has configured. - You can call
run_callbacks
without a block, too. In this case, the:before
callbacks are invoked, and then the:after
callbacks are invoked immediately afterward.
Do you have any helpful tricks that you’ve used to avoid Call Super? Share them in the comments!