buildclass: a Purely Declarative Approach to Build Classes

Image generated by DALL·E from a prompt by the author

Image generated by DALL·E from a prompt by the author

DynDesign recently introduced a release featuring buildclass, a new function enabling the Python developers to build a new class from a base class through a purely declarative approach.

This article presents a direct comparison between buildclass and a more conventional approach: the Builder Design Pattern.

Motivation

Consider this scenario: a hierarchy of classes connected to each other through inheritance or composition, where the connections between the classes are configured based on dynamic options. The goal is to produce code that is efficient, readable, maintainable, and aligns closely with SOLID principles, particularly the Single Responsibility Principle (SRP) and the Open Closed Principle (OCP).

The buildclass function provides developers with a streamlined approach to attain this goal, by replacing manual code implementation of the configuration with a standardized solution that uses a set of simple declarations.

Testing Ground: Building a Car

Our testing environment will consist of Python scripts simulating the construction of a car. The scripts will take the following arguments into account: - Certain car properties that need to be assigned to the car, such as the Model; - Mandatory component specifications, which will be assigned by default if not provided, such as the Engine type; and - Optional component specifications, which may or may not be added, such as a Sunroof.

The complete code for all the scripts discussed in this article can be found at this GitHub repository.

image1

In the above image, the components are highlighted in yellow, while the alternative choices are highlighted in orange.

Two approaches will be compared in the scripts: one employing the Builder Design Pattern (implemented in the “car_builder.py” scripts) and the other utilizing ``**buildclass**`` (implemented in the “car_dynconfig.py” scripts).

Base Car with a Builder Pattern

Below is an example of how the construction of a base car can be implemented using a simplified Builder Design Pattern.

# car_builder.py

# Base component classes
class Engine:
    def __init__(self, engine_type):
        self.engine_type = engine_type or "V6"

    def echo(self):
        print(f"\tEngine: {self.engine_type}")


class Transmission:
    def __init__(self, transmission_type):
        self.transmission_type = transmission_type or "Manual"

   ...
...


# Class to manage car components
class Components:
    def __init__(self):
        self.components = []

    def add(self, component):
        self.components.append(component)

    def echo(self):
        if self.components:
            print(" with components:")
            for component in self.components:
                component.echo()


# Base car class
class Car:
    def __init__(self, make, model):
        self.make = make
        self.model = model
        self.components = Components()

    def echo(self):
        print(f"Building {self.make} {self.model}", end='')
        self.components.echo()
        print()


# Class Builder class to assemble a car based on the options
class CarBuilder:
    def __init__(self, args):
        self.car = Car(args.make, args.model)
        self.args = args

    def build(self):
        self.car.components.add(Engine(self.args.Engine))
        self.car.components.add(Transmission(self.args.Transmission))

        if self.args.GPS:
            self.car.components.add(GPSNavigation())
        ...
        return self.car


if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument('-make')
    parser.add_argument('-model')
    parser.add_argument('-Engine')
    ...
    args = parser.parse_args()

    # Create a car
    car_builder = CarBuilder(args)
    car = car_builder.build()

    car.echo()

The script above essentially parses the script arguments and forwards them to a CarBuilder instance. This CarBuilder instance is responsible for creating an instance of the Car class and incorporating the required and optional components.

Below is an example of script usage.

$ python car_builder.py -make=Toyota -model=Camry  -Engine=V8  -GPS
Building Toyota Camry with components:
   Engine: V8
   Transmission: Manual
   GPS Navigation

Base Car with buildclass

Instead of writing our own CarBuilder class, we can use the DynDesign’s buildclass function. The Car class is decorated with the @dynconfig decorator, passing in the CarConfigurator class as an argument. The CarConfigurator class contains all of the possible required and optional car properties. Then, the Car class and the script arguments is passed to buildclass .

# car_dynconfig.py

from dyndesign import buildclass, dynconfig, ClassConfig, LocalClassConfig
import argparse

...

# DynConfig Configurator class
class CarConfigurator:
    Engine = ClassConfig(component_class=Engine, force_add=True)
    Transmission = ClassConfig(component_class=Transmission, force_add=True)
    GPS = ClassConfig(component_class=GPSNavigation)
    Camera = ClassConfig(component_class=Camera)
    Sunroof = ClassConfig(component_class=Sunroof)

    DYNDESIGN_LOCAL_CONFIG = LocalClassConfig(
        component_attr="components",
        init_args_from_option=True,
        structured_component_type=Components
    )


# Base car class
@dynconfig(CarConfigurator)
class Car:
    ...


if __name__ == "__main__":
    ...

    # Create a car
    CarClass = buildclass(Car, args)
    car = CarClass(args.make, args.model)

    car.echo()

The CarConfigurator class includes an attribute declaration for each mandatory and optional component, specifying mandatory components with the force_add=True setting. At the bottom, the configurator defines a series of additional settings (which can be further explored in the documentation) using DYNDESIGN_LOCAL_CONFIG .

The script works in the same way as the one implemented with the Builder Design Pattern:

$ python car_dynconfig.py -make=Toyota -model=Camry  -Engine=V8  -GPS
Building Toyota Camry with components:
   Engine: V8
   Transmission: Manual
   GPS Navigation

car_builder vs car_dynconfig

The best way to gauge our progress is a side-by-side comparison between the code of the two scripts.

image2

Examining “car_dynconfig.py” (at the right side) from top to bottom: - CarConfigurator class is defined and then linked to the Car class via @dynconfig decorator; - “self.components” initialization is no longer required in the constructor, as it is performed through the setting structured_component_type=Components; - CarBuilder class is no longer required ; - The car object is constructed in two steps: first, CarClass is configured based on the selected components, then it is instantiated using the car properties.

Luxury option

Next, we want to add a “-Luxury” option to the scripts. If this option is selected, the car will automatically be equipped with a Sunroof and a Back Camera. Additionally, a “(Luxury Edition)” label will be displayed.

# car_builder_luxury.py

...

# Mixin class for luxury option
class LuxuryMixin:
    def __init__(self):
        self.add_luxury_components()

    def add_luxury_components(self):
        self.components.add(Sunroof())
        self.components.add(Camera())

    def echo(self):
        print("(Luxury Edition)")


# Base car class
class Car:
    ...


# Create a luxury car using the mixin class
class LuxuryCar(Car, LuxuryMixin):
    def __init__(self, make, model):
        super().__init__(make, model)
        super(Car, self).__init__()

    def echo(self):
        super().echo()
        super(Car, self).echo()


# Class Builder class to assemble a car based on the options
class CarBuilder:
    def __init__(self, args):
        CarClass = LuxuryCar if args.Luxury else Car
        self.car = CarClass(args.make, args.model)
        self.args = args

    ...


if __name__ == "__main__":
    ...
    parser.add_argument('-Luxury', action='store_true')
    ...

The Luxury option is implemented through a LuxuryMixin class. If the “-Luxury” option is selected, the car is built based on LuxuryCar (which inherits from LuxuryMixin) instead of on Car.

Below is the output with “-Luxury” set.

$ python car_builder_luxury.py -make=Toyota -model=Camry  -Engine=V8 -Luxury
Building Toyota Camry with components:
 Sunroof
 Backup camera
 Engine: V8
 Transmission: Manual

(Luxury Edition)

If we apply the same upgrade to the “car_dynconfig” script, we can: - Eliminate the constructor and the “add_luxury_components” method from the LuxuryMixin class since the Sunroof and Camera components can be associated with the Luxury option in the configurator. - Bypass the LuxuryCar class implementation and simply define the LuxuryMixin class as a potential parent dependency within the same Luxury option.

# car_dynconfig_luxury.py

from dyndesign import ..., decoratewith

...

# Mixin class for luxury option
class LuxuryMixin:
    def echo_luxury(self, func):
        func(self)
        print("(Luxury Edition)")


# DynConfig Configurator class
class CarConfigurator:
    Luxury = (
        ClassConfig(inherit_from=LuxuryMixin),
        ClassConfig(component_class=Camera),
        ClassConfig(component_class=Sunroof)
    )
    ...


# Base car class
@dynconfig(CarConfigurator)
class Car:
    def __init__(self, make, model):
        self.make = make
        self.model = model

    @decoratewith("echo_luxury")
    def echo(self):
        print(f"Building {self.make} {self.model}", end='')
        self.components.echo()
        print()


if __name__ == "__main__":
    ...
    parser.add_argument('-Luxury', action='store_true')
    ...

The “echo” method in LuxuryMixin class has been converted into the “echo_luxury” Dynamic Decorator, and it is applied to the Car’s “echo” method using DynDesign’s decoratewith meta decorator when “-Luxury” is enabled.

car_builder_luxury vs car_dynconfig_luxury

Here is the side-by-side comparison of the upgraded scripts.

image3

Adding One More Level to the Component Hierarchy

Now, let’s take it a step further. What if we want to build not only the Car but also the Engine and Transmission components? In essence, Transmission and Engine would instantiate sub-component classes based on the selected options.

The following script is an adaptation of “car_builder_luxury” that uses the Builder Pattern to build each mandatory component.

# car_builder_2_levels.py

...

# Sub-component classes
class EngineV6(Engine):
    def __init__(self):
        super().__init__("V6")


class EngineV8(Engine):
    def __init__(self):
        super().__init__("V8")

...

# Class Builders for specific options
class EngineBuilder:
    def __init__(self, engine_type = None):
        self.engine_type = engine_type

    def build(self):
        return EngineV8() if self.engine_type == "V8" else EngineV6()

class TransmissionBuilder:
    def __init__(self, transmission_type = None):
        self.transmission_type = transmission_type

    def build(self):
        return AutomaticTransmission() if self.transmission_type == "Automatic" else ManualTransmission()

...

# Class Builder class to assemble a car based on the options
class CarBuilder:
    ...

    def build(self):
        engine_builder = EngineBuilder(self.args.Engine)
        self.car.components.add(engine_builder.build())

        transmission_builder = TransmissionBuilder(self.args.Transmission)
        self.car.components.add(transmission_builder.build())
  ...

Once again, the flexibility of buildclass comes to our rescue, helping us maintain a low level of code complexity. Below is the corresponding modified version of “car_dynconfig_luxury” that does not require us to implement Class Builders for the components; instead, it simply changes the type of the corresponding configuration options to a switch, by specifying all the possible switch options.

# car_dynconfig_2_levels.py

...

# DynConfig Configurator class
class CarConfigurator:
    ...
    Engine = {
        "V6": ClassConfig(component_class=EngineV6),
        "V8": ClassConfig(component_class=EngineV8),
        dynconfig.SWITCH_DEFAULT: ClassConfig(component_class=EngineV6),
    }
    Transmission = {
        "Manual": ClassConfig(component_class=ManualTransmission),
        "Automatic": ClassConfig(component_class=AutomaticTransmission),
        dynconfig.SWITCH_DEFAULT: ClassConfig(component_class=ManualTransmission),
    }
 ...

car_builder_2_levels vs car_dynconfig_2_levels

image4

As mentioned earlier, the EngineBuilder and TransmissionBuilder classes of “car_builder_2_levels” (on the top-left side) are not needed in “car_dynconfig_2_levels” because buildclass automatically instantiate the components and then inject them into the Car class based on the corresponding switch configuration options.

Conclusion

This article directly compares a simplified Builder Design Pattern and the buildclass function provided by DynDesign. To evaluate their effectiveness, we selected a script for building a car, ranging from a basic version to a more advanced one.

Below, we summarize the advantages and disadvantages of ‘buildclass’ compared to other approaches.

Advantages

  • Maintaining SRP and OCP compliance with code simplicity : The purely declarative approach of buildclass in class configuration aligns with the SOLID principles while ensuring that the code remains easily comprehensible and maintainable. This benefit becomes even more evident as the complexity of class dependencies (i.e., inheritance and composition) increases.

  • Automating the configuration logic : buildclass eliminates the need for writing boilerplate code to explicitly implement class configuration.

Disadvantage

  • Limited support for certain advanced code features in IDEs : Certain advanced IDE features, like source code navigation, may have limitations when using buildclass . These limitations can be mitigated by adhering to best practices, such as incorporating Type Hinting, as elaborated in the documentation.

Despite the mentioned disadvantage, which can be mitigated through best practices, buildclass has the potential to significantly save developers time and streamline the development process.

Written by Patrizio Gelosi