AlgoInsight

Free lesson · Python Basic to Advance

Defining a Class

Medium9 min readpython

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):

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:

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:

  1. Creates a new empty instance (via __new__)
  2. Calls __init__ on it with the args
  3. 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 class statement syntax
  • The role of __init__ - initializer, not constructor
  • Why self is explicit (PEP 20: explicit is better than implicit)
  • That __init__ must return None
  • The difference between class attributes and instance attributes (foreshadowing Topic 12.4)
  • Validation in __init__ for invariants

A common interview moment: a candidate writes def method(value) without self. 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 self with implicit this in 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 without self as the first parameter look like static methods but aren't. Calling them on an instance produces confusing errors.

Returning from __init__. __init__ must return None. Returning anything else raises TypeError.

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. Writing value += 1 instead of self.value += 1 modifies a local, not the attribute. The local goes out of scope when the method returns.

Calling other instance methods without self.. Calling helper(x) instead of self.helper(x) looks for a function named helper at 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:

  1. Define a Point class with an __init__ that takes x and y. Create a point and access its coordinates.
  2. Add a distance_to method that takes another point and returns the Euclidean distance.
  3. Define a Rectangle class with width, height, and a method area(). Create a rectangle and verify the area.
  4. Try defining a method without self. Call it on an instance and observe the TypeError.
  5. Try returning a value from __init__. Observe the TypeError.
  6. Add validation to a class's __init__ - for example, reject negative width in Rectangle. Verify creation with bad values raises.

Back to Python Basic to Advance