I (happily) write a lot of OOP code, "inheritance is bad, use composition" is such a trite and unhelpful dogma that gets in the way of any actual discussion about where inheritance is useful. IMO, the case where inheritance makes the most sense is when you have a set of objects polymorphically answering some question, usually with a simple answer. class Subset class Whole which is used as such: subset = Subset::Whole…
that approach gives me headaches to think about. Why not just have polymorphic functions? fn subset(superset, start, end){ // superset is type inferred as long as it supports the [] operator // logic to collect superset[start] to superset[end] into an array and return it } with uniform function call syntax: [1,2,3,4,5,6].subset(1,4) == [2,3,4,5] If you really want to reuse a subset range, you can use lambdas/closures…
As another example I've encountered in the past, let's say you have some object that can dynamically define fields. Once you define a field, you can retrieve its value or maybe some default value e.g.
model = Model.new
model.define("points", default: 1)
model.store("points", 10)
points = model.retrieve("points")
puts points # => 10
Let's say doing anything with an undefined field is invalid. Here's my first pass at an implementation: class Model
def initialize
@fields = {}
end
def define(name, default: nil)
@fields[name] = Field.new(name, default)
end
def retrieve(name)
@fields[name].value
end
def store(name, value)
@fields[name].value = value
end
end
class Field
attr_reader :name
attr_accessor :value
def initialize(name, value)
@name = name
@value = value
end
end
Works great! One day a requirement comes along that default values need to be lambdas, too, which are called every time the value is retrieved. How do we implement that? One way is to add a conditional to the Field class: class Field
attr_reader :name
attr_writer :value
def initialize(name, value)
@name = name
@value = value
end
def value
if value.is_a?(Proc)
@value.call
else
@value
end
end
end
But now Field knows that it can be passed a lambda, so testing it needs to account for that case (among many other considerations, probably, in a real-world system). And any time we add more cases for default values, let alone changes to regular values like type casting or something, the Field class becomes more complicated. I'd probably reach for a new object instead: class Model
def initialize
@fields = {}
end
def define(name, default: nil)
@fields[name] = Field.new(name, nil, Default.for(default))
end
def retrieve(name)
@fields[name].value
end
def store(name, value)
@fields[name].value = value
end
end
class Field
def initialize(name, value, default)
@name = name
@value = value
@default = default
end
def value
if @value.nil?
@default.value
else
@value
end
end
end
class Default
def self.for(indicator)
if indicator.is_a?(Proc)
Default::Dynamic.new(indicator)
elsif indicator.nil?
Default::None.new
else
Default::Static.new(indicator)
end
end
class Static
Now we've changed the conditional in the Field class to one that's actually relevant to it (do I have a value yet?) and won't change when the kinds of default values that it can accept change. Because we dependency-injected the Default object into the Field object, testing that conditional becomes a binary of retrieving the default value when no value is set, and retrieving the value once it's set. We can then test each kind of Default on its own, and changes to Default don't impact Field. If we really, really wanted to we could even eliminate the conditional in Field alltogether by unifying the interface for @default and @value such that they're both objects with a #value method (or maybe rename it to something else so we don't write @value.value). In either case we've made each piece simpler to reason about and pushed conditionals up the call stack so the resulting code is more straightforward.I can probably recall more examples of simplifications like this, but this is where I find inheritance the most useful: a known set of things that each polymorphically conform to some interface. In these examples I don't actually use the superclass for any shared behavior, but you can imagine a case where I might.
One other benefit that I really like from the inheritance-object-modeling-as-pushing-up-conditionals perspective is that it makes you define what the different cases of something are as distinct objects, and give names to them. It's a similar benefit that falls out of using named sum types instead of signal values or tagged unions or something, but has the opposite effect (overall reduction of conditionals rather than proliferation).