perloopwatchmojoliciousmoo

Watch change of attribute inside Perl class


Can anyone provide a code example how do you set watchers on variable change inside of class ? I tried to do it several ways using different features (Scalar::Watcher, trigger attribute of Moo) and OOP frameworks (Moo, Mojo::Base) and but all failed.

Below is my failed code for better understanding of my task. In this example i need to update attr2 everytime when attr1 changed.

Using Mojo::Base and Scalar::Watcher:

package Cat;
use Mojo::Base -base;
use Scalar::Watcher qw(when_modified);
use feature 'say';

has 'attr1' => 1;
has 'attr2' => 2;

has 'test' => sub { # "fake" attribute for getting access to $self
  my $self = shift;
  when_modified $self->attr1, sub { $self->attr2(3); say "meow" };
};


package main;
use Data::Dumper;

my $me = Cat->new;
$me->attr1;
warn Dumper $me;
say $me->attr1(3)->attr2; # attr2 is still 2, but must be 3

Using Moo and trigger:

package Cat;
use Moo;
use Scalar::Watcher qw(when_modified);
use feature 'say';

has 'attr1' => ( is => 'rw', default => 1, trigger => &update() ); 
has 'attr2' => ( is => 'rw', default => 1);

sub update {
  my $self = shift;
  when_modified $self->attr1, sub { $self->attr2(3); say "meow" }; # got error here: Can't call method "attr1" on an undefined value
};


package main;
use Data::Dumper;

my $me = Cat->new;
$me->attr1;
warn Dumper $me;
say $me->attr1(3)->attr2;

Any suggestion is much appreciated.


Solution

  • The Moo part

    got error here: Can't call method "attr1" on an undefined value

    This is because Moo expects a code reference as a trigger for has. You are passing the result of a call to update. The & here doesn't give you a reference, but instead tells Perl to ignore the prototypes of the update function. You don't want that.

    Instead, create a reference with \&foo and do not add parenthesis (). You don't want to call the function, you want to reference it.

    has 'attr1' => ( is => 'rw', default => 1, trigger => \&update );
    

    Now once you've done that, you don't need the Scalar::Watcher any more. The trigger already does that. It gets called every time attr1 gets changed.

    sub update {
        my $self = shift;
        $self->attr2(3);
        say "meow";
    };
    

    If you run the whole thing now, it will work a little bit, but crash with this error:

    Can't locate object method "attr2" via package "3" (perhaps you forgot to load "3"?) at

    That's because attr1 returns the new value, and not a reference to $self. All Moo/Moose accessors work like that. And 3 is not an object, so it doesn't have a method attr2

    #       this returns 1
    #               |
    #               V
    say $me->attr1(3)->attr2;
    

    Instead, do this as two calls.

    $me->attr1(3);
    say $me->attr2;
    

    Here's a complete example.

    package Cat;
    use Moo;
    
    use feature 'say';
    
    has 'attr1' => ( is => 'rw', default => 1, trigger => \&update );
    has 'attr2' => ( is => 'rw', default => 1 );
    
    sub update {
        my $self = shift;
        $self->attr2(3);
        say "meow";
    }
    
    package main;
    my $me = Cat->new;
    
    say $me->attr2;
    $me->attr1(3);
    say $me->attr2;
    

    And the output:

    1
    meow
    3
    

    Why Scalar::Watcher does not work with Mojo

    First of, Mojo::Base does not provide a trigger mechanism. But the way you implemented Scalar::Watcher could not work, because the test method was never called. I tried hooking around new in the Mojo::Base based class to do the when_modified call in a place where it would always be called.

    Everything from here is on is mere speculation.

    The following snippet is what I tried, but it does not work. I'll explain why further below.

    package Cat;
    use Mojo::Base -base;
    use Scalar::Watcher qw(when_modified);
    use feature 'say';
    
    has 'attr1' => '1';
    has 'attr2' => 'original';
    
    sub new {
        my $class = shift;
    
        my $self = $class->SUPER::new(@_);
        when_modified $self->{attr1}, sub { $self->attr2('updated'); say "meow" };
    
        return $self;
    }
    

    As you can see, this is now part of the new call. The code does get executed. But it doesn't help.

    The documentation of Scalar::Watcher states that the watcher should be there until the variable goes out of scope.

    If when_modified is invoked at void context, the watcher will be active until the end of $variable's life; otherwise, it'll return a reference to a canceller, to cancel this watcher when the canceller is garbage collected.

    But we don't actually have a scalar variable. If we try to do

    when_modified $self->foo
    

    then Perl does a method call of foo on $self and when_modified will get that call's return value. I also tried reaching into the internals of the object above, but that didn't work either.

    My XS is not strong enough to understand what is going on here, but I think it is having some trouble attaching that magic. It can't work with hash ref values. Probably that's why it's called Scalar::Watch.