Skip to main content

Object-Oriented Programming

Learn object-oriented programming concepts in Python

Object-oriented programming in Python organizes code into classes that bundle state with the methods that act on it. A class defines the blueprint, an instance is one concrete object built from that blueprint, and methods are the behaviors attached to it. Coursework at CS50P, CS106A, and DATA 100 treats OOP as the bridge between scripting and software engineering, so the patterns below show up across every intermediate Python assignment.

How a class definition and __init__ build an instance

A class definition introduces a new type. The class body lists the attributes and methods every instance gets, and the dunder method **__init__** runs once per instantiation to set the initial state. The first parameter of every instance method is **self**, the reference to the instance being acted on. Python passes it automatically when you call `obj.method()`.

The pattern below is the one shown in 90% of CS50P submissions. Notice three things: the constructor takes the data needed to make a meaningful object, every attribute is assigned to `self`, and a regular method reads those attributes through `self.` again. No `self.` prefix means a local variable that disappears when the method returns.

Instantiation looks like a function call on the class name. `Student("Alice", 21)` builds a fresh object with its own `name` and `age`. Two students built this way share no state, which is the whole point.

Example

                      
                        class Student:
    def __init__(self, name, age):
        # self.name and self.age are instance attributes
        self.name = name
        self.age = age

    def introduce(self):
        # methods read state through self
        return f"I am {self.name}, age {self.age}."


alice = Student("Alice", 21)
bob = Student("Bob", 19)
print(alice.introduce())  # I am Alice, age 21.
print(bob.introduce())    # I am Bob, age 19.
                      
                    

Instance attributes versus class attributes

Instance attributes belong to one object. Class attributes belong to the class itself and are shared across every instance. The distinction trips up students because Python lets you read a class attribute through any instance, which makes the two look identical until somebody writes to it.

Write to `self.x` and Python creates a new instance attribute that shadows the class attribute for that one object. Write to `Cls.x` and every instance sees the change. Use class attributes for constants tied to the type, like a tax rate or a default category. Use instance attributes for anything that varies per object.

Mutable class attributes are the trap. A list set at class level is shared, so appending through one instance mutates what every other instance sees. Course graders for CS106A flag this as a top-five bug in submitted code.

Example

                      
                        class Account:
    # class attribute: shared across all accounts
    interest_rate = 0.04

    def __init__(self, owner, balance):
        self.owner = owner
        self.balance = balance


a = Account("Alice", 1000)
b = Account("Bob", 500)
print(a.interest_rate, b.interest_rate)  # 0.04 0.04

# Change the class attribute once, every instance sees it.
Account.interest_rate = 0.05
print(a.interest_rate, b.interest_rate)  # 0.05 0.05

# Shadow it on one instance.
a.interest_rate = 0.10
print(a.interest_rate, b.interest_rate)  # 0.10 0.05
                      
                    

Instance, class, and static methods compared

Three method types exist on a Python class, each marked by a decorator. Instance methods take `self` and operate on one object. Class methods, marked with **@classmethod**, take `cls` and operate on the class itself, which makes them the natural place for alternative constructors. Static methods, marked with **@staticmethod**, take neither and act as plain functions grouped under the class for namespace reasons.

The most common real use: a `@classmethod` factory like `from_string` or `from_dict` that parses input and returns a fresh instance via `cls(...)`. Calling `cls` instead of the literal class name keeps the factory working in subclasses without rewriting it. A `@staticmethod` belongs on the class when the logic is conceptually related but never touches instance or class state, like a validator that checks a date string.

Example

                      
                        class Date:
    def __init__(self, year, month, day):
        self.year, self.month, self.day = year, month, day

    @classmethod
    def from_string(cls, text):
        # alternative constructor: parse "2026-05-15" into a Date
        y, m, d = map(int, text.split("-"))
        return cls(y, m, d)

    @staticmethod
    def is_valid_year(year):
        # no self, no cls: pure helper
        return 1900 <= year <= 2100


d = Date.from_string("2026-05-15")
print(d.year, d.month, d.day)     # 2026 5 15
print(Date.is_valid_year(2026))   # True
                      
                    

Inheritance and super() in 5 lines or fewer

Inheritance lets a subclass reuse a parent's attributes and methods, then add or override its own. Declare the parent in parentheses after the subclass name: `class Manager(Employee):`. The subclass automatically gains every method on the parent. The call **super().__init__(...)** delegates to the parent's constructor so you do not duplicate attribute assignment.

Override a method by redefining it in the subclass. Inside the override, `super().method()` runs the parent's version, which is the pattern for "do everything the parent does, then add this". Python uses MRO (method resolution order) to find the right method on multiple inheritance. The MRO is visible through `Cls.__mro__` and follows the C3 linearization, which keeps single-inheritance behavior intact and gives a deterministic answer for the diamond cases.

Example

                      
                        class Employee:
    def __init__(self, name, salary):
        self.name = name
        self.salary = salary

    def describe(self):
        return f"{self.name} earns {self.salary}."


class Manager(Employee):
    def __init__(self, name, salary, reports):
        super().__init__(name, salary)  # reuse parent setup
        self.reports = reports

    def describe(self):
        base = super().describe()       # call the parent method
        return f"{base} Manages {self.reports} people."


m = Manager("Priya", 95000, 4)
print(m.describe())
# Priya earns 95000. Manages 4 people.
                      
                    

Encapsulation through _protected and __private names

Python has no true private attributes. The language uses naming conventions and one piece of mild compiler help. A single underscore prefix (`_balance`) signals "internal, do not touch from outside". A double underscore prefix (`__balance`) triggers **name mangling**: the attribute is stored as `_ClassName__balance` to prevent accidental override in subclasses.

The right tool for controlled access is **@property**. A property wraps an attribute behind getter and setter methods while keeping the syntax `obj.x` and `obj.x = value` unchanged. The getter runs on read, the setter on write, which gives you validation and computed attributes without breaking calling code. Properties are the idiomatic answer in Python; getter/setter method pairs copied from Java are not.

Example

                      
                        class BankAccount:
    def __init__(self, balance):
        self._balance = balance  # convention: internal

    @property
    def balance(self):
        return self._balance

    @balance.setter
    def balance(self, value):
        if value < 0:
            raise ValueError("balance cannot be negative")
        self._balance = value


acc = BankAccount(100)
print(acc.balance)   # 100 (calls the getter)
acc.balance = 250    # calls the setter, runs validation
print(acc.balance)   # 250
                      
                    

Dunder methods that make a class feel native

Special methods, also called dunder methods, hook your class into Python's built-in syntax. Define **__str__** and `print(obj)` returns a readable description. Define **__repr__** and the interactive interpreter shows a precise, unambiguous one. Define **__eq__** and `obj1 == obj2` compares values instead of identity. Define **__len__** and `len(obj)` works.

Four dunders cover most coursework: `__init__`, `__str__`, `__repr__`, `__eq__`. Add `__lt__` and Python's `sorted()` can order instances. Add `__hash__` and the object becomes usable as a dict key. The pattern is consistent: implement the dunder, get the built-in syntax for free.

The **@dataclass** decorator from the standard library writes `__init__`, `__repr__`, and `__eq__` automatically from your type annotations. For pure data containers, dataclasses cut 15 lines of boilerplate to 3. Use a regular class when you need behavior, a dataclass when you mostly need fields.

Example

                      
                        from dataclasses import dataclass

@dataclass
class Point:
    x: float
    y: float


p1 = Point(1.0, 2.0)
p2 = Point(1.0, 2.0)
print(p1)         # Point(x=1.0, y=2.0)  __repr__ generated
print(p1 == p2)   # True                 __eq__ generated

# Equivalent hand-written class for comparison:
class PointManual:
    def __init__(self, x, y):
        self.x, self.y = x, y

    def __repr__(self):
        return f"PointManual(x={self.x}, y={self.y})"

    def __eq__(self, other):
        return isinstance(other, PointManual) and (self.x, self.y) == (other.x, other.y)
                      
                    

Polymorphism through duck typing

Polymorphism in Python rests on duck typing, not on type declarations. If an object has the method a function expects, it works. The function never checks the class. This is why `for line in file_obj` and `for line in string_list` both iterate, even though the two types share no parent.

In practice, polymorphism appears when several classes implement the same method name. A function that calls `shape.area()` works on `Circle`, `Rectangle`, and `Triangle` instances without isinstance checks, as long as each class defines `area`. This pattern dominates Django models, scikit-learn estimators (`.fit`, `.predict`), and pytest fixtures.

Common pitfalls

Mutable default argument in __init__, like def __init__(self, items=[]). Every instance that omits items shares the same list.

Set the default to None and assign inside the body: if items is None: items = []. The default expression evaluates once at definition time, not per call.

Forgetting self in a method definition. The error TypeError: method() takes 0 positional arguments but 1 was given.

Add self as the first parameter to every instance method. Static methods skip it and need @staticmethod; class methods take cls and need @classmethod.

Calling super() with the wrong arguments in Python 3, like super(MyClass, self).__init__(). Verbose and easy to get wrong on rename.

Use the bare super().__init__(...) form in Python 3. The compiler fills in the class and instance automatically.

Treating __private as truly private and crying when it leaks. Name mangling stores it as _ClassName__private, still reachable from outside.

Use a single underscore _balance for "internal" and trust the convention. Reserve __dunder__ for actual dunder methods.

Using a class to hold five fields with no methods, writing __init__ and __repr__ by hand. 20 lines of boilerplate for a record type.

Use @dataclass from the dataclasses module. It generates __init__, __repr__, and __eq__ from type annotations in 3 lines.

Confusing class attributes with instance attributes by writing self.list = [] in __init__ versus list = [] at class level. The class-level list is shared across every instance.

Initialize mutable state inside __init__ so each instance gets its own copy. Reserve class-level assignments for genuine constants and configuration.

When to use object-oriented programming

Use OOP when you have multiple objects with shared structure plus per-object state, especially for entities like User, Order, Graph, or Account that appear repeatedly in assignment specs. Stick with functions and dictionaries for one-shot scripts and data transformations where no state survives between calls.

Need Help?

Having trouble with this topic on an assignment? Our Python developers ship working code plus a walkthrough that helps you explain the code in class.