Immutable Objects
I’ve been spending time designing Corinna , a new object system to be shipped with the Perl language. Amongst its many features, it’s designed to make it easier to create immutable objects, but not everyone is happy with that. For example, consider the following class:
class Box {
has ($height, $width, $depth) :reader :new;
has $volume :reader = $width * $height * $depth;
}
my $original_box = Box->new(height=>1, width=>2, depth=>3);
my $updated_box = $original_box->clone(depth=>9); # h=1, w=2, d=9
Because none of the slots have a :writer
attribute, there is no way to mutate
this object. Instead you call a clone
method, supplying an overriding value
for the constructor argument you need to change. The $volume
argument
doesn’t get copied over because it’s derived from the constructor arguments.
But not everyone is happy with this
approach . Aside from arguments about
utility of the clone
method, the notion that objects should be immutable by
default has frustrated some developers reading the Corinna proposal. Even when I
point out just adding a :writer
attribute is all you need to do to get your
mutability, people still object. So let’s have a brief discussion about
immutability and why it’s useful.
But first, here’s my last 2020 Perl Conference presentation on Corinna.
The Problem
Imagine, for example, that you have a very simple Customer
object:
my $customer = Customer->new(
name => "Ovid",
birthdate => DateTime->new( ... ),
);
In the code above, we’ll assume the $customer
can give us useful information
about the state of that object. For example, we have a section of code guarded
by a check to see if they are old enough to drink alcohol:
if ( $ovid->old_enough_to_drink_alcohol ) {
...
}
The above looks innocent enough and it’s the sort of thing we regularly see in code. But then this happens:
if ( $ovid->old_enough_to_drink_alcohol ) {
my $date = $ovid->birthdate;
...
# deep in the bowels of your code
my $cutoff_date = $date->set( year => $last_year ); # oops!
...
}
We had a guard to ensure that this code would not be executed if the customer
wasn’t old enough to drink, but now in the middle of that code, due to how
DateTime
is designed, someone’s set the customer birth date to last year!
The code, at this point, is probably in an invalid state and its behavior can
no longer be considered correct.
But clearly no one would do something so silly, would they?
Global State
We’ve known about the dangers of global state for a long time. For example, if I call the following subroutine, will the program halt or not?
sub next ($number) {
if ( $ENV{BLESS_ME_LARRY_FOR_I_HAVE_SINNED} ) {
die "This was a bad idea.";
}
return $number++;
}
You literally cannot inspect the above code and tell me if it will die
when called because you cannot know, by inspection, what the
BLESS_ME_LARRY_FOR_I_HAVE_SINNED
environment variable is set to. This is
one of the reasons why global environment variables are discouraged.
But here we’re talking about mutable state. You don’t want the above code to die, so you do this:
$ENV{BLESS_ME_LARRY_FOR_I_HAVE_SINNED} = 0;
say next(4);
Except that now you’ve altered that mutable state and anything else which
relies on that environment variable being set is unpredicatable. So we need to
use local
to safely change that in the local scope:
{
local $ENV{BLESS_ME_LARRY_FOR_I_HAVE_SINNED} = 0;
say next(4);
}
Even that is not good because there’s no indication of why we’re doing this , but at least you can see how we can safely change that global variable in our local scope.
ORMs
And I can hear your objection now:
“But Ovid, the DateTime object in your first example isn’t global!”
That’s true. What we had was this:
if ( $ovid->old_enough_to_drink_alcohol ) {
my $date = $ovid->birthdate;
...
# deep in the bowels of your code
my $cutoff_date = $date->set( year => $last_year ); # oops!
...
}
But the offending line should have been this:
# note the clone().
my $cutoff_date = $date->clone->set( year => $last_year );
This is because the set
method mutates the object in place, causing
everything holding a reference to that object to silently change. It’s not
global in the normal sense, but this action at a distance is a source of very
real bugs .
It’s a serious enough problem that
DateTime::Moonpig and
DateTimeX::Immutable have
both been written to provide immutable DateTime
objects, and that brings me
to DBIx::Class , an excellent ORM
for Perl.
As of this writing, it’s been around for about 15 years and provides a component called DBIx::Class::InflateColumn::DateTime . This allows you to do things like this:
package Event;
use base 'DBIx::Class::Core';
__PACKAGE__->load_components(qw/InflateColumn::DateTime/);
__PACKAGE__->add_columns(
starts_when => { data_type => 'datetime' }
create_date => { data_type => 'date' }
);
Now, whenever you call starts_when
or create_date
on an Event
instance,
you’ll get a DateTime
object instead of just the raw string from the
database. Further, you can set a DateTime
object and not worry about your
particular database’s date syntax. It just works.
Except that the object is mutable and we don’t want that. You can fix this
by writing your own DBIx::Class
component to use immutable DateTime
objects.
package My::Schema::Component::ImmutableDateTime;
use DateTimeX::Immutable;
use parent 'DBIx::Class::InflateColumn::DateTime';
sub _post_inflate_datetime {
my ( $self, @args ) = @_;
my $dt = $self->next::method(@args);
return DateTimeX::Immutable->from_object( object => $dt );
}
1;
And then load this component:
__PACKAGE__->load_components(
qw/+My::Schema::Component::ImmutableDateTime/
);
And now, when you fetch your objects from the database, you get nice,
immutable DateTime
s. And it will be interesting to see where your codebase
fails!
Does all of this mean we should never use mutable objects? Of course not. Imagine creating an immutable cache where, if you wanted to add or delete an entry, you had to clone the entire cache to set the new state. That would likely defeat the main purpose of a cache: speeding things up. But in general, immutability is a good thing and is something to strive for. Trying to debug why code far, far away from your code has reset your data is not fun.