Object-oriented programming (OOP) is a way to organize code around objects: bundles of state (attributes) and behavior (methods) that belong together. Instead of scattering a user's data across several dictionaries and scattering the functions that operate on it across several modules, OOP gives you a single User class that owns both. Done well, OOP makes large programs easier to navigate because related code lives close together.
Python's OOP is opinionated but lightweight. You define a class with class Name:, initialize an instance with __init__, and add behaviors as ordinary functions whose first parameter is self. There is no required ceremony — no public or private keywords, no separate header files. A class with a handful of methods and a handful of attributes is perfectly normal.
OOP is not a replacement for functions, data structures, or modules; it is a complement to them. Reach for a class when the code has a clear identity ("a user", "an order", "a connection") and when you find yourself passing the same group of values into function after function. For simpler code, a dict, a NamedTuple, or a dataclass might do the job with less ceremony.
The four classic OOP pillars are encapsulation (bundling state with the methods that act on it), inheritance (building new classes on top of existing ones), polymorphism (using objects of different classes interchangeably), and abstraction (hiding implementation details behind a simple interface). This lesson introduces encapsulation; the next lessons cover the rest.
Defining a class with state and behavior
class Counter: def __init__(self, start=0): self.value = start; def tick(self): self.value += 1. The __init__ method runs when you call Counter(); self is the new instance. Methods are plain functions that receive the instance as their first argument.
Each instance gets its own copy of the attributes assigned on self. Attributes defined on the class itself (outside any method) are shared across all instances and are called class attributes.
Picking between OOP and lighter alternatives
For a record of fields without much behavior, a NamedTuple or dataclass is almost always simpler. For configuration or a one-off bag of values, a dict works fine. Use a class when it gains you something real: a natural home for methods, shared invariants, a reusable type.
Avoid the "manager class" antipattern: a class with no state and a single public method is just a function dressed up. Write it as a function instead.
The OOP vocabulary you will use from day one.
| Tool | Purpose |
|---|---|
class Name:statement | Defines a new class (type). |
__init__(self, ...)method | Initializer run when creating an instance. |
self.attrattribute | Per-instance state set on self. |
class attributeconcept | Data defined at class scope, shared across instances. |
isinstance(obj, Cls)built-in | Tests whether obj is an instance of Cls (or subclass). |
__repr__(self)method | Unambiguous developer-facing representation. |
@dataclassdecorator | Auto-generates __init__, __repr__, __eq__. |
typing.NamedTupleclass | Typed immutable record alternative. |
Introduction to Object-Oriented Programming code example
The script defines a small Counter class and demonstrates instances, methods, and a class attribute.
# Lesson: Introduction to Object-Oriented Programming
class Counter:
created = 0 # class attribute (shared)
def __init__(self, start: int = 0) -> None:
self.value = start
Counter.created += 1
def tick(self) -> int:
self.value += 1
return self.value
def reset(self) -> None:
self.value = 0
def __repr__(self) -> str:
return f"Counter(value={self.value})"
a = Counter()
b = Counter(start=100)
a.tick(); a.tick(); a.tick()
b.tick()
print("a:", a)
print("b:", b)
print("a.value, b.value:", a.value, b.value)
print("Counter.created:", Counter.created)
# Methods are plain functions with self as the first argument
a.reset()
print("after reset:", a)
# isinstance / type introspection
print("isinstance(a, Counter):", isinstance(a, Counter))
print("type(a).__name__ :", type(a).__name__)
What to read for:
1) `__init__` runs once per `Counter()` call and initializes self.value.
2) Each instance holds its own value; the class attribute `created` is shared.
3) `__repr__` gives a useful default for `print` and debugging.
4) `isinstance` is the standard way to check that you got the right type.
Build a tiny class with two methods.
class Bag:
def __init__(self):
self.items: list[str] = []
def add(self, item: str) -> None:
self.items.append(item)
def __len__(self) -> int:
return len(self.items)
b = Bag()
b.add("apple"); b.add("book")
print(len(b)) # 2
print(b.items)
Check the per-instance state rule.
class P:
shared = []
def __init__(self, x):
self.x = x
a = P(1); b = P(2)
assert a.x == 1 and b.x == 2
assert a.shared is b.shared
a.shared.append("ok")
assert b.shared == ["ok"]
Running prints:
a: Counter(value=3)
b: Counter(value=101)
a.value, b.value: 3 101
Counter.created: 2
after reset: Counter(value=0)
isinstance(a, Counter): True
type(a).__name__ : Counter