Dans un billet précédent sur l’égalité d’identité et celle de valeurs, je vous ai parlé d’objets immutables pour lesquels l’égalité de valeur et l’égalité d’identité se confondent. J’ai souvent vu dans divers blogues sur l’immutabilité en Java l’utilisation du mot clé final
. J’ai toujours trouvé son usage pour réaliser l’immutabilité comme absurde et surtout par trop contraignant. Pour moi, il ne sert à rien de qualifier les propriétés des objets comme des constantes étant donné que celles-ci doivent être encapsulées selon les principes de la programmation orienté objet. Non, l’immutabilité des objets devrait au contraire se faire au niveau du comportement et surtout des mutateurs de ces objets.
Pour illustrer ceci, je vous propose de reprendre l’exemple du billet précédent avec la classe CalendarEvent
. Dans cet exemple, les événements d’un agenda sont représentés par les objets métiers de CalendarEvent
:
public interface CalendarEvent {
static CalendarEvent on(final Period period) {
return new CalendarEventFactory().makeEventOn(period);
}
CalendarEvent planOn(final Calendar calendar);
CalendarEvent withTitle(String aTitle);
CalendarEvent withDescription(String aDescription);
...
}
Le comportement de ces objets métiers est quant à lui réalisé par l’interface suivante (qui peut être une classe abstraite) :
interface CalendarEventBehaviour extends CalendarEvent {
CalendarEvent setCalendar(final Calendar calendar);
@Override
default CalendarEvent planOn(final Calendar calendar) {
return Transaction.perform(() ->
CalendarEventRepository.get().put(this.setCalendar(calendar))
);
}
@Override
default CalendarEvent update() {
return Transaction.perform(() ->
CalendarEventRepository.get().put(this)
);
...
}
Et la structure est donnée par la classe concrête :
class CalendarEventState implements CalendarEventBehaviour, Cloneable {
private OffsetDateTime startDateTime;
private OffsetDateTime endDateTime;
private boolean allDay;
private String title;
private String description;
private String location;
private Calendar calendar;
CalendarEventState(final OffsetDateTime startDateTime, final OffsetDateTime endDateTime) {
this.startDateTime = startDateTime.withOffsetSameInstant(ZoneOffset.UTC);
this.endDateTime = endDateTime.withOffsetSameInstant(ZoneOffset.UTC);
this.allDay = this.startDateTime.isEqual(
this.startDateTime.toLocalDate().atStartOfDay().atOffset(ZoneOffset.UTC)) &&
this.endDateTime.isEqual(this.endDateTime.toLocalDate()
.atStartOfDay()
.atOffset(ZoneOffset.UTC));
}
@Override
public CalendarEventState withTitle(String aTitle) {
this.title = aTitle;
return this;
}
...
}
Voici un exemple d’utilisation d’un tel code :
CalendarEvent event = CalendarEvent.on(Period.on(today))
.withTitle(aTitle)
.withDescription(aDescription)
.inLocation(aLocation);
Afin de rendre immutable nos objets CalendarEvent
, une réalisation simple serait pour chaque mutateur de cloner l’objet courant, de valoriser l’attribut du clone et de retourner ce dernier. Or, si ceci est suffisant pour une simple modification d’une propriété d’un événement calendaire, ça s’avère plutôt lourd pour initialiser l’événement juste instancié. En effet, à chaque initialisation d’une des propriétés de l’événement, on clonerait celui-ci ! Une pratique courante, et typique des langages comme Java, serait d’initialiser l’objet à son instanciation (avec le constructeur). Or, pour un objet comportant un nombre important de propriétés, ceci peut vite devenir là aussi lourd et surtout désagréable à lire. Non, j’ai souvent préféré l’approche de Smalltalk dans lequel l’initialisation de l’objet est distincte de sa construction. C’est ici qu’une classe de construction (builder en anglais) prend tout son intérêt : elle créée un objet CalendarEvent
et l’initialise à la demande avant de retourner l’objet prêt ; ceci ne peut évidemment pas se faire si les attributs de la classe CalendarEventState
sont tous marqués final
.
public interface CalendarEventBuilder {
static CalendarEventState.MyCalendarEventBuilder get() {
return new CalendarEventState.MyCalendarEventBuilder();
}
CalendarEventBuilder withTitle(String aTitle);
CalendarEventBuilder withDescription(String aDescription);
CalendarEventBuilder inLocation(String aLocation);
CalendarEvent build();
}
L’interface qui définit le constructeur propose une méthode statique pour obtenir une instance de la classe qui implémente l’interface. Cette classe concrète est interne à la classe CalendarEventState
:
public class CalendarEventState implements CalendarEventBehaviour, Cloneable {
...
static class MyCalendarEventBuilder implements CalendarEventBuilder {
private CalendarEventState event;
MyCalendarEventBuilder() {
}
CalendarEventBuilder between(final OffsetDateTime start, final OffsetDateTime end) {
this.event = new CalendarEventState(start, end);
return this;
}
@Override
public CalendarEventBuilder withTitle(String aTitle) {
this.event.title = aTitle;
return this;
}
@Override
public CalendarEventBuilder withDescription(String aDescription) {
this.event.description = aDescription;
return this;
}
@Override
public CalendarEventBuilder inLocation(String aLocation) {
this.event.location = aLocation;
return this;
}
...
@Override
public CalendarEvent build() {
return this.event;
}
}
}
Ce constructeur est utilisé en lieu et place de la fabrique dans CalendarEvent
:
interface CalendarEvent {
static CalendarEventBuilder on(final Period period) {
return CalendarEventBuilder.get().between(period.getStartDateTime(), period.getEndDateTime());
}
...
}
Ce qui donnerait à l’utilisation :
CalendarEvent event = CalendarEvent.on(Period.on(today))
.withTitle(aTitle)
.withDescription(aDescription)
.inLocation(aLocation)
.build();
Ce qui me gêne ici est la méthode build()
pour la raison que c’est une méthode technique utilisée avec des méthodes à connotation métier. Je n’ai jamais vraiment été fan de ce genre de constructeur à cause de cette méthode finale. Une manière de remédier à ceci est de la remplacer par une méthode métier des événements. La méthode qui me parait la plus pertinente ici serait celle de planification. Pour ce faire, il est nécessaire de séparer la planification des événements sur un agenda de leur persistance même. Ce qui donnerait :
public interface CalendarEventBuilder {
...
CalendarEvent planOn(final Calendar aCalendar);
}
et :
public class CalendarEventState implements CalendarEventBehaviour, Cloneable {
...
static class MyCalendarEventBuilder implements CalendarEventBuilder {
private CalendarEventState event;
...
@Override
public CalendarEvent planOn(final Calendar aCalendar) {
this.event.calendar = aCalendar
return this.event;
}
}
}
La méthode de planification qui était dans CalendarEvent
est remplacée par une méthode de sauvegarde de l’événement.
interface CalendarEvent {
...
CalendarEvent save();
...
}
interface CalendarEventBehaviour extends CalendarEvent {
...
@Override
default CalendarEvent save() {
return Transaction.perform(() ->
CalendarEventRepository.get().put(this)
);
}
...
}
Ce qui donne finalement :
CalendarEvent event = CalendarEvent.on(Period.on(today))
.withTitle(aTitle)
.withDescription(aDescription)
.inLocation(aLocation)
.planOn(myCalendar);
ou :
CalendarEvent event = CalendarEvent.on(Period.on(today))
.withTitle(aTitle)
.withDescription(aDescription)
.inLocation(aLocation)
.planOn(myCalendar)
.save();
C’est tout de même plus agréable à lire.
Si on prend du recul vis à vis de notre classe de construction, on pourrait dire que celle-ci ressemble plus à un brouillon d’un événement que l’on n’a pas encore planifié. Brouillon que l’on pourrait reprendre plus tard. Dans d’autres contextes métiers, ces brouillons pourraient même servir de modèle pour créer de nouveaux objets métiers avec des modifications légères, contextuelles. Notre CalendarEventBuilder
pourrait être renommé en CalendarEventDraft
; nous sommes passés d’une classe à consonance technique à une classe métier.