Ruby legend Xavier Noria recently penned a tweet that got me thinking about how we organize and package up our native Ruby code:
How often do we just dump our code into mix-in modules (that never get reused) when our classes get too large? What if we continued to organize our code into separate files, but reopened classes?
This is something we can debate all day in the comments, but that sparked my Ruby Science question:
What if one of these approaches was faster?
Well, thanks to benchmark-ips, (which I have also bundled into my own performance logging gem) we don't have to wonder.
I fired up three example cases (Reopen, Include mixin module, Extend mixin module) and ran them against each other in a simple test case. Code below:
module MixinDependency
def open_door
puts 'Door is open!'
end
end
class DoorOpenerMixin
include MixinDependency
def close_door
puts 'Door is closed!'
end
end
class DoorOpenerMixinExtend
extend MixinDependency
end
class DoorOpenerReopen
def close_door
puts 'Door is closed!'
end
end
class DoorOpenerReopen
def open_door
puts 'Door is open!'
end
end
And we tested it with:
require 'benchmark/ips'
Benchmark.ips do |x|
x.report('Include') { DoorOpenerMixin.new.open_door }
x.report('Extend') { DoorOpenerMixinExtend.open_door }
x.report('Reopen') { DoorOpenerReopen.new.open_door }
x.compare!
end
Results? Nothing exciting.
Comparison:
Extend: 214816.5 i/s
Reopen: 197784.3 i/s - same-ish: difference falls within error
Include: 182298.2 i/s - same-ish: difference falls within error
Even inconclusive findings are still findings, and I hope you enjoyed seeing how I picked through this hypothesis to give a clear answer. If you're unfamiliar with benchmark-ips, copy this code and try it out yourself! It really is one of my favorite tools in my toolbox.
One small but fun finding, I tried running this on different Ruby versions, you can see a clear speed bump since Ruby 2.1! Here's the results on the old version:
Comparison:
Extend: 178840.8 i/s
Include: 167210.7 i/s - same-ish: difference falls within error
Reopen: 166005.2 i/s - same-ish: difference falls within error
If you have any 'experiments', feel free to share your results and thoughts below! 🥼 🤔
Top comments (2)
One of the big reasons I’ve preferred mixins over reopening classes is that it ‘reads’ a bit better. To me there is less cognitive load to thinking about collections of like behavior separated out into modules which are self-documenting rather than having the same class opened in multiple places which can ‘hide’ behavior.
I usually take the ‘middle way’ when trying to thin out my classes and extract common behavior into service objects.
I think you're right. There's a benefit to using well-trodden patterns to keep code readable for others as well.
Plus in my mind I do like one file being the 'home base' for a class, and then using mixins to group and store methods used by it elsewhere.
The class-reopening-approach definitely is used a lot more in gems though (in my experience!)