Post

Jenga? Solid Foundation – Part 1

In software development, crafting well-designed code is an art form. This blog series is part one of key takeaways from the book “Software Design by Example” by Greg Wilson, focusing on the key principles to learn software designing as building blocks.

Jenga? Solid Foundation – Part 1

In software development, crafting well-designed code is an art form. This blog series will explore my key takeaways from the book Software Design by Example by Greg Wilson, focusing on a unique approach to mastering design principles. The book assumes a basic understanding of Python and Unix shell commands.

Software Design: Build Like an Architect, Not a Bricklayer

Imagine we are tasked with building a magnificent skyscraper. Instead of a confusing blueprint with endless technical jargon, we are given a toolbox filled with pre-fabricated building blocks – walls, windows, and support beams. These represent well-defined, modular components (individual building blocks) that we can snap together to create a complex structure, floor by floor. It is the core idea behind “Software Design by Example” – learning software design through practical exercises that build better versions of the tools that programmers use daily and, most importantly, how experienced programmers efficiently use them.

Break Down the System: Complexity vs. Comprehensibility

The book hinges on three key principles, the first being the battle against complexity. As the number of components (individual building blocks) in a system grows, understanding it becomes a daunting task. This is where the Lego analogy comes in. By breaking down large systems into smaller, well-defined, and interacting components, we can grasp the intricacies more effectively.

Expert vs. Novice: Abstraction for Efficiency, Clarity for Learning

The second principle acknowledges the gap between experienced programmers and beginners. Seasoned architects can visualize the entire house from a single blueprint and can effortlessly switch between low-level details (specific instructions like the size and type of each building block) and high-level abstractions (broader concepts like the overall structure and functionality of the building). Beginners, however, need detailed instructions (like step-by-step construction guides). Hence, while designing software, it is important to strike the right balance between abstraction and complexity.

Code as the Building Blocks: A New Way to Construct

Our code isn’t just a set of instructions for the computer. It’s actually like the building materials themselves – data (text files) that can be manipulated and analyzed, just like construction plans. When a program runs, it exists in memory as a data structure. This structure can be inspected, modified, and manipulated just like any other data structure in memory. By treating code as data, we can create sophisticated solutions to complex problems. For example, we can write programs that generate other programs (metaprogramming), optimize code dynamically, or perform advanced debugging and analysis which further opens up possibilities for automation, code transformation, and more dynamic programming techniques. This code as data approach requires a shift in perspective. It’s not just about writing code, but also about using code to manipulate other code. By achieving the optimal balance between clear, understandable structures and powerful, concise solutions, we’ll be well on our way to designing software that’s both effective and elegant.

OOPS! Beyond Blueprints?

We’ve explored breaking down complex systems. Now, Object-Oriented Programming (OOP), a style of programming in which functions and data are bound together in objects that only interact with each other through well-defined interfaces takes it a step further. This approach tackles two challenges:

  • What is a natural way to represent real-world “things” in code?
  • How can we organize code to make it easier to understand, test, and extend?

Let’s dive into the practical aspects of software design, focusing on objects and classes to craft software with good design principles.

Design by Contract

A core principle in OOP is Design by Contract (DbC). It is a style of designing software in which functions specify the pre-conditions that must be true for them to run and the post-conditions they guarantee will be true when they return. Design by contract is intended to enforce the Liskov Substitution Principle which states that it should be possible to replace objects in a program with objects of derived classes without breaking the program.

Here’s an example using a Shape class:

1
2
3
4
5
6
7
8
9
class Shape:
    def __init__(self, name):
        self.name = name

    def perimeter(self):
        raise NotImplementedError("perimeter")

    def area(self):
        raise NotImplementedError("area")

The Shape class sets the foundation. Specific shapes like squares and circles must implement their own perimeter and area methods:

1
2
3
4
5
6
7
8
9
10
class Square(Shape):
    def __init__(self, name, side):
        super().__init__(name)
        self.side = side

    def perimeter(self):
        return 4 * self.side

    def area(self):
        return self.side ** 2

The Square class adheres to the contract by providing implementations for perimeter and area.

Using Dictionaries to Emulate Objects

In some scenarios, using dictionaries can provide a flexible way to emulate objects without the overhead of classes.

1
2
3
4
5
6
7
8
9
10
11
12
def square_area(thing):
    return thing["side"] ** 2

def square_new(name, side):
    return {
        "name": name,
        "side": side,
        "area": square_area
    }

square = square_new("Square1", 5)
print(square["area"](square))

The function call looks up the function stored in the dictionary, then calls that function with the dictionary as its first object; in other words, instead of using obj.meth(arg) we use obj["meth"](obj, arg).

Using Dictionaries to Emulate Classes

Taking it further, we can create a class-like structure using dictionaries, storing methods within a class dictionary.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Square = {
    "perimeter": square_perimeter,
    "_classname": "Square"
}

def square_new(name, side):
    return {
        "name": name,
        "side": side,
        "_class": Square
    }

square = square_new("Square1", 5)
print(square["_class"]["perimeter"](square))

Calling a method now involves one more lookup because we have to go from the object to the class to the method, but once again we call the “method” with the object as the first argument.

Arguments: Catering to Flexibility

In OOP, functions (or methods within classes) often require arguments to perform their tasks. Arguments provide a way to pass data to the function when it’s called. But what if a function needs to handle a variable number of arguments? OOP offers mechanisms to address this flexibility.

varargs

Variable Arguments is a mechanism that captures any “extra” arguments to a function or method: different methods might need different numbers of arguments. Python gives us a better way. If we define a parameter in a function with a leading *, it captures any “extra” values passed to the function that don’t line up with named parameters. Similarly, if we define a parameter with two leading stars **, it captures any extra-named parameters.

1
2
3
4
5
6
7
def show_args(title, *args, **kwargs):
    print(f"{title} args '{args}' and kwargs '{kwargs}'")

show_args("nothing")
show_args("one unnamed argument", 1)
show_args("one named argument", second="2")
show_args("one of each", 3, fourth="4")
1
2
3
4
nothing args '()' and kwargs '{}'
one unnamed argument args '(1,)' and kwargs '{}'
one named argument args '()' and kwargs '{'second': '2'}'
one of each args '(3,)' and kwargs '{'fourth': '4'}'

Spreading

It allows us to take a list or dictionary full of arguments and spread them out in a call to match a function’s parameters.

1
2
3
4
5
6
7
8
def show_spread(left, middle, right):
    print(f"left {left} middle {middle} right {right}")

all_in_list = [1, 2, 3]
show_spread(*all_in_list)

all_in_dict = {"right": 30, "left": 10, "middle": 20}
show_spread(**all_in_dict)
1
2
3
# output
left 1 middle 2 right 3
left 10 middle 20 right 30

By incorporating arguments or spreading, we can write more versatile OOP functions that adapt to different input scenarios and promote cleaner code by reducing the need to explicitly handle various argument combinations.


In the next post, we’ll continue our exploration of software design, delving into more advanced strategies explained in this book. Stay tuned!


Please go ahead and read the whole book Software Design by Example here. This is just a track of my learning journey as I explore the concepts.

This post is licensed under CC BY 4.0 by the author.