Designing an OOP System

ovid

Ovid

Posted on March 3, 2021

Designing an OOP System

Language design is all fun and games until you cut yourself on the edge cases. Case in point would be Corinna, an attempt to bring modern OOP (object-oriented programming) to the Perl programming language. It's been fun, but painful. And the core design of Corinna has been revisited multiple times to sand down the edges. It's been a journey.

The Background

Perl has a multiverse of broken, half-implemented, slow, inconsistent OOP implementations available on the CPAN. If you've ever read The Lisp Curse you understand why this is.

Newcomers to Perl are often perplexed by strange talk about blessing references, but today, they're just told to download Moose from the CPAN. Or Moo. Or the long tail of many other OOP implementations.

This situation has been unsatisfying for many experienced Perl developers. Stevan Little worked for a long time trying to define a solid OOP system for the Perl core, but stopped. He did some great work, so I picked up the baton. And then the Perl Pumpking (the person who formerly oversaw Perl development), Sawyer X, told me to stop worrying about making something work and focus on design. If the design was good enough, they'd get someone to make it work.

Hence, Corinna.

Corinna

So what is Corinna? In short, it's an attempt to rewrite a part of the Perl language to allow a clean, class-based OO system that is comfortable for those preferring the dominant Moo/se model. It offers subtle affordances for doing the right thing, but still gives you the freedom to do something others would consider wrong.

For example, if you use Moo, how would you declare a Person object with just a name and an optional title attribute (think of attributes as "instance data")? (For the sake of brevity, we're going to ignore discussions about type constraints)

package Person {
    use Moo;

    has 'name' => (
        is       => 'ro',
        required => 1,
    );

    has 'title' => (
        is => 'ro',
    );
}
Enter fullscreen mode Exit fullscreen mode

I can then call my $person = Person->new( name => 'Ovid' ); and later, $person->name returns "Ovid", as expected.

If you're familiar with OO design, you know that just sharing all of your instance data by default is not a good idea. But in Moo (and Moose), it's difficult to write a useful attribute without giving it a public reader. Imagine you have a business rule which states that you must always refer to a customer by title+name if they have a title. You might want the name attribute reader to go away and be replaced by your own. To do that and keep your code working, you would add an init_arg key to the name declaration and rename name to _name:

package Person {
    use Moo;

    # init_arg is the name used in the constructor
    has '_name' => (
        is       => 'ro',
        required => 1,
        init_arg => 'name',
    );

    has 'title' => (
        is => 'ro',
    );

    sub name {
        my $self = shift;
        return defined $self->title 
            ? $self->title . ' ' . $self->_name
            : $self->_name;
    }
}
Enter fullscreen mode Exit fullscreen mode

That's looking a touch awkward and maybe you don't want them to be able to read the title directly and you certainly don't want people calling $person->_name. And if a subclass happens to override the "private" _name method? Your code breaks. So here's how it looks in Corinna:

class Person {
    has $name  :new;
    has $title :new = undef; # optional

    method name () {
        return defined $title ? "$title $name" : $name;
    }
}
Enter fullscreen mode Exit fullscreen mode

In the above, you can pass name and an optional title to the constructor and they're perfectly encapsulated. You don't wind up with extra $person->title or $person->_name methods littering your namespace, and it's easier to read, to boot.

What if I want people to be able to read the title? Just add a :reader:

has $title :new :reader = undef;
Enter fullscreen mode Exit fullscreen mode

Note that you can't actually set the title after object construction. This is one of the ways that Corinna tries to subtly reinforce not only encapsulation, but immutability. However, the design encourages immutability; it doesn't require it. If you want mutability, it's trivial. Just add a :writer and you can do $person->set_title($new_title):

has $title :new :reader :writer = undef;
Enter fullscreen mode Exit fullscreen mode

Builders

And then we have little things called "builders" in Moo/se terminology. Sometimes you want to call code to construct your attribute value:

package Thing {
    use Moo;

    has 'attr' => (
        is       => 'ro',
        required => 1,
    );

    has 'reverse_attr' => (
        is      => 'ro',
        builder => '_build_reverse_attr',
    );

    sub _build_reverse_attr {
        my ($self) = shift;
        return scalar reverse $self->attr;
    }
}

say Thing->new( attr => 'pupils' )->reverse_attr;
Enter fullscreen mode Exit fullscreen mode

The above prints slipup as you might expect. But what if attr was named source?

package Thing {
    use Moo;

    has 'source' => (
        is       => 'ro',
        required => 1,
    );

    has 'reverse_attr' => (
        is      => 'ro',
        builder => '_build_reverse_attr',
    );

    sub _build_reverse_attr {
        my ($self) = shift;
        return scalar reverse $self->source;
    }
}

say Thing->new( source => 'pupils' )->reverse_attr;
Enter fullscreen mode Exit fullscreen mode

Now you get a warning:

Use of uninitialized value in reverse at person.pl line 20.

That's because attributes are built in alphabetical order for Moo. This leads to people regularly declaring attributes as lazy to ensure the builder isn't called until (and if) the attribute is called:

    has 'reverse_attr' => (
        is      => 'ro',
        builder => '_build_reverse_attr',
        lazy    => 1,
    );
Enter fullscreen mode Exit fullscreen mode

Before we explore further, let's look at the above in Corinna. Attributes are (generally) assigned values in the order they're declared (the exception being that, once you hit a :new attribute, they're all gathered and assigned as a group). Here's equivalent code in Corinna:

class Thing {
    has $source       :reader :new;
    has $reverse_attr :reader = scalar reverse $source;
}
Enter fullscreen mode Exit fullscreen mode

No fancy tricks required.

That fixes this situation, but what's that _build_reverse_attr doing there in the Moo code in first place?

Unfortunately it creates a new method in your namespace. Thus, any subclass can now override this behavior and you have little control over it. I've discussed this more at length elsewhere. If you have any unusual construction requirements for your attributes in Moo/se, the natural default behavior is to allow subclasses to violate encapsulation! I ran git grep -c '\<builder\s\+=>' lib over a few local codebases and was disturbed by how common this behavior was. Even a brief perusal of the code showed that for most of these classes, I did not want to expose this behavior.

But let's say you really, really want to let subclasses override your instance data. It's still possible. You still create a "builder" method, but you have to call it explicitly in the ADJUST phase (called after the object is constructed, but immediately before it's returned from the constructor).

class Thing {
    has $source       :reader :new;
    has $reverse_attr :reader;

    ADJUST (%args) {
        $reverse_attr = $self->_build_reverse_attr;
    }

    method _build_reverse_attr () {
        return scalar reverse $source;
    }
}

class Child isa Thing {
    method _build_reverse_attr () {
        return uc scalar reverse $self->source;
    }
}
Enter fullscreen mode Exit fullscreen mode

So Corinna doesn't take anything away. It just makes the natural use of the class a touch closer to OOP best practices, but still allows you to "be naughty" if you really insist upon it. This fits well with the spirit of Perl.

It's also interesting to consider this:

my $object = Some::Class->new;
Enter fullscreen mode Exit fullscreen mode

What happens when you call ->new? Well, it creates the object. In commonly recommended "best practice" for OOP, whatever object that is returned by the constructor should be complete and ready for use. You usually don't want to call new and then init or other methods to make the object usable.

However, up until the point that the constructor returns the instance, there is no guarantee that its usable. Why does this matter? Because between the time you call the constructor and the time it returns an instance, the class is doing a bunch of stuff to set itself up. And if you're calling builder methods—which might in turn call other methods—during this time, you're not guaranteed that the class is ready for use. This is why in our Moo code we had to declare reverse_attr as lazy to ensure it wouldn't be called before the instance was ready.

We managed to skip that in Corinna because the attributes are assigned their values in declaration order, so by the time we got to $reverse_attr, we knew that $source should have a value (again, punting on types).

So those are just a few of the design considerations which have gone into Corinna. The resulting Perl code tends to be shorter and more declarative than Moo/se, and it tends to push you towards better encapsulation and immutability, though it doesn't require it.

If there are particular topics about Corinna you would like to hear about, please let me know.

💖 💪 🙅 🚩
ovid
Ovid

Posted on March 3, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related