Post

The Art of Interpretation – Part 2

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.

The Art of Interpretation – Part 2

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.

Two Ways to Run Code

Compilers and interpreters are just programs. They both serve the purpose of translating a high-level programming language into machine code that a computer’s processor can execute, but they do so in different ways:

Compilers vs. Interpreters

🔹 Translation Time

  • Compiler: Translates the entire program into machine code before any code runs.
  • Interpreter: Translates the program into machine code on the fly, line by line, as it runs.

🔹 Execution

  • Compiler: Once compiled, the program can be executed multiple times without recompiling. Machine code runs directly on the hardware.
  • Interpreter: Interprets and runs the program line by line every time it runs.

🔹 Performance

  • Compiler: Generally faster since the translation happens only once.
  • Interpreter: Slower as it translates and executes concurrently.

🔹 Examples

  • Compiler: C, C++, Rust
  • Interpreter: Python, Ruby, JavaScript

Blurry Lines in Practice

In reality, the distinction between compilers and interpreters is not always clear-cut. Many languages and their implementations use a mix of both approaches. For instance, Python:

  • Bytecode Compilation: When a Python program runs, the Python interpreter first compiles the source code into an intermediate form known as bytecode. This bytecode is not machine code but a lower-level, platform-independent representation of the source code.
  • Caching Bytecode: To improve performance, Python saves the compiled bytecode in .pyc files. The next time the program runs, Python can skip the compilation step if the bytecode is up-to-date, directly interpreting the bytecode instead of the source code.
  • Interpreting Bytecode: The Python virtual machine (PVM) then interprets the bytecode, which translates it into machine code just in time for execution. This approach gives Python some of the benefits of both compilation (like saving time on subsequent runs) and interpretation (like flexibility and ease of debugging).

Got it! Here’s the complete content in clean Markdown format without fenced code blocks breaking the flow, so you can just copy-paste it as a full .md file:


Building a Simple Expression Interpreter

We’ll build an interpreter that can handle operations like addition using nested lists to represent the expressions by using prefix notation where the operation comes first, followed by its operands. For example:

  • ["add", ["abs", -5], 9] represents abs(-5) + 9
  • ["add", 1, 2] represents 1 + 2
  • ["abs", -3.5] represents abs(-3.5)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import json
import sys

def do_abs(args):
    assert len(args) == 1
    val = do(args[0])
    return abs(val)

def do_add(args):
    assert len(args) == 2
    left = do(args[0])
    right = do(args[1])
    return left + right

def do(expr):
    # Integers evaluate to themselves.
    if isinstance(expr, int):
        return expr

    # Lists trigger function calls.
    assert isinstance(expr, list)
    if expr[0] == "abs":
        return do_abs(expr[1:])
    if expr[0] == "add":
        return do_add(expr[1:])
    assert False, f"Unknown operation {expr[0]}"

def main():
    assert len(sys.argv) == 2
    with open(sys.argv[1], "r") as reader:
        program = json.load(reader)
    result = do(program)
    print(f"=> {result}")

if __name__ == "__main__":
    main()

Example Usage

  1. Create a file expr.tll with the content:
1
["add", ["abs", -3], 2]
  1. Run the interpreter:
1
python expr.py expr.tll
  1. Output:
1
=> 5

Let’s Expand on Dynamic Dispatch and Recursion

Dynamic Dispatch

Dynamic dispatch is the process where the program decides at runtime which function to call based on the type or value of the arguments. In our interpreter, the do function decides which specific function to call (do_add or do_abs) based on the first element of the list.

Recursion

Recursion occurs when a function calls itself, either directly or indirectly. It’s powerful for solving problems by breaking them into smaller, similar subproblems.

In our interpreter:

  • do calls do_add
  • do_add calls do again to evaluate its arguments

This continues recursively until the base case (an integer) is reached.

For example, evaluating the expression ["abs", ["add", 1, 2]] involves:

  1. do calls do_abs with ["add", 1, 2]
  2. do_abs calls do with ["add", 1, 2]
  3. do calls do_add with [1, 2]
  4. do_add calls do on 1 and 2 (both integers, returned as is)
  5. Sum 1 + 2 = 3, return 3 to do_abs
  6. do_abs returns abs(3), which is 3

Introduction to Variables in Interpreter

To handle variables, we use an environment: a dictionary that stores variable names as keys and their values. This environment acts as the context in which expressions are evaluated.

We modify functions to accept the environment as a parameter so they can read and update variables.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
def do_get(env, args):
    assert len(args) == 1
    assert isinstance(args[0], str) 
    assert args[0] in env, f"Unknown variable {args[0]}"
    return env[args[0]] 

def do_set(env, args):
    assert len(args) == 2
    assert isinstance(args[0], str)
    value = do(env, args[1])
    env[args[0]] = value 
    return value

def do(env, expr):
    if isinstance(expr, int):
        return expr
    assert isinstance(expr, list)
    if expr[0] == "abs":
        return do_abs(env, expr[1:])
    if expr[0] == "add":
        return do_add(env, expr[1:])
    if expr[0] == "get":
        return do_get(env, expr[1:])
    if expr[0] == "set":
        return do_set(env, expr[1:])
    assert False, f"Unknown operation {expr[0]}"

Streamlining Function Calls with a Lookup Table

Using many if statements to choose the function to call can become unwieldy. We can automate this by creating a lookup table that maps operation names to their functions using introspection:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
OPS = {
    name.replace("do_", ""): func
    for (name, func) in globals().items()
    if name.startswith("do_")
}

def do(env, expr):
    if isinstance(expr, int):
        return expr

    assert isinstance(expr, list)
    assert expr[0] in OPS, f"Unknown operation {expr[0]}"
    func = OPS[expr[0]]
    return func(env, expr[1:])

Benefits of Using Introspection

  • Reduced Complexity: do function is simpler and easier to read.
  • Scalability: Adding new operations only requires defining a new do_* function.
  • Reliability: Automatically includes all do_* functions, reducing human error.

Potential Downsides

  • Can be harder to understand for beginners.
  • Operations are not explicitly listed; you must check the code to see all available operations.

Explicit Lookup Table (Alternative)

If introspection feels obscure, define the lookup table explicitly:

1
2
3
4
5
6
OPS = {
    "abs": do_abs,
    "add": do_add,
    "get": do_get,
    "set": do_set,
}

Conclusion

This chapter introduced interpreters — programs that translate code line by line during execution — and contrasted them with compilers, which translate entire programs upfront.

We built a simple expression interpreter capable of handling basic mathematical operations, explored dynamic dispatch (selecting which function to run based on input), and implemented recursion (functions calling themselves to break problems down).

We also extended the interpreter with variables using an environment dictionary to store and retrieve values, then improved code maintainability by using introspection for operation dispatch.

Understanding interpreters helps grasp how programming languages execute and how language design supports flexibility and ease of development — key knowledge for becoming a proficient programmer.


In the next post, we’ll continue exploring advanced software design 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.