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 onKernel
, 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