In Progress
Unit 1, Lesson 21
In Progress

Conversion Function

Video transcript & code

We're still working on our class for representing quantities of feet. Up till now, we've been creating new Feet objects with an ordinary .new message. There's nothing wrong with this. But compared to using core numeric types, it feels clunky.

require "./feet"

the_answer = 42
elevation  = Feet.new(6644)

Also, the .new method will only create a Feet object from a raw numeric. If we feed in another Feet object, we get an exception.

When is this a problem? Well, consider this logging method. It's been around since before we started converting our program to use dedicated quantity types. In the older areas of the program, it is still being passed raw numbers representing feet quantities. We'd like to continue using the method in code that has been updated to use Feet objects as well.

def report_elevation_change(amount)
  feet = Feet.new(amount)
  puts "Elevation changed by #{feet}"
end

If we decide to update the method to use Feet internally, we have a problem. We can pass in a raw floating point number with no problem. But when we pass in Feet values from newer code, it blows up.

require "./feet"
require "./report"

report_elevation_change(763)
# >> Elevation changed by 763.0 feet
require "./feet"
require "./report"

report_elevation_change(Feet.new(763))
# ~> /home/avdi/Dropbox/rubytapas/207-conversion-function/feet.rb:17:in `initialize': undefined method `to_f' for #<Feet:763.0> (NoMethodError)
# ~>    from /home/avdi/Dropbox/rubytapas/207-conversion-function/report.rb:3:in `new'
# ~>    from /home/avdi/Dropbox/rubytapas/207-conversion-function/report.rb:3:in `report_elevation_change'
# ~>    from -:4:in `<main>'

Today we're going to address both of these issues, and to do so we're going to use something called a conversion function.

What's a conversion function? In fact it's something we've seen quite recently. You might recall that in Episode 206, we built some Rational numbers using this a somewhat odd-looking syntax.

require "rational"

ratio = Rational(2,3)
ratio                           # => (2/3)

A conversion function is a Ruby method that is built according to a few conventions.

  • First, it is named with a capital letter, unlike all other Ruby methods. This doesn't give it any special powers; it's just a way to communicate to other developers that it serves the purpose of a conversion function.
  • Second, it is normally called without an explicit receiver. The Rational method is defined on Kernel, so it becomes a private method available to every class. While technically a method, because it is used the way functions are used in other languages, we call it a "function".
  • Third, its purpose is to return an object of the class after which it is named, starting with the arguments it is given—if such a conversion is possible.

There are actually conversion functions for all the core and standard library numeric types in Ruby. We can use them to convert values to integers, floating point numbers, and decimal numbers, for instance.

Integer("23")                   # => 23
Float("3.14159")                # => 3.14159
require "bigdecimal"
BigDecimal("1_000_000")         # => #<BigDecimal:1c82b60,'0.1E7',9(18)>

One question you might have is this: why would we use these conversion functions for integers and floats, when we have shorter and more commonly used explicit conversion methods?

So, for instance, we can just as easily convert a string containing the number "23" into an integer by sending it the #to_i message.

Integer("23")                   # => 23
"23".to_i                       # => 23

The answer is that the semantics of these two conversions differ. Let's look at an example that demonstrates the difference. When we take a string which does not have any meaningful conversion to an integer, and send it the #to_i message, we get the result 0. But when we pass the same string the the Integer() conversion function, we get an exception.

"ham sandwich".to_i             # => 0
Integer("ham sandwich")         # => 
# ~> -:2:in `Integer': invalid value for Integer(): "ham sandwich" (ArgumentError)
# ~>    from -:2:in `<main>'

This neatly lays out the difference between explicit conversion methods and conversion functions: the method is lenient, whereas the function is strict. Here's another example, this time using nil:

nil.to_i                        # => 0
Integer(nil)                    # => 
# ~> -:2:in `Integer': can't convert nil into Integer (TypeError)
# ~>    from -:2:in `<main>'

The conversion method returns 0, whereas the function raises an error.

This is generally the case with most conversion functions in Ruby: the function may convert many different types into the target type, but only if there is a sensible conversion of the value.

One final property of conversion functions is that they are idempotent: that is, given an object of the target type, they will return an identical object of the target type.

Integer(23)                     # => 23

This means that it is safe to invoke a conversion function multiple times on a value. We won't get an exception.

Integer(Integer("23"))          # => 23

So now we have an idea of the kind of semantics we are shooting for in a Feet conversion function. We'll write our conversion function outside of the Feet class. We will name it after the class. Inside, we'll switch on the argument type. If it is already a Feet object, we'll simply return it unchanged. On the other hand, if it is anything else, we need to convert it.

Now remember, we expect a conversion function to be strict in its input. But so far, we've been pretty lenient with the Feet constructor. Inside it, we use #to_f to convert the given magnitude to a Float. This means we could be constructing Feet objects out of questionable input.

require "./feet"

Feet.new("eleventy-six")        # => #<Feet:0.0>

This might not have been the best choice for the constructor, but let's put off that discussion for a bit. It's definitely not the right choice for a strict conversion function.

To tighten things up in our conversion function, we'll lean on the Float conversion function to bounce out values which can't be reasonably converted to a floating point number.

def Feet(value)
  case value
  when Feet then value
  else
    value = Float(value)
    Feet.new(value)
  end
end

We now have a conversion function we can use to construct Feet objects both more concisely and more safely. We can feed it an integer; or a floating point number; or a string representing a number; or another Feet object, and it will return a Feet object in every case. But give it a value which can't be sensibly converted, and it will fail.

require "./feet"

Feet(763)                       # => #<Feet:763.0>
Feet(9.876)                     # => #<Feet:9.876>
Feet("10000")                   # => #<Feet:10000.0>
Feet(Feet(763))                 # => #<Feet:763.0>
Feet("eleventy-six")            # => 

# ~> /home/avdi/Dropbox/rubytapas/207-conversion-function/feet.rb:77:in `Float': invalid value for Float(): "eleventy-six" (ArgumentError)
# ~>    from /home/avdi/Dropbox/rubytapas/207-conversion-function/feet.rb:77:in `Feet'
# ~>    from -:7:in `<main>'

We can now update our report_elevation method to use this new conversion function, and it will be compatible with both old code that passes raw numbers and new code that passes Feet.

def report_elevation_change(amount)
  feet = Feet(amount)
  puts "Elevation changed by #{feet}"
end

One last thing: we've defined this method outside of any class, which makes it available anywhere. If we packaged our Feet object into a gem, this might not be the best practice. If we wanted to make the function available only in code that makes use of these quantity objects, we could enclose the whole class in a Quantity module. Then we could define the Feet() conversion function in that module. Finally, we could use module_function, as seen in Episode #49, to make it available both as a private method in including classes, and as a public module-level method.

module Quantities
  class Feet
    # ...
  end

  def Feet(value)
    case value
    when Feet then value
    else
      value = Float(value)
      Feet.new(value)
    end
  end

  module_function :Feet
end

That way we have two ways of using the conversion function: in places where we include Quantities, we can call it directly. And elsewhere, we can still access it by qualifying it with the module name.

include Quantities

Feet(1234)

# or:

Quantities.Feet(1234)

OK, that's enough for today. Happy hacking!

Responses