Classes, Objects & State¶
What this lesson gives you
A class is a blueprint; an object is an instance. Encapsulating state and behavior together is the core idea of OOP.
Estimated time: 22 min read · Part: Object-Oriented Python
Learning objectives
- Understand the full anatomy of a Python class: instance attributes, class attributes, methods
- Distinguish
@classmethod,@staticmethod, and instance methods — when to use each - Use
@propertyto implement controlled attribute access with clean syntax - Avoid the class-attribute vs instance-attribute mutation trap
- Model real-world domain objects with validation, invariants, and computed properties
A class bundles data (attributes) and behavior (methods) into a reusable blueprint. Each object is an independent instance of that class — with its own state but sharing the class's method definitions. This encapsulation — keeping related data and behavior together, and controlling how the outside world accesses them — is the core idea that makes large codebases navigable.
Python's OOP is deliberately flexible. Privacy is by convention rather than enforcement; methods are just functions with self as the first argument; everything, including classes themselves, is an object. Understanding these mechanics — rather than treating OOP as a set of rules imported from Java — is what distinguishes idiomatic Python OOP from verbose, un-Pythonic code.
Class Anatomy: Attributes and Methods¶
Python Class Structure
──────────────────────────────────────────────────────────────
class BankAccount: ← class definition
bank_name = "PyBank" ← class attribute: SHARED by all instances
def __init__(self, owner, bal): ← initializer (not "constructor")
self.owner = owner ← instance attribute: per-object
self._balance = bal ← _ prefix = "internal by convention"
def deposit(self, amount): ← instance method: first arg = self
...
@classmethod ← class method: first arg = cls
def from_csv(cls, row): ← alternative constructor pattern
...
@staticmethod ← static method: no self or cls
def validate_amount(amount): ← utility, logically grouped here
...
@property ← computed attribute
def balance(self): ← obj.balance reads like an attribute
...
Object in memory:
account = BankAccount("Ada", 100)
┌──────────────────────────────────────┐
│ account.__dict__ │
│ {"owner": "Ada", "_balance": 100} │ ← only INSTANCE attributes stored here
│ │
│ account.__class__ → BankAccount │ ← pointer to the class
└──────────────────────────────────────┘
Method lookup: account.deposit → BankAccount.deposit (found in class, not instance)
from dataclasses import dataclass
from datetime import datetime
class BankAccount:
bank_name = "PyBank" # class attribute: shared by all instances
_interest = 0.025 # class attribute: convention = internal
def __init__(self, owner: str, initial_balance: float = 0.0):
self.owner = owner # instance attribute
self._balance = initial_balance # _ = "internal, not public API"
self._history: list[dict] = []
# ── Instance methods ──────────────────────────────────────────────────────
def deposit(self, amount: float) -> None:
if amount <= 0:
raise ValueError(f"Deposit amount must be positive, got {amount}")
self._balance += amount
self._history.append({"type": "deposit", "amount": amount,
"ts": datetime.utcnow().isoformat()})
def withdraw(self, amount: float) -> None:
if amount <= 0:
raise ValueError("Withdrawal must be positive")
if amount > self._balance:
raise ValueError(f"Insufficient funds: {self._balance:.2f} < {amount:.2f}")
self._balance -= amount
self._history.append({"type": "withdraw", "amount": amount,
"ts": datetime.utcnow().isoformat()})
# ── Property: controlled read access ─────────────────────────────────────
@property
def balance(self) -> float:
return round(self._balance, 2)
@property
def transaction_count(self) -> int:
return len(self._history)
# ── Class method: alternative constructor ────────────────────────────────
@classmethod
def from_dict(cls, data: dict) -> "BankAccount":
"""Create an account from a serialized dict (e.g., from a database row)."""
account = cls(data["owner"], data.get("balance", 0.0))
return account
# ── Static method: utility with logical coupling to this class ────────────
@staticmethod
def validate_amount(amount) -> bool:
"""Returns True if amount is a valid positive number."""
return isinstance(amount, (int, float)) and amount > 0
def __repr__(self) -> str:
return f"BankAccount(owner={self.owner!r}, balance={self.balance})"
# Usage
acc = BankAccount("Ada", 500.0)
acc.deposit(250)
acc.withdraw(100)
print(acc.balance) # 650.0
print(acc) # BankAccount(owner='Ada', balance=650.0)
# Alternative constructor
acc2 = BankAccount.from_dict({"owner": "Bob", "balance": 200.0})
# Class attribute access
print(BankAccount.bank_name) # "PyBank"
print(acc.bank_name) # "PyBank" — instances inherit class attrs
650.0 BankAccount(owner='Ada', balance=650.0)
Class attributes vs instance attributes: the mutation trap
A class attribute shared by all instances becomes a problem when it's mutable. If you write MyClass.shared_list = [] and one instance appends to it, all instances see the change — it's the same object. When you assign self.shared_list = [...], you create a new instance attribute that shadows the class attribute for that instance only. Keep per-object state in __init__ and only use class attributes for constants or shared configuration that should genuinely be the same for every instance.
# Class attribute mutation trap
class Config:
flags = [] # SHARED — dangerous if you append to it
c1 = Config()
c2 = Config()
c1.flags.append("debug") # mutates the SHARED class attribute
c2.flags # ["debug"] — c2 is affected!
# CORRECT: create per-instance in __init__
class Config:
def __init__(self):
self.flags = [] # each instance gets its own list
c1 = Config(); c2 = Config()
c1.flags.append("debug")
c2.flags # [] — unaffected
# Reading vs assigning class attributes through instances
class Counter:
count = 0 # class attribute
c = Counter()
Counter.count += 1 # modifies the class attribute — all see it
c.count # 1
c.count = 99 # creates an INSTANCE attribute, shadowing the class one
Counter.count # 1 — class attribute unchanged
c.count # 99 — instance attribute hides it
Properties: Controlled Attribute Access¶
Python has no built-in private access modifier. The convention is: single leading underscore (_name) means "internal implementation detail, don't rely on this from outside." The @property decorator lets you expose a read-only (or read-write) interface that looks like a plain attribute but runs code — validation, computation, logging — transparently.
The key advantage: you can start with a plain public attribute and later add a property without changing any calling code. Callers always write obj.balance, whether that's a direct attribute or a property method. This is the Pythonic alternative to Java-style getBalance()/setBalance() methods.
class Temperature:
def __init__(self, celsius: float):
self.celsius = celsius # triggers the setter below if defined
@property
def celsius(self) -> float:
return self._celsius
@celsius.setter
def celsius(self, value: float) -> None:
if value < -273.15:
raise ValueError(f"Temperature below absolute zero: {value}")
self._celsius = float(value)
@celsius.deleter
def celsius(self) -> None:
raise AttributeError("Temperature cannot be deleted")
@property
def fahrenheit(self) -> float:
"""Read-only computed property — no setter."""
return self._celsius * 9/5 + 32
@property
def kelvin(self) -> float:
return self._celsius + 273.15
t = Temperature(100)
t.fahrenheit # 212.0 — computed, no stored state
t.kelvin # 373.15
t.celsius = -10 # triggers setter, validates
t.celsius = -300 # ValueError: Temperature below absolute zero
# Property as a refactor safety net:
# Start with plain attribute: self.radius = r
# Later add validation: @property + setter
# ALL callers that wrote obj.radius += 1 continue to work unchanged
Properties as bank tellers
A plain attribute is a self-service ATM — you reach in and change the balance directly, no checks. A @property with a setter is a bank teller: you make a request, the teller validates it (positive amount? sufficient funds?), executes it, and records the transaction. Callers still use the same natural syntax (account.balance = x), but the class controls what actually happens. This is encapsulation in practice: the API stays stable while the implementation can verify invariants.
classmethod vs staticmethod vs Instance Method¶
| Decorator | First argument | Accesses instance? | Accesses class? | Common use |
|---|---|---|---|---|
| (none) | self — the instance | Yes | Via self.__class__ | Normal behavior that works on the object's state |
@classmethod | cls — the class | No | Yes (directly) | Alternative constructors, factory methods |
@staticmethod | (none) | No | No | Utility functions logically grouped with the class |
import json
from datetime import date
class Report:
default_format = "pdf"
def __init__(self, title: str, data: list, fmt: str = None):
self.title = title
self.data = data
self.format = fmt or self.__class__.default_format
# Instance method: works on self
def render(self) -> str:
return f"[{self.format.upper()}] {self.title}: {len(self.data)} rows"
# @classmethod: alternative constructors — the most common use case
@classmethod
def from_json(cls, json_str: str) -> "Report":
"""Deserialize from JSON — uses cls so subclasses work correctly."""
payload = json.loads(json_str)
return cls(payload["title"], payload["data"],
payload.get("format", cls.default_format))
@classmethod
def daily_template(cls) -> "Report":
"""Factory for today's daily report template."""
return cls(f"Daily Report {date.today()}", [])
# @staticmethod: pure utility with no class/instance access needed
@staticmethod
def sanitize_title(title: str) -> str:
"""Remove special characters from a report title."""
return "".join(c for c in title if c.isalnum() or c in " _-")
# Usage
r1 = Report("Sales Q2", [1, 2, 3])
r2 = Report.from_json('{"title": "Marketing", "data": [], "format": "html"}')
r3 = Report.daily_template()
safe = Report.sanitize_title("Q2 Report! (Final)") # "Q2 Report Final"
Python has no real 'private' — it's convention
Single underscore _name: "internal — don't use this from outside the class." This is a social contract, not enforced. Double underscore __name (name mangling): Python rewrites it to _ClassName__name at compile time, preventing accidental subclass clashes. It's still accessible; it's not truly private. Don't use double underscores as a privacy mechanism — use them only to avoid accidental name collisions in class hierarchies. The Pythonic approach is: trust your users, document your API, and use _name to signal "this is implementation detail."
WHY Why does Python use self explicitly? In Python, methods are just regular functions stored in the class — there's no special "method" syntax. When you call obj.method(arg), Python translates it to Class.method(obj, arg). The explicit self makes this transparent: there's no magic receiver, just a regular argument. Guido van Rossum has said this is intentional — explicit is better than implicit, and the mechanism is understandable without special knowledge.
HOW How does attribute lookup work? Python searches for an attribute in this order: (1) the instance's __dict__, (2) the class's __dict__, (3) base classes in MRO order. This is why instance attributes shadow class attributes: the instance dict is searched first. Properties and descriptors intercept the lookup before instance dicts are checked, which is how they work at all.
WHERE Where does @classmethod matter most? Alternative constructors. When you have a class that can be built from a CSV row, a database record, a JSON string, or an environment variable, each deserves a named constructor: Config.from_env(), Config.from_file(path), Config.from_dict(d). Using cls instead of hardcoding the class name means subclasses get the right type back — a critical detail when building class hierarchies.
Knowledge check
InterviewWhat does @property let you do, and why is it preferred over get_x()/set_x() methods in Python?
- It makes the attribute truly private and inaccessible from outside.
- It lets a method be called with attribute syntax (
obj.x), so you can add validation or computation without changing calling code — preserving the clean attribute interface. - It saves the attribute automatically to a database.
- It makes the class faster because it avoids method call overhead.
Answer
It lets a method be called with attribute syntax (obj.x), so you can add validation or computation without changing calling code — preserving the clean attribute interface.
@property turns a method into a managed attribute: callers write obj.x , but behind it you control reads (and with a setter, writes) — adding validation, computation, or logging. The benefit is backward compatibility: you can start with a plain attribute and later promote it to a property without changing a single line of calling code. Java-style getX() methods require callers to change from obj.x to obj.getX() .
Knowledge check
GotchaClass Team has members = [] as a class attribute. Two instances each call instance.members.append(name). What happens?
- Each instance has its own independent list.
- Both instances share the same list — appending to it from one instance changes what the other sees, because they're both referencing the same class attribute object.
- A
TypeErroris raised because class attributes are read-only. - Python creates a copy of the class attribute for each instance on first access.
Answer
Both instances share the same list — appending to it from one instance changes what the other sees, because they're both referencing the same class attribute object.
A class attribute is a single object shared by all instances. Calling instance.members.append(x) mutates the list object in-place — it doesn't create a new instance attribute. All instances still point to the same list, now with an extra element. Fix: create the list in init so each instance gets its own: self.members = [] .
Knowledge check
InterviewWhen would you choose @classmethod over @staticmethod?
- Use @classmethod for all utility functions; @staticmethod is never needed.
- Use @classmethod when the method needs to create instances (factory/alternative constructor) or access class-level state via
cls; use @staticmethod for pure utilities that have no need for class or instance access. - They are interchangeable; it's purely stylistic.
- Use @staticmethod for subclasses and @classmethod for base classes.
Answer
Use @classmethod when the method needs to create instances (factory/alternative constructor) or access class-level state via cls; use @staticmethod for pure utilities that have no need for class or instance access.
The key distinction is cls . A @classmethod receives the class (or subclass, when called on a subclass) as its first argument — essential for alternative constructors that must return the correct type in a hierarchy. A @staticmethod gets no implicit argument; it's just a function scoped inside the class for organizational clarity. If you need to create an instance or access class-level state, use classmethod. If not, staticmethod is cleaner.
Exercise: Validated Domain Model
Implement a Product class for an e-commerce system. It should have: sku (string, uppercase, no spaces), name (string), _price (float, must be > 0), exposed via a property with a setter that validates. Add: a @classmethod from_dict(cls, d) alternative constructor, a @staticmethod is_valid_sku(s) validator, a discounted_price(pct) method, and a useful __repr__.
Show solution
import re
class Product:
_SKU_PATTERN = re.compile(r"^[A-Z0-9\-]{3,20}$")
def __init__(self, sku: str, name: str, price: float):
if not self.is_valid_sku(sku):
raise ValueError(f"Invalid SKU {sku!r}: must be 3-20 uppercase alphanumeric/dash chars")
if not name or not name.strip():
raise ValueError("Product name cannot be empty")
self.sku = sku.upper()
self.name = name.strip()
self.price = price # triggers the setter
@property
def price(self) -> float:
return self._price
@price.setter
def price(self, value: float) -> None:
if not isinstance(value, (int, float)) or value <= 0:
raise ValueError(f"Price must be a positive number, got {value!r}")
self._price = round(float(value), 2)
def discounted_price(self, pct: float) -> float:
"""Return price after applying discount percentage (0-100)."""
if not 0 <= pct <= 100:
raise ValueError(f"Discount must be 0-100%, got {pct}")
return round(self._price * (1 - pct / 100), 2)
@classmethod
def from_dict(cls, data: dict) -> "Product":
return cls(
sku=data["sku"],
name=data["name"],
price=data["price"],
)
@staticmethod
def is_valid_sku(s: str) -> bool:
return bool(Product._SKU_PATTERN.match(s)) if isinstance(s, str) else False
def __repr__(self) -> str:
return f"Product(sku={self.sku!r}, name={self.name!r}, price={self.price})"
# Tests
p = Product("PEN-001", "Fountain Pen", 19.99)
print(p) # Product(sku='PEN-001', name='Fountain Pen', price=19.99)
print(p.discounted_price(20)) # 15.99
p.price = 24.99 # setter validates
p2 = Product.from_dict({"sku": "PAD-002", "name": "Notebook", "price": 8.50})
print(Product.is_valid_sku("inv alid")) # False
try:
Product("bad sku!", "test", 1.0)
except ValueError as e:
print(e)
Key takeaways
- Class attributes are shared across all instances; instance attributes (in
__init__) are per-object. @propertyexposes controlled attribute access with cleanobj.xsyntax — add validation without breaking callers.@classmethodreceivescls: use for factory/alternative constructors.@staticmethod: pure utility with no class/instance access.- Python privacy is by convention (
_name), not enforcement — no truly private attributes. - Always implement
__repr__— it makes debugging far easier in logs and the REPL.