NAME
MooseX::Extended::Manual::Tutorial - Building a Better Moose
VERSION
version 0.35
GENESIS
MooseX::Extended is built on years of experience hacking on Moose and being the lead designer of the Corinna project to bring modern OO to the Perl language. We love Moose, but over the years, it's become clear that there are some problematic design choices. Further, Corinna is not yet in core as we write this (though the Perl Steering Committee has accepted it), so for now, let's see how far we can push the envelope. Interestingly, in some respects, MooseX::Extended offers more than the initial versions of Corinna (though this won't last).
BEST PRACTICES
MooseX::Extended has the philosophy of providing best practices, but not enforcing them. We try to make many best practices the default, but you can opt out of them. For more background, see the article Common Problems in Object-Oriented Code. That's what lead to the creation of MooseX::Extended.
In particular, it's designed to make large-scale OOP systems written in Moose easier to maintain by removing many common failure modes, while still allowing you full control over what features you do and do not want.
What follows is a fairly decent overview of MooseX::Extended. See the documentation of individual modules for more information.
What's the Point.pm?
Let's take a look at a simple Point
class in Moose. We want it to have x/y coordinates, and the creation time as "seconds from epoch". We'd also like to be able to "invert" points.
package
My::Point {
has
'x'
=> (
is
=>
'rw'
,
isa
=>
'Num'
,
writer
=>
'set_x'
);
has
'y'
=> (
is
=>
'rw'
,
isa
=>
'Num'
,
writer
=>
'set_y'
);
has
'created'
=> (
is
=>
'ro'
,
isa
=>
'Int'
,
default
=>
sub
{
time
} );
sub
invert {
my
$self
=
shift
;
my
(
$x
,
$y
) = (
$self
->x,
$self
->y );
$self
->set_x(
$y
);
$self
->set_y(
$x
);
}
}
1;
To the casual eye, that looks fine, but there are already many issues with the above.
The class is not immutable
You almost always want to end your Moose classes with
__PACKAGE__->meta->make_immutable
. Doing this causes Moose to close the class definition for modifications (if that doesn't make sense, don't worry about it), and speeds up the code considerably.Dirty namespace
Currently,
My::Point->can('has')
returns true, even thoughhas
should not be a method. This, along with a bunch of other functions exported into your class by Moose, can mislead your code and confuse your method resolution order. For this reason, it's generally recommended that you usenamespace::autoclean
ornamespace::clean
. To remove those functions from your class.Unknown constructor arguments
my
$point
= My::Point->new(
X
=> 3,
y
=> 4 );
In the above, the first named argument should be
x
, notX
. Moose simply throws away unknown constructor arguments. One way to handle this might be to set your fields asrequired
:has
'x'
=> (
is
=>
'rw'
,
isa
=>
'Num'
,
writer
=>
'set_x'
,
required
=> 1 );
has
'x'
=> (
is
=>
'rw'
,
isa
=>
'Num'
,
writer
=>
'set_y'
,
required
=> 1 );
That causes
My::Point->new( X => 3, y => 4 )
to throw an exception, but not this:My::Point->new( x => 3, y => 4, z => 5 )
. For this trivial example, it's probably not a big deal, but for a large codebase, where many Moose classes might have a huge variety of confusing arguments, it's easy to make mistakes.For this, we recommend MooseX::StrictConstructor. Unknown arguments are fatal.
Innappropriate constructor arguments
my
$point
= My::Point->new(
x
=> 3,
y
=> 4,
created
=> 42 );
The above works, but the author of the class almost certainly didn't intend for you to be passing
created
to the constructor, but to the programmer reading the code, that's not always clear:has
'created'
=> (
is
=>
'ro'
,
isa
=>
'Int'
,
default
=>
sub
{
time
} );
The fix for this is to add
init_arg => undef
to the attribute definition and hope the maintenance programmer notices this:has
'created'
=> (
is
=>
'ro'
,
isa
=>
'Int'
,
init_arg
=>
undef
,
default
=>
sub
{
time
} );
Misspelled types
What if
created
was defined like this?has
'created'
=> (
is
=>
'ro'
,
isa
=>
'int'
,
default
=>
sub
{
time
} );
The type constraint is named
Int
, notint
. You won't find out about that little issue until runtime. There are a number of ways of dealing with this, but we recommend the Type::Tiny family of type constraints. Misspelling a type name becomes a compile-time failure:has
'created'
=> (
is
=>
'ro'
,
isa
=> Int,
default
=>
sub
{
time
} );
No signatures
Let's look at our method:
sub
invert {
my
$self
=
shift
;
my
(
$x
,
$y
) = (
$self
->x,
$self
->y );
$self
->set_x(
$y
);
$self
->set_y(
$x
);
}
What if someone were to write
$point->invert( 4, 7 )
? That wouldn't make any sense, but it also wouldn't throw an exception or even a warning, despite it obviously not being what the programmer wanted. The simplest solution is to just use signatures:no
warnings
'experimental::signatures'
;
# 5.34 and below
sub
invert (
$self
) { ... }
Fixing our Moose class
Taking all of the above into consideration, we might rewrite our Moose class as follows:
package
My::Point {
no
warnings
'experimental::signatures'
;
has
'x'
=> (
is
=>
'rw'
,
isa
=> Num,
writer
=>
'set_x'
);
has
'y'
=> (
is
=>
'rw'
,
isa
=> Num,
writer
=>
'set_y'
);
has
'created'
=> (
is
=>
'ro'
,
isa
=> Int,
init_arg
=>
undef
,
default
=>
sub
{
time
} );
sub
invert (
$self
) {
my
(
$x
,
$y
) = (
$self
->x,
$self
->y );
$self
->set_x(
$y
);
$self
->set_y(
$x
);
}
__PACKAGE__->meta->make_immutable;
}
1;
That's a lot of boilerplate for a simple x/y point class! Out of the box (but almost completely customisable), MooseX::Extended provides the above for you.
package
My::Point {
param [
'x'
,
'y'
] => (
is
=>
'rw'
,
isa
=> Num,
writer
=> 1 );
field
'created'
=> (
isa
=> Int,
lazy
=> 0,
default
=>
sub
{
time
} );
sub
invert (
$self
) {
my
(
$x
,
$y
) = (
$self
->x,
$self
->y );
$self
->set_x(
$y
);
$self
->set_y(
$x
);
}
}
No need use those various modules. No need to declare the class immutable or end it with a true value (MooseX::Extended does these for you). Instead of remembering a bunch of boilerplate, you can focus on writing your code.
INSTANCE ATTRIBUTES
In the Moose world, we use the has
function to declare an "attribute" to hold instance data for your class. This function is still available, unchanged in MooseX::Extended
, but two new functions are now introduced, param
and field
, which operate similarly to has
. Both of these functions default to is => 'ro'
, so that may be omitted if the attribute is read-only.
A param
is a required parameter (defaults may be used). A field
is not intended to be passed to the constructor (but see the extended explanation below). This makes it much easier for a developer, either writing or reading the code, to be clear about the intended class interface.
So instead of this (and having the poor maintenance programmer wondering what is and is not allowed in the constructor):
has
name
=> (...);
has
uuid
=> (...);
has
id
=> (...);
has
backlog
=> (...);
has
auth
=> (...);
has
username
=> (...);
has
password
=> (...);
has
cache
=> (...);
has
this
=> (...);
has
that
=> (...);
You have this:
param
name
=> (...);
param
backlog
=> (...);
param
auth
=> (...);
param
username
=> (...);
param
password
=> (...);
field
cache
=> (...);
field
this
=> (...);
field
that
=> (...);
field
uuid
=> (...);
field
id
=> (...);
Now the interface is much clearer.
param
param
name
=> (
isa
=> NonEmptyStr );
A similar function to Moose's has
. A param
is required. You may pass it to the constructor, or use a default
or builder
to supply this value.
The above param
definition is equivalent to:
has
name
=> (
is
=>
'ro'
,
isa
=> NonEmptyStr,
required
=> 1,
);
If you want a parameter that has no default
or builder
and can optionally be passed to the constructor, just use required => 0
.
param
title
=> (
isa
=> Str,
required
=> 0 );
Note that param
, like field
, defaults to read-only, is => 'ro'
. You can override this:
param
name
=> (
is
=>
'rw'
,
isa
=> NonEmptyStr );
# or
param
name
=> (
is
=>
'rwp'
,
isa
=> NonEmptyStr );
# adds _set_name
Otherwise, it behaves like has
. You can pass in any arguments that has
accepts.
# we'll make it private, but allow it to be passed to the constructor
# as `name`
param
_name
=> (
isa
=> NonEmptyStr,
init_arg
=>
'name'
);
The param
's is
option accepts rwp
, like Moo. It will create a writer in the name _set_${attribute_name|
.
field
field
cache
=> (
isa
=> InstanceOf [
'Hash::Ordered'
],
default
=>
sub
{ Hash::Ordered->new },
);
A similar function to Moose's has
. A field
is not intended to be passed to the constructor, but you can still use default
or builder
, as normal.
The above field
definition is equivalent to:
has
cache
=> (
is
=>
'ro'
,
isa
=> InstanceOf[
'Hash::Ordered'
],
init_arg
=>
undef
,
# not allowed in the constructor
default
=>
sub
{ Hash::Ordered->new },
lazy
=> 1,
);
Note that field
, like param
, defaults to read-only, is => 'ro'
. You can override this:
field
some_data
=> (
is
=>
'rw'
,
isa
=> NonEmptyStr );
#
field
some_data
=> (
is
=>
'rwp'
,
isa
=> NonEmptyStr );
# adds _set_some_data
Otherwise, it behaves like has
. You can pass in any arguments that has
accepts.
The field
's is
option accepts rwp
, like Moo. It will create a writer in the name _set_${attribute_name|
.
If you pass field
an init_arg
with a defined value, the code will usually throw a Moose::Exception::InvalidAttributeDefinition exception. However, if the init_arg begins with an underscore, it's allowed. This is designed to allow developers writing tests to supply their own values more easily.
field
cache
=> (
isa
=> InstanceOf [
'Hash::Ordered'
],
default
=>
sub
{ Hash::Ordered->new },
init_arg
=>
'_cache'
,
);
With the above, you can pass _cache => $my_testing_cache
in the constructor.
A field
is automatically lazy if it has a builder
or default
. This is because there's no guarantee the code will call them, but this makes it very easy for a field
to rely on a param
value being present. It's a common problem in Moose that attribute initialization order is alphabetical order and if you define an attribute whose default
or builder
relies on another attribute, you have to remember to name them correctly or declare the field as lazy.
Note that is does mean if you need a field
to be initialized at construction time, you have to take care to declare that it's not lazy:
field
created
=> (
isa
=> PositiveInt,
lazy
=> 0,
default
=>
sub
{
time
} );
In our opinion, this tiny little nit is a fair trade-off for this issue:
package
Person {
has
name
=> (
is
=>
'ro'
,
required
=> 1 );
has
title
=> (
is
=>
'ro'
,
required
=> 0 );
has
full_name
=> (
is
=>
'ro'
,
default
=>
sub
{
my
$self
=
shift
;
my
$title
=
$self
->title;
my
$name
=
$self
->name;
return
defined
$title
?
"$title $name"
:
$name
;
},
);
}
my
$person
= Person->new(
title
=>
'Doctor'
,
name
=>
'Who'
);
say
$person
->title;
say
$person
->full_name;
The code looks fine, but it doesn't work. In the above, $person->full_name
is always undefined because attributes are processed in alphabetical order, so the full_name
default code is run before name
or title
is set. Oops! Adding lazy => 1
to the full_name
attribute definition is required to make it work.
Here's the same code for MooseX::Extended
. It works correctly:
package
Person {
param
'name'
;
param
'title'
=> (
required
=> 0 );
field
full_name
=> (
default
=>
sub
{
my
$self
=
shift
;
my
$title
=
$self
->title;
my
$name
=
$self
->name;
return
defined
$title
?
"$title $name"
:
$name
;
},
);
}
Note that param
is not lazy by default, but you can add lazy => 1
if you need to.
NOTE: We were sorely tempted to change attribute field definition order from alphabetical to declaration order, as that would also solve the above issue (and might allow for deterministic destruction), but we decided to play it safe.
Attribute shortcuts
When using field
or param
(but not has
), we have some attribute shortcuts:
param
name
=> (
isa
=> NonEmptyStr,
writer
=> 1,
# set_name
reader
=> 1,
# get_name
predicate
=> 1,
# has_name
clearer
=> 1,
# clear_name
builder
=> 1,
# _build_name
);
sub
_build_name (
$self
) {
...
}
These should be self-explanatory, but see MooseX::Extended::Manual::Shortcuts for a full explanation.
EXCLUDING FEATURES
You may find some features to be annoying, or even cause potential bugs (e.g., if you have a croak
method, our importing of Carp::croak
will be a problem.
For example, if you wish to eliminate MooseX::StrictConstructor and the carp
and croak
behavior:
You can exclude the following:
StrictConstructor
Excluding this will no longer import
MooseX::StrictConstructor
.autoclean
Excluding this will no longer import
namespace::autoclean
.c3
Excluding this will no longer apply the C3 mro.
carp
Excluding this will no longer import
Carp::croak
andCarp::carp
.immutable
Excluding this will no longer make your class immutable.
true
Excluding this will require your module to end in a true value.
param
Excluding this will make the
param
function unavailable.field
Excluding this will make the
field
function unavailable.
TYPES
We bundle MooseX::Extended::Types to make it easier to have compile-time type checks, along with type checks in functions. Here's a silly example:
package
Not::Corinna {
# these default to 'ro' (but you can override that) and are required
param
_name
=> (
isa
=> NonEmptyStr,
init_arg
=>
'name'
);
param
title
=> (
isa
=> NonEmptyStr,
required
=> 0 );
# fields must never be passed to the constructor
# note that ->title and ->name are guaranteed to be set before
# this because fields are lazy by default
field
name
=> (
isa
=> NonEmptyStr,
default
=>
sub
(
$self
) {
my
$title
=
$self
->title;
my
$name
=
$self
->_name;
return
$title
?
"$title $name"
:
$name
;
},
);
sub
add (
$self
,
$args
) {
state
$check
= compile( ArrayRef [ Num, 1 ] );
(
$args
) =
$check
->(
$args
);
return
List;:Util::sum(
$args
->@* );
}
}
See MooseX::Extended::Types for more information.
ASSEMBLING YOUR OWN MOOSE
After you get used to MooseX::Extended
, you might get tired of exchanging the old boilerplate for new boilerplate. So don't do that. Instead, create your own.
Define your own version of MooseX::Extended:
package
My::Moose::Role {
sub
import
{
my
(
$class
,
%args
) =
@_
;
MooseX::Extended::Role::Custom->create(
excludes
=> [
qw/ carp /
],
includes
=> [
'multi'
],
%args
# you need this to allow customization of your customization
);
}
}
# no need for a true value
And then use it:
package
Some::Class::Role {
param
numbers
=> (
isa
=> ArrayRef[Num] );
multi
sub
foo (
$self
) { ... }
multi
sub
foo (
$self
,
$bar
) { ... }
}
See MooseX::Extended::Custom for more information.
ROLES
Of course we support roles. Here's a simple role to add a created
field to your class:
package
Not::Corinna::Role::Created {
# mark it as non-lazy to ensure it's run at construction time
field
created
=> (
isa
=> PositiveInt,
lazy
=> 0,
default
=>
sub
{
time
} );
}
And then consume like you would any other role:
See MooseX::Extended::Role for information about what features it provides and how to adjust its behavior.
MIGRATING FROM MOOSE
For a clean Moose hierarchy, switching to MooseX::Extended is often as simple at replacing Moose with MooseX::Extended and running your tests. Then you can start deleting various bits of boilerplate in your code (such as the make_immutable
call).
Unfortunately, many Moose hierarchies are not clean. You might fail on the StrictConstructor
, or find that you use multiple inheritance and rely on dfs (depth-first search) instead of the C3 mro, or maybe (horrors!), you have classes that aren't declared as immutable and you have code that relies on this. A brute-force approach to handling this could be the following:
package
My::Moose {
sub
import
{
my
(
$class
,
%args
) =
@_
;
MooseX::Extended::Custom->create(
excludes
=> [
qw/
StrictConstructor autoclean
c3 carp
immutable true
field param
/
],
%args
# you need this to pass your own import list
);
}
}
# no need for a true value
With the above, you've excluded almost everything except signatures and postderef features (we will work on getting around that limitation). From there, you can replace Moose with My::Moose
(and do something similar with roles) and it should just work. Then, start slowing deleting the items from excludes
until your tests fail and address them one-by-one.
MOOSE INTEROPERABILITY
Moose and MooseX::Extended
should be 100% interoperable. Let us know if it's not.
VERSION COMPATIBILITY
We use GitHub Actions to run full continuous integration tests on versions of Perl from v.5.20.0 and up. We do not release any code that fails any of those tests.
AUTHOR
Curtis "Ovid" Poe <curtis.poe@gmail.com>
COPYRIGHT AND LICENSE
This software is Copyright (c) 2022 by Curtis "Ovid" Poe.
This is free software, licensed under:
The Artistic License 2.0 (GPL Compatible)