Advanced Python Refactoring: Transforming Conway's Game of Life

The Anatomy of Brittle Python Code

Writing code that works is only the first step. Writing code that lasts requires a deep understanding of structure and maintainability. When reviewing an implementation of

, several common architectural red flags often emerge. These issues frequently include a lack of type annotations, monolithic file structures, and violations of the Law of Demeter.

A primary concern in many beginner-to-intermediate projects is the "God File"—a single Python script where classes for logic, data storage, and visualization all live together. This makes navigation a chore and testing nearly impossible. Furthermore, ignoring type hints creates a layer of "imprecise errors." For instance, a method named is_alive should return a boolean, but without annotations, it might return an integer like 0 or 1, leading to subtle logic bugs that are difficult to track down during execution.

Advanced Python Refactoring: Transforming Conway's Game of Life
Refactoring Conway's Game of Life | ArjanCodes Code Roast

Moving from Unit Test to Pytest

offers the built-in unittest module, but
pytest
provides a much cleaner, more idiomatic approach to verifying code behavior. The transition involves moving away from rigid class-based structures toward simple, functional tests.

One of the most powerful features to adopt is the pytest fixture. Instead of repetitive setup code inside every test method, you define a fixture to handle the initialization of your grid or game state.

import pytest
from grid import Grid

@pytest.fixture
def basic_grid():
    return Grid(rows=3, cols=3)

def test_grid_initialization(basic_grid):
    assert basic_grid.rows == 3
    assert basic_grid.cols == 3

By passing the fixture as an argument to your test functions, you create a clear, modular safety net. This allows you to refactor your core logic with the confidence that any breaking change will be caught immediately by your 27+ passing tests.

Simplification Through Functional Design

It is a common mistake to over-engineer logic by wrapping every simple behavior in a class. In the original version of this project, each rule for the cellular automaton—such as birth or overpopulation—was its own class with a static method. This is unnecessary overhead.

A rule is, at its heart, a function: it takes inputs (current state and neighbors) and returns an output (new state). By leveraging the latest features in

, we can define a Callable type alias to enforce this pattern strictly.

from typing import Callable, Optional

type Rule = Callable[[int, int], Optional[int]]

def birth_rule(cell: int, neighbors: int) -> Optional[int]:
    if cell == 0 and neighbors == 3:
        return 1
    return None

Replacing class instances with a list of rule functions makes the update loop in the

class significantly more readable. You no longer need to instantiate objects just to check a condition; you simply iterate through a list of callables.

Decoupling and the Law of Demeter

The Law of Demeter suggests that a module should not reach deep into the internals of another object. The original code violated this by having the main function reach through the Game class into the Grid class and then into a raw list of lists to set a cell's value. This "dot-walking" (e.g., game.grid.grid[0][0] = 1) makes your code extremely brittle.

If you decide to change your internal storage from a list of lists to a

array for performance, every single line of code that accessed that nested list will break. The solution is to provide clear interfaces. The Grid class should have a set_cell method and a raw_grid property. This way, the caller doesn't need to know how the grid is stored, only that it can be updated and retrieved.

Implementing Iterators and Protocols

To further clean up the simulation loop, you can turn your

into an iterable. Instead of using nested for loops with range(rows) and range(cols) inside your game logic, let the grid handle its own iteration. Using the yield keyword, the grid can provide the row, column, and cell value directly to the loop.

def __iter__(self):
    for r in range(self.rows):
        for c in range(self.cols):
            yield r, c, self.grid[r][c]

For visualization, use the Protocol class from the typing module. This allows you to define exactly what a visualizer needs—such as an update method and a raw_grid property—without forcing a strict inheritance hierarchy. This makes it easy to swap between a

plot visualizer and a simple console output without changing the core game engine.

Tips and Syntax Best Practices

Modern Python development favors clarity and configuration over hard-coded values. Move your magic numbers and strings—like row counts or sleep times—out of the main function and into constants at the top of your file. This makes the code easier to tune and eventually move to environment variables or a pyproject.toml configuration.

Always place your imports at the top of the file. While "lazy loading" imports inside functions can technically save a few milliseconds of startup time, it hides dependencies and makes the code harder to debug. Use tools like Pylint to catch these style violations early. By combining strict type hinting, decoupled class structures, and a robust functional approach, you transform a fragile script into a professional, maintainable library.

Advanced Python Refactoring: Transforming Conway's Game of Life

Fancy watching it?

Watch the full video and context

5 min read