How do I construct an object in Perl6 from a hash?
Asked Answered
T

1

7

In Perl5 you can do something like this:

#!/usr/bin/env perl
use 5.010;

package Local::Class {
  use Moo;
  has [qw( x y )] => ( is => 'ro');
  sub BUILDARGS { shift; return (@_) ? (@_ > 1) ? { @_ } : shift : {} }
}

use Local::Class;

# Create object directly
my $x = Local::Class->new( x => 1, y => 10 );
say $x->x, ' ', $x->y; # 1 10

# Arguments from a hash
my %hash = ( x => 5, y => 20 );
$x = Local::Class->new(%hash);
say $x->x, ' ', $x->y; # 5 20

# Arguments from a hash reference
$x = Local::Class->new(\%hash);
say $x->x, ' ', $x->y; # 5 20

The two calls in the bottom work the same because of the custom BUILDARGS method, which basically turns them both into the sort of hash references expected by Moo(se)?.

But how can I do the same in Perl6?

#!/usr/bin/env perl6

class Local::Class {
  has $.x;
  has $.y;
}

my $x;

# This works
$x = Local::Class.new( x => 1, y => 10 );
say $x.x, ' ', $x.y; # 1 10

# This doesn't
my %hash = %( x => 5, y => 20 );
$x = Local::Class.new(%hash);

# This doesn't either
$x = Local::Class.new(item(%hash));

# Both die with:
# Default constructor for 'Local::Class' only takes named arguments

So how can I take a hash that has been created elsewhere, and convert it into the sort of named arguments needed by the default constructor of a class?

Thremmatology answered 1/12, 2016 at 0:43 Comment(0)
B
6

Using the default constructor

The default .new constructor maps named arguments to public attributes.

In your example, you pass a hash as a positional argument. You can use the | syntax to interpolate the hash entries into the argument list as named arguments:

$x = Local::Class.new(|%hash);

However, note that this will cause problems if your class has an array attribute like has @.z:

class Local::Class {
    has $.x;
    has $.y;
    has @.z;
}

my %hash = x => 5, y => 20, z => [1, 2];
my $x = Local::Class.new(|%hash);

say $x;  # Local::Class.new(x => 5, y => 20, z => [[1, 2],])

This is because like all hashes, %hash places each of its values in an item container. So the attribute will be initialized as @.z = $([1, 2]), which results in an array of a single element which is the original array.

One way to avoid this, is to use a Capture instead of a Hash:

my $capture = \( x => 5, y => 20, z => [1, 2] );
my $x = Local::Class.new(|$capture);

say $x;  # Local::Class.new(x => 5, y => 20, z => [1, 2])

Or use a Hash but then de-containerize its values with <> and turn the whole thing into a Map (which, unlike a Hash, won't add back the item containers) before interpolating it into the argument list:

my %hash = x => 5, y => 20, z => [1, 2];
my $x = Local::Class.new(|Map.new: (.key => .value<> for %hash));

say $x;  # Local::Class.new(x => 5, y => 20, z => [1, 2])

Using a custom constructor

If you'd rather want to deal with this in the class itself rather than in code that uses the class, you can amend the constructor to your liking.

Note that the default constructor .new calls .bless to actually allocate the object, which in turn calls .BUILD to handle initialization of attributes.

So the easiest way is to keep the default implementation of .new, but provide a custom .BUILD. You can map from named arguments to attributes directly in its signature, so the body of the BUILD routine can actually stay empty:

class Local::Class {
    has $.x;
    has $.y;
    has @.z;

    submethod BUILD (:$!x, :$!y, :@!z) { }
}

my %hash = x => 5, y => 20, z => [1, 2];
my $x = Local::Class.new(|%hash);

say $x;  # Local::Class.new(x => 5, y => 20, z => [1, 2])

Binding an array-in-an-item-container to a @ parameter automatically removes the item container, so it doesn't suffer from the "array in an array" problem described above.

The downside is that you have to list all public attributes of your class in that BUILD parameter list. Also, you still have to interpolate the hash using | in the code that uses the class.

To get around both of those limitations, you can implement a custom .new like this:

class Local::Class {
    has $.x;
    has $.y;
    has @.z;

    method new (%attr) {
        self.bless: |Map.new: (.key => .value<> for %attr)
    }
}

my %hash = x => 5, y => 20, z => [1, 2];
my $x = Local::Class.new(%hash);

say $x;  # Local::Class.new(x => 5, y => 20, z => [1, 2])
Bucharest answered 1/12, 2016 at 1:0 Comment(8)
This is great. But how do I turn %( x => 5, z => [1, 2] ) into \( x => 5, z => [1, 2] )? How do I turn an already existing hash (like one read from a JSON string, for instance) into a suitable capture? I tried doing Local::Class.new(%hash.Capture), but that results in the same issue you pointed out.Thremmatology
@Thremmatology I've updated the answer. (I turn it into a Map instead of a Capture, which is less messy but works the same for this purpose.)Bucharest
Nice! However, I read that "writing your own new method [...] is considered poor practice, because it makes correct initialization of objects from subclasses harder". Does that apply here as well? Or am I correct in thinking that this new new would only be called when the signature matched (=when passed a hash), and would therefore not have that problem?Thremmatology
@Thremmatology If you want it work in addition to the default .new, you have to define it as a multi-method, i.e. replace method new with multi method new.Bucharest
If you want to create a class from a JSON blob, maybe the JSON::Unmarshal module can help you.Diocletian
@Diocletian That sounds useful, but in this case the JSON data I'm reading is an array of hashes, and I need to work with each hash in the array. By the time I have the hash, I am no longer working with JSON, and JSON::Unmarshal wants the JSON string. What I would need is something like Hash::Unmarshal.Thremmatology
i just submitted a pull request for JSON::Unmarshal that will allow you to unmarshal($jsontext, Array[Dog]) or unmarshal($jsontext, Hash[Dog]). That should help, once it gets merged!Diocletian
I use BUILDALL and @a .= flatmap(*.flat) to handle the json issueAssured

© 2022 - 2024 — McMap. All rights reserved.