Free lesson · Python Basic to Advance
Defining a Class
Defining a Class
What the class Statement Does
The class statement creates a new class object and binds it to a name. The body of the class is executed in its own namespace; whatever is defined there becomes attributes of the class.
class Dog:
species = "Canis familiaris"
def __init__(self, name, age):
self.name = name
self.age = age
def bark(self):
return f"{self.name} says woof!"
buddy = Dog("Buddy", 3)
print(buddy.bark())
# Buddy says woof!
print(buddy.species)
# Canis familiaris
This code defines a Dog class with a class-level constant, an init method, and an instance method. The init runs when a new Dog is created - receiving the name and age and storing them on self. The bark method uses self to read the name. The class-level species is shared across all instances.
The shape is consistent: class keyword, class name, colon, indented body. The body usually contains an __init__ method and one or more other methods.
The __init__ Method
__init__ is the initializer - it runs immediately after a new instance is created. Its job is to set up the instance's initial state.
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
p = Point(3, 4)
print(p.x, p.y)
# 3 4
This code shows the basic init pattern. The init method takes self and the parameters needed to set up the instance. Assigning to self dot attribute creates an attribute on the new instance. After init returns, the instance is ready to use.
__init__ is not a constructor in the strictest sense - the actual construction happens in __new__ (covered in Module 18). For everyday class definitions, __init__ is what matters, and treating it as "the constructor" is fine.
__init__ can take any parameters. The caller passes them as arguments to ClassName(...). Defaults work normally:
class Point:
def __init__(self, x=0, y=0):
self.x = x
self.y = y
origin = Point()
print(origin.x, origin.y)
# 0 0
p = Point(3, 4)
print(p.x, p.y)
# 3 4
This code uses default parameters in init. The Point class can be created with no arguments (defaults to origin) or with explicit x and y. Default arguments work in init the same way as in any function.
The Role of self
self is the first parameter of every instance method. It refers to the instance the method was called on.
class Counter:
def __init__(self):
self.value = 0
def increment(self):
self.value += 1
c1 = Counter()
c2 = Counter()
c1.increment()
c1.increment()
c2.increment()
print(c1.value)
# 2
print(c2.value)
# 1
This code shows two counters with independent state. When c1 dot increment is called, self is c1. When c2 dot increment is called, self is c2. The two instances have separate value attributes; modifications to one don't affect the other.
self is conventional - it could be any name, and Python wouldn't complain. But the convention is universal; any other name (this, me, it) reads as wrong to a Python programmer.
Calling c1.increment() is roughly equivalent to Counter.increment(c1). The dot notation finds the method on the class and passes the instance as the first argument. This is sometimes called "bound method" call - the instance is bound to the method as self.
Why self Is Explicit
Many languages have an implicit this - methods access instance state without an explicit parameter. Python makes self explicit. The rationale (PEP 8 and the Zen of Python):
- Explicit is better than implicit. Reading the method, it's clear which name refers to the instance.
- Methods are just functions. A method is a regular function attached to a class. Passing the instance as the first argument is consistent with how functions normally work.
- Less magic. No special scope rules for "this is implicitly available." The instance is a normal parameter.
This consistency is a deep design choice. Functions and methods behave the same way; methods just happen to receive the instance as their first argument.
Multiple __init__ Patterns
Several common patterns for __init__:
Plain attributes
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
This code shows the simplest init pattern. Each parameter is stored directly as an attribute with the same name. This is by far the most common shape.
With validation
class Person:
def __init__(self, name, age):
if not isinstance(age, int) or age < 0:
raise ValueError("age must be a non-negative int")
if not name:
raise ValueError("name is required")
self.name = name
self.age = age
This code validates inputs in init. If a parameter is invalid, init raises an exception, preventing the instance from being created. This pattern enforces invariants - guarantees that a successfully-constructed instance is in a valid state.
Validation in __init__ is the standard way to guarantee an instance is always in a valid state. Code that uses the instance later can assume the invariants hold.
With computed attributes
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
self.area = width * height
self.perimeter = 2 * (width + height)
This code computes derived attributes in init. The area and perimeter are calculated once from the inputs and stored. This is efficient when the inputs don't change after creation - but if they do, the derived attributes become stale.
This pattern is efficient for immutable objects. For objects where attributes can change, computed values should use @property (Topic 12.6) so they're always current.
With optional parameters
class Config:
def __init__(self, host, port=8080, debug=False, timeout=30):
self.host = host
self.port = port
self.debug = debug
self.timeout = timeout
This code uses keyword arguments with defaults for optional configuration. The host is required; other parameters have sensible defaults. Callers can override only what they need: Config of localhost or Config of localhost with port equals 9090. This is the standard pattern for configurable objects.
What Goes In the Class Body
The class body can contain:
- Methods (functions defined inside)
- Class attributes (assignments at the class level)
- Inner classes (rare but allowed)
- Docstrings (the first string literal)
class Account:
"""A bank account with deposit and withdraw operations."""
DEFAULT_INTEREST_RATE = 0.025 # class attribute (constant)
next_id = 1 # class attribute (mutable counter)
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
self.id = Account.next_id
Account.next_id += 1
def deposit(self, amount):
self.balance += amount
This code shows multiple things in a class body. The docstring documents the class. Class attributes are constants and shared mutable state. The init uses a class attribute to assign a unique ID to each new instance. Class attributes are shared across instances; instance attributes are per-instance.
The class docstring is accessible as Account.__doc__ and shown by help(Account). Use it to document what the class is for.
Class attributes vs instance attributes are covered in detail in Topic 12.4. Briefly: class-level assignments belong to the class; self.x = ... assignments belong to the instance.
Calling the Class
Calling the class as ClassName(args) does three things:
- Creates a new empty instance (via
__new__) - Calls
__init__on it with the args - Returns the initialized instance
class Greeting:
def __init__(self, name):
self.name = name
g = Greeting("World") # equivalent to: g = Greeting.__call__("World")
print(type(g))
# <class '__main__.Greeting'>
This code shows that calling the class is the standard way to create an instance. The call invokes init under the hood. The result is a new instance. The type confirms it's an instance of the class.
The user typically never deals with __new__ directly. Override __init__ for setup; __new__ is only relevant for advanced cases like immutable types or singletons.
Returning From __init__
__init__ must return None. Returning anything else raises TypeError:
class BadInit:
def __init__(self, x):
self.x = x
return self.x # error
BadInit(42)
# TypeError: __init__() should return None, not 'int'
This code shows a common mistake. Init should not return a value other than None. The error is raised when the instance is created. The fix is to remove the return statement - init's job is to set up state, not return anything.
This is a common beginner confusion. __init__ is for setup, not for returning the instance. The instance is returned automatically by the class call.
Defining Methods After __init__
Methods can be defined anywhere in the class body. Convention is __init__ first, then other methods grouped logically (related methods together, public before private, etc.):
class FileReader:
def __init__(self, path):
self.path = path
self._lines = None
# Public interface
def read(self):
self._lines = self._load_file()
return self._lines
def line_count(self):
if self._lines is None:
self.read()
return len(self._lines)
# Private helpers
def _load_file(self):
with open(self.path) as f:
return f.readlines()
This code shows method organization. Init comes first. Public methods come next, grouped by purpose. Private helpers prefixed with underscore come last. This is a common organizational pattern, though not enforced.
The grouping is convention, not rule. Consistency within a codebase is what matters most.
Type Hints in __init__
Modern Python uses type hints in method signatures, including __init__:
class Point:
def __init__(self, x: float, y: float) -> None:
self.x = x
self.y = y
class Profile:
def __init__(self, name: str, age: int, email: str | None = None) -> None:
self.name = name
self.age = age
self.email = email
This code uses type hints in init. The parameter types are documented inline. The return type of init is None - init doesn't return anything. Type hints don't enforce types at runtime; they document the contract for human readers and type checkers.
Type hints are optional but increasingly common. Tools like mypy, pyright, and modern IDEs use them for static checking. Module 17 covers type hints in depth.
Performance of Method Calls
A method call has overhead beyond a plain function call: name lookup on the instance, then on the class, then binding self. For hot loops, accessing attributes via self.x repeatedly is slower than caching in a local.
class Processor:
def __init__(self):
self.scale = 2.5
self.offset = 10
def process_slow(self, items):
return [x * self.scale + self.offset for x in items]
def process_fast(self, items):
# Cache attributes as locals
scale = self.scale
offset = self.offset
return [x * scale + offset for x in items]
This code shows attribute-access optimization. In the slow version, self dot scale and self dot offset are looked up on every iteration. In the fast version, they're cached as locals once. For tight loops over many items, this can make a real difference. For ordinary code, prefer the readable form.
This optimization is rarely needed. Apply it only to verified hot loops.
Interview Relevance
Interview: Class definition is OOP foundation:
- The
classstatement syntax- The role of
__init__- initializer, not constructor- Why
selfis explicit (PEP 20: explicit is better than implicit)- That
__init__must returnNone- The difference between class attributes and instance attributes (foreshadowing Topic 12.4)
- Validation in
__init__for invariantsA common interview moment: a candidate writes
def method(value)withoutself. The right diagnosis is the missing first parameter.Another moment: a candidate tries to return a value from
__init__. The right explanation is that the instance is returned automatically; init only sets up state.Another moment: comparing Python's explicit
selfwith implicitthisin other languages. The right answer involves the design philosophy - explicit, less magical, methods as ordinary functions.Module 30 covers Python interview preparation.
Common Pitfalls
Warning: Forgetting
self. Methods withoutselfas the first parameter look like static methods but aren't. Calling them on an instance produces confusing errors.Returning from
__init__.__init__must returnNone. Returning anything else raisesTypeError.Heavy work in
__init__. Loading files, hitting the network, building large data structures - these make instance creation slow and easy to fail. Prefer lazy initialization or a separate method.Skipping
self.for attribute access inside methods. Writingvalue += 1instead ofself.value += 1modifies a local, not the attribute. The local goes out of scope when the method returns.Calling other instance methods without
self.. Callinghelper(x)instead ofself.helper(x)looks for a function namedhelperat the module level - usually not what's intended.Mutable class attributes. A list or dict defined at the class level is shared across all instances. If one instance mutates it, all others see the change. Topic 12.4 covers this in detail.
Using
__init__for the wrong job.__init__initializes a fresh instance. For alternative construction (e.g., from a string), use a classmethod (Topic 12.4).
Try It
Exercise: Practice defining classes:
- Define a
Pointclass with an__init__that takesxandy. Create a point and access its coordinates.- Add a
distance_tomethod that takes another point and returns the Euclidean distance.- Define a
Rectangleclass withwidth,height, and a methodarea(). Create a rectangle and verify the area.- Try defining a method without
self. Call it on an instance and observe theTypeError.- Try returning a value from
__init__. Observe theTypeError.- Add validation to a class's
__init__- for example, reject negative width inRectangle. Verify creation with bad values raises.