Understanding the Strategy Pattern: Elegant Solutions for Flexible Code in PHP
By Giuseppe Maccario on Fri Jan 31 in PHP, Programming, Refactoring
Summary
- Introduction
- What is the Strategy Pattern?
- Strategy Pattern: A Practical Example
- Advantages of the Strategy Pattern
- Comparison with Alternative Approaches: Conditional Statements
- Comparison with Alternative Approaches: Inheritance
- Conclusion
Introduction
One of the most interesting topics when discussing programming is undoubtedly Design Patterns. Design Patterns are standard solutions to recurring problems in programming. This is not a new topic: One of the most famous and important books on the subject dates back to 1994 and is titled “Design Patterns: Elements of Reusable Object-Oriented Software”, written by the Gang of Four (Gamma, Helm, Johnson, Vlissides). The book includes 23 Design Patterns, featuring concrete examples like Lexi, a text editor, and explains how to use and implement different patterns in various real-world scenarios.
After the article on Churn PHP, I want to expand on the Strategy Pattern, which I have used frequently and always found extremely useful for two reasons: Improving code quality and making the system more extensible.
This pattern allows adding new behaviors (or algorithms) without modifying existing code, adhering to the second S.O.L.I.D. principle, the Open/Closed Principle. This principle states that software should be open for extension but closed for modification.
What is the Strategy Pattern?
The Strategy Pattern is a Behavioral Design Pattern that allows us to organize a family of algorithms into different classes and make objects of these classes interchangeable.
This approach favors composition over inheritance, enabling us to change an object’s behavior at runtime without modifying its code. The Strategy Pattern is particularly useful when we want to avoid complex conditional constructs (such as if-else or switch-case) and ensure greater maintainability and flexibility in our code.
Let’s see how it works with a practical example in PHP.
Strategy Pattern: A Practical Example
Imagine you have different types of vehicles, such as cars, motorcycles, buses, trucks, trains, ships, and airplanes. Each vehicle has a specific type of engine and a different mode of movement:
- Diesel or gasoline cars, motorcycles, buses, and trucks: have combustion engines (diesel or gasoline) and move on land.
- Electric cars: have electric engines and move on land.
- Trains: have either electric or combustion engines and move on tracks (land).
- Ships: have marine engines and move on water.
- Airplanes: have jet or turbofan engines and move in the air.
Using the Strategy Pattern, we separate the engine behavior and movement mode into distinct classes. The system assigns each vehicle interchangeable strategies for both the engine and movement, allowing it to swap them at runtime.
First, we need to define an interface that allows us to group the classes under a common contract. Let’s start by writing the engine interface.
interface Engine
{
public function getEngineType(): string;
}
At this point, we can create all the engine types needed for our application.
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";
}
}
Let’s do the same for the different modes of movement. First, we define a common interface.
interface Movement
{
public function move(): string;
}
And then we write the specific classes:
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";
}
}
Now we need a context, which is a class that holds references to the strategy objects. The context delegates the work to a linked strategy object instead of executing it itself. Let’s see how it works:
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();
}
}
Note: As you can see, we have leveraged the PHP 8 feature called constructor property promotion. This allows us to avoid explicitly declaring the engine and movement properties in the Vehicle class.
Now we have everything we need to work with our vehicles. Here’s an example:
$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;
Here’s the 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
The key point here is the interface, or contract. By taking advantage of a common interface, it is possible to replace objects during execution, and it is this mechanism that makes the Strategy Pattern so powerful.
Advantages of the Strategy Pattern
- Flexibility: Any type of engine can be combined with any type of movement.
- Modularity: Each behavior (engine and movement) is separated into dedicated classes, making it easier to modify or add new functionality.
- Extensibility: New types of engines and movements can be added without modifying existing classes, making the code easier to maintain and adhering to the Open/Closed principle (see S.O.L.I.D.).
Each vehicle is built “piece by piece” by combining an engine and a movement. This makes it easier to extend the system without changing existing classes and avoids a complex class hierarchy.
Comparison with Alternative Approaches: Conditional Statements
The Strategy Pattern is preferable to more traditional approaches, such as large conditional statements. It improves maintainability, flexibility, readability, and extensibility by separating different behavioral logics into distinct, interchangeable classes. This reduces code complexity and makes it easier to add new functionality without modifying existing code.
In the case of large conditional statements, the Strategy Pattern helps prevent exponential complexity growth and reduces the risk of errors during maintenance. Imagine a system where the logic for determining how vehicles move and which engine they use is managed through large conditional statements, such as if
or switch
. Here’s an example of how it might be structured:
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;
Here’s the output:
Moving on land with Internal combustion engine
Moving on water with Diesel engine
Now let’s look at the main problems with this approach:
- Exponential complexity growth: As you add new vehicles or behaviors, the conditional logic grows rapidly. Every time a new type of vehicle or engine is introduced, you must update the conditional logic, increasing complexity (it might be useful to recall churn).
- Maintenance difficulties: If, for example, the specifications for how a vehicle should move change (e.g., modifications to an airplane or truck engine), you must update all the affected conditions. This makes the code difficult to maintain in the long run.
- Limited scalability: Any new feature requiring complex condition-based logic (such as adding a new type of movement or engine) requires modifying existing conditional structures, which are not easy to extend without compromising code readability and robustness.
Comparison with Alternative Approaches: Inheritance
Now let’s highlight the difference between inheritance and composition.
Revisiting Our Domain
Each vehicle has an engine that varies depending on the type (e.g., a combustion engine for a car, an electric engine for a train, etc.). Additionally, each vehicle moves in a different environment: land, water, or air.
The Challenge with Inheritance
With inheritance, the class hierarchy is based on shared characteristics among vehicles. However, if we wanted to introduce new modes or behaviors, we would need to extend existing classes. This can lead to rigid structures and unnecessary complexity.
Here’s the same example implemented using inheritance:
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;
Here’s the output:
Moving on land with Internal combustion engine
Moving on water with Diesel engine
The Issues with Inheritance
With inheritance, we define common behavior by extending a base class (superclass). However, this approach comes with several drawbacks:
- Fixed behavior: Once the hierarchy is defined, behavior cannot be easily changed.
- Difficult to modify or extend: Adding new behaviors requires modifying the hierarchy or creating new subclasses.
- Complex hierarchies: Over time, class hierarchies can become difficult to manage.
- Code duplication: The “Diesel engine” behavior has been defined at least three times, once for each diesel-powered vehicle.
- Violates the Single Responsibility Principle: Each class has multiple responsibilities, breaking the first S.O.L.I.D. principle, which states that a class or method should have only one responsibility.
Conclusion
The Strategy Pattern is an elegant and flexible solution for managing interchangeable behaviors in our code. It teaches us a fundamental principle of object-oriented programming: separating behaviors from the core logic to promote flexibility and reusability. This pattern enables developers to create code that meets current requirements while designing it to adapt easily to new needs without compromising system stability.
In our vehicle example, transitioning from traditional approaches based on conditional statements or complex hierarchies to the Strategy Pattern demonstrated how composition can make code clearer, more maintainable, and easily extendable. This approach prevents the proliferation of specific classes or deeply nested conditions, simplifying the management of new behaviors or features.
As shown, separating behaviors into dedicated classes allows us to build systems that adhere to the Single Responsibility and Open/Closed principles of S.O.L.I.D., making it easier to add new functionalities without introducing regressions or errors.
Ultimately, the Strategy Pattern is not just a fundamental programming technique but a design philosophy that emphasizes modular organization and adaptability. Adopting it in our projects means investing in high-quality code, ready to tackle future challenges.
No comments yet. Be the first one!