DEV Community

Paweł bbkr Pabian
Paweł bbkr Pabian

Posted on

Guard state transitions with proto methods

Problem

When implementing stateful object it is very important to enforce proper transition between states. Take a look at this happy-path code:

class File {

    has IO::Path $.path is required;
    has IO::Handle $!handle;

    method open () {
        $!handle = $!path.open();
        say 'opened';
    }

    method lines-count ( --> Int ) {
        return $!handle.lines.Int;
    }

    method close () {
        $!handle.close();
        $!handle = Nil;
        say 'closed';
    }

}

my $file = File.new( path => '/var/log/messages'.IO );
$file.open();
say $file.lines-count;
$file.close();
Enter fullscreen mode Exit fullscreen mode

We implemented some abstraction over file, that requires its methods to be called in certain order. But what if someone misuses our class and for example calls close() without prior open()?

my $file = File.new( path => '/var/log/messages'.IO );
$file.close();
Enter fullscreen mode Exit fullscreen mode
Invocant of method 'close' must be an object instance of type
'IO::Handle', not a type object of type 'IO::Handle'.  Did you forget a
'.new'?
Enter fullscreen mode Exit fullscreen mode

User gets very confusing error message.

Naive fix

First thing that comes to mind to improve user experience is to inject some state guards with descriptive messages:

class File {

    method open () {
        die 'Cannot call open if already opened' if $!handle.defined;
        ...
    }

    method lines-count () {
        die 'Cannot count lines if not opened' unless $!handle.defined;
        ...
    }

    method close () {
        die 'Cannot call close if not opened' unless $!handle.defined;
        ...
    }
}
Enter fullscreen mode Exit fullscreen mode

There is nothing wrong with this approach. Go for it if your code is simple and call it a day.

Troubles ahead

But few months later you may need to extend this code and implement remote functionality over your File abstraction that should transparently download it before original open() and remove after original close().

class RemoteFile is File {

    has $.url;

    method open () {
        HTTP::Tiny::mirror( $.url, $.path );
        say 'downloaded';
        callsame();
    }

    method close () {
        callsame();
        $.path.unlink();
        say 'removed';
    }

}

my $file = RemoteFile.new(
    path => '/tmp/example.html'.IO,
    url => 'http://example.com'
);
$file.open();
say $file.lines-count;
$file.close();
Enter fullscreen mode Exit fullscreen mode

Suddenly you are back at square one. You need to inject another state guard in your top open() method, because double open() misuse won't be detected early enough.

my $file = RemoteFile.new(
    path => '/var/log/messages'.IO,
    url => 'http://example.com'
);
$file.open();
$file.open();
Enter fullscreen mode Exit fullscreen mode

Will download file twice before detecting misuse in parent class.

downloaded
opened
downloaded
Cannot call open if already opened
Enter fullscreen mode Exit fullscreen mode

Proto methods for the rescue!

Proto methods are used to "formally declare signature commonalities between multi candidates". But less known fact is that they can have body on their own that will be executed before their candidates.

enum State <Opened Closed>;

role FileStates {
    has State $!state;

    proto method open () {
        die 'Cannot call open if already opened' if $!state ~~ State::Opened;
        {*}
        $!state = State::Opened;
    }

    proto method lines-count ( --> Int ) {
        die 'Cannot count lines if not opened' unless $!state ~~ State::Opened;
        {*}
    }

    proto method close () {
        die 'Cannot call close if already closed' if $!state ~~ State::Closed;
        die 'Cannot call close if not opened' unless $!state ~~ State::Opened;
        {*}
        $!state = State::Closed;
    }

}
Enter fullscreen mode Exit fullscreen mode

Here we defined proto methods to encapsulate state guards in a Role. Each proto method checks if it was called when object instance was in in expected State, calls its multi candidate using {*} placeholder and optionally saves new State afterwards.

  • When composed in base File class it reduces line noise because we no longer have to implement state guards scattered across every method. All we have to do to take advantage of proto declarations is to change our methods to multi variant ones:
class File does FileStates { # compose Role here

    has IO::Path $.path is required;
    has IO::Handle $!handle;

    multi method open () { # change method to multivariant
        $!handle = $!path.open();
        say 'opened';
    }

    multi method lines-count ( --> Int ) {
        return $!handle.lines.Int;
    }

    multi method close () {
        $!handle.close();
        $!handle = Nil;
        say 'closed';
    }

}

my $file = File.new( path => '/var/log/messages'.IO );
$file.open();
$file.open(); # test bad method call order
Enter fullscreen mode Exit fullscreen mode
opened
Cannot call open if already opened
Enter fullscreen mode Exit fullscreen mode

Yay! Clean and tidy.

  • Child classes are automatically protected by proto method in parent class. All we have to do is to also change them to multi variant ones:
class RemoteFile is File {

    has $.url;

    multi method open () { # change method to multivariant
        HTTP::Tiny::mirror( $.url, $.path );
        say 'downloaded';
        callsame();
    }

    ...
}

my $file = RemoteFile.new(
    path => '/var/log/messages'.IO,
    url => 'http://example.com'
);
$file.open();
$file.open(); # test bad method call order
Enter fullscreen mode Exit fullscreen mode
downloaded
opened
Cannot call open if already opened
Enter fullscreen mode Exit fullscreen mode

Misuse was detected immediately by proto, without second download.

It's a win-win

By using proto methods we implemented isolated, easy to read and reusable state transition guards. We can go further and define/visualize our state machine using Graph module for more complex cases.

I recommend also looking at Event Sourcing article if your state changes chain needs to be traced as immutable log of events.

Can we implement state guards to detect misuse in compile time?

To be honest I don't think it is possible in Raku. It can be done in Rust using it's ownership transfers and borrow checker, but that is subject for another blog post :)

Top comments (0)