Some common questions I get on pull requests are "what's with those with-methods?" and "why do you use DateTimeImmutable?". The reason is I am a fan of immutability. I became a fan of immutability the hard way; By introducing bugs caused by (unintentional) mutability.
What is immutability
When an object is immutable its state can not be modified after the object has been created. Concrete this means that an immutable object does not have any writable properties nor any setters to update the properties.
For clarification; The most simple implementation of a mutable object:
final class MutableObject
{
public string $type = '';
}
A more elaborate implementation could have a constructor and a setter. Using a constructor and a setter allows for validation on the value for both initialization and for mutation:
final class MutableObject
{
private string $type;
public function __construct(string $type)
{
$this->setType($type);
}
public function setType(string $type): void
{
if (empty($type)) {
throw new InvalidArgumentException('Type cannot be empty');
}
$this->type = $type;
}
public function getType(): string
{
return $this->type;
}
}
Why make an object immutable
In short; To guarantee the validity of an object.
In PHP scalar values - integer, float, string and boolean are passed by value by default. That means that in a function or method where I pass an integer as argument we can assign the value of that integer without any side effects on the code that made the call:
function doSomething(int $number): void
{
$number += 5;
}
$number = 12345;
echo $number . PHP_EOL;
doSomething($number);
echo $number . PHP_EOL;
This above example will output:
12345
12345
Objects and arrays1 are by default passed by reference. That means that any changes I apply on an object inside a function or method that was passed as an argument will have side effects on the case that made the call:
function doSomething(DateTime $date): void
{
$date->add(new DateInterval('P1D'));
}
$date = new DateTime("2022-04-26");
echo $date->format('Y-m-d') . PHP_EOL;
doSomething($date);
echo $date->format('Y-m-d') . PHP_EOL;
This above example will output:
2022-04-26
2022-04-27
The contents of $date
has been mutated. And there is no way to prevent this in PHP. Apart from making sure your objects can not be mutated. With an immutable object you can be sure that another piece of code does not (accidentally) change the object causing unexpected side effects. Let's assume another example - a scenario I actually encountered myself:
$today = new DateTime(); // this could be injected from a clock
$openingHours = $this->openingHoursService->getOpeningHoursForUpcomingDays($store, $today, 7);
if ($this->isCurrentlyClosed($today, $openingHours)) {
// try to find an alternative that is currently open
$currentlyOpenStores = $this->openingHoursService->getCurrentlyOpen($today);
// ...
}
After releasing this change it took a few days for one of the stakeholders to start complaining about wrong information being shown. We were unable to reproduce this until a colleague decided to set $today
to the moment the stakeholder reported the issue. Now we saw the wrong information as well! Stepping through the code we discovered that properties of $today
were changing after calling the opening hours service. We had a look at the implementation and found that it queried the database as follows:
select
open, closed
from
opening_hours
where
store = {store_id}
and open >= {$today}
and open < {$today->add(new DateInterval("P${days}D"))}
Because $today
is an instance of DateTime
calling the add()
method on it actually updated the internal value of $today
.
We can now get into a discussion about this implementation. If the developer that implemented this method had used the date manipulation features of the database this issue would not have existed. More important is that unexpectedly an unrelated piece of code changed "my object". This is exactly the scenario where immutability can help; If we were to pass an immutable object we can be absolutely certain that no matter how getOpeningHoursForUpcomingDays()
is implemented the object that we pass to it will never change.
The solution for this specific scenario was to replace the DateTime
with DateTimeImmutable
. It required a change in signature for the service we called, but from that moment on our bug was fixed.
How to implement an immutable object
The immutable equivalent of MutableObject
could look something like this:
final class ImmutableObject
{
private string $type;
public function __construct(string $type)
{
if (empty($type)) {
throw new InvalidArgumentException('Type cannot be empty');
}
$this->type = $type;
}
public function getType(): string
{
return $this->type;
}
}
So what about those "with-methods" mentioned earlier. Ideally all properties are to be part of the constructor. This can lead to some readability difficulties when working with a large amount of properties and when some properties are optional. Another scenario is when you want to change one or more values after instantiation of the object. The convention we have been using - and that is used in PSR's as well - is to use methods that start with with
:
final class ImmutableObject
{
private string $type;
private ?string $label = null;
public function __construct(string $type, ?string $label = null)
{
if (empty($type)) {
throw new InvalidArgumentException('Type cannot be empty');
}
$this->type = $type;
$this->label = $label;
}
public function getType(): string
{
return $this->type;
}
public function withLabel(string $label): static
{
return new self($this->type, $label);
}
}
Another implementation uses clone
. This implementation is useful when an object had a high number of properties or when the validity of your object requires a combination of arguments to be specified2:
final class ImmutableObject
{
private string $type;
private ?string $label = null;
public function __construct(string $type)
{
if (empty($type)) {
throw new InvalidArgumentException('Type cannot be empty');
}
$this->type = $type;
}
public function getType(): string
{
return $this->type;
}
public function withLabel(string $label): static
{
$clone = clone $this;
$clone->label = $label;
return $clone;
}
}
Instantiating these objects becomes a bit more verbose, but will almost resemble prose:
$object = (new ImmutableObject('my-type'))
->withLabel('My type');
In the examples shown so far I have opted for verbosity. With the syntactic sugar that is introduced in PHP 8 and PHP 8.1 we can shorten object definitions drastically:
final class ImmutableObject
{
public function __construct(
public readonly string $type
) {}
}
This example will make the property available for usage by other objects. If you prefer to use methods for retrieval of data all you need to do is make the property private
and introduce a getter
similar to the examples above.
Should every object be immutable?
Short answer: no. In my opinion writing code is like being in traffic; You need to be predictable. I think most data containers would benefit from immutability. There are however some scenarios where a mutable object makes more sense as some objects are expected to maintain state. For example a form abstraction; A form contains objects that represent the fields and these fields have a value. This value can be pre-populated, but will be updated based on the info that is submitted. In that scenario it makes sense that the form instance has some form of state.
tl/dr;
Mutable objects can cause bugs that might be difficult to find. By making object immutable you have the guarantee that you are in control of your objects and their state. Making objects in immutable requires only a relative small amount of code.
-
Arrays are technically passed by reference, but because most operations on an array creates a new array instance this is not very apparent. Calling methods that change the internal pointer of an array - like
reset()
andnext()
- do have effects on the array instance and can therefore "leak". ↩ -
PHP 8 has introduced named arguments. This makes working with a large number of arguments easier. The downsides of this are that your argument names have now become part of your public facing API and you will have to add more complex validation rules when arguments can only be set in combination with other arguments. ↩
Top comments (0)