Moose and Type Constraints
Overview
This is the first in a series of posts documenting my experiments in Moose, an OOP framework for Perl 5. Throughout this series, I will be exploring its feature set by building a simple system for writing Interactive Fiction.
The particular focus of this article is Moose
’s type system and pitfalls that might crop up on first use.
Prerequisites
If you want to run the examples that follow, you’ll need to install Moose
. The easiest way to do so is through CPAN
:
# cpan Moose
or
# cpanm Moose
Getting Started
To get started, we’ll need to define classes representing both generic objects in the game world and the player character. For now we won’t worry about the world as a whole (nor its I/O) and will ignore special object behaviour (for example, containment and supporter relationships). Also note that the design decisions taken here are subject to change in later posts — we might, for example, wish to make the Player
a special kind of Person
.
At its most fundamental, a game object should provide a description to return upon examination and a name to be used by the parser for referring to the object in question. Here’s a first pass,
package Object;
use Moose;
has 'name' => (
isa => 'Str',
is => 'rw',
required => 1,
);
has 'description' => (
isa => 'Str',
is => 'rw',
default => sub {
"You see nothing special about the " . shift->name;
},
);
no Moose;
1;
This class introduces us to Moose
in general. As with standard Perl OOP, a class is simply a package. Using Moose
’s syntactic sugar, we define two attributes for holding an object’s internal name
(which will eventually manifest itself as a noun referring to the instance) and a description
. Attributes defined with has
are similar to C++ member variables or Java fields — though closer to C# properties as Moose
provides us with implicit accessor methods.
Both name
and description
are marked as rw
meaning that public accessors are available for setting and getting these attributes. Both are also defined as isa => 'Str'
— this is the first sign of the type checking provided out of the box by Moose
. Any attempt to set name
or description
to values that aren’t understood to be strings will result in an exception. Consider the following script,
#!/usr/bin/env perl
use strict;
use warnings;
use Object;
my $foo = Object->new(name => "lamp");
my $bar = Object->new(name => {});
Notice that we attempt to construct $bar
by supplying an anonymous hash to name
. This is what happens at runtime,
Attribute (name) does not pass the type constraint because: Validation failed for 'Str' with value HASH(0x803eb0) at /Users/marc/perl5/perlbrew/perls/perl-5.12.3/lib/site_perl/5.12.3/darwin-2level/Moose/Meta/Attribute.pm line 883
Moose::Meta::Attribute::_coerce_and_verify('Moose::Meta::Attribute=HASH(0x9b06d0)', 'HASH(0x803eb0)', 'Object=HASH(0x9199d0)') called at /Users/marc/perl5/perlbrew/perls/perl-5.12.3/lib/site_perl/5.12.3/darwin-2level/Moose/Meta/Attribute.pm line 483
Moose::Meta::Attribute::initialize_instance_slot('Moose::Meta::Attribute=HASH(0x9b06d0)', 'Moose::Meta::Instance=HASH(0x9b2120)', 'Object=HASH(0x9199d0)', 'HASH(0x803e80)') called at /Users/marc/perl5/perlbrew/perls/perl-5.12.3/lib/site_perl/5.12.3/darwin-2level/Class/MOP/Class.pm line 603
Class::MOP::Class::_construct_instance('Moose::Meta::Class=HASH(0x97c430)', 'HASH(0x803e80)') called at /Users/marc/perl5/perlbrew/perls/perl-5.12.3/lib/site_perl/5.12.3/darwin-2level/Class/MOP/Class.pm line 576
Class::MOP::Class::new_object('Moose::Meta::Class=HASH(0x97c430)', 'HASH(0x803e80)') called at /Users/marc/perl5/perlbrew/perls/perl-5.12.3/lib/site_perl/5.12.3/darwin-2level/Moose/Meta/Class.pm line 256
Moose::Meta::Class::new_object('Moose::Meta::Class=HASH(0x97c430)', 'HASH(0x803e80)') called at /Users/marc/perl5/perlbrew/perls/perl-5.12.3/lib/site_perl/5.12.3/darwin-2level/Moose/Object.pm line 26
Moose::Object::new('Object', 'name', 'HASH(0x803eb0)') called at ./mismatch.pl line 9
Now for the player character. A trivial implementation that meets the requirements outlined above would be:
package Player;
use Moose;
has 'inventory' => (
default => sub { [] },
is => 'ro',
);
sub list_inventory {
my $self = shift;
my @inv = @{ $self->inventory };
return "You are carrying nothing." unless @inv;
my $output = "You are currently carrying:\n";
$output .= ("* " . $_->name . "\n") foreach(@inv);
return $output;
}
sub obtain {
my ($self, $object) = @_;
push @{ $self->inventory }, $object;
}
no Moose;
1;
Player
has a single attribute, inventory
, an array reference used to store a list of objects being carried. The list_inventory
method returns a string listing the current inventory in a structured way. An obtain
method is used to add objects to the player’s inventory by push
ing them onto the end of the array referenced by inventory
.
Let’s try this out;
#!/usr/bin/env perl
use strict;
use warnings;
use feature 'say';
use Object;
use Player;
my $lamp = Object->new(name => "lamp");
my $player = Player->new;
say $player->list_inventory;
$player->obtain($lamp);
say $player->list_inventory;
# but:
$player->obtain($player);
Running entry.pl
yields the following:
# ./entry.pl
You are carrying nothing.
You are currently carrying:
* lamp
The code works as expected, but notice that the final line of our script — $player->obtain($player)
— is perfectly legal, meaning that a player can pick up instances of Player
as well as Object
. Is this desirable?
Programmers versed in dynamically typed object systems might well answer yes; we don’t need typing on the inventory to prevent such errors. Rather, we need good API documentation and well written client code. Type mismatches will manifest when, for example, we try to list the inventory after a Player
instance has been added:
Can't locate object method "name" via package "Player" at Player.pm line 16.
This run time exception indicates that we’re attempting to call a method on an object of inappropriate type. Good testing practices could iron out such errors, case closed.
If you find this reasoning satisfactory, the rest of this post probably won’t be of interest. My intention is not to debate the relative merits or shortcomings of approaches to typing in general. Rather, I take the position that catching as many errors as early as possible is A Good Thing. While compile-time type checking with Moose
isn’t possible, we can prevent instances of the wrong type from being added to our inventory; as such, we don’t have to wait for a method call on an object to tell us that we’ve screwed up somewhere along the way.
Moose and Types
Conceptually, we want to encode the following in Player
:
- A
Player
has aninventory
; - This inventory only contains instances of type
Object
(or its subtypes)
Notice that I talk of types and subtypes here rather than classes and subclasses. We have opened the door to a headache or two as will become clear below.
Type Parameters
Moose
offers a type system for attributes. As noted in the documentation, though,
this is not a “real” type system. Moose does not magically make Perl start associating types with variables. This is just an advanced parameter checking system which allows you to associate a name with a constraint.
With that in mind, let’s add type checking to our inventory
:
package Player;
use Moose;
has 'inventory' => (
handles => { obtain => 'push' },
isa => 'ArrayRef[Object]',
default => sub { [] },
traits => ['Array'],
is => 'ro',
);
sub list_inventory {
my $self = shift;
my @inv = @{ $self->inventory };
return "You are carrying nothing." unless @inv;
my $output = "You are currently carrying:\n";
$output .= ("* " . $_->name . "\n") foreach(@inv);
return $output;
}
no Moose;
1;
Notice that we have:
- Removed the
obtain
method in favour of ahandle
key that delegates topush
. - Changed the type of inventory to
ArrayRef[Object]
- Specified the
Array
trait.
The array trait,
provides native delegation methods for array references
In particular, it allows us to define obtain
with respect to a parameterised type. For more information on the trait, see here.
First Problem
Running our entry script again, we see the same output:
# ./entry.pl
You are carrying nothing.
You are currently carrying:
* lamp
Why hasn’t the type parameter taken effect? The answer is that our class Object
clashes with a built in type.
Which Object?
Browsing the Moose
documentation, one might be inclined to think that we’re defining a class that already exists — namely, Moose::Object
. Consider the following,
[Moose::Object] is the default base class for all Moose-using classes. When you use Moose in this class, your class will inherit from this class.
This description will be familiar to Java and Smalltalk programmers as it suggests a Unified Type System (where the class/type hierarchy has a single root node, Object
).
This is not the case here though. Let’s review the isa
key used with has
:
The isa option uses Moose’s type constraint facilities to set up runtime type checking for this attribute. Moose will perform the checks during class construction, and within any accessors. The
$type_name
argument must be a string. The string may be either a class name or a type defined using Moose’s type definition features.
We haven’t used any of Moose
’s custom type definition features, so let’s take a look at the builtin type hierarchy:
Any
Item
Bool
Maybe[`a]
Undef
Defined
Value
Str
Num
Int
ClassName
RoleName
Ref
ScalarRef[`a]
ArrayRef[`a]
HashRef[`a]
CodeRef
RegexpRef
GlobRef
FileHandle
Object
Notice that Object
is a built-in type constraint — this constraint shares a name with our Object
class and has taken precedence. We are warned of such behaviour in the documentation.
To understand why our Object
and Player
instances meet the constraint, consider its definition:
subtype 'Object' => as 'Ref' =>
where { blessed($_) && blessed($_) ne 'Regexp' } =>
optimize_as \&Moose::Util::TypeConstraints::OptimizedConstraints::Object;
in Moose::Util::TypeConstraints
(see here). Any blessed reference will meet this constraint and can therefore be stored in inventory
.
Solution
We need to disambiguate our Object
— either by placing it in a different package or renaming the class so that it doesn’t clash with a built in type constraint name. For simplicitly, let’s pick a different name, Entity
:
package Entity;
use Moose;
has 'name' => (
isa => 'Str',
is => 'rw',
required => 1,
);
has 'description' => (
isa => 'Str',
is => 'rw',
default => sub {
"You see nothing special about the " . shift->name;
},
);
no Moose;
1;
meaning that Player
needs to be changed to read,
package Player;
use Moose;
has 'inventory' => (
handles => { obtain => 'push' },
isa => 'ArrayRef[Entity]',
default => sub { [] },
traits => ['Array'],
is => 'ro',
);
sub list_inventory {
my $self = shift;
my @inv = @{ $self->inventory };
return "You are carrying nothing." unless @inv;
my $output = "You are currently carrying:\n";
$output .= ("* " . $_->name . "\n") foreach(@inv);
return $output;
}
no Moose;
1;
We also need to update entity.pl
:
#!/usr/bin/env perl
use strict;
use warnings;
use feature 'say';
use Entity;
use Player;
my $lamp = Entity->new(name => "lamp");
my $player = Player->new;
say $player->list_inventory;
$player->obtain($lamp);
say $player->list_inventory;
# but:
$player->obtain($player);
Running it now gives the following output:
# ./entity.pl
You are carrying nothing.
You are currently carrying:
* lamp
A new member value for 'inventory' does not pass its type constraint because:
Validation failed for 'Entity' with value Player=HASH(0x803f20) (not isa Entity)
at ./entity.pl line 20
Attempting to add $player
to the inventory has thrown an exception as desired.
Finally, we can modify entry.pl
to fix the error:
#!/usr/bin/env perl
use strict;
use warnings;
use feature 'say';
use Entity;
use Player;
my $lamp = Entity->new(name => "lamp");
my $player = Player->new;
say $player->list_inventory;
$player->obtain($lamp);
say $player->list_inventory;
# this will throw an exception:
# $player->obtain($player);
Summary of pitfalls
Consider what we have learnt so far,
- The existence of a type in
Moose
is either the result of an explicit constraint or an existing class.- In the latter case, an implicit type will be inferred on first use.
- While a derived class can be considered a subtype of its base class, we don’t have a homogeneous type system;
- Arbitrary constraints can be written that disregard inheritance relationships entirely.
- As such, there is no clear relationship between subtype and subclass.
Conclusion
First of all, it would be unfair not to repeat the important caveat presented in the Moose
documentation:
This is NOT a type system for Perl 5. These are type constraints, and they are not used by Moose unless you tell it to. No type inference is performed, expressions are not typed, etc. etc. etc.
A type constraint is at heart a small “check if a value is valid” function. A constraint can be associated with an attribute. This simplifies parameter validation, and makes your code clearer to read, because you can refer to constraints by name.
Reasoning about types in a Moose
system seems quite problematic given the overlap between two rather distinct means of predicating on instances.
Rather than thinking in terms of type systems, it may be more helpful to think of Moose
’s “type constraints” as simple predicates that have nothing to do with OOP typing. Rather, Moose
provides:
- A hierarchy of built in predicates that can be used to place constraints on data and,
- A means for automatically generating transitive predicates on the basis of the class hierarchy.
comments powered by Disqus