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.