In Progress
Unit 1, Lesson 21
In Progress

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