Using Python types for fun and profit: the Builder

Introduction

The builder pattern is a creational design pattern, i.e. it is a pattern for creating or instantiang objects of classes. It is used for breaking down the construction process, into smaller, more manageable and testable steps.

It looks like this:

Let’s break this down into its parts:

  1. The Director. This is the client class for the Builder, and it wants some product to be built.
  2. The Builder interface. This is the generic interface for any Builder, and it contains the methods to build a Product.
  3. The ConcreteBuilder. This is the class where we built the Product. Because we only use an interface, ConcreteBuilders are swapped in and out to build different products.
  4. The product we want to build is in the Product class. This could also define an interface, or be referenced through one of its superclasses.

This is all rather abstract, so we will build an example.

Implementation in Python

Importing the prerequisites

Before we start we need to import the following:

from abc import ABC, abstractmethod
from typing import List, Optional

Line by line:

  1. Because the base class for our builder will contain some abstract methods, it will derive from ABC, the Abstract Base Class. Also some methods in this base class will have sub class specific implementation, so the @abstractmethod decorator is needed.
  2. the imports from typing are needed to enable type annotations. Optional is basically the equivalent of Type | None union annotation.

The Bicycle class

We start by defining the basic bicycle. In our example the main properties of a bike are its type and its number of wheels:

class Bicycle:
def __init__(self):
self._bike_type: str = ""
self._number_of_wheels: int = 0
self._frame_material: str = ""
self._brake_type: str = ""
self._gear_count: int = 0
self._suspension: str = ""
self._accessories: List[str] = []
self._tire_type: str = ""
self._weight: float = 0.0

@property
def bike_type(self) -> str:
return self._bike_type

@bike_type.setter
def bike_type(self, value: str):
self._bike_type = value

@property
def number_of_wheels(self) -> int:
return self._number_of_wheels

@number_of_wheels.setter
def number_of_wheels(self, value: int):
self._number_of_wheels = value

@property
def frame_material(self) -> str:
return self._frame_material

@frame_material.setter
def frame_material(self, value: str):
self._frame_material = value

@property
def brake_type(self) -> str:
return self._brake_type

@brake_type.setter
def brake_type(self, value: str):
self._brake_type = value

@property
def gear_count(self) -> int:
return self._gear_count

@gear_count.setter
def gear_count(self, value: int):
self._gear_count = value

@property
def suspension(self) -> str:
return self._suspension

@suspension.setter
def suspension(self, value: str):
self._suspension = value

@property
def accessories(self) -> List[str]:
return self._accessories

@accessories.setter
def accessories(self, value: List[str]):
self._accessories = value

@property
def tire_type(self) -> str:
return self._tire_type

@tire_type.setter
def tire_type(self, value: str):
self._tire_type = value

@property
def weight(self) -> float:
return self._weight

@weight.setter
def weight(self, value: float):
self._weight = value

def add_accessory(self, accessory: str):
self._accessories.append(accessory)

def __str__(self):
accessories_str = ", ".join(self._accessories) if self._accessories else "None"
return (f"Bicycle Type: {self._bike_type}\n"
f"Wheels: {self._number_of_wheels}\n"
f"Frame: {self._frame_material}\n"
f"Brakes: {self._brake_type}\n"
f"Gears: {self._gear_count}\n"
f"Suspension: {self._suspension}\n"
f"Tires: {self._tire_type}\n"
f"Weight: {self._weight} kg\n"
f"Accessories: {accessories_str}")

Some points:

  1. A bike in our example has nine properties.
  2. In the constructor, we set these properties to default values.
  3. Next we have a series of getter and setter methods for all the properties.
  4. The add_accessory() method adds to the accessories list.
  5. The __str__() method returns the string representation of the bicycle.

The BicycleBuilder base class

Now we come to the BicycleBuilder. This will be the base class for the builder classes, and since many of its method will be implemented by the

class BicycleBuilder(ABC):
    def __init__(self):
        self.reset()

    def reset(self):
        self._bike = Bicycle()

    @abstractmethod
    def set_bike_type(self) -> BicycleBuilder:
        pass

    @abstractmethod
    def set_frame_material(self) -> BicycleBuilder:
        pass

    @abstractmethod
    def set_wheels(self) -> BicycleBuilder:
        pass

    @abstractmethod
    def set_brakes(self) -> BicycleBuilder:
        pass

    @abstractmethod
    def set_gears(self) -> BicycleBuilder:
        pass

    @abstractmethod
    def set_suspension(self) -> BicycleBuilder:
        pass

    @abstractmethod
    def set_tires(self) -> BicycleBuilder:
        pass

    @abstractmethod
    def set_weight(self) -> BicycleBuilder:
        pass

    def add_accessory(self, accessory: str) -> BicycleBuilder:
        self._bike.add_accessory(accessory)
        return self

    def build(self) -> Bicycle:
        result = self._bike
        self.reset()
        return result

Some notes:

  • In the BicycleBuilder class we introduce a reset method, which resets the bicycle to be build to the default bike.
  • We have a bunch of methods like set_bike_type() which are all decorated with the @abstractmethod decorator. They will be used in the subclasses of BicycleBuilder to set properties of the newly built bike. Note that they all return a BicycleBuilder. This means that these methods can be chained.
  • A bike can have accessories, however, they are not required. That is why the add_accessory is not an abstract method because its implementation by subclasses is not required.
  • Finally we have the build() method itself, which returns the bike the builder has built, and assigns self._bike to the default bike.

Concrete implementations

We will start by implementing a builder for mountainbikes:

class MountainBikeBuilder(BicycleBuilder):
    
    def set_bike_type(self):
        self._bike.bike_type = "Mountain Bike"
        return self

    def set_frame_material(self):
        self._bike.frame_material = "Aluminum Alloy"
        return self

    def set_wheels(self):
        self._bike.number_of_wheels = 2
        return self

    def set_brakes(self):
        self._bike.brake_type = "Hydraulic Disc"
        return self

    def set_gears(self):
        self._bike.gear_count = 21
        return self

    def set_suspension(self):
        self._bike.suspension = "Full Suspension"
        return self

    def set_tires(self):
        self._bike.tire_type = "Knobby Off-Road"
        return self

    def set_weight(self):
        self._bike.weight = 14.5
        return self
    
class RoadBikeBuilder(BicycleBuilder):

    
    def set_bike_type(self):
        self._bike.bike_type = "Road Bike"
        return self

    def set_frame_material(self):
        self._bike.frame_material = "Carbon Fiber"
        return self

    def set_wheels(self):
        self._bike.number_of_wheels = 2
        return self

    def set_brakes(self):
        self._bike.brake_type = "Caliper"
        return self

    def set_gears(self):
        self._bike.gear_count = 16
        return self

    def set_suspension(self):
        self._bike.suspension = "Rigid"
        return self

    def set_tires(self):
        self._bike.tire_type = "Smooth Road"
        return self

    def set_weight(self):
        self._bike.weight = 8.2
        return self

Observe that we do not need to implement the build() or reset() methods here, since they are part of the base class.

Next, the builder for an electric bike:

class ElectricBikeBuilder(BicycleBuilder):
    
    def set_bike_type(self):
        self._bike.bike_type = "Electric Bike"
        return self

    def set_frame_material(self):
        self._bike.frame_material = "Steel"
        return self

    def set_wheels(self):
        self._bike.number_of_wheels = 2
        return self

    def set_brakes(self):
        self._bike.brake_type = "Mechanical Disc"
        return self

    def set_gears(self):
        self._bike.gear_count = 7
        return self

    def set_suspension(self):
        self._bike.suspension = "Front Fork"
        return self

    def set_tires(self):
        self._bike.tire_type = "Hybrid"
        return self

    def set_weight(self):
        self._bike.weight = 22.0
        return self   

And we will also implement a RoadBikeBuilder:

class RoadBikeBuilder(BicycleBuilder):
    
    def set_bike_type(self):
        self._bike.bike_type = "Road Bike"
        return self

    def set_frame_material(self):
        self._bike.frame_material = "Carbon Fiber"
        return self

    def set_wheels(self):
        self._bike.number_of_wheels = 2
        return self

    def set_brakes(self):
        self._bike.brake_type = "Caliper"
        return self

    def set_gears(self):
        self._bike.gear_count = 16
        return self

    def set_suspension(self):
        self._bike.suspension = "Rigid"
        return self

    def set_tires(self):
        self._bike.tire_type = "Smooth Road"
        return self

    def set_weight(self):
        self._bike.weight = 8.2
        return self

Implementing the BicycleDirector

Now that we have all the methods to set features on the bicycles, we need a way to put them together. That is the task of the BicycleDirector:

class BicycleDirector:
    
    def __init__(self, builder: BicycleBuilder):
        self._builder = builder

    def construct_basic_bicycle(self) -> Bicycle:

        return (self._builder
                .set_bike_type()
                .set_frame_material()
                .set_wheels()
                .set_brakes()
                .set_gears()
                .build())

    def construct_full_bicycle(self) -> Bicycle:

        return (self._builder
                .set_bike_type()
                .set_frame_material()
                .set_wheels()
                .set_brakes()
                .set_gears()
                .set_suspension()
                .set_tires()
                .set_weight()
                .build())

    def construct_custom_bicycle(self, accessories: Optional[List[str]] = None) -> Bicycle:

        if accessories is None:
            accessories = []
            
        bicycle = (self._builder
                  .set_bike_type()
                  .set_frame_material()
                  .set_wheels()
                  .set_brakes()
                  .set_gears()
                  .set_suspension()
                  .set_tires()
                  .set_weight())
        
        for accessory in accessories:
            bicycle.add_accessory(accessory)
        
        return bicycle.build()

Again some notes:

  • We have three methods. The first one is to build a basic bicycle which for example lacks suspension. The second method is to build a full bicycle, with all the features, and the third one is to build a custom bicycle where some accessories can be added.
  • The constructor receives a BicycleBuilder as an argument. Note that since for example MountainBikeBuilder inherits from BicycleBuilder it can be passed to the constructor. This allows us to use the BicycleDirector class with any builder class deriving from BicycleBuilder.

Time to test

Now we can test our code:

if __name__ == "__main__":
    print("=== Builder Pattern Demo ===\n")
    
    mountain_builder = MountainBikeBuilder()
    director = BicycleDirector(mountain_builder)
    
    print("1. Full Mountain Bike:")
    mountain_bike = director.construct_full_bicycle()
    print(mountain_bike)
    print()
    

    road_builder = RoadBikeBuilder()
    director = BicycleDirector(road_builder)
    
    print("2. Custom Road Bike with accessories:")
    road_bike = director.construct_custom_bicycle(["Water Bottle", "Bike Computer", "LED Lights"])
    print(road_bike)
    print()
    

    electric_builder = ElectricBikeBuilder()
    
    print("3. Electric Bike built directly:")
    electric_bike = (electric_builder
                    .set_bike_type()
                    .set_frame_material()
                    .set_wheels()
                    .set_brakes()
                    .set_gears()
                    .add_accessory("Battery")
                    .add_accessory("Display")
                    .build())
    print(electric_bike)
    print()
    

    print("4. Reusing Mountain Bike builder for basic bike:")
    basic_mountain = director.construct_basic_bicycle()
    print(basic_mountain)

Line by line:

  1. We start by constructing a MountainBikeBuilder and passing it to the constructor of the BicycleDirector class.
  2. Now we can construct a full mountain bike and print it out.
  3. Next we do the same for a road bike but this time we add some accessories.
  4. Next to demonstrate that we can also construct a bike by hand by doing that for an electric bike.
  5. Next we show that we can also build a basic road bike from the same director.

Conclusion

The Builder pattern is a powerful creational design pattern that allows for the step-by-step construction of complex objects. As we’ve seen through the bicycle example, it provides a clear separation between the object’s construction and its representation. This makes the code more readable, maintainable, and flexible.

By using a Director class, we can encapsulate the construction process and reuse it with different Builder implementations to create various product configurations. This pattern is particularly useful when an object can have many different attributes, some of which are optional. It prevents the need for a “telescoping constructor” with numerous parameters, which can be hard to manage and understand. Ultimately, the Builder pattern enhances code flexibility and simplifies the creation of diverse and complex objects.

The Code Nomad
The Code Nomad
Articles: 165

Leave a Reply

Your email address will not be published. Required fields are marked *