Skip to content

Instantly share code, notes, and snippets.

@Ovid
Last active December 6, 2022 10:40
Show Gist options
  • Save Ovid/e0381355da7136654e92418a43c97611 to your computer and use it in GitHub Desktop.
Save Ovid/e0381355da7136654e92418a43c97611 to your computer and use it in GitHub Desktop.
Corinna Class Tutorial

UPDATE

This document now resides at https://github.com/Ovid/Cor/blob/master/pod/perlclasstut.pod

NAME

perlclasstut - Object-Oriented Programming via the class keyword

DISCLAIMER

This tutorial is a rough work-in-progress and only covers features of the new object syntax that are well-defined. The implementation is ongoing and while most of the basics are mapped out, there are some edge cases still being nailed down. We will not discuss those here.

DESCRIPTION

With the release of Perl version X, the Perl language has added a new object system. Originally code-named Corinna, the new object system is a result of years of collaboration between the Corinna team and the Perl community to bring a modern, easy-to-use object system that still feels like Perl. If you're looking for information about Perl's original object system, see perlobj (and later, perloot to see object systems built on top of the legacy system).

Note that the following assumes you understand Perl and have a beginner's knowledge of object-oriented programming. You should understand classes, instances, inheritance, and methods. We'll cover the rest. Also, this is only an introduction, not a full manual.

Further, for simplicity, we'll often refer to "object-oriented programming" as OOP.

The legacy object system in Perl remains and no code will be broken by this change.

OBJECT-ORIENTED FUNDAMENTALS

There are many ways to describe object systems. Some focus on the implementation ("structs with behavior"), but we'll focus on the purpose. Objects are experts about a problem domain. You construct them with the information they need to do their job. For example, consider the case of an LRU cache. An LRU cache is a type of cache that keeps cache size down by deleting the least-recently-used cache entry. Let's construct a hypothetical cache:

my $cache = Cache::LRU->new( max_size => 20 );

In the above example, we will assume that max_size is the maximum number of entries in the cache. Adding a 21st unique entry will cause the "least recently used" entry to be ejected from the cache.

And then you can tell the object to do something, by calling "methods" on the object. Let's save an item in the cache and retrieve it.

$cache->set( $customer_id => $customer );

my $cached_customer = $cache->get($customer_id);

How does it work internally? You don't care. You should trust the object to do the right thing. Read the docs. That's the published interface.

In this tutorial, we'll build the Cache::LRU class so you can see how this works, but after we have described a few fundamentals.

The Four Keywords

use feature 'class';

When you use the class feature, four new keywords are introduced into the current scope.

  • class

    Declare a class.

  • method

    Declare a method

  • field

    Declare a field (data for the class)

  • role

    Declare a role.

Note the use of the word "declare" for all of those definitions. Use of the class feature allows a declarative way of writing OOP code in Perl. It's both concise and expressive. Because you're declaring your intent instead of manually wiring all of the bits together, there are fewer opportunities for bugs.

That the general syntax for each of these keywords is:

KEYWORD IDENTIFIER MODIFIERS? DEFINITION?

For example:

class Employee :isa(Person) {
    ...
}

In the above, class is the KEYWORD, Employee is the IDENTIFIER (the unique name of the thing), :isa(Person) is an optional MODIFIER that assigns additional properties to the thing you've identified (in this case, Employee inherits from Person), and the postfix block is the DEFINITION of the class.

Note that, like the package declarator, class does not require a postfix-block, even though we'll show some examples using it.

Also, modifiers are almost always regular Perl attributes, with an exception made for declaring the class version.

class

The class keyword declares a class and the namespace for that class. In future versions of Perl, it's possible we'll have private classes which are lexically bound, so do not make assumptions about the implementation.

Let's get started on our Cache::LRU class.

use feature 'class'; # from now on, this will be assumed

class Cache::LRU {};
# or
class Cache::LRU;

The above shows declaring a class and you can now make a new instance of it:

my $cache = Cache::LRU->new;

if ( $cache->isa('Cache::LRU') ) { # true
    ...
}
else {
    # we never get to here
}

Of course, that's all you can do. It's kinda useless, but we'll cover more in a bit.

Note that the new method is provided for you automatically. Do not declare your own new method in the class.

Versions

Any valid v-string may be used to declare the class version. This should be after the identifier:

class Cache::LRU v0.1;
my $cache = Cache::LRU->new;
say $cache->VERSION; # prints v0.1

Note: due to how the Perl grammar works, the version declaration must come before any attributes.

Inheritance

In OOP, sometimes you want a class to inherit from another class. This means that your class will extend the behavior of the parent class (er, that's the simple explanation. We'll keep it simple).

For example, a Cat might inherit from Mammal. In OOP, we often say that a Cat isa Mammal. You do this with the :isa(...) modifier.

class Cat :isa(Mammal);

Note that objects declared with class are single-inheritance only. As an alternative to multiple inheritance, we provide roles. More on that later.

Abstract Classes

In OOP, an abstract class is a class that cannot be instantiated. Instead, another class must inherit from the abstract class and provide the full functionality. In the "Inheritance" example above, the Mammal class might be abstract, so we declare it with the :abstract modifier.

class Mammal :abstract {
    ...
}

Any attempt to instantiate an abstract class is a fatal error.

my $mammal = Mammal->new; # boom

Methods declared with a forward declaration (i.e. any method whose name is declared, but without any corresponding code block) must be provide by a subclass, either via direct implementation or via a role. At the present time, forward declarations of methods do not take signatures due to more work being needed to make signatures introspectable.

class Mammal :abstract {
    method eat; # must be declared in a subclass at compile-time
}

Multiple Modifiers

Note that modifiers may not be duplicated, but the order in which they're specified does not matter.

class Mammal v1.0 :abstract :isa(Animalia);

class Mammal v1.0 :isa(Animalia) :abstract; # same thing

(With apologies to the biology fans who know that biological taxonomy is both misrepresented here and more complex than this simple hierarchy).

field

The field keyword allows you to create data storage for your class. You can create instance data and class data. This data is stored in normal Perl variables, but with special syntax to bind them to the class.

Instance Data

Classes are not very useful without data. In our Cache::LRU class, we have a max_size field to indicate how many cache entries we can have. Let's declare that field, provide a "reader" for that field, and a default value of 20.

Underneath the hood, we'll also use the Hash::Ordered module to provide the actual caching. Note that Hash::Ordered is written using legacy Perl, but you shouldn't (and don't) have to care about that.

class Cache::LRU {
    use Hash::Ordered;

    field $cache            { Hash::Ordered->new };

    field $max_size :reader { 20 };
}

my $cache = Cache::LRU->new;
say $cache->max_size;    # 20

In the above example, both $cache and $max_size are instance variables, which are unique to every instance of the class. They are never available outside the class. For each of them, we have an optional postfix block to assign a default value to those fields. If you omit the block, those fields will contain the value undef unless your class assigns a value to them.

Unlike Perl's legacy OOP system, you cannot use $cache->{cache}, $cache->{'$class'} or any other tricks to get at this data. It's completely encapsulated. However, in case of emergency, the meta-object protocol (MOP) will allow access to this data (but that's beyond the scope of this tutorial).

So how can we read the max_size data? Because we used the :reader attribute (also called a "modifier"). By default, the :reader modifier removes the $ sigil from the variable name and that becomes the name of a read-only method. So declaring field $foo :reader will create a foo method that will return the value contained in $foo. However, you can change the name of the method:

field $max_size :reader(max_entries);

Naturally, we provide a corresponding :writer modifier

field $rank :reader :writer;

By default, the :writer modifier will prepend a set_ to the method name, so the above allows:

say $object->rank;             # returns the value of $rank

$object->set_rank('General');  # sets the value of $rank.

Important: being able to mutate an object (i.e. change the values of its fields via writer methods) is often a dangerous thing, as other code using that object may have already made decisions or assumptions based on the previous value of that field. If that previous value is no longer valid, those decisions or assumptions may now be inconsistent or incorrect.

Each writer method returns its own invocant to allow chaining:

$object->set_rank('General')
       ->set_name('Toussaint Louverture');

Though it's discouraged, you can set the name of the writer to the same name as the reader:

field $rank :writer(rank) :reader;

This allows for a common Perl convention of creating a single reader/writer method by overloading the behaviour of the method based on whether or not it is passed an argument:

say $object->rank;         # returns the value of $rank
$object->rank('General');  # sets the value of $rank.

Obviously, the rank method now does two entirely separate things, which can be confusing and error-prone, but this technique is ingrained in Perl OOP culture, so we support this edge case.

Having a default of 20 for max_size is useful, but we need to allow the programmer to say what the max size is. We do this with the :param modifier.

field $max_size :reader :param { 20 };

This tells the class that this value may be passed as a named parameter to the contructor.

my $cache = Cache::LRU->new( max_size => 100 );
say $cache->max_size; # 100

It's important to remember that every constructor parameter is required to be passed to the constructor if a default is not provided. Thus, if we have this:

class NamedPoint {
    field ( $x, $y ) :param :reader {0};
    field $name      :param :reader;
}

The above would allow you to do any of these:

my $point = NamedPoint->new( name => 'Origin' );
my $point = NamedPoint->new( name => 'Origin', x => 3 );
my $point = NamedPoint->new( name => 'Origin', x => 3, y => 3.14 );

But not this:

my $point = NamedPoint->new( x => 23, y => 42 );   # Missing 'name' initializer

If a field is required, but not passed to the constructor, you will get a fatal runtime error.

method

Now that we know how to contruct a basic object, we probably want to do things with it. To do that, we write methods. Methods use the method keyword instead of sub. They also take argument lists. Let's look at a "transposable" point class (i.e. X,Y --> Y,X).

class Point {
    field ( $x, $y ) :reader :param;

    method invert () {
        ( $x, $y ) = ( $y, $x );
    }

    method to_string () {
        return sprintf "(%d, %d)" => $x, $y;
    }
}

my $point = Point->new( x => 23, y => 42 );
say $point->to_string; # (23, 42)

$point->invert;
say $point->to_string; # (42, 23)

In the above, you can see that methods have direct access to field variables. However, they also have $self injected in them. So you could also write invert as follows:

method invert () {
    ( $x, $y ) = ( $self->y, $self->x );
}

However, method calls are not only slower than direct variable access, but it's more typing. Plus, if we don't use :reader for a given field, we have no method to call.

Putting all of this together, we get the following as a very basic Cache::LRU class:

use feature 'class';
class Cache::LRU {
    use Hash::Ordered;

    field $cache                   { Hash::Ordered->new };
    field $max_size :param :reader { 20 };

    method set ( $key, $value ) {
        if ( $cache->exists($key) ) {
            # we delete the key because we're going to (re)set
            # the value for this key later, after which this
            # key will be the one most recently used
            $cache->delete($key);
        }
        elsif ( $cache->keys >= $max_size ) {
            # remove the oldest key
            $cache->pop;
        }

        # new values are added to the front of the ordered hash
        $cache->set( $key, $value );
    }

    method get($key) {
        if ( $cache->exists($key) ) {
            my $value = $cache->get($key);

            # move the key to the front of the cache
            # (note the use of the $self variable)
            $self->set( $key, $value );

            return $value;
        }
        return;
    }
}

With the above, we have a working LRU cache. It doesn't have a lot of features, but it shows you the core of writing OOP code with the class feature. We have a powerful, well-encapsulated declarative means of writing objects without having to wire together all of the various bits and pieces.

role

The new class syntax only provides for single inheritance. Sometimes you need additional behavior that you would like to "transparently" provide. For example, you might want two or more unrelated classes to be able to serialize themselves to JSON, even though each class itself has nothing to do with JSON. Let's do that with our Cache::LRU class.

To provide functionality shared across unrelated classes, we use the role keyword. A role is similar to a class, but it cannot be instantiated. Instead, it is "consumed" by a class and the class provides the specifics of the role behavior. Roles can both provide methods and exclude methods. For our JSON role, it might look like this:

use feature 'class';

role Role::Serializable::JSON {
    use JSON::PP 'encode_json';  # provided in core Perl since v5.13.9

    method to_hash;   # forward declaration: the class must provide this

    method to_json () {
        encode_json( $self->to_hash );
    }
}

And you can use this in your class with the :does attribute.

class Cache::LRU :does(Role::Serializable::JSON) {
    ...
}

But our class fails at compile-time because it doesn't have a to_hash method. So let's write one.

class Cache::LRU  v0.1.0  :does(Role::Serializable::JSON) {
    use Hash::Ordered;
    use Carp 'croak';

    field $cache                   { Hash::Ordered->new };
    field $max_size :param :reader { 20 };

    method set ( $key, $value ) {...}

    method get($key) {...}

    method to_hash () {
        my %entries;
        foreach my $key ($cache->keys) {
            my $value      = $cache->get($key);
            my $ref        = defined $value ? (ref $value || 'SCALAR') : 'UNDEF';
            $entries{$key} = $ref;
        }
        return {
            max_size => $max_size,
            entries => \%entries,
        }
    }
}

In the above, the method to_hash; forward declaration defines a method that the Role::Serializable::JSON role requires the consuming class to provide. It can do so by either having the method defined in the class or consuming it from another role.

The method to_json provided by the role will be "flattened" into the Cache::LRU class almost as if it had been written there. However, fields defined in the class are always lexically scoped (like a my or state variable) and so are not directly accessible to the role method.

With that, can do this:

my $cache = Cache::LRU->new(max_size => 5);
$cache->set( first  => undef );
$cache->set( second => 'bob' );
$cache->set( third  => { foo => 'bar' } );
say $cache->to_json;

And we should get output similar to the following:

{"max_size":5,"entries":{"first":"UNDEF","third":"HASH","second":"SCALAR"}}

You can also consume multiple roles:

class Foo :does(Role1) :does(Role2) {
    ...
}

Roles may declare fields, but those field variables are private to that role. This protects against the case where a class and a role might both define field $x.

If any method defined directly in the class has the same name as a method provided by a role, a compile-time error will result. If two roles have duplicate method names, this will also cause a compile-time failure if they're consumed together. Traditionally, roles have syntax for "excluding" or "aliasing" methods, but this is not (yet) provided by the new mechanism. In practice, we find this is rarely an issue, but as roles are more widely shared, this will need to be addressed.

As a workaround, you can create a new object that consumes the role and store that object in a field, or you can use interstitial base classes that consume the role. Neither solution is great.

Miscellaneous

Non-scalar Fields

You can declare arrays and hashes as fields, with or without defaults:

field @colors { qw/green yellow red/ };
field %seen;

However, array and hash fields cannot have modifiers:

field %seen :reader;  # compile-time error
field @array :param;  # compile-time error
field %hash :writer;  # compile-time error

Class Data, Methods, and Phasers

Class data and methods are shared by all instances of a given class. They are declared with the :common attribute. For example, let's say you're making a game and you only allow 20 point objects to be created. How do you track how many are created? You don't. That's the responsibility of the class. Let's use class data for this.

class Point {
    field $num_points :common :reader { 0 }; # all classes share this
    field ( $x, $y ) :param  :reader;

    ADJUST {
        $num_points++;
        if ( $num_points > 20 ) {
            die "No more than 20 points may be created at any time";
        }
    }

    DESTRUCT { $num_points-- }
}

In the above, ADJUST is a phaser (like BEGIN or END), which is called every time a class is instantiated. You can have multiple ADJUST phasers and they are called in order declared. So you could also write the above ADJUST as follows:

ADJUST { $num_points++ }

ADJUST {
    if ( $num_points > 20 ) {
        die "No more than 20 points may be created at any time";
    }
}

The DESTRUCT phaser behaves similarly to ADJUST, but only fires when the reference count of the object drops to zero (in other words, when it goes out of scope).

We can now do this:

say Point->num_points;   # 0

my $point1 = Point->new( x => 2, y => 4 );
say Point->num_points;   # 1
# or
say $point1->num_points; # 1

my $point2 = Point->new; # accepts defaults
say Point->num_points;   # 2
undef $point1;           # triggers DESTRUCT
say $point1->num_points; # 1

There's a lot more to say about ADJUST and DESTRUCT, but some of the finer points are sill being nailed down.

Types?

In Moose, you can declare attributes like this:

has limit => (
    is  => 'rw',
    isa => 'Int',
);

With that, you can cannot pass anything but an integer to the constructor, nor can you later do $object->limit('unlimited'). Sadly, we do not have this at the present time for the class syntax, but there is a work around: Types::Standard and ADJUST. Note that this workaround is only safe for immutable objects. Mutable objects will (for the time being) have to jump through more hoops to ensure type safety.

The following trivial example shows the potential, but obviously, there's a lot more you could do with Types::Standard to make this more robust.

class Point {
    use Types::Standard qw(is_Int);

    field ( $x, $y ) :reader :param;

    ADJUST {
        my @errors;
        is_Int($x) or push @errors => "x must be an integer, not $x.";
        is_Int($y) or push @errors => "y must be an integer, not $y.";
        if (@errors) {
            die join ', ' => @errors;
        }
    }
}

With the above, you can guarantee that your Point object only has integer values for $x and $y.

Putting It All Together

use feature 'class';
role Role::Serializable::JSON {
    use JSON::PP 'encode_json'; # provided in core Perl since v5.13.9
    method to_hash; # the class must provide this

    method to_json () {
        encode_json($self->to_hash);
    }
}o
class Cache::LRU v0.1.0 :does(Role::Serializable::JSON) {
    use Hash::Ordered;
    use Carp 'croak';

    field $num_caches :common :reader { 0 };
    field $cache                      { Hash::Ordered->new };
    field $max_size   :param  :reader { 20 };
    field $created    :reader         { time };

    ADJUST { # called after new()
        $num_caches++;
        if ( $max_size < 1 ) {
            croak(...);
        }
    }

    DESTRUCT { $num_caches-- }

    method set ( $key, $value ) {
        if ( $cache->exists($key) ) {
            $cache->delete($key);
        }
        elsif ( $cache->keys > $max_size ) {
            $cache->pop;
        }
        $cache->set( $key, $value );  # new values in front
    }

    method get($key) {
        if ( $cache->exists($key) ) {
            my $value = $cache->get($key);
            $self->set( $key, $value );
            return $value;
        }
        return;
    }

    method to_hash () {
        my %entries;
        foreach my $key ($cache->keys) {
            my $value = $cache->get($key);
            my $ref        = defined $value ? (ref $value || 'SCALAR') : 'UNDEF';
            $entries{$key} = $ref;
        }
        return {
            max_size   => $max_size,
            entries    => \%entries,
            created    => $created,
            num_caches => $num_caches,
        }
    }
}

Conclusion

I hope you've enjoyed this far-too-brief introduction to the new class keyword. This has been the result of years of design effort from the Corinna design team and the Perl community at large.

This work is dedicated to the memory of Jeff Goff and David Adler, two prominent members of the Perl community who were wonderful people and left this life far too soon.

Contributors

Paul "LeoNerd" Evans and Damian Conway both were kind enough to help with some of my silly mistakes.

@briang
Copy link

briang commented Dec 2, 2022

class NamedPoint is still using has instead of field

@Ovid
Copy link
Author

Ovid commented Dec 2, 2022

class NamedPoint is still using has instead of field

@briang Fixed. Thank you.

@benvanstaveren
Copy link

By default, the :writer modifier will append a set_ to ...

Should that not be "prepend a set_ to ..." ?

@wchristian
Copy link

"The Four Keywords" maybe better as "Meta Declaration Keywords"

@sigzero
Copy link

sigzero commented Dec 2, 2022

"How does it work internally? You don't care. You should trust the object to do the right thing. Read the docs. That's the published interface."

I am not sure that paragraph is needed. It seems needlessly abrupt to me. Just a thought.

@matthewpersico
Copy link

matthewpersico commented Dec 3, 2022

By default, the :writer modifier will prepend a set_ to the method name, so the above allows:

say $object->rank; # returns the value of $rank
$object->set_rank('General'); # sets the value of $rank.

I think this is the worst of both worlds. My opinion is that the default should be either get/set or overloaded function, but not this mashup of the two. I've never seen any OOP system where a default of 'set' wasn't accompanied by 'get'. Easy enough to fix, as demonstrated in the gist, but as a default, I think this is just going to make people scratch their heads. It made me do so.

In fact, I wonder if we could use :reader, :writer for overloaded functions and :get :set for named. In this way,

:reader :writer means - the reader and writer are named foo

:get :set means - there is a get_foo() and a set_foo()

Now there are no tricky defaults. You specify exactly what you want.
And if you want to use something else, then :reader() and :writer() are still available to set the names, if so desired.

And your example above is defined with
foo :reader :set

@matthewpersico
Copy link

This allows for a common Perl convention of creating a single reader/writer method

Is that just Perl or do other OOP systems do this?

@matthewpersico
Copy link

matthewpersico commented Dec 3, 2022

class NamedPoint {
  field ( $x, $y ) :param :reader {0};
  field $name :param :reader;
}

So that {0} is applied to all fields. I assume if ANY are different then you must specify them ALL:

class NamedPointOnHigh {
  field ( $x, $y, $z ) :param :reader {0, 0, 1};
  field $name :param :reader;
}

at which point you'll probably do

class NamedPointOnHigh {
  field $x :param :reader {0};
  field $y :param :reader {0};
  field $z :param :reader {1};
  field $name :param :reader;
}

for clarity?

@matthewpersico
Copy link

matthewpersico commented Dec 3, 2022

However, array and hash fields cannot have modifiers:

field %seen :reader;  # compile-time error
field @array :param;  # compile-time error
field %hash :writer;  # compile-time error

This needs examples of how to access them - reading and writing.

@Ovid
Copy link
Author

Ovid commented Dec 3, 2022

@matthewpersico Great questions. I'm going to walk through them one-by-one so that you can understand the design team's reasoning.

:get and :set

:get and :set are not included because a lot of work has been done to reduce the chance of writing field attributes which produce confusing or undefineed behavior, such as:

field $x :reader :get;

We could write a bunch of code to try to catch all "illegal" combinations and I've done so in the past. It's a nightmare and some cases are really hard to decide, so I went through and pared all field attributes to a minimum to see how far we could get with a minimalistic interface. So far, I think it's pretty good and now, it's hard to write an invalid field specification.

Also, yes, field $name :reader :writer; generating name() and set_name() methods might be slightly inconsistent, but we're making the "reader" common behavior a touch easier. Having it be get_name() is probably better because it's not ambiguous, but Corinna is a compromise between what the design team felt were best practices and what the wider Perl community would accept. Even allowing field $name :reader :writer(name); is one of those compromises. More on this at the end.

Differing field defaults in a single declaration

As for whether or not we support field ( $x, $y, $z ) :param :reader {0, 0, 1};, that would be a question for Paul Evans.

Array and hash fields

However, array and hash fields cannot have modifiers:

field %seen :reader; # compile-time error
field @array :param; # compile-time error
field %hash :writer; # compile-time error

This needs examples of how to access them - reading and writing.

For arrays and hashes, those are "internal only." You can manually write readers and writers for them, but in trying to do this with attributes, there were too many issues. Many of them boil down to the following problem:

field $x :reader;
field @x :reader;
field %x :reader;

What's the read method for those? Rather than risk making a quick decision and later realizing we made a mistake, we've deliberately decided to be "safe".

Overloading name() to be both a reader and writer

This allows for a common Perl convention of creating a single reader/writer method
Is that just Perl or do other OOP systems do this?

Other languages allow that. It's easier to do with multimethods (which are not native to Perl), but it's still generally a bad practice.

Imagine a Person object with the following methods. For security reasons, the code is never allowed to read the password from the object.

  • name
  • login_name
  • password
  • person_id

Which of those can be read from or written to? Who knows? Now imagine a Person object with the following methods:

  • name
  • login_name
  • person_id
  • set_name
  • set_password

Now it's crystal clear that the login_name and person_id cannot be changed via this object. For the first set of methods we have this:

  • name lets you both read and set the name.
  • login_name lets you read the login name, but not set it.
  • password lets you set the password, but not read it.
  • person_id lets you you read the id, but not set it.

Madness! It's a very confusing, inconsistent interface. That's because the traditional Perl way of using the field name as both a reader and writer, or sometimes just a reader, almost never as just a writer, makes it much harder to deduce from the name of the method what it's supposed to do.

The objection is "just read the documentation or tests!" My current client has millions of lines of Perl code, most without documentation or tests (or the documentation and/or tests are woefully out of date, having been written years ago and subsequently ignored). It's a very tedious process trying to constantly read the legacy code every time to figure out both what it's intended to do and what it actually does.

The second set of methods is very clear in their intent.

  • name lets you read the name.
  • login_name lets you read the login name.
  • person_id lets you you read the id.
  • set_name lets you set the name.
  • set_password lets you set the password.

There is no password method, so it's clear that you can't read it. That's why :reader uses (by default) the name of the field variable and :writer prepends set_ to the name.

But you can still have an inconsistent interface, you can have it:

class Person {   # inconsistent interface
    field $name       :reader :writer(name);
    field $login_name :reader;
    field $password   :writer(password);
    field $person_id  :reader;
    ...
}

It's not too onerous and there's special code to handle the case of the reader and writer having the same name. But we make it easier to do the right thing:

class Person {   # consistent interface
    field $name       :reader :writer;
    field $login_name :reader;
    field $password   :writer;
    field $person_id  :reader;
    ...
}

That's easier to write and gives you a consistent interface.

Conclusion

I'm not saying that all of our decisions were the best decisions, but we spent a huge amount of time trying to design a declarative syntax which makes it easier to do the right thing. Because we've built so much, we're deliberately being conservative about adding even more. We need to make sure that the core of Corinna is working, or adjust if it's not. The more we add in, the more bugs we're
going to have.

I hope that helps!

@matthewpersico
Copy link

matthewpersico commented Dec 3, 2022

@Ovid Looking at the length of this reply, you have patience that I cannot even fathom.

:get and :set

So, you were looking for the minimal set of user typing and maintainer coding and :reader, :writer fit the bill. Defaulting :writer to produce set_foo() while keeping :reader at foo() also contributes to more unambiguous code with respect to which fields can be set vs. which fields are read only. Fair enough. I'll probably lean on :writer(foo) heavily.

field $x :reader :get;

Eliminating foot-guns is a noble goal.

Array and hash fields

Manually writing readers and writers for these is good enough for MVP #1. Not sure why that didn't dawn on me. Maybe you should explicitly say "In order to read and write such fields, you will need to write your own accessor methods."

As to the given example:

field $x :reader;
field @x :reader;
field %x :reader;

I'd be tempted to outlaw this in Corina so as to eliminate reader function ambiguity. Yeah, this is Perl, but Just Because We Can Doesn't Mean We Should. Certainly, you cannot do it with the canonical bless-a-hash implementation we have today, so I doubt anyone would expect it to work in Corina; why open that can of worms? The whole "same-text,different-sigil=>different variables" paradigm is probably the one "weird" thing about Perl I can never seem to defend; I've never "gotten" what semantical convenience this paradigm provides.

Overloading name() to be both a reader and writer

It's easier to do with multimethods (which are not native to Perl), but it's still generally a bad practice.

After reading your explanation, I am starting to come around to :reader -> foo(), :writer -> set_foo().

@pmqs
Copy link

pmqs commented Dec 4, 2022

Possible typo in this section of code that uses has

class NamedPoint {
    has ( $x, $y ) :reader {0};
    has $name :reader;
}

should be this, I think

class NamedPoint {
    field ( $x, $y ) :reader {0};
    field $name :reader;
}

@rwp0
Copy link

rwp0 commented Dec 5, 2022

Object::Pad has the = syntax for fields.

Should the {} be replaced to = then? (Not sure if Corinna follows/co-ordinates.)

@Ovid
Copy link
Author

Ovid commented Dec 5, 2022

@rwp0 There's still some ongoing discussion on that. It's possible both will be supported (which might get confusing). We're still hammering our a few details that have emerged as we shift from proof-of-concept to the core implementation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment