← All writing

Object Oriented Programming

On this page

Animated overview of object-oriented programming concepts.

During two semesters of object-oriented programming at NTNU, I spent more time learning how to organize a program than learning the syntax. I was used to describing a sequence of steps. Designing objects meant deciding where state belonged and which part of the program should be responsible for changing it.

A different mental model

A task manager was a useful example because a task has both state and operations that change that state:

from dataclasses import dataclass
from datetime import datetime

@dataclass
class Task:
title: str
created_at: datetime
completed_at: datetime | None = None
subtasks: list['Task'] | None = None

def mark_complete(self) -> None:
self.completed_at = datetime.now()
if self.subtasks:
for subtask in self.subtasks:
subtask.mark_complete()

@property
def is_complete(self) -> bool:
if not self.completed_at:
return False
if self.subtasks:
return all(subtask.is_complete for subtask in self.subtasks)
return True

Putting completion state and the mark_complete operation in the same class gives the rest of the program one place to work with a task. It also gives the class a clear responsibility, which makes later changes easier to reason about.

Inheritance

An animal example is a convenient way to show inheritance and polymorphism. Different subclasses can provide their own implementation of a shared operation:

Animal base class with Dog, Bird, and Cat subclasses and their methods.
from abc import ABC, abstractmethod

class Animal(ABC):
def __init__(self, name: str, age: int):
self.name = name
self.age = age

def make_sound(self) -> str:
return "..."

@abstractmethod
def move(self) -> None:
pass

class Dog(Animal):
def __init__(self, name: str, age: int, breed: str):
super().__init__(name, age)
self.breed = breed

def fetch(self, item: str) -> None:
print(f"{self.name} fetched the {item}!")

def move(self) -> None:
print(f"{self.name} runs on four legs.")

I found it easy to overuse inheritance at first. A class hierarchy works when the subclasses can sensibly be used as the parent type, but many relationships are better expressed by giving one object another object to use. After building a few tangled hierarchies, I started considering composition earlier.

SOLID

The SOLID principles felt abstract when they first came up in lectures. Refactoring my own code gave me examples of the problems they were trying to address.

Single responsibility was the most useful starting point. In a task manager, managing tasks and delivering notifications are separate concerns, so I moved the notification code into its own service:

from typing import Protocol
from task import Task

class NotificationService(Protocol):
def notify(self, message: str) -> None:
pass

class TaskManager:
def __init__(self, notification_service: NotificationService) -> None:
self.notification_service = notification_service
self.tasks: list[Task] = []

def add_task(self, task: Task) -> None:
self.tasks.append(task)
self.notification_service.notify(f"New task created: {task.title}")

def complete_task(self, task_title: str) -> None:
task = self._find_task(task_title)
if task:
task.mark_complete()
self.notification_service.notify(f"Task Completed: {task.title}")

def _find_task(self, title: str) -> Task | None:
return next((task for task in self.tasks if task.title == title), None)

With notifications behind a separate interface, the task manager doesn’t need to know how a message gets delivered. An email or Slack implementation can use the same interface without changing the task-management logic.

Design patterns

I initially learned design patterns as names and diagrams from a textbook. They were easier to understand after I’d written similar code and run into the problems myself.

For example, the observer pattern lets several notification handlers subscribe to changes in a task:

Observer pattern: a TaskSubject notifies EmailNotifier and SlackNotifier observers.
from abc import ABC, abstractmethod

class Observer(ABC):
@abstractmethod
def update(self, event: str) -> None:
pass

class Subject(ABC):
def __init__(self):
self._observers: set[Observer] = set()

def attach(self, observer: Observer) -> None:
self._observers.add(observer)

def detach(self, observer: Observer) -> None:
self._observers.discard(observer)

def notify(self, event: str) -> None:
for observer in self._observers:
observer.update(event)

class TaskSubject(Subject):
def create_task(self, title: str) -> None:
self.notify(f"Task created: {title}")

The subject keeps track of its subscribers and notifies them when something happens. A new subscriber can be added without teaching the subject how that subscriber handles the event.

Refactoring larger programs

I would spend more time thinking about how objects depend on one another. Making each class tidy doesn’t help much if every change requires updates across half the program.

Refactoring was useful because I could compare two designs for a problem I already understood. The principles were much easier to apply when I had a specific source of confusion or duplication to fix.

A lot of this came together in Recess Chess, a chess engine I built with classmates. Representing pieces, moves, board states, and turns gave us enough interacting parts to see where a design helped and where it made the code harder to change.

Discussion

Read or leave a comment on GitHub.