I am creating a module that has some fairly heavily nested hashes. The hash needs to be semi-regularly modified by the module, which unfortunately rules out using Map
.
Generally, a branch of the nested hash will be returned to users of the module [1], and the simplest thing to do is to just return that nested hash, e.g.:
return %data{$branch}{$subbranch}
# ↪︎ %(subsubbranch1 => ... , subsubbranch2 => ... )
However, the nature of containers like arrays or hashes is that while you can make them read-only, the key/values can still be modified. The module users though should not actually modify those values for a number of reasons. Coercing to Map
won't help, because if any of the values are also containers, they too will be modifiable.
My first thought was to subclass Hash
(or otherwise make a custom Associative
), but autovivification by default still goes to Hash. That, however, can be easily solved by overriding both AT-KEY
and ASSIGN-KEY
so that the AT-KEY
returns an instance of the subclass if the key doesn't already exist:
class ProtectedHash is Hash {
has %!hash = ();
method EXISTS-KEY ($key) { %!hash{$key}:exists }
method ASSIGN-KEY ($key, \value) { %!hash{$key} = value }
method AT-KEY ($key) {
%!hash{$key} := ProtectedHash.new unless %!hash{$key}:exists;
%!hash{$key};
}
}
What I'd like to do is to fail if the ASSIGN-KEY
(or the autovivification part of AT-KEY
) is called from outside my module. I thought about using something like $?MODULE but that would be set at compile time and always be true. It looks like I can shimmy off of Backtrace a bit and check for the name of the file that called, but how consistent can I assume the call trace to those two functions?
For example, for ASSIGN-KEY
I've got:
method ASSIGN-KEY ($key, \value) {
my @trace = Backtrace.new.list[3..*];
# The first three can be ignored:
# 0: code at ...Backtrace.pm6
# 1: method new at ...Backtrace.pm6
# 2: method AT-KEY at ...ThisFile.pm6
if/unless ??? {
%!hash{$key} = value
}
}
AT-KEY
is normally called by the sub postcircumfix<{ }>
(in which case @trace[0]
can be ignored, and trace[1]
would be the one of interest) but could also be, albeit rarely, called directly, in which case trace[0]
is where I'd want to verify the file name.
Are there any other common ways in which AT-KEY
or ASSIGN-KEY
might be called? Or should check those two steps account for 99.9% of calls to those methods? [2]
[1] There are only a few subx4 branches that a user might want to manipulate, and so I figure it's best to provide them with the necessarily-slower .Hash
method for when they really need it than to assume they always need a manipulable container. At times these may be called enough (particularly via a get-branch($foo){$subbranch}{$subsubbranch}
pattern), that the addition overhead in creating a deepclone of the Hash becomes decently consequential.
[2] I'm not too concerned about preventing ANY access (although I'm certainly curious if that's possible purely via subclassing), because I'm sure that a fairly industrious coder could always figure something out, but I'd like to catch the most common ones as a way of saying "Can't touch this!" (cue the 90's music…) and provide an Awesome error message.
It's probably easier to achieve this by returning something wrapping the original Array
or Hash
, or alternatively using but
to do a shallow copy and mix in to it (which means you retain the original type).
We can declare a role like this:
role Can'tTouchThis {
method AT-KEY(|) {
untouchable callsame
}
method ASSIGN-KEY(|) {
die "Cannot assign to this";
}
method AT-POS(|) {
untouchable callsame
}
method ASSIGN-POS(|) {
die "Cannot assign to this";
}
}
Where the sub
untouchable
is defined as:
multi untouchable(Positional \p) {
p but Can'tTouchThis
}
multi untouchable(Associative \a) {
a but Can'tTouchThis
}
multi untouchable(\o) {
o
}
Thus handling nested data structures by - on access - creating a read-only facade to those too.
Here's an example and some test cases to illustrate the effect:
class Example {
has %!foo = a => [ 1, 2, [ 3, 4] ], b => { c => { d => 42, e => 19 }, f => 100 };
method get($sym) {
untouchable %!foo{$sym}
}
}
given Example.new {
use Test;
# Positional cases
is .get('a')[0], 1;
is .get('a')[2][1], 4;
dies-ok { .get('a')[1] = 42 };
is .get('a')[1], 2;
# Associative cases
is .get('b')<c><d>, 42;
dies-ok { .get('b')<f> = 99 };
dies-ok { .get('b')<c><d> = 99 };
is .get('b')<f>, 100;
is .get('b')<c><d>, 42;
# Auto-viv also doesn't work
dies-ok { .get('a')[4]<a> = 99 };
dies-ok { .get('a')[4][0] = 99 };
}
Remove the untouchable
call in the get
method to see the majority of the tests here fail due to lack of protection.
The solution I ultimately employed served my needs, and I'm posting it here for those who may encounter similar situations. (The answer with role mixing unfortunately doesn't survive binding)
My ultimate approach was to worry the most about unintended editing. To protect against this, I created an Associative
-type class called DB-Item that internally has a hash. The AT-KEY
method returns the item from the hash if it exists, but ASSIGN-KEY
and BIND-KEY
simply immediately fail with an appropriate error message. The only other method is ADD-TO-DATABASE
. That method handles adds leafs/branches depending on what it's passed (and in general end users should be wary of using all caps methods directly). Since branches can be of different lengths, this also greatly simplifies the initial DB creation:
class DB-Item does Associative {
has %!hash = ();
my $epitaph = "Modification of the database is not a good idea:\n" ~
" - Use .clone if you want to get a editable branch.\n" ~
" - If you really know what you're doing, use .ADD-TO-DATABASE";
method ADD-TO-DATABASE (*@branch) {
if @branch == 2 {
%!hash{@branch.head} = @branch.tail
}else{
%!hash{@branch.head} = DB-Item.new;
%!hash{@branch.head}.ADD-TO-DATABASE(@branch[1..*]);
}
}
method ASSIGN-KEY(|) is hidden-from-backtrace { die $epitaph }
method BIND-KEY(|) is hidden-from-backtrace { die $epitaph }
method EXISTS-KEY($key) { %!hash{$key}:exists }
method AT-KEY($key) { %!hash{$key}:exists ?? %!hash{$key} !! Nil }
method clone { ... }
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With