Hacker News new | ask | show | jobs
by nauticacom 1653 days ago
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 < Subset
            def of(items)
                items
            end
        end

        class Range < Subset
            def initialize(from:, to:)
                @from = from
                @to   = to
            end

            def of(items)
                items[@from:@to]
            end
        end
    end
which is used as such:

    subset = Subset::Whole.new
    puts subset.of(["a", "b", "c"]) # => ["a", "b", "c"]

    subset = Subset::Range.new(from: 0, to: 1)
    puts subset.of(["a", "b", "c"]) # => ["a"]
You can then pass around a Subset object anywhere (aka dependency injection) and push conditionals up the stack as far as possible.

Simply saying "inheritance is bad" gets nobody anywhere.

5 comments

In most languages Subset would be called a trait or interface, rather than general inheritance. You've picked an example with no fields or overriden methods, so it's impossible for it to demonstrate the shortcomings of inheritance.
What is the practical difference between inheritance, and a trait or interface with a default implementation? It seems like both risk the addAll() bug.
Not all languages allow for an interface to have a default implementation though. Delphi for example does not.

This leads the programmer towards composition and delegation.

To aid with this, Delphi even has some sugar for delegation[1].

[1]: https://docwiki.embarcadero.com/RADStudio/Alexandria/en/Usin...

Multiple inheritance is basically strictly more powerful than either traits or interfaces with default implementations.

I don't think traits or interfaces with default implementations prevent the bug, as I can imagine a Rust implementation that would do something similar.

OP title said "general inheritance is bad", not " Generally, inheritance is bad".

And the text support that. The "general inheritance" the author describe is not the one you've just used.

And i'm hijacking your post, sorry, but i really agree with the author with the "incidental inheritance" point. This is the worst. I lost a month to a bug caused by this kind of inheritance (Jenkins package that tried to be cute and interfered with a cloudbees class). I won't take a java gig ever again. Not worth the brain damage.

The thing I don’t like about passing objects around is that the state inside the object is opaque, and debugging it can be extremely frustrating, especially in something like Ruby some people are way too liberal with magic for my taste. My personal preference is to see immutable data structures being passed around through reasonably named functions, and that the is usually good enough for me.
The thing I like about passing objects around is that the state inside the object is opaque :). Thus when changes to the internal details of Person happen, the behavior of which is depended on by Inbox and Message, as long as I have properly depended on its public behavior, I don't need to change anywhere else. If I was just using plain data values as is common in e.g. Clojure, every change to something's internal representation would require changes to places which depend on it.
In a language where most errors will be runtime errors (Ruby), the inheritace problem described in the article is much less of a problem.

In such a language (e.g. Ruby), you will need test suites where languages with (strong) types use the type system to prove some level of correctness.

I used to be a fan of dyn typed langs (Ruby), but I've changed, I prefer strongly typed langs now for anything more than quick throw away scripts.

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, or in this case a simple wrapper

    // in some code
    fn subset1to4(superset){
        return subset(superset,1,4)
    }
    array.subset1to4()
    anotherArray.subset1to4()
Sure, that works for some specific problems where you're computing a value from a defined set of data types. "Subset of this data" was an example I've encountered in the past and used here because it has clearly distinct cases—give me the whole thing, give me some index-delimited range, possibly others—but there are plenty of other examples that don't fit a polymorphic function model (and let's forget that I've never even used a language with polymorphic functions).

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 < Default
            def initialize(value)
                @value = value
            end

            def value
                @value
            end
        end

        class Dynamic < Default
            def initialize(callable)
                @callable = callable
            end

            def value
                @callable.call
            end
        end

        class None < Default
            def value
                nil
            end
        end
    end
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).