Subscript Constructor
Video transcript & code
Recently an alert viewer pointed out an inconsistency in episode #432. We had created a conversion function for Name
value objects.
def Name(content)
case content
when String then Name.new(content)
when Name then content
else fail TypeError, "Can't make Name from #{content.inspect}"
end
end
Like most Ruby conversion functions, this one has strict semantics. Meaning that if we give it input it doesn't know how to handle, it immediately raises an error.
require "./app"
Name(23) # => TypeError: Can't make Name from 23
# ~> TypeError
# ~> Can't make Name from 23
# ~>
# ~> /home/avdi/Dropbox/rubytapas-shared/working-episodes/438-subscript-constructor/app.rb:85:in `Name'
# ~> xmptmp-in15439SFL.rb:3:in `<main>'
We had also written a conversion function for Duration
objects.
def Duration(raw_value)
case raw_value
when Duration
raw_value
when /\A(\d+)\s+months\z/i
Months[$1.to_i]
when /\A(\d+)\s+weeks\z/i
Weeks[$1.to_i]
when /\A(\d+)\s+days\z/i
Days[$1.to_i]
else
ExceptionalValue.new(raw_value, reason: "Unrecognized format")
end
end
After a few different iterations on this function, we had left it in a state where, when given unrecognized input, it returns an ExceptionalValue
object rather than raising an error.
require "./app"
Duration(3.14159)
# => #<ExceptionalValue:0x0056225a70ac60
# @raw=3.14159,
# @reason="Unrecognized format">
This is both inconsistent with the conversion function idiom, as well as inconsistent with the behavior of another conversion function in the same program. This code as written isn't doing a very good job of observing the principle of least astonishment.
(By the way, if you're not clear on this whole "conversion function" concept, check out episode #207 for a refresher.)
OK, so what part of these methods should we change to make them more consistent with convention, and with each other?
Well, the first and easiest thing we can do is to notice that we've been too broad in the case statement for the Duration
conversion function.
The ExceptionalValue
class was intended for dealing with un-parseable user input in the form of a string. There's no reason for this function to accept any type of data without raising an exception.
Let's constrain this line to only handle unrecognized strings.
Then we can add a new else
clause for values other than Durations
and strings.
def Duration(raw_value)
case raw_value
when Duration
raw_value
when /\A(\d+)\s+months\z/i
Months[$1.to_i]
when /\A(\d+)\s+weeks\z/i
Weeks[$1.to_i]
when /\A(\d+)\s+days\z/i
Days[$1.to_i]
when String
ExceptionalValue.new(raw_value, reason: "Unrecognized format")
else
fail TypeError, "Can't make a Duration from #{raw_value.inspect}"
end
end
Now when we supply something total out of bounds for a Duration
, we get a TypeError
.
Duration(23)
# ~> TypeError
# ~> Can't make a Duration from 23
This brings the conversion function more in line with expectations, and makes it more similar to the Name()
function.
Still, both of these methods diverge a bit from the orthodox conversion function idiom.
When we use Ruby's built-in Integer()
conversion function, we either get an Integer
back, or an exception.
There's no middle ground for unrecognized values.
Integer("twenty-three") # ~> ArgumentError: invalid value for Integer(): "twenty-three"
# =>
# ~> ArgumentError
# ~> invalid value for Integer(): "twenty-three"
# ~>
# ~> xmptmp-in15439J4s.rb:1:in `Integer'
# ~> xmptmp-in15439J4s.rb:1:in `<main>'
From this perspective, I wonder if a conversion function is the right place for this logic.
Where else might we put it? Well, in the case of Duration
, we could always make a special class-level constructor method, something we talked about way, way back in episode #007. We could drop the conversion function logic into this method unchanged.
class Duration < WholeValue
def self.for(raw_value)
case raw_value
when Duration
raw_value
when /\A(\d+)\s+months\z/i
Months[$1.to_i]
when /\A(\d+)\s+weeks\z/i
Weeks[$1.to_i]
when /\A(\d+)\s+days\z/i
Days[$1.to_i]
when String
ExceptionalValue.new(raw_value, reason: "Unrecognized format")
else
fail TypeError, "Can't make a Duration from #{raw_value.inspect}"
end
end
# ...
end
Now we can write code like Duration.for("2 weeks")
, and get back the appropriate concrete representation.
Duration.for("2 weeks")
# => Weeks(2)
I think this is a reasonable strategy, and I've written code like it in the past.
But there's another, related approach that's worth considering.
Classes based on Ruby's Struct
class have an interesting shorthand for constructing new instances: we can use the subscript operator on the class itself.
Point = Struct.new(:x, :y)
Point[23, 42]
# => #<struct Point x=23, y=42>
We can also construct hashes from lists using the same convention.
Hash["a", "apple", "b", "banana"]
# => {"a"=>"apple", "b"=>"banana"}
Using square brackets has some interesting semantic implications. When we have an array, and we supply an index that's out of bounds, we get a nil
back. This may not be the value we hoped for, but there's no exception raised. Execution of the code continues onward.
numbers = ["pagh", "wa'", "cha'"]
numbers[3] # => nil
So, there's a sort of implicit expectation that square brackets may or may not give us back something of the type we wanted. But even if they don't, they still won't raise an exception.
These are exactly the semantics we want to suggest for a method that might return an ExceptionalValue
object instead of a Duration
.
And, as it happens, we actually already have a square bracket construction operator on this class.
It's just that the existing one was only intended for constructing concrete subclass, like the Months
or Days
objects.
Let's change the argument name, but preserve the existing semantics of this method with a guard clause at the beginning. We pass through to the new
method if we're in a concrete subclass.
Then, we'll move our construction case statement into this method's implementation.
require "./app"
class Duration < WholeValue
def self.[](raw_value)
return new(raw_value) if self < Duration
case raw_value
when Duration
raw_value
when /\A(\d+)\s+months\z/i
Months[$1.to_i]
when /\A(\d+)\s+weeks\z/i
Weeks[$1.to_i]
when /\A(\d+)\s+days\z/i
Days[$1.to_i]
when String
ExceptionalValue.new(raw_value, reason: "Unrecognized format")
else
fail TypeError, "Can't make a Duration from #{raw_value.inspect}"
end
end
# ...
end
Now we're able to construct durations from appropriately formatted strings.
Badly formatted strings return ExceptionalValue
objects.
And passing in nonsensical values still raises an exception.
Duration["5 months"]
# => Months(5)
Duration["a fortnight"]
# => #<ExceptionalValue:0x00562bfafd7808
# @raw="a fortnight",
# @reason="Unrecognized format">
Duration[{bad: "value"}]
# =>
# ~> TypeError
# ~> Can't make a Duration from {:bad=>"value"}
# ~>
# ~> xmptmp-in15439iGo.rb:18:in `[]'
# ~> xmptmp-in15439iGo.rb:29:in `<main>'
But wait a minute: didn't we just say that with square brackets, we don't expect exceptions?
Well, that's not entirely true.
When we try to use non-integer index to an array, we get a TypeError
.
numbers = ["pagh", "wa'", "cha'"]
numbers["three"] # => TypeError: no implicit conversion of String into Integer
# ~> TypeError
# ~> no implicit conversion of String into Integer
# ~>
# ~> xmptmp-in15439Jzi.rb:2:in `[]'
# ~> xmptmp-in15439Jzi.rb:2:in `<main>'
So, in fact, our use of the square brackets is still entirely in line with the idiomatic expectations set by the Ruby core classes.
We can quickly make the same change to the Name Whole Value
class.
class Name < WholeValue
def self.[](content)
case content
when String then Name.new(content)
when Name then content
else fail TypeError, "Can't make Name from #{content.inspect}"
end
end
# ...
end
We've now eliminated the cognitive dissonance caused by having a conversion function that behaves differently from Ruby's built-in examples of conversion functions. And we've made our value object subscript constructors consistent with each other as well.
This is now a slightly less surprising, more idiomatically conventional codebase. And that should hopefully lead to easier maintenance and extension down the road. Happy hacking!
Responses