In Progress
Unit 1, Lesson 21
In Progress

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