Search code examples
perloopmoose

What is the best way to do Object Composition with Moose?


Just a beginners question on Best Practice with Moose:

Starting on the simple "point" example I want to build a "line" - object, consisting of two points and having a lenght attribute, describing the distance between starting and ending point.

{
  package Point;
  use Moose;

  has 'x' => ( isa => 'Int', is => 'rw' );
  has 'y' => ( isa => 'Int', is => 'rw' );
}

{
  package Line;
  use Moose;

  has 'start' => (isa => 'Point', is  => 'rw', required => 1, );
  has 'end' => (isa => 'Point', is  => 'rw', required => 1, );
  has 'length' => (isa => 'Num', is => 'ro', builder => '_length', lazy => 1,);

  sub _length {
    my $self = shift;
    my $dx = $self->end->x - $self->start->x;
    my $dy = $self->end->y - $self->start->y;
    return sqrt( $dx * $dx + $dy * $dy );
  }
}

my $line = Line->new( start => Point->new( x => 1, y => 1 ), end => Point->new( x => 2, y => 2 ) );
my $len = $line->length;

The code above works as expected. Now my questions:

  • Is this the best way to solve the problem /to do simple object composition?

  • Is there another way to create the line with something like this (example does not work!) (BTW: Which other ways do exist at all?):

>

my $line2 = Line->new( start->x => 1, start->y => 1, end => Point->new( x => 2, y => 2 ) );
  • How can I trigger an automatic recalculation of length when coordinates are changed? Or does it make no sense to have attributes like length which can "easily" derived from other attributes? Should those values (length) better be provided as functions?

>

$line->end->x(3);
$line->end->y(3);
$len = $line->length;
  • How can I make something like this possible? What's the way to change the point at once - instead of changing each coordinate?

>

$line2->end(x => 3, y =>3);

Thanks for any answers!


Solution

  • Is this the best way to solve the problem to do simple object composition?

    That's too subjective to answer without knowing what you're going to do with it, and the problem is overly simplistic. But I can say there's nothing wrong with what you're doing.

    The change I'd make is to move the work to calculate the distance between two points into Point. Then others can take advantage.

    # How do I do something like this?
    my $line2 = Line->new(
        start->x => 1, start->y => 1,
        end => Point->new( x => 2, y => 2 )
    );
    

    First thing I'd note is you're not saving much typing by foregoing the object... but like I said this is a simplistic example so let's presume making the object is tedious. There's a bunch of ways to get what you want, but one way is to write a BUILDARGS method which transforms the arguments. The example in the manual is kinda bizarre, here's a more common use.

    # Allow optional start_x, start_y, end_x and end_y.
    # Error checking is left as an exercise for the reader.
    sub BUILDARGS {
        my $class = shift;
        my %args = @_;
    
        if( $args{start_x} ) {
            $args{start} = Point->new(
                x => delete $args{start_x},
                y => delete $args{start_y}
            );
        }
    
        if( $args{end_x} ) {
            $args{end} = Point->new(
                x => delete $args{end_x},
                y => delete $args{end_y}
            );
        }
    
        return \%args;
    }
    

    There is a second way to do it with type coercion, which in some cases makes more sense. See the answer to how to do $line2->end(x => 3, y =>3) below.

    How can I trigger an automatic recalculation of length when coordinates are changed?

    Oddly enough, with a trigger! A trigger on an attribute will be called when that attribute changes. As @Ether pointed out, you can add a clearer to length which the trigger can then call to unset length. This does not violate length being read-only.

    # You can specify two identical attributes at once
    has ['start', 'end'] => (
        isa             => 'Point',
        is              => 'rw',
        required        => 1,
        trigger         => sub {
            return $_[0]->_clear_length;
        }
    );
    
    has 'length' => (
        isa       => 'Num',
        is        => 'ro',
        builder   => '_build_length',
        # Unlike builder, Moose creates _clear_length()
        clearer   => '_clear_length',
        lazy      => 1
    );
    

    Now whenever start or end are set they will clear the value in length causing it to be rebuilt the next time it's called.

    This does bring up a problem... length will change if start and end are modified, but what if the Point objects are changed directly with $line->start->y(4)? What if your Point object is referenced by another piece of code and they change it? Neither of these will cause a length recalculation. You have two options. First is to make length entirely dynamic which might be costly.

    The second is to declare Point's attributes to be read-only. Instead of changing the object, you create a new one. Then its values cannot be changed and you're safe to cache calculations based on them. The logic extends out to Line and Polygon and so on.

    This also gives you the opportunity to use the Flyweight pattern. If Point is read-only, then there only needs to be one object for each coordinate. Point->new becomes a factory either making a new object OR returning an existing one. This can save a lot of memory. Again, this logic extends out to Line and Polygon and so on.

    Yes it does make sense to have length as an attribute. While it can be derived from other data, you want to cache that calculation. It would be nice if Moose had a way to explicitly declare that length was purely derived from start and end and thus should automatically cache and recalculate, but it doesn't.

    How can I make something like this possible? $line2->end(x => 3, y => 3);

    The least hacky way to accomplish this would be with type coercion. You define a subtype which will turn a hash ref into a Point. It's best to define it in Point, not Line, so that other classes can make use of it when they use Points.

    use Moose::Util::TypeConstraints;
    subtype 'Point::OrHashRef',
        as 'Point';
    coerce 'Point::OrHashRef',
        from 'HashRef',
        via { Point->new( x => $_->{x}, y => $_->{y} ) };
    

    Then change the type of start and end to Point::OrHashRef and turn on coercion.

    has 'start' => (
        isa             => 'Point::OrHashRef',
        is              => 'rw',
        required        => 1,
        coerce          => 1,
    );
    

    Now start, end and new will accept hash refs and turn them silently into Point objects.

    $line = Line->new( start => { x => 1, y => 1 }, end => Point->new( x => 2, y => 2 ) );
    $line->end({ x => 3, y => 3 ]);
    

    It has to be a hash ref, not a hash, because Moose attributes only take scalars.

    When do you use type coercion and when do you use BUILDARGS? A good rule of thumb is if the argument to new maps to an attribute, use type coercion. Then new and the attributes can act consistently and other classes can use the type to make their Point attributes act the same.

    Here it is, all together, with some tests.

    {
        package Point;
        use Moose;
    
        has 'x' => ( isa => 'Int', is => 'rw' );
        has 'y' => ( isa => 'Int', is => 'rw' );
    
        use Moose::Util::TypeConstraints;
        subtype 'Point::OrHashRef',
          as 'Point';
        coerce 'Point::OrHashRef',
          from 'HashRef',
          via { Point->new( x => $_->{x}, y => $_->{y} ) };
    
        sub distance {
            my $start = shift;
            my $end = shift;
    
            my $dx = $end->x - $start->x;
            my $dy = $end->y - $start->y;
            return sqrt( $dx * $dx + $dy * $dy );
        }
    }
    
    {
      package Line;
      use Moose;
    
      # And the same for end
      has ['start', 'end'] => (
          isa             => 'Point::OrHashRef',
          coerce          => 1,
          is              => 'rw',
          required        => 1,
          trigger         => sub {
              $_[0]->_clear_length();
              return;
          }
      );
    
      has 'length' => (
          isa       => 'Num',
          is        => 'ro',
          clearer   => '_clear_length',
          lazy      => 1,
          default   => sub {
              return $_[0]->start->distance( $_[0]->end );
          }
      );
    }
    
    
    use Test::More;
    
    my $line = Line->new(
        start => { x => 1, y => 1 },
        end   => Point->new( x => 2, y => 2 )
    );
    isa_ok $line,           "Line";
    isa_ok $line->start,    "Point";
    isa_ok $line->end,      "Point";
    like $line->length, qr/^1.4142135623731/;
    
    $line->end({ x => 3, y => 3 });
    like $line->length, qr/^2.82842712474619/,      "length is rederived";
    
    done_testing;