Comprendere lo Strategy Pattern: Soluzioni Eleganti per un Codice Flessibile in PHP
By giuseppemaccario on Sat Jan 4 in Design Pattern, PHP, Programming, refactoring
Sommario
- Introduzione
- Cos’é lo Strategy Pattern?
- Strategy Pattern: un esempio concreto
- Vantaggi dello Strategy Pattern
- Confronto con approcci alternativi: istruzioni condizionali
- Confronto con approcci alternativi: ereditarietá
- Conclusione
Introduzione
Uno degli argomenti piú interessanti quando parlo di programmazione é sicuramente quello riguardante i Design Pattern. I Design Pattern sono soluzioni standard a problemi ricorrenti nella programmazione. Non é un argomento nuovo, infatti uno dei libri piú famosi e importanti a riguardo risale al 1994 e si intitola “Design Patterns: Elements of Reusable Object-Oriented Software” scritto dalla Gangs of Four (Gamma, Helm, Johnson, Vlissides). Il libro racchiude 23 Design Pattern, con esempi concreti come Lexi, un editor di testo, e spiega come utilizzare ed implementare i diversi pattern in diverse situazioni reali.
Dopo l’articolo su Churn PHP, voglio estendere il discorso sullo Strategy Pattern che ho utilizzato molto spesso e che ho sempre trovato utilissimo per due motivi: migliorare la qualitá del codice, e facilitare l’estendibilità del sistema. Questo pattern infatti permette di aggiungere nuovi comportamenti (o algoritmi) senza modificare il codice esistente, rispettando il secondo principio S.O.L.I.D., l’Open/Closed, ovvero quel principio che afferma che un software deve essere aperto per estensioni, e chiuso per modifiche.
Cos’é lo Strategy Pattern?
Lo Strategy Pattern é un Behavioral Design Pattern (o modello di progettazione comportamentale in italiano), che ci permette di organizzare una famiglia di algoritmi in differenti classi e ci permette di rendere gli oggetti di queste classi intercambiabili.
Questo approccio promuove il principio di composizione rispetto all’ereditarietà, consentendo di cambiare il comportamento di un oggetto a runtime senza dover modificare il suo codice. Lo Strategy Pattern è particolarmente utile quando si desidera evitare complessi costrutti condizionali (come if-else o switch-case) e garantire una maggiore manutenibilità e flessibilità del codice.
Vediamo come con un esempio pratico, scritto in PHP.
Strategy Pattern: un esempio concreto
Immagina di avere diversi veicoli come macchina, moto, bus, camion, treno, nave e aereo. Ogni veicolo ha un motore e un modo di spostamento diverso:
- Auto diesel o a benzina, moto, bus, camion: hanno motori a combustione (diesel o benzina) e si spostano sulla terra.
- Auto elettrica: ha un motore elettrico e si sposta sulla terra.
- Treno: ha un motore elettrico o a combustione e si sposta su binari (terra).
- Nave: ha un motore marino e si sposta sull’acqua.
- Aereo: ha un motore a reazione o turbofan e si sposta nell’aria.
Usando lo Pattern Strategy, separiamo il comportamento del motore e del modo di spostamento in classi separate. Ogni veicolo avrà delle strategie per il motore e per il modo di spostamento, che possono essere intercambiabili a runtime.
Per prima cosa dobbiamo definire un’interfaccia che ci permetterá di raggruppare le classi secondo un contratto comune. Partiamo scrivendo l’interfaccia del motore.
interface Engine
{
public function getEngineType(): string;
}
A questo punto possiamo creare tutti i tipi di motore necessari alla nostra applicazione:
class CombustionEngine implements Engine
{
public function getEngineType(): string
{
return "Internal combustion engine";
}
}
class ElectricEngine implements Engine
{
public function getEngineType(): string
{
return "Electric engine";
}
}
class DieselEngine implements Engine
{
public function getEngineType(): string
{
return "Diesel engine";
}
}
class FuelEngine implements Engine
{
public function getEngineType(): string
{
return "Fuel engine";
}
}
class ReactionEngine implements Engine
{
public function getEngineType(): string
{
return "Reaction engine";
}
}
Facciamo la stessa cosa con i differenti modi di spostamento. Per prima cosa scriviamo un’interfaccia comune:
interface Movement
{
public function move(): string;
}
E quindi scriviamo le classi specifiche:
class LandMovement implements Movement
{
public function move(): string
{
return "Moving on land";
}
}
class WaterMovement implements Movement
{
public function move(): string
{
return "Moving on water";
}
}
class AirMovement implements Movement
{
public function move(): string
{
return "Flying in the air";
}
}
Ora ci serve un contesto, ovvero una classe che deve avere i campi per memorizzare i riferimento delle strategie. Il contesto delega il lavoro a un oggetto strategia collegato, invece di eseguirlo da solo. Vediamo come:
class Vehicle
{
public function __construct(
private Engine $engine,
private Movement $movement,
) {}
public function move()
{
return $this->movement->move();
}
public function getEngineType()
{
return $this->engine->getEngineType();
}
}
Nota: come vedete, abbiamo sfruttato la funzionalitá di PHP 8 chiamata constructor property promotion, in questo modo non dobbiamo scrivere esplicitamente le proprietá engine e movement della class Vehicle.
Abbiamo tutto quello che ci serve per poter lavorare con i nostri veicoli. Ecco un esempio:
$car = new Vehicle(new DieselEngine(), new LandMovement());
echo $car->move() . " with " . $car->getEngineType() . PHP_EOL;
$car = new Vehicle(new ElectricEngine(), new LandMovement());
echo $car->move() . " with " . $car->getEngineType() . PHP_EOL;
$ship = new Vehicle(new DieselEngine(), new WaterMovement());
echo $ship->move() . " with " . $ship->getEngineType() . PHP_EOL;
$train = new Vehicle(new CombustionEngine(), new LandMovement());
echo $train->move() . " with " . $train->getEngineType() . PHP_EOL;
$airplane = new Vehicle(new ReactionEngine(), new AirMovement());
echo $airplane->move() . " with " . $airplane->getEngineType() . PHP_EOL;
Ecco il nostro output:
Moving on land with Diesel engine
Moving on land with Electric engine
Moving on water with Diesel engine
Moving on land with Internal combustion engine
Flying in the air with Reaction engine
Credo sia importante sottolineare come l’interfaccia, o contratto, sia il punto fondamentale qui. Sfruttando un’interfaccia comune, é possibile sostituire gli oggetti durante l’esecuzione, ed é proprio questo meccanismo che rende lo Strategy Pattern cosí potente.
Vantaggi dello Strategy Pattern
- Flessibilità: é possibile combinare qualsiasi tipo di motore con qualsiasi tipo di movimento.
- Modularità: ogni comportamento (motore e movimento) è separato in classi dedicate, quindi è più facile modificare o aggiungere nuove funzionalità.
- Estensibilità: é possibile aggiungere nuovi tipi di motori e movimenti senza modificare le classi esistenti, rendendo il codice più facile da mantenere e rispettando cosí il principio Open/Closed (vedi S.O.L.I.D.).
Ogni veicolo è costruito “a pezzi” combinando un motore e un movimento. Questo rende più facile estendere il sistema senza cambiare le classi esistenti, ed evita una complessa gerarchia di classi.
Confronto con approcci alternativi: istruzioni condizionali
Lo Strategy Pattern è preferibile rispetto a metodi più classici come grandi istruzioni condizionali: manutenibilità, flessibilità, leggibilità e estensibilità del codice sono aspetti che lo Strategy Pattern aiuta a migliorare, permettendo di separare le diverse logiche di comportamento in classi distinte e intercambiabili, riducendo la complessità del codice e facilitando l’aggiunta di nuove funzionalità senza dover modificare il codice esistente.
Nel caso di grandi istruzioni condizionali, lo Strategy Pattern aiuta ad evitare la crescita esponenziale della complessità e riducendo il rischio di errori in fase di manutenzione. Immagina di avere un sistema in cui la logica per determinare come si spostano i veicoli e quale motore utilizzano è gestita tramite grandi istruzioni condizionali, come if o switch. Ecco un esempio di come potrebbe essere strutturato:
class Vehicle
{
public function __construct(
private string $type,
) {}
public function move()
{
if ($this->type == "car") {
return "Moving on land with Internal combustion engine";
} elseif ($this->type == "truck") {
return "Moving on land with Diesel engine";
} elseif ($this->type == "airplane") {
return "Flying in the air with Jet engine";
} elseif ($this->type == "ship") {
return "Moving on water with Diesel engine";
} else {
return "Unknown vehicle";
}
}
}
$car = new Vehicle("car");
echo $car->move() . PHP_EOL;
$ship = new Vehicle("ship");
echo $ship->move() . PHP_EOL;
E questo é l’output:
Moving on land with Internal combustion engine
Moving on water with Diesel engine
Vediamo ora i problemi principali di questo tipo di approccio:
- Crescita esponenziale della complessità: man mano che aggiungi nuovi veicoli o nuovi comportamenti, la logica condizionale cresce rapidamente. Ogni volta che un nuovo tipo di veicolo o di motore viene introdotto, devi aggiornare la logica condizionale, aumentando la complessità (puó tornare utile ricordarsi del churn).
- Difficoltà di manutenzione: se, ad esempio, cambiano le specifiche di come un veicolo deve muoversi (ad esempio, cambiano i dettagli del motore di un aereo o di un camion), devi modificare tutte le condizioni interessate. Questo rende il codice difficile da mantenere nel lungo termine.
- Scalabilità limitata: ogni nuova funzionalità che richiede una logica complessa basata su condizioni (come l’aggiunta di un nuovo tipo di movimento o di motore) richiede modifiche alle strutture condizionali esistenti, che non sono facili da estendere senza compromettere la leggibilità e la robustezza del codice.
Confronto con approcci alternativi: ereditarietá
Ora mettiamo in evidenza la differenza tra ereditarietà e composizione.
Ricordiamo il nostro dominio: ogni mezzo di trasporto ha un motore che può variare a seconda del tipo di veicolo (ad esempio, un motore a combustione per una macchina, un motore elettrico per un treno, e così via). Inoltre, ogni mezzo si sposta in un ambiente diverso: terra, acqua, o aria.
Con l’ereditarietà, la gerarchia delle classi dipende dalle caratteristiche comuni dei mezzi di trasporto, ma se volessimo aggiungere nuove modalità o comportamenti, sarebbe necessario estendere le classi già esistenti.
Ecco lo stesso esempio usando l’ereditarietá:
class Vehicle
{
public function move()
{
return "Moving...";
}
public function getEngineType()
{
return "Generic engine";
}
}
class Car extends Vehicle
{
public function move()
{
return "Moving on land";
}
public function getEngineType()
{
return "Internal combustion engine";
}
}
class Motorcycle extends Vehicle
{
public function move()
{
return "Moving on land";
}
public function getEngineType()
{
return "Internal combustion engine";
}
}
class Bus extends Vehicle
{
public function move()
{
return "Moving on land";
}
public function getEngineType()
{
return "Diesel engine";
}
}
class Truck extends Vehicle
{
public function move()
{
return "Moving on land";
}
public function getEngineType()
{
return "Diesel engine";
}
}
class Train extends Vehicle
{
public function move()
{
return "Moving on rails";
}
public function getEngineType()
{
return "Electric engine";
}
}
class Ship extends Vehicle
{
public function move()
{
return "Moving on water";
}
public function getEngineType()
{
return "Diesel engine";
}
}
class Airplane extends Vehicle
{
public function move()
{
return "Flying in the air";
}
public function getEngineType()
{
return "Jet engine";
}
}
// Application
$car = new Car();
echo $car->move() . " with " . $car->getEngineType() . PHP_EOL;
$ship = new Ship();
echo $ship->move() . " with " . $ship->getEngineType() . PHP_EOL;
Ed ecco il nostro output:
Moving on land with Internal combustion engine
Moving on water with Diesel engine
Con l’ereditarietá definiamo un comportamento comune tramite l’estensione di una classe base (superclasse).
Vediamo ora i problemi principali di questo tipo di approccio:
- Il comportamento è fisso una volta definita la gerarchia.
- Cambiare o aggiungere comportamenti richiede modifiche alla gerarchia o la creazione di nuove sottoclassi.
- Può portare a una gerarchia complessa e difficile da gestire nel tempo.
- Duplicazione del codice: abbiamo definito almeno 3 volte il “Diesel engine”, una volta per ogni veicolo a motore diesel.
- Ogni classe ha piú di una responsabilitá, violando cosí il primo principio S.O.L.I.D. riguardo la responsabilitá singola di classi e metodi.
Conclusione
Lo Strategy Pattern rappresenta una soluzione elegante e flessibile per gestire comportamenti intercambiabili nel nostro codice. Ci insegna un principio fondamentale della programmazione orientata agli oggetti: separare i comportamenti dalla logica principale per promuovere flessibilità e riusabilità. Questo pattern consente di creare un codice che non solo soddisfa i requisiti attuali, ma è anche progettato per adattarsi facilmente a nuove necessità senza compromettere la stabilità del sistema.
Nel nostro esempio dei veicoli, il passaggio da approcci tradizionali basati su istruzioni condizionali o gerarchie complesse all’uso dello Strategy Pattern ha evidenziato come la composizione possa rendere il codice più chiaro, manutenibile e facilmente estendibile. Questo approccio evita la proliferazione di classi specifiche o di condizioni nidificate, rendendo più semplice gestire l’aggiunta di nuovi comportamenti o caratteristiche.
Come dimostrato, separare i comportamenti in classi dedicate ci permette di costruire sistemi che rispettano i principi Single responsability e Open/Closed di S.O.L.I.D., semplificando l’aggiunta di nuove funzionalità senza rischiare di introdurre regressioni o errori.
In definitiva, lo Strategy Pattern non è solo una tecnica fondamentale di programmazione, ma una filosofia di progettazione che pone l’enfasi sull’organizzazione modulare e sulla capacità di evolversi. Adottarlo nei nostri progetti significa investire in un codice di qualità, pronto a rispondere alle sfide di domani.
No comments yet. Be the first one!