Command Design Pattern Tutorial

Introduction

The Command Design Pattern is a behavioral design pattern that turns a request into a stand-alone object containing all information about the request. This transformation allows you to:

  • Parameterize objects with operations
  • Queue or log requests
  • Support undoable operations
  • Compose simple commands into complex ones

At its core, the Command pattern encapsulates a request as an object, thereby allowing you to parameterize clients with different requests, queue or log requests, and support undoable operations.

Real-World Analogy: Restaurant Order System

Imagine you’re at a restaurant:

  1. Customer (Client): You decide what food you want to order
  2. Waiter (Invoker): Takes your order and passes it to the kitchen
  3. Order Slip (Command): Contains all details about what to prepare
  4. Chef (Receiver): Receives the order slip and prepares the food

The key insight here is that the waiter doesn’t need to know how to cook the food. They just take the order (command) and pass it to the chef. The order slip contains all the necessary information for the chef to prepare the meal.

Benefits of this approach: - The waiter doesn’t need to know how to cook - The chef doesn’t need to interact with customers - Orders can be queued during busy times - Orders can be logged for inventory management - Special instructions can be added to orders

Command Pattern Structure

The Command pattern typically consists of:

  1. Command: An interface with an execute method
  2. Concrete Command: Implements the Command interface and defines the binding between a Receiver object and an action
  3. Client: Creates the ConcreteCommand and sets its receiver
  4. Invoker: Asks the command to carry out the request
  5. Receiver: Knows how to perform the operations

Python Implementation

Let’s implement a simple example of the Command pattern in Python:

from abc import ABC, abstractmethod

# Command interface
class Command(ABC):
    @abstractmethod
    def execute(self):
        pass
    
    @abstractmethod
    def undo(self):
        pass

# Receiver
class Light:
    def __init__(self, location):
        self.location = location
        self.is_on = False
        
    def turn_on(self):
        self.is_on = True
        print(f"{self.location} light is now ON")
        
    def turn_off(self):
        self.is_on = False
        print(f"{self.location} light is now OFF")

# Concrete Commands
class LightOnCommand(Command):
    def __init__(self, light):
        self.light = light
        
    def execute(self):
        self.light.turn_on()
        
    def undo(self):
        self.light.turn_off()

class LightOffCommand(Command):
    def __init__(self, light):
        self.light = light
        
    def execute(self):
        self.light.turn_off()
        
    def undo(self):
        self.light.turn_on()

# Invoker
class RemoteControl:
    def __init__(self):
        self.command = None
        self.history = []
        
    def set_command(self, command):
        self.command = command
        
    def press_button(self):
        if self.command:
            self.command.execute()
            self.history.append(self.command)
            
    def press_undo_button(self):
        if self.history:
            last_command = self.history.pop()
            last_command.undo()

# Client code
if __name__ == "__main__":
    # Create receivers
    living_room_light = Light("Living Room")
    kitchen_light = Light("Kitchen")
    
    # Create commands
    living_room_light_on = LightOnCommand(living_room_light)
    living_room_light_off = LightOffCommand(living_room_light)
    kitchen_light_on = LightOnCommand(kitchen_light)
    kitchen_light_off = LightOffCommand(kitchen_light)
    
    # Create invoker
    remote = RemoteControl()
    
    # Execute commands
    remote.set_command(living_room_light_on)
    remote.press_button()  # Living Room light is now ON
    
    remote.set_command(kitchen_light_on)
    remote.press_button()  # Kitchen light is now ON
    
    remote.set_command(living_room_light_off)
    remote.press_button()  # Living Room light is now OFF
    
    # Undo last command
    remote.press_undo_button()  # Living Room light is now ON

More Complex Example: Smart Home Automation

Let’s expand our example to a more complex smart home automation system:

from abc import ABC, abstractmethod
import time

# Command interface
class Command(ABC):
    @abstractmethod
    def execute(self):
        pass
    
    @abstractmethod
    def undo(self):
        pass

# Receivers
class Light:
    def __init__(self, location):
        self.location = location
        self.is_on = False
        self.brightness = 0
        
    def turn_on(self):
        self.is_on = True
        self.brightness = 100
        print(f"{self.location} light is now ON at {self.brightness}% brightness")
        
    def turn_off(self):
        self.is_on = False
        self.brightness = 0
        print(f"{self.location} light is now OFF")
        
    def dim(self, level):
        if 0 <= level <= 100:
            self.brightness = level
            if level > 0:
                self.is_on = True
            else:
                self.is_on = False
            print(f"{self.location} light is now at {self.brightness}% brightness")

class Thermostat:
    def __init__(self, location):
        self.location = location
        self.temperature = 72  # Default temperature in Fahrenheit
        
    def set_temperature(self, temperature):
        self.temperature = temperature
        print(f"{self.location} thermostat is set to {self.temperature}°F")

class MusicPlayer:
    def __init__(self):
        self.is_playing = False
        self.volume = 50
        self.track = None
        
    def play(self, track):
        self.is_playing = True
        self.track = track
        print(f"Playing '{self.track}' at volume {self.volume}%")
        
    def stop(self):
        if self.is_playing:
            self.is_playing = False
            print(f"Stopped playing '{self.track}'")
        
    def set_volume(self, volume):
        if 0 <= volume <= 100:
            self.volume = volume
            print(f"Volume set to {self.volume}%")

# Concrete Commands
class LightOnCommand(Command):
    def __init__(self, light):
        self.light = light
        self.prev_brightness = 0
        
    def execute(self):
        self.prev_brightness = self.light.brightness
        self.light.turn_on()
        
    def undo(self):
        if self.prev_brightness == 0:
            self.light.turn_off()
        else:
            self.light.dim(self.prev_brightness)

class LightOffCommand(Command):
    def __init__(self, light):
        self.light = light
        self.prev_brightness = 0
        
    def execute(self):
        self.prev_brightness = self.light.brightness
        self.light.turn_off()
        
    def undo(self):
        if self.prev_brightness > 0:
            self.light.dim(self.prev_brightness)

class DimLightCommand(Command):
    def __init__(self, light, level):
        self.light = light
        self.level = level
        self.prev_level = 0
        
    def execute(self):
        self.prev_level = self.light.brightness
        self.light.dim(self.level)
        
    def undo(self):
        self.light.dim(self.prev_level)

class SetThermostatCommand(Command):
    def __init__(self, thermostat, temperature):
        self.thermostat = thermostat
        self.temperature = temperature
        self.prev_temperature = 0
        
    def execute(self):
        self.prev_temperature = self.thermostat.temperature
        self.thermostat.set_temperature(self.temperature)
        
    def undo(self):
        self.thermostat.set_temperature(self.prev_temperature)

class PlayMusicCommand(Command):
    def __init__(self, music_player, track):
        self.music_player = music_player
        self.track = track
        self.was_playing = False
        self.prev_track = None
        
    def execute(self):
        self.was_playing = self.music_player.is_playing
        self.prev_track = self.music_player.track
        self.music_player.play(self.track)
        
    def undo(self):
        if self.was_playing and self.prev_track:
            self.music_player.play(self.prev_track)
        else:
            self.music_player.stop()

class StopMusicCommand(Command):
    def __init__(self, music_player):
        self.music_player = music_player
        self.was_playing = False
        self.prev_track = None
        
    def execute(self):
        self.was_playing = self.music_player.is_playing
        self.prev_track = self.music_player.track
        self.music_player.stop()
        
    def undo(self):
        if self.was_playing and self.prev_track:
            self.music_player.play(self.prev_track)

# Macro Command (Composite Command)
class MacroCommand(Command):
    def __init__(self, commands):
        self.commands = commands
        
    def execute(self):
        for command in self.commands:
            command.execute()
            
    def undo(self):
        # Undo in reverse order
        for command in reversed(self.commands):
            command.undo()

# Invoker
class SmartHomeController:
    def __init__(self):
        self.command = None
        self.history = []
        self.scheduled_commands = {}
        
    def set_command(self, command):
        self.command = command
        
    def press_button(self):
        if self.command:
            self.command.execute()
            self.history.append(self.command)
            
    def press_undo_button(self):
        if self.history:
            last_command = self.history.pop()
            last_command.undo()
            
    def schedule_command(self, command, delay_seconds):
        execution_time = time.time() + delay_seconds
        self.scheduled_commands[execution_time] = command
        print(f"Command scheduled to execute in {delay_seconds} seconds")
        
    def check_scheduled_commands(self):
        current_time = time.time()
        commands_to_execute = []
        times_to_remove = []
        
        for execution_time, command in self.scheduled_commands.items():
            if current_time >= execution_time:
                commands_to_execute.append(command)
                times_to_remove.append(execution_time)
                
        for command in commands_to_execute:
            command.execute()
            self.history.append(command)
            
        for time_to_remove in times_to_remove:
            del self.scheduled_commands[time_to_remove]

# Client code
if __name__ == "__main__":
    # Create receivers
    living_room_light = Light("Living Room")
    kitchen_light = Light("Kitchen")
    bedroom_light = Light("Bedroom")
    living_room_thermostat = Thermostat("Living Room")
    music_player = MusicPlayer()
    
    # Create commands
    living_room_light_on = LightOnCommand(living_room_light)
    living_room_light_off = LightOffCommand(living_room_light)
    kitchen_light_on = LightOnCommand(kitchen_light)
    kitchen_light_off = LightOffCommand(kitchen_light)
    bedroom_light_on = LightOnCommand(bedroom_light)
    bedroom_light_off = LightOffCommand(bedroom_light)
    dim_living_room = DimLightCommand(living_room_light, 30)
    
    set_thermostat = SetThermostatCommand(living_room_thermostat, 70)
    play_jazz = PlayMusicCommand(music_player, "Jazz Playlist")
    stop_music = StopMusicCommand(music_player)
    
    # Create macro commands
    evening_mode = MacroCommand([
        dim_living_room,
        set_thermostat,
        play_jazz
    ])
    
    all_lights_off = MacroCommand([
        living_room_light_off,
        kitchen_light_off,
        bedroom_light_off
    ])
    
    # Create invoker
    smart_home = SmartHomeController()
    
    # Execute commands
    print("--- Setting Evening Mode ---")
    smart_home.set_command(evening_mode)
    smart_home.press_button()
    
    print("\n--- Turning Kitchen Light On ---")
    smart_home.set_command(kitchen_light_on)
    smart_home.press_button()
    
    print("\n--- Scheduling All Lights Off in 5 seconds ---")
    smart_home.schedule_command(all_lights_off, 5)
    
    # Simulate time passing
    print("\nWaiting for scheduled command...")
    time.sleep(5)
    smart_home.check_scheduled_commands()
    
    print("\n--- Undoing Last Command ---")
    smart_home.press_undo_button()

How Command Pattern Handles OOP Principles

The Command pattern exemplifies several key OOP principles:

1. Encapsulation

  • Commands encapsulate a request as an object
  • All details about the operation are contained within the command object
  • The invoker doesn’t need to know how the request is handled

2. Polymorphism

  • Different commands implement the same interface
  • The invoker can work with any command without knowing its concrete class
  • New commands can be added without changing existing code

3. Single Responsibility Principle

  • Each class has a single responsibility:
    • Command: Knows how to perform an action
    • Receiver: Knows how to execute the operations
    • Invoker: Knows when to execute the command
    • Client: Knows which commands to create

4. Open/Closed Principle

  • The system is open for extension (new commands can be added)
  • But closed for modification (existing code doesn’t need to change)

5. Dependency Inversion

  • High-level modules (invoker) depend on abstractions (command interface)
  • Low-level modules (concrete commands) also depend on abstractions
  • This reduces coupling between components

Pain Points Without the Command Pattern

Without the Command pattern, you might encounter these issues:

1. Tight Coupling

Without Command pattern:

class RemoteControl:
    def __init__(self, light, thermostat, music_player):
        self.light = light
        self.thermostat = thermostat
        self.music_player = music_player
        
    def turn_on_light(self):
        self.light.turn_on()
        
    def set_temperature(self, temp):
        self.thermostat.set_temperature(temp)
        
    def play_music(self, track):
        self.music_player.play(track)

Problems: - The RemoteControl is tightly coupled to specific devices - Adding a new device requires modifying the RemoteControl class - The RemoteControl needs to know how to operate each device

2. Lack of Extensibility

  • Adding new operations requires modifying existing code
  • Can’t easily add features like undo/redo
  • Can’t compose operations into macros

3. Difficult to Implement Advanced Features

Without Command pattern, these features become complex: - Queueing operations - Scheduling operations for later execution - Logging operations for audit purposes - Supporting transactional behavior - Implementing undo/redo functionality

4. Code Duplication

  • Similar operation logic may be duplicated across different controllers
  • Changes to operation logic require updates in multiple places

5. Testing Challenges

  • Hard to test operations in isolation
  • Can’t mock or substitute operations easily
  • Difficult to verify operation sequences

When to Use the Command Pattern

The Command pattern is particularly useful when:

  1. You need to parameterize objects with operations
  2. You want to queue, specify, or execute requests at different times
  3. You need to support undo/redo operations
  4. You want to structure a system around high-level operations
  5. You need to implement callback functionality
  6. You want to create composite commands (macros)

Conclusion

The Command Design Pattern is a powerful tool in your OOP toolkit. It helps you decouple objects that invoke operations from objects that perform these operations. This separation provides flexibility, extensibility, and maintainability to your code.

By encapsulating requests as objects, you can: - Pass commands as method arguments - Store commands in data structures - Schedule command execution - Support undoable operations - Compose simple commands into complex ones

Understanding and applying the Command pattern will help you write more maintainable, flexible, and robust code.