blog

Moose and Type Constraints

# April 2, 2011

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 pushing 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 an inventory;
  • 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:

  1. Removed the obtain method in favour of a handle key that delegates to push.
  2. Changed the type of inventory to ArrayRef[Object]
  3. 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:

  1. A hierarchy of built in predicates that can be used to place constraints on data and,
  2. A means for automatically generating transitive predicates on the basis of the class hierarchy.

comments powered by Disqus