In section 6.5.2 of Sandi Metz's OOP Ruby book, it discusses base classes sending hook messages to allow subclasses to contribute information while reducing coupling.
In the base class the local_spares
method is a default empty hash.
Firstly, why does the call to local_spares in merge get delegated to the implementation of local_spares in the subclass and not to local_spares in the base class?
class Bicycle
#...
def spares
{tire_size: tire_size,
chain: chain}.merge(local_spares)
end
def local_spares
{}
end
end
class RoadBike < Bicycle
#...
def local_spares
{ tape_color: tape_color }
end
end
Is it because once RoadBike has inherited from Bicycle, spares 'becomes' a method of RoadBike and it 'looks' in its own class first?
Secondly, what is the purpose of the base class local_spares returning an empty hash here? Could this method raise NotImplementedError instead to enforce its implementation in subclasses (template method pattern)?
CodePudding user response:
Yes once Roadbike is inherited spares 'becomes' a method of RoadBike. As for overridden method "local_spares" in roadbike, it is called whenever referenced from roadbike class.
Is it because once RoadBike has inherited from Bicycle, spares 'becomes' a method of RoadBike and it 'looks' in its own class first?
As of why we have empty hash "local_spares" is because local_spares is referenced in Bicycle class so to avoid "method not found" when spares is called by an instance of Bicycle.
As per the logic it is assumed that spares method will be common across all the nested classes hence it is added to Bicycle class.
CodePudding user response:
Firstly, why does the call to local_spares in merge get delegated to the implementation of local_spares in the subclass and not to local_spares in the base class?
That's how Ruby's method lookup works. When you send a message to an object, Ruby traverses its class, superclass(es) and (prepended/included) modules until it finds a corresponding method with the same name and calls it.
The lookup starts at the object's singleton class and follows the list of ancestors
:
road_bike = RoadBike.new
road_bike.singleton_class.ancestors
#=> [#<Class:#<RoadBike:0x00007fe0b59b9478>>, RoadBike, Bicycle, Object, Kernel, BasicObject]
When you invoke road_bike.spare
, Ruby looks for a spare
instance method in the above list. We can rebuild that lookup in Ruby like this: (very roughly)
road_bike.singleton_class.ancestors.find do |mod|
mod.instance_methods(false).include?(:spares)
end
#=> Bicycle
It finds spares
in Bicycle
and calls it. Within the method, the message local_spares
is sent to the current receiver (which is still road_bike
) and the lookup starts again:
road_bike.singleton_class.ancestors.find do |mod|
mod.instance_methods(false).include?(:local_spares)
end
#=> RoadBike
This time, Ruby finds the corresponding method in RoadBike
.
Note that a method with the same name can exist multiple times in the ancestors list, e.g. there's RoadBike#local_spares
and Bicycle#local_spares
. Ruby will always call the first one according to the ancestors list. You can however invoke a superclass method from within a subclass via super
.
Secondly, what is the purpose of the base class local_spares returning an empty hash here? Could this method raise NotImplementedError instead to enforce its implementation in subclasses (template method pattern)?
Raising a NotImplementedError
is used for default_tire_size
in the book's example:
def default_tire_size
raise NotImplementedError
end
By raising such error, you enforce all subclasses to provide an implementation.
I think the main difference is that not all bike variants have local spares. It’s an addition that gets merged into an existing hash. By providing a default value in the base class, you allow subclasses to leave out the method implementation.