TL;DR
A value object can be much more concisely defined if you dedicate a different class to handling null values. The null value class can be a Ruby singleton, so only one instance is ever instantiated, saving memory.
Problem
So value objects in Ruby are great.
But not everything has a value all of the time. Nils are everywhere, and how do you efficiently handle them?
If you get it wrong then you're going to be staring at this sort of thing:
class ISBN def initialize(input) @isbn_string = input end def gs1_prefix if isbn_string.nil? nil else isbn_string[0..2] end end def checksum_number if isbn_string.nil? nil else isbn_string[-1] end end def valid? if isbn_string.nil? false else etc end end delegate :nil?, to: :isbn_string end
... or maybe you rely on empty strings giving you the right behaviour throughout ...
class ISBN def initialize(input) @isbn_string = input.to_s end def gs1_prefix isbn_string[0..2] end def checksum_number isbn_string[-1] end def valid? etc end def nil? isbn_string.empty? end end
An empty string is not a nil though, so this is a convenience that is also a little bit wrong.
A Solution
One solution to consider is an explicit “nil object” approach, which provides a response to everything that an instance of ISBN responds to, with the appropriate value for an ISBN that has no specified value or is missing.
As a bonus you only ever need one instance of this object, so you can use a Ruby singleton to represent it.
class ISBN class NilObject include Singleton def gs1_prefix nil end def checksum nil end def valid? false end def nil? true end end end
... and your ISBN object's instance methods can be simplified to:
class ISBN def initialize(input) @isbn_string = input end def gs1_prefix isbn_string[0..2] end def checksum_number isbn_string[-1] end def valid? etc end def nil? false end end
How to invoke this object? This works nicely ...
class ISBN def self.new(input) if input.nil? NilObject.instance else super(input) end end def initialize(input) @isbn_string = input end etc end
Yeah that's right, we overrode the new
class method to return a different class. That's problematic if you enjoy type checking, but I don't think you should – you should perhaps care more that whatever is returned responds correctly.
Further thoughts
If that is not your style, see the initialisation methods considered here and consider:
module ISBNInitializerExtensions refine String do def to_isbn ISBN.new(gsub(/[^[:digit:]]/,"")) end end refine NilClass do def to_isbn ISBN::NilObject.instance end end end
This lets nil.to_isbn
return a concisely defined singleton that has just the appropriate behaviour.
And why the #nil?
method? You're going to need that for validations ... another post later.
Make sure you have code coverage of the nil object class, and that you have tested that it responds to everything it needs to respond to.
And lastly, other specialised object types can also be defined, so you might have ISBN
, ISBN::NilObject
, and ISBN::Invalid
for complex cases where the behaviour of an object initialised with an invalid value differs from that of a valid value.
Top comments (0)