• The cover of the 'Perl Hacks' book
  • The cover of the 'Beginning Perl' book
  • An image of Curtis Poe, holding some electronic equipment in front of his face.

Understanding Class Inheritance

minute read



Find me on ... Tags


The Proposal

There has been some discussion of whether or not the new Perl OO model, Corinna , should support exposing field variables to subclasses:

class Parent {
    field $name :inheritable;
}

class Child :isa(Parent) {
    field $name :inherited;
}

There are a few benefits cited there.

  1. If field $name doesn’t exist in the parent, the child class throws a compile-time error.
  2. If the child can access $name directly, it’s a performance win (no method call).
  3. By being explicit about :inheritable and :inherited, we’re not exposing data by accident.

It seems like a win, but it’s not.

A Little Background

Most OO developers today use class-based OO, but JavaScript is popular enough that prototype-based OO is getting better known. Then there’s Dr. Alan Kay, the man who coined the term “object-oriented programming” five decades ago and is considered one of the fathers of the concept. For him, OOP is actually about:

  • Message passing
  • Isolation
  • Extreme late binding of everything

So that’s another way of looking at OOP. And by “isolation”, Kay actually said “local retention and protection and hiding of state-process.” He would not approve of exposing field variables because it’s explicitly exposing the state, not hiding it.

An image from the movie, 'The Big Leboski'
Yeah, well, you know, that’s just like your opinion, man.

So clearly there are different ideas about how OOP should be implemented, but Kay is focusing on how to do it safely. He has a maths and biology background and he thought about the billions of cells in our body which die every day, but we don’t. He wants that sort of robustness in object-oriented code. To him, the only truly successful demonstration of OOP is the internet. Tons of servers crash every day, but the internet does not.

In fact, before he got to this conception of OOP, his original description of OOP left out inheritance because it was so problematic. The way I like to describe it is the Person :isa(Invoice) problem. Even if you can get that to work, it simply doesn’t make sense and until AI becomes a hell of a lot better, the software has no way of knowing if what you’re doing makes sense.

What is Inheritance?

Per Wikipedia, inheritance is:

In object-oriented programming, inheritance is the mechanism of basing an object or class upon another object (prototype-based inheritance) or class (class-based inheritance), retaining similar implementation.

Some languages, such as Perl, allow multiple inheritance (Corinna does not). Other languages, such as Java and Ruby, only allow single inheritance, but provide you with tools (interfaces and mixins, respectively) as a substitute. In fact, inheritance is widely viewed as so problematic that Go and some Visual Basic variants don’t provide inheritance at all!

What’s worse, I’ve found that many developers kind of assume that behavioral inheritance and subtype inheritance are the same thing, but they’re not. Behavioral inheritance (which is what Perl tends to use), merely uses syntax to say “hey, I’m borrowing my parent class’s behavior.” There are no guarantees. Subtype inheritance, however, ... wait? What’s a subtype?

In the Raku language , here are two subtypes of Int: Odd and Even:

subset Even of Int where * %% 2;
subset Odd  of Int where !(* %% 2);

my Odd $x = 4;  # we get an exception here

You can use the Even and Odd subtypes anywhere you can use an Int and your program is still guaranteed to be correct, but with the added safety of knowing that those variables will always be even or odd if they’re set.

Here’s an important concept to remember (you’ll be tested on it later!): the parent type does not know, or care, about what subtypes will be created from it. You can subtype anything in Raku (or other OO languages) and while subtypes know about their parents, the reverse is not true.

So subtype inheritance, used by languages such as Eiffel or Beta, guarantees that you can use an instance of a subclass anywhere you can use an instance of a parent class and the program still works.

Of course, that’s what the Liskov Substitution Principle is all about.

If you’re subclassing, you probably want to enforce subtype subclasses, but neither current Perl OO (nor Corinna, to be fair), can do that easily (when/if we get types/type constraints in Perl, it will help).

So that gives you an idea about divergent views on inheritance and was probably boring as heck. So forget about that for now and let’s move on to an example.

A Bank Account Example

You need to create a simple, in-memory Bank::Account class. Obviously, this is a toy example, but bear with me.

  • You instantiate it with a customer name
  • Initial balance is zero
  • You can withdraw money, but throw an exception if the balance would go below zero
  • You can deposit unlimited amounts of money

Because Corinna, does not (yet) support type constraints, we’ll ignore them for now.

class Bank::Account {

    # Of *course* this is too simplistic. The first pedant
    # who points this out loses 500 internet points.

    use My::Exceptions qw(InsufficientFunds)
    field $customer :param :reader;
    field $balance  :reader {0};

    method deposit ($amount) {
        $balance += $amount;
    }

    method withdraw ($amount) {
        if ( ( $balance - $amount ) < 0 ) {
            InsufficientFunds->throw("Naughty, naughty");
        }
        $balance -= $amount;
    }
}

OK, so far, so good. The code works fine and everyone is pleased. Later, business requirements are updated because Mr. Moneybags has tons of money, but sometimes he wants to be overdrawn a bit. Since Mr. Moneybags is an important customer, you need to create a Bank::Account::Premium class to allow premium customers to be overdrawn.

You’re a good programmer, you know you shouldn’t rewrite code, so you just want to subclass it:

class Bank::Account::Premium :isa(Bank::Account) {
    sub withdraw($amount) {
        ...
    }
}

But what goes in the withdraw method? You properly encapsulated your first class, so now you can’t get around that. In your Bank::Account class, you make a tiny change:

field $balance :inheritable :reader {0};
class Bank::Account::Premium :isa(Bank::Account) {
    field $balance :inherited;
    sub withdraw($amount) {
        $balance -= $amount;
    }
}

And now, everything works fine and you move your code into production.

You them immediately move it back out of production because production is throwing exceptions left and right. Why? Your system knows that if $instance isa Bank::Account holds true, the balance can never be less than zero and because the code knows it can trust your Bank::Account class, it’s safe.

Bank::Account::Premium is a behavioral subclass, not a subtype subclass because the behavior of the child differs from that of the parent. It allows negative balances and the parent does not.

Oh, but maybe that doesn’t happen. I’m just being paranoid, right? Or maybe it only happens in one place and you can write one tiny little hack rather than remove this valuable functionality.

Months pass and your code is running fine and you’ve moved on to another department, when you get a frantic call to find out why everything is failing. After investigation, you discover the bug. Someone’s released a module allowing :isa type constraints and the person maintaining the Bank::Account module has realized that it’s safer to constrain the $balance:

field $balance :inheritable :reader :isa(ZeroOrPositiveNum) {0};

But your code which does allow negative balances blows up because it’s inherited this field.

Remember: the parent classes don’t know how they’re going to be subclassed and they shouldn’t care. Instead, they should do everything in their power to be as correct as possible and the :isa(ZeroOrPositiveNum) constraint is perfectly appropriate.

By exposing our internal state to our subclass, we’ve tightly coupled them because the cheap :inheritable workaround seems so easy. But again, Kay reminds us of “local retention and protection and hiding of state-process.” State and process are tightly coupled inside of a class and exposing state without the process that guarantees the state is correct leads to fragile code.

Here’s the real issue: ignoring encapsulation because it’s “easy” means we don’t have to think about our object design. Instead, we should have had something like this:

class Bank::Account :abstract {
    field $customer :param :reader;
    field $balance  :reader {0};

    method deposit ($amount) {
        $balance += $amount;
    }

    method withdraw ($amount) {
        $balance -= $amount;
    }
}

class Bank::Account::Regular :isa(Bank::Account) {
    use My::Exceptions qw(InsufficientFunds)

    method withdraw ($amount) {
        if ( ( $self->balance - $amount ) < 0 ) {
            InsufficientFunds->throw("Naughty, naughty");
        }
        $self->next::method($amount);
    }
}

class Bank::Account::Premium :isa(Bank::Account) {
    # no need to override any behavior, but we could
    # provide more behavior as needed
}

Seems like more work up front, but we have known for a long time that fixing broken code in the design stage is far cheaper than fixing it after it’s in production. But we’re so used to violating encapsulation, often without even realizing it, that we fall back on this rather than using proper design.

Yes, this example is contrived, but it’s based on my decades of OO programming. I also started off as a rubbish OO developer, having learned the syntax and not the theory. My punishment was spending years working with multiple clients, cleaning up the messes of other developers with the same background.

Conclusion

Encapsulation has been accepted best practice for OOP developers for decades. For Perl developers, it’s a contentious issue, due in part to how easy it is to apply “quick fixes” without having to revisit our design. And then, everyone quotes Larry. Larry Wall famously said :

Perl doesn’t have an infatuation with enforced privacy. It would prefer that you stayed out of its living room because you weren’t invited, not because it has a shotgun.

That might work for disciplined developers who understand OOP. But we’re often undisciplined. We often don’t understand OOP. We’re often under time pressure and it’s easy to make mistakes then.

Larry has done great things with Perl, but this saying has outlived its usefulness. For small, quick hacks, or proofs-of-concept, that’s fine. But as systems grow, it’s not fine. There’s a saying that wise men make saying so fools can repeat them. I have long been one of those fools, but years of fixing broken OOP systems in many shops have taught me that I was wrong.

Corinna likely will allow encapsulation violation via a cumbersome meta-object protocol (MOP), but this will glaringly stand out like a sore thumb as a code smell. It will be clear that there’s a design flaw and the design should be revisited.

Not being able to easily violate encapsulation seems like a burden, but only because we’ve grown lazy and have skipped thinking about design. In reality, it will help us build more robust systems and teach us when our design is flawed. Far from being a limitation, encapsulation will be a powerful tool.

Please leave a comment below!



If you'd like top-notch consulting or training, email me and let's discuss how I can help you. Read my hire me page to learn more about my background.


Copyright © 2018-2024 by Curtis “Ovid” Poe.