Protected
protected
Video transcript & code
If you've been using Ruby for any length of time you probably know about public and private methods. But there is a third method visibility level: protected
. Many Ruby programmers have a solid policy on when to use public
or private
visibility, but are a little fuzzy on when to use protected
. Today I'm going to try and supply some answers.
So we have this Feet
class that we've been slowly building up over the last thousand episodes or so. It represents measurements in feet. Internally, it uses an attribute called magnitude
to refer to hold the floating-point number of feet being represented.
class Feet
include Comparable
CoercedNumber = Struct.new(:value) do
def +(other) raise TypeError; end
def -(other) raise TypeError; end
def *(other)
other * value
end
def /(other) raise TypeError; end
end
attr_reader :magnitude
alias_method :to_f, :magnitude
def initialize(magnitude)
@magnitude = magnitude.to_f
freeze
end
def to_s
"#{@magnitude} feet"
end
def to_i
to_f.to_i
end
def inspect
"#<Feet:#{@magnitude}>"
end
def +(other)
raise TypeError unless other.is_a?(Feet)
Feet.new(@magnitude + other.magnitude)
end
def -(other)
raise TypeError unless other.is_a?(Feet)
Feet.new(@magnitude - other.magnitude)
end
def *(other)
multiplicand = case other
when Numeric then other
when Feet then other.magnitude
else
raise TypeError, "Don't know how to multiply by #{other}"
end
Feet.new(@magnitude * multiplicand)
end
def /(other)
raise TypeError unless other.is_a?(Feet)
Feet.new(@magnitude / other.magnitude)
end
def <=>(other)
raise TypeError unless other.is_a?(Feet)
other.is_a?(Feet) && magnitude <=> other.magnitude
end
def hash
[magnitude, Feet].hash
end
def coerce(other)
unless other.is_a?(Numeric)
raise TypeError, "Can't coerce #{other}"
end
[CoercedNumber.new(other), self]
end
alias_method :eql?, :==
end
def Feet(value)
case value
when Feet then value
else
value = Float(value)
Feet.new(value)
end
end
require "./feet"
f = Feet(1234.5)
f.magnitude # => 1234.5
Recently we added the ability to convert a Feet
object to a floating-point value using the conventional explicit conversion method #to_f
. This means that in theory, clients no longer have any need to know about the magnitude
.
require "./feet"
f = Feet(1234.5)
f.to_f # => 1234.5
Earlier, we also learned that the #magnitude
method had an inadvertent naming collision with a Numeric
method of the same name. For Feet
object, #magnitude
is the raw number of feet. But for Ruby numeric values, #magnitude
is a synonym for #abs
, meaning the absolute value.
require "./feet"
Feet.new(-1234.5).magnitude # => -1234.5
-1234.5.magnitude # => 1234.5
This could lead to some nasty mixed-unit bugs, at least in theory. To offer a contrived example: say we have some measurements representing amounts of change, in feet, from some source. And we have some other measurements which also represent amounts of change from another source. This second set of measurements are represented by raw numeric values. They also happen to have been measured in meters, not feet, although there's nothing about the values themselves to indicate this fact.
Now let's say we put all these values together and try to add up the total variance. Bear in mind, while I'm giving these variables clear names in this example in order to illustrate the problem, the assumption is that in the real world it wouldn't be nearly so obvious that units are being mixed here.
require "./feet"
changes_in_feet = [
Feet(12.3),
Feet(-8.7),
Feet(2.9)]
changes_in_meters = [
17.5,
-10.6,
-3.2
]
total_variance = (changes_in_meters + changes_in_feet).reduce(0) {
|total,value|
total + value.magnitude
}
total_variance # => 37.800000000000004
This calculation appears to succeed. It raises no red flags as a result of the mixing of units. Unfortunately the result is doubly wrong. magnitude
means two different things when sent to Float
objects and Feet
objects. It's just bad luck that they share a name. So not only are feet measurements being combined with meter measurements, but only the meters are being correctly converted to absolute values.
It's worth highlighting here that this is one of the sneakier gotchas when using a duck-typed language. Just as in human language, the same word can mean two different things to two different recipients. And remember, even if we were careful to always use #abs
instead of #magnitude
in our own code, we have no control over third party libraries. If we used a library for statistical calculations, this use of #magnitude
might well be hidden in code we wouldn't think to inspect.
So we have an attribute which is both an implementation detail, and which might conceivably lead to hidden mistakes if it remains exposed. This seems like a solid justification to changing this attribute from public to private. Let's go ahead and do that. Rather than scooting it around into a private section, we'll just explicitly make it private. We do this after the alias to #to_f
, so that the #to_f
alias remains public.
# ...
attr_reader :magnitude
alias_method :to_f, :magnitude
private :magnitude
# ...
Now when we try to execute this calculation it raises an exception, as it should.
require "./feet2"
changes_in_feet = [
Feet(12.3),
Feet(-8.7),
Feet(2.9)]
changes_in_meters = [
17.5,
-10.6,
-3.2
]
total_variance = (changes_in_meters + changes_in_feet).reduce(0) {
|total,value|
total + value.magnitude
}
total_variance # =>
# ~> -:18:in `block in <main>': private method `magnitude' called for #<Feet:12.3> (NoMethodError)
# ~> from -:16:in `each'
# ~> from -:16:in `reduce'
# ~> from -:16:in `<main>'
Unfortunately, we have a problem. When we try to compare two identical Feet
objects, we now get false
.
require "./feet2"
Feet(1234) == Feet(1234) # => false
This is super weird. Substituting the spaceship operator clarifies the issue a little bit.
require "./feet2"
Feet(1234) <=> Feet(1234) # =>
# ~> /home/avdi/Dropbox/rubytapas/211-protected/feet2.rb:62:in `<=>': private method `magnitude' called for #<Feet:1234.0> (NoMethodError)
# ~> from -:3:in `<main>'
This time, we get an error that says we tried to invoke a private method. When we refer back to the spaceship operator definition we can see why: we are sending the #magnitude
message, to both self and the other object. Since #magnitude
method is now private the other object rejects this send.
def <=>(other)
raise TypeError unless other.is_a?(Feet)
other.is_a?(Feet) && magnitude <=> other.magnitude
end
We can see that we do the same thing in other operator implementations, such as addition.
So why did the equality operation return false? As you may recall, we used the Comparable
module to derive equality comparisons from the spaceship operator. In Ruby 2.1, the equality method provided by Comparable
hides exceptions and just returns false. Note that this behavior will change in a future version of Ruby.
Here, at last, we arrive at the reason for the existence of the protected
visibility level. protected
is for this specific case: when we want to make an attribute private except for when it is accessed from other objects of the same class. This pretty much only happens in operator methods.
Let's change #magnitude
to be protected
.
protected :magnitude
Now when we try to compare Feet
objects, or perform other operations on them, they once again work properly. But our buggy variance-summing code still fails with a protection violation.
require "./feet3"
Feet(123) == Feet(123) # => true
Feet(123) + Feet(456) # => #<Feet:579.0>
changes_in_feet = [
Feet(12.3),
Feet(-8.7),
Feet(2.9)]
changes_in_meters = [
17.5,
-10.6,
-3.2
]
total_variance = (changes_in_meters + changes_in_feet).reduce(0) {
|total,value|
total + value.magnitude
}
total_variance # =>
# ~> -:19:in `block in <main>': protected method `magnitude' called for #<Feet:12.3> (NoMethodError)
# ~> from -:17:in `each'
# ~> from -:17:in `reduce'
# ~> from -:17:in `<main>'
Now, as you might have already realized, this isn't the only way we could have proceeded. After all, we already have a public
way to get at a Feet
object's magnitude: the #to_f
conversion method. So another tack would be to keep #magnitude
private, and instead change all operators to use #to_f
instead.
class Feet
include Comparable
CoercedNumber = Struct.new(:value) do
def +(other) raise TypeError; end
def -(other) raise TypeError; end
def *(other)
other * value
end
def /(other) raise TypeError; end
end
attr_reader :magnitude
alias_method :to_f, :magnitude
private :magnitude
def initialize(magnitude)
@magnitude = magnitude.to_f
freeze
end
def to_s
"#{@magnitude} feet"
end
def to_i
to_f.to_i
end
def inspect
"#<Feet:#{@magnitude}>"
end
def +(other)
raise TypeError unless other.is_a?(Feet)
Feet.new(@magnitude + other.to_f)
end
def -(other)
raise TypeError unless other.is_a?(Feet)
Feet.new(@magnitude - other.to_f)
end
def *(other)
multiplicand = case other
when Numeric then other
when Feet then other.to_f
else
raise TypeError, "Don't know how to multiply by #{other}"
end
Feet.new(@magnitude * multiplicand)
end
def /(other)
raise TypeError unless other.is_a?(Feet)
Feet.new(@magnitude / other.to_f)
end
def <=>(other)
raise TypeError unless other.is_a?(Feet)
other.is_a?(Feet) && magnitude <=> other.to_f
end
def hash
[magnitude, Feet].hash
end
def coerce(other)
unless other.is_a?(Numeric)
raise TypeError, "Can't coerce #{other}"
end
[CoercedNumber.new(other), self]
end
alias_method :eql?, :==
end
def Feet(value)
case value
when Feet then value
else
value = Float(value)
Feet.new(value)
end
end
This highlights a recurring theme when dealing with protected visibility: in my experience there is very rarely a cut-and-dried case for using protected
. I probably wouldn't have even considered making #magnitude
inaccessible in the first place if it weren't for the unfortunate naming collision with Numeric#magnitude
. And even now, there are alternatives.
The important thing to understand is that protected
access is for cases when objects of the same or related classes need access to each others' internals in order to fulfill their responsibilities. This almost always means implementing operator methods, or some similar method that involves one object either comparing itself to, or combining itself with, another object of the same type.
In certain cases the internal representation of such an object may legitimately be a private implementation detail. It might be in a highly optimized format that only that object knows how to manipulate. Or the internal representation might be subject to frequent change. Or, as in our case, exposing the internal representation may lead to an interface that unintentionally enables misuse. In such cases, it can make sense to make an object's internal attributes protected
, rather than either public
or private
.
Otherwise, we generally have no cause to think about protected
visibility. Happy hacking!
Responses