Back when we learned about type checking with Moo we had an example where a Person had an age attribute. Unfortunately as time passes, the person represented by that object had a birthday since then and so the age attribute does not reflect the correct number any more.
Probably it was not a good idea in the first place that we used age as an attribute as it basically changes every second. Even if we only have birthdays once a year.
It would have been much better to have an attribute "birthdate" as that's something fixed.
But now it is already done. Our class is in use by lots of people around the world. We cannot just remove the "age" attribute and add the "birthdate" attribute.
How can we fix this without breaking all the code out there?
Just to clarify, this is not a rare mistake. As we are always in a rush to release the new version of our module/application whatever we always make such mistakes. Even if not specifically with "age".
The origin
So we have this code in the Person.pm file:
package Person;
use Moo;
has age => (is => 'rw');
has name => (is => 'rw');
1;
and we have the following code using it:
use strict;
use warnings;
use 5.010;
use Person;
my $teacher = Person->new(name => 'Foo');
$teacher->age(20);
say $teacher->name;
say $teacher->age;
my $student = Person->new(age => 18, name => 'Bar');
say $student->age;
say $student->name;
Nothing special here.
Replace the attribute by a method
The age attribute was exposed to the outside world (people using the class) in two
places. One of them was the constructor where we could pass a value to the
age attribute, and the other one was the ->age
accessor.
In the first attempt we will make sure the accessor still works.
We will introduce a new attribute called birthdate
which will be just a timestamp
returned by the time()
function of perl,
and we will remove the age
attribute.
We also add a new method
called age
that will have two ways of operation.
If called with a number as the age of the person in years, the value will be converted
to the internal representation (which is the current time less the year in seconds) and
stored it in the birthdate
attribute.
If called without a parameter, then it will calculate the current age (in whole numbers)
as the difference between the current time and the birthdate
.
Here is the new version of Person.pm:
package Person;
use Moo;
has birthdate => (is => 'rw');
has name => (is => 'rw');
my $YEAR = 60*60*24*365;
sub age {
my ($self, $age) = @_;
if ($age) {
$self->birthdate( time - $age * $YEAR );
}
return int( (time - $self->birthdate) / $YEAR );
}
1;
In order to simplify the example I used the variable $YEAR
that holds an approximate number of seconds in a year.
This is good enough for our current example, though in a real application we would probably use a DateTime object there.
Let's see how well this works:
use strict;
use warnings;
use 5.010;
use Person;
my $teacher = Person->new(name => 'Foo');
$teacher->age(20);
say $teacher->name;
say $teacher->age;
prints
Foo
20
which is what we expected.
We can even extend our code with a little experiment:
my $teacher2 = Person->new(name => 'FooBar');
$teacher2->age(19.99999999);
say $teacher2->name;
say $teacher2->age;
sleep 5;
say $teacher2->age;
We set the age to be just before the age of 20, we wait and see how the returned age (that we wanted to be a whole number) changes from 19 to 20:
FooBar
19
20
Last, but not least, we try to pass age
in the constructor:
my $student = Person->new(age => 18, name => 'Bar');
say $student->name;
say $student->age;
The output shows that the age attribute was swallowed by Moo and thus we get a warning:
Bar
Use of uninitialized value in subtraction (-) at Person.pm line 16.
Fixing the constructor
In order to fix the constructor we are going to use the BUILDARGS
method of Moo.
If you implement this method in your class, Moo will call it before calling the constructor.
It will get all the parameters the constructor gets (the name of the class and all the arguments
you passed to the ->new
call), and the method should return a reference to a hash
holding the (probably updated) parameters of the constructor.
When we call Person->new(name => 'Bar', age => 18)
, perl would call the new
method
with the following arguments: 'Person', name => 'Bar', age => 18
, which is just the same
a 'Person', 'name', 'Bar', 'age', 18
.
(If in doubt, see the explanation about the fat-arrow.)
Moo already provides the new
constructor, hence we don't have to call it.
Moo also check is we have implemented a method called BUILDARGS
. If we have such function
in our module, then, before calling the new
method, Moo will call this method.
So in our case $class
will be 'Person' and %args
will contain two key-value pairs:
'name' => 'Bar',
'age' => 18
We remove the age
and replace it with a birthdate
key and with the appropriate value.
Then we return the reference to this hash.
Moo will grab that hash-reference and call new
passing the name of the class ('Person') and the
new set of arguments that can be found in this hash reference.
Thus we could replace the 'age' argument passed in by the user, by the 'birthdate' argument.
sub BUILDARGS {
my ($class, %args) = @_;
my $age = delete $args{age};
if ($age) {
$args{birthdate} = time - $age * $YEAR;
}
return \%args;
}
If you want to see it for yourself, add the following code to the Person.pm file:
sub BUILDARGS {
my ($class, %args) = @_;
use Data::Dumper;
print "Class: $class\n";
warn Dumper \%args;
my $age = delete $args{age};
if ($age) {
$args{birthdate} = time - $age * $YEAR;
}
return \%args;
}
and run the following script:
use strict;
use warnings;
use 5.010;
use Person;
my $teacher = Person->new(name => 'Foo');
my $student = Person->new(age => 18, name => 'Bar');
The output will look like this:
Class: Person
$VAR1 = {
'name' => 'Foo'
};
Class: Person
$VAR1 = {
'name' => 'Bar',
'age' => 18
};
You can see the name of the class was 'Person' in both cases and the
data received in %args matches what we passed to the new
call.
Put it together and test it
The whole Person.pm file:
package Person;
use Moo;
has birthdate => (is => 'rw');
has name => (is => 'rw');
my $YEAR = 60*60*24*365;
sub BUILDARGS {
my ($class, %args) = @_;
my $age = delete $args{age};
if ($age) {
$args{birthdate} = time - $age * $YEAR;
}
return \%args;
}
sub age {
my ($self, $age) = @_;
if ($age) {
$self->birthdate( time - $age * $YEAR );
}
return int( (time - $self->birthdate) / $YEAR );
}
1;
The script:
use strict;
use warnings;
use 5.010;
use Person;
my $teacher = Person->new(name => 'Foo');
$teacher->age(20);
say $teacher->name;
say $teacher->age;
my $student = Person->new(age => 18, name => 'Bar');
say $student->name;
say $student->age;
The output:
Foo
20
Bar
18
Conclusion
So we changed the internal representation of age replacing the age attribute by a birthdate attribute. The users of our class don't need to change anything as the retained the old API.
We could now decide to deprecate the old API but keep it around for a while to let our users adjust their code, or we can decide to keep it around forever.