8.5. Builder

  • EN: Builder

  • PL: Budowniczy

  • Type: object

  • Why: To separate the construction of an object from its representation

  • Why: The same construction algorithm can be applied to different representations

  • Usecase: Export data to different formats

The Builder design pattern is a creational design pattern that separates the construction of a complex object from its representation. It is useful when the construction process must allow different representations for the object that's constructed.

Here's a simple example of the Builder pattern in Python:

>>> class Builder:
...     def build_part_a(self):
...         pass
...
...     def build_part_b(self):
...         pass
...
>>> class ConcreteBuilder(Builder):
...     def build_part_a(self):
...         return "Part A"
...
...     def build_part_b(self):
...         return "Part B"
...
>>> class Director:
...     def __init__(self, builder):
...         self.builder = builder
...
...     def construct(self):
...         part_a = self.builder.build_part_a()
...         part_b = self.builder.build_part_b()
...         return f"{part_a} and {part_b}"
...
>>> builder = ConcreteBuilder()
>>> director = Director(builder)
>>> product = director.construct()
>>> print(product)
Part A and Part B

In this example, Builder is an interface that specifies methods for creating the parts of a complex object. ConcreteBuilder is a class that implements these operations to create concrete parts. Director is a class that constructs an object using the Builder interface.

8.5.1. Pattern

../../_images/designpatterns-builder-pattern.png

8.5.2. Problem

  • Violates Open/Close Principle

  • Tight coupling between Presentation class with formats

  • PDF has pages, Movies has frames, this knowledge belongs to somewhere else

  • Duplicated code

  • Magic number

../../_images/designpatterns-builder-problem.png

from enum import Enum


class Slide:
    text: str

    def __init__(self, text: str) -> None:
        self.text = text

    def get_text(self) -> str:
        return self.text


#%% Formats
class PresentationFormat(Enum):
    PDF = 1
    IMAGE = 2
    POWERPOINT = 3
    MOVIE = 4

class PDFDocument:
    def add_page(self, text: str) -> None:
        print('Adding a page to PDF')

class Movie:
    def add_frame(self, text: str, duration: int) -> None:
        print('Adding a frame to a movie')


#%% Main
class Presentation:
    slides: list[Slide]

    def __init__(self) -> None:
        self.slides = []

    def add_slide(self, slide: Slide) -> None:
        self.slides.append(slide)

    def export(self, format: PresentationFormat) -> None:
        if format == PresentationFormat.PDF:
            pdf = PDFDocument()
            pdf.add_page('Copyright')
            for slide in self.slides:
                pdf.add_page(slide.get_text())
        elif format == PresentationFormat.MOVIE:
            movie = Movie()
            movie.add_frame('Copyright', duration=3)
            for slide in self.slides:
                movie.add_frame(slide.get_text(), duration=3)

8.5.3. Solution

  • Use the builder pattern to separate the exporting logic from the presentation format

  • The same exporting logic belongs to the different formats

../../_images/designpatterns-builder-solution.png

from enum import Enum


class Slide:
    text: str

    def __init__(self, text: str) -> None:
        self.text = text

    def get_text(self) -> str:
        return self.text


class PresentationBuilder:
    def add_slide(self, slide: Slide) -> None:
        raise NotImplementedError


#%% Formats
class PresentationFormat(Enum):
    PDF = 1
    IMAGE = 2
    POWERPOINT = 3
    MOVIE = 4

class PDFDocument:
    def add_page(self, text: str) -> None:
        print('Adding a page to PDF')

class Movie:
    def add_frame(self, text: str, duration: int) -> None:
        print('Adding a frame to a movie')

class PDFDocumentBuilder(PresentationBuilder):
    document: PDFDocument

    def __init__(self):
        self.document = PDFDocument()

    def add_slide(self, slide: Slide) -> None:
        self.document.add_page(slide.get_text())

    def get_pdf_document(self) -> PDFDocument:
        return self.document


class MovieBuilder(PresentationBuilder):
    movie: Movie

    def __init__(self):
        self.movie = Movie()

    def add_slide(self, slide: Slide) -> None:
        self.movie.add_frame(slide.get_text(), duration=3)

    def get_movie(self) -> Movie:
        return self.movie


#%% Main
class Presentation:
    slides: list[Slide]

    def __init__(self) -> None:
        self.slides = []

    def add_slide(self, slide: Slide) -> None:
        self.slides.append(slide)

    def export(self, builder: PresentationBuilder) -> None:
        builder.add_slide(Slide('Copyright'))
        for slide in self.slides:
            builder.add_slide(slide)


if __name__ == '__main__':
    presentation = Presentation()
    presentation.add_slide(Slide('Slide 1'))
    presentation.add_slide(Slide('Slide 2'))

    builder = PDFDocumentBuilder()
    presentation.export(builder)
    movie = builder.get_pdf_document()

    builder = MovieBuilder()
    presentation.export(builder)
    movie = builder.get_movie()

8.5.4. Use Case - 0x01

class ReadCSV:
    filename: str
    delimiter: str
    encoding: str
    chunksize: int

    def __init__(self, filename):
        self.filename = filename

    def withChunksize(self, value):
        self.chunksize = value
        return self

    def withDelimiter(self, value):
        self.delimiter = value
        return self

    def withEncoding(self, value):
        self.encoding = value
        return self


if __name__ == '__main__':
    data = (
        ReadCSV('myfile.csv')
        .withChunksize(10_1000)
        .withDelimiter(',')
        .withEncoding('UTF-8')
    )

8.5.5. Use Case - 0x02

>>> def read_csv(filepath_or_buffer, sep=', ', delimiter=None, header='infer',
...              names=None, index_col=None, usecols=None, squeeze=False,
...              prefix=None, mangle_dupe_cols=True, dtype=None, engine=None,
...              converters=None, true_values=None, false_values=None,
...              skipinitialspace=False, skiprows=None, nrows=None,
...              na_values=None, keep_default_na=True, na_filter=True,
...              verbose=False, skip_blank_lines=True, parse_dates=False,
...              infer_datetime_format=False, keep_date_col=False,
...              date_parser=None, dayfirst=False, iterator=False,
...              chunksize=None, compression='infer', thousands=None,
...              decimal=b'.', lineterminator=None, quotechar='"',
...              quoting=0, escapechar=None, comment=None, encoding=None,
...              dialect=None, tupleize_cols=None, error_bad_lines=True,
...              warn_bad_lines=True, skipfooter=0, doublequote=True,
...              delim_whitespace=False, low_memory=True, memory_map=False,
...              float_precision=None): ...
>>> data = read_csv('myfile.csv', ', ', None, 'infer', None, None, None,
...                 False, None, True, None, None, None, None, None, False,
...                 None, None, None, True, True, False, True, False, False,
...                 False, None, False, False, None, 'infer', None, b'.',
...                 None, '"', 0, None, None, None, None, None, True, True,
...                 0, True, False, True, False, None)
>>> data = read_csv('myfile.csv',
...     chunksize=10_000,
...     delimiter=',',
...     encoding='utf-8')

8.5.6. Use Case - 0x02

>>> class Person:
...     def __init__(self, firstname, lastname, email, age, height, weight):
...         self.firstname = firstname
...         self.lastname = lastname
...         self.email = email
...         self.age = age
...         self.height = height
...         self.weight = weight
>>> mark = Person( 'Mark', 'Watney', 'mwatney@nasa.gov', 42, 178.0, 75.5)
>>> mark = Person(
...     firstname='Mark',
...     lastname='Watney',
...     email='mwatney@nasa.gov',
...     age=42,
...     height=178.0,
...     weight=75.5,
... )

8.5.7. Use Case - 0x02

>>> class Person:
...     def __init__(self, firstname, lastname, is_astronaut, is_retired,
...                  is_alive, friends, assignments, missions, assigned):
...         ...
>>> mark = Person('Mark', 'Watney', True, False, True, None, 1, 17, False)
>>> mark = Person(
...     firstname = 'Mark',
...     lastname = 'Watney',
...     is_astronaut = True,
...     is_retired = False,
...     is_alive = True,
...     friends = None,
...     assignments = 1,
...     missions = 17,
...     assigned = False,
... )
>>> class Person:
...     def __init__(self):
...         ...
...
...     def withFirstname(self, firstname):
...         self.firstname = firstname
...         return self
...
...     def withLastname(self, lastname):
...         self.lastname = lastname
...         return self
...
...     def withIsAstronaut(self, is_astronaut):
...         self.is_astronaut = is_astronaut
...         return self
...
...     def withIsRetired(self, is_retired):
...         self.is_retired = is_retired
...         return self
...
...     def withIsAlive(self, is_alive):
...         self.is_alive = is_alive
...         return self
...
...     def withFriends(self, friends):
...         self.friends = friends
...         return self
...
...     def withAssignments(self, assignments):
...         self.assignments = assignments
...         return self
...
...     def withMissions(self, missions):
...         self.missions = missions
...         return self
...
...     def withAssigned(self, assigned):
...         self.assigned = assigned
...         return self
>>>
>>>
>>> mark = (
...     Person()
...     .withFirstname('Mark')
...     .withLastname('Watney')
...     .withIsAstronaut(True)
...     .withIsRetired(False)
...     .withIsAlive(True)
...     .withFriends(None)
...     .withAssignments(1)
...     .withMissions(17)
...     .withAssigned(False)
... )

8.5.8. Assignments

Code 8.48. Solution
"""
* Assignment: DesignPatterns Creational BuilderEmail
* Complexity: easy
* Lines of code: 15 lines
* Time: 5 min

English:
    1. Create class `Email`
    2. Use builder pattern to set:
        a. `recipient: str` verify email address using regex
        b. `sender: str` verify email address using regex
        c. `subject: str` encode to bytes
        d. `body: str` encode to bytes
    3. Run doctests - all must succeed

Polish:
    1. Stwórz klasę `Email`
    2. Użyj wzorca builder, aby ustawić:
         a. `recipient: str` zweryfikuj adres e-mail za pomocą wyrażenia regularnego
         b. `sender: str` zweryfikuj adres e-mail za pomocą wyrażenia regularnego
         c. `subject: str` koduje do bajtów
         d. `body: str` koduje na bajty
    3. Uruchom doctesty - wszystkie muszą się powieść

Tests:
    >>> import sys; sys.tracebacklimit = 0
    >>> from pprint import pprint

    >>> result = (
    ...     Email()
    ...     .with_recipient('mwatney@nasa.gov')
    ...     .with_sender('mlewis@nasa.gov')
    ...     .with_subject('Hello from Mars')
    ...     .with_body('Greetings from Red Planet')
    ... )

    >>> pprint(vars(result), width=72, sort_dicts=False)
    {'recipient': 'mwatney@nasa.gov',
     'sender': 'mlewis@nasa.gov',
     'subject': 'Hello from Mars',
     'body': 'Greetings from Red Planet'}
"""


class Email:
    recipient: str
    sender: str
    subject: str
    body: str
    attachment: bytes


Code 8.49. Solution
"""
* Assignment: DesignPatterns Creational BuilderEmail
* Complexity: easy
* Lines of code: 3 lines
* Time: 3 min

English:
    1. Create class `Email`
    2. Use builder pattern to set:
        a. `recipient: str` verify email address using regex
        b. `sender: str` verify email address using regex
        c. `subject: str` encode to bytes
        d. `body: str` encode to bytes
        e. `attachment: bytes` base64 encoded
    3. Run doctests - all must succeed

Polish:
    1. Stwórz klasę `Email`
    2. Użyj wzorca builder, aby ustawić:
         a. `recipient: str` zweryfikuj adres e-mail za pomocą wyrażenia regularnego
         b. `sender: str` zweryfikuj adres e-mail za pomocą wyrażenia regularnego
         c. `subject: str` koduje do bajtów
         d. `body: str` koduje na bajty
         e. `attachment: bytes` zakodowane w standardzie base64
    3. Uruchom doctesty - wszystkie muszą się powieść

Tests:
    >>> import sys; sys.tracebacklimit = 0
    >>> from pprint import pprint

    >>> result = (
    ...     Email()
    ...     .with_recipient('mwatney@nasa.gov')
    ...     .with_sender('mlewis@nasa.gov')
    ...     .with_subject('Hello from Mars')
    ...     .with_body('Greetings from Red Planet')
    ...     .with_attachment('myfile.txt'.encode())
    ... )

    >>> pprint(vars(result), width=72, sort_dicts=False)
    {'recipient': 'mwatney@nasa.gov',
     'sender': 'mlewis@nasa.gov',
     'subject': 'Hello from Mars',
     'body': 'Greetings from Red Planet',
     'attachment': b'bXlmaWxlLnR4dA=='}
"""
from base64 import b64encode


class Email:
    recipient: str
    sender: str
    subject: str
    body: str
    attachment: bytes

    def with_recipient(self, recipient: str):
        self.recipient = recipient
        return self

    def with_sender(self, sender: str):
        self.sender = sender
        return self

    def with_subject(self, subject: str):
        self.subject = subject
        return self

    def with_body(self, body: str):
        self.body = body
        return self


Code 8.50. Solution
"""
* Assignment: DesignPatterns Creational BuilderTexture
* Complexity: easy
* Lines of code: 18 lines
* Time: 8 min

English:
    1. Create class `Texture`
    2. Use builder pattern to set:
        a. `file: str`
        b. `width: int` value greater than 0
        c. `height: int` value greater than 0
        d. `quality: int` from 1 to 100 percent
    3. Run doctests - all must succeed

Polish:
    1. Stwórz klasę `Texture`
    2. Użyj wzorca builder, aby ustawić:
        a. `file: str`
        b. `width: int` wartość większa niż 0
        c. `height: int` wartość większa niż 0
        d. `quality: int` od 1 do 100 procent
    3. Uruchom doctesty - wszystkie muszą się powieść

Tests:
    >>> import sys; sys.tracebacklimit = 0
    >>> from pprint import pprint

    >>> result = (
    ...     Texture()
    ...     .with_file('img/dragon/alive.png')
    ...     .with_height(100)
    ...     .with_width(50)
    ...     .with_quality(75)
    ... )

    >>> vars(result)
    {'file': 'img/dragon/alive.png', 'height': 100, 'width': 50, 'quality': 75}
"""

class Texture:
    file: str
    width: int
    height: int
    quality: int