Consider the following fairly simple class, which creates a lookup object for month names:
use v5.24; package Local::MonthList { use experimental qw( signatures ); use Class::Tiny { months => sub ( $self ) { die "`months` is required" }, _lookup => sub ( $self ) { $self->{_lookup} //= $self->_build_lookup }, }; use overload ( q[bool] => sub { 1 }, q[@{}] => sub { shift->months }, fallback => 1, ); sub _build_lookup ( $self ) { my $n = 0; my %lookup = map { lc($_) => ++$n; } $self->months->@*; return \%lookup; } sub lookup_name ( $self, $month_name ) { return $self->_lookup->{ lc $month_name }; } sub lookup_number ( $self, $month_number ) { return $self->months->[ $month_number - 1 ]; } }
It can be used as follows:
use v5.24; use Test2::V0; my $list = 'Local::MonthList'->new( months => [ qw{ January February March April May June July August September October November December } ] ); is( $list->lookup_name( 'augUST' ), 8, 'lookup_name' ); is( $list->lookup_number( 7 ), 'July', 'lookup_number' ); is( [ $list->@* ], [ qw{ January February March April May June July August September October November December } ], 'overloaded as array', ); done_testing;
However, there is a potential issue with any class which has attributes that are references to mutable data structures like arrays and hashes.
push $list->months->@*, 'Extrember'; # add an extra month
Even if we do in fact want to allow users to add extra months, this will invalidate the cached lookup hash held in _lookup
, making the lookup_name
method no longer work reliably.
A solution at the API level is to provide a method like this:
sub push_month ( $self, $month_name ) { push $self->months->@*, $month_name; delete $self->{_lookup}; return $self; }
People can add their months via:
$list->push_month( 'Extrember' );
While this does provide a sanctioned way for people to add months to the list, it doesn’t do anything to prevent them adding months (or removing them!) the old way.
Internals::SvREADONLY to the rescue
Internals::SvREADONLY
is a Perl internal function for marking a scalar, array, or hash read-only or not. The first argument is the thing you want to tweak. The second argument is a boolean indicating whether you want to make it read-only (true) or writable (false).
(The Internals package contains a bunch of functions which are theoretically unstable and experimental, but in practice haven’t been changed in a while. Nevertheless a degree of caution should be employed when using its functions. It may be a better idea to use a third-party package which wraps their functionality. Some of these will be explored later in this article.)
By adding a one line BUILD
method (the BUILD
method is automatically called by the constructor in classes based on Moose, Mouse, Moo, Class::Tiny, etc) we can lock down the months
array:
sub BUILD ( $self, $arg ) { Internals::SvREADONLY( $self->months->@*, 1 ); }
Our push_month
method will need a few changes to be able to alter the read-only array:
sub push_month ( $self, $month_name ) { Internals::SvREADONLY( $self->months->@*, 0 ); push $self->months->@*, $month_name; Internals::SvREADONLY( $self->months->@*, 1 ); delete $self->{_lookup}; return $self; }
We can test that this has worked:
{ my $e = dies { push $list->@*, 'Extrember'; }; like $e, qr/read-only/, 'dies trying to push onto overloaded array'; } { my $e = dies { push $list->months->@*, 'Extrember'; }; like $e, qr/read-only/, 'dies trying to push onto months array'; }
One thing to note is that Internals::SvREADONLY
is extremely shallow. It will prevent items being added to or removed from the months
array, but it doesn’t prevent the items on the array being altered.
$list->months->[0] = 'Not January?';
Applying and removing the read-only flag recursively is left as an exercise to the reader.
Sub::Trigger::Lock
A while ago I wrote a module that packages up this behaviour for Moose, Mouse, Moo, and sufficiently-compatible frameworks.
First of all, let’s rewrite our original class using Moo.
package Local::MonthList { use Moo; use Types::Common qw( -types ); use experimental qw( signatures ); has months => ( is => 'ro', isa => ArrayRef ); has _lookup => ( is => 'lazy', builder => 1, clearer => 1 ); use overload ( q[bool] => sub { 1 }, q[@{}] => sub { shift->months }, fallback => 1, ); sub _build__lookup ( $self ) { my $n = 0; my %lookup = map { lc($_) => ++$n; } $self->months->@*; return \%lookup; } sub lookup_name ( $self, $month_name ) { return $self->_lookup->{ lc $month_name }; } sub lookup_number ( $self, $month_number ) { return $self->months->[ $month_number - 1 ]; } sub push_month ( $self, $month_name ) { push $self->months->@*, $month_name; $self->_clear_lookup; return $self; } }
As before, it is possible to directly push to the months
array:
push $list->months->@*, 'Extrember'; # add an extra month
Sub::Trigger::Lock will lock down the attribute:
use Sub::Trigger::Lock -all; has months => ( is => 'ro', isa => ArrayRef, trigger => Lock );
And our push_month
method becomes:
sub push_month ( $self, $month_name ) { my $guard = unlock( $self->months ); push $self->months->@*, $month_name; $self->_clear_lookup; return $self; }
What is this $guard
variable? It is an object which will re-lock the $self->months
array after it has gone out of scope.
While Sub::Trigger::Lock doesn’t fully recurse into locked data structures, it does go one level deep, which means this is prevented:
$list->months->[0] = 'Not January?';
Mite
Mite also makes locking attributes reasonably easy, using locked => true
in the attribute definition. The push_month
method can also be included declaratively via Mite’s support for handles_via => 'Array'
. The only additional step is an after push_month
method modifier to clear the _lookup
hashref.
package Local2::MonthList; use Local2::Mite qw( -default -bool ); use experimental qw( signatures ); has months => ( is => 'ro', isa => 'ArrayRef', locked => true, handles_via => 'Array', handles => { push_month => 'push' }, ); has _lookup => ( is => 'lazy', builder => true, clearer => true, ); use overload ( q[bool] => sub { 1 }, q[@{}] => sub { shift->months }, fallback => 1, ); sub _build__lookup ( $self ) { my $n = 0; my %lookup = map { lc($_) => ++$n; } $self->months->@*; return \%lookup; } sub lookup_name ( $self, $month_name ) { return $self->_lookup->{ lc $month_name }; } sub lookup_number ( $self, $month_number ) { return $self->months->[ $month_number - 1 ]; } after push_month => sub ( $self, $month_name ) { $self->_clear__lookup; }; 1;
Alternative approaches
An alternative approach to locking attributes is cloning them. The basic idea is whenever somebody requests $list->months
, instead of returning a reference to your internal array, return a deep clone of it.
This way, if they alter the clone, your internal copy is unaffected.
A major difference with this approach is that there is no exception thrown when they alter the clone. In some cases, this will be preferable. In others, it may be a source of confusion.
MooseX::Extended offers a clone
feature to make this approach easy. Mite also supports clone
. One drawback is that this can be an expensive operation for large and deeply nested structures.
Conclusion
Locking reference attributes can be a fast and easy way to protect the internal state of your objects.
Perl has built-in support for read-only arrays and hashes via Internals::SvREADONLY
, but modules like Sub::Trigger::Lock exist to make using the feature simpler in object-oriented code.
You can find the full code and test cases for the classes discussed in this module here: