Python classes OOP __init__ self property str repr guide FP2

Python classes — objects, encapsulation and magic methods from scratch

Python classes are the tool that takes your programs from a collection of functions to a structured, reusable system. In FP1 you wrote code that ran from top to bottom. In FP2 you start modelling the real world — a student, a bank account, a product in a catalogue — as objects that have data and behaviour. This article covers everything: __init__, self, instance methods, private attributes, @property, __str__ and __repr__.

What is a class and what is an object?

A class is a blueprint — it defines the structure. An object is an instance created from that blueprint.

The analogy that always works: a class is like an architectural plan for a house. The plan itself isn’t a house — it’s the description of what a house should have: two floors, three bedrooms, a kitchen. When you actually build a house following that plan, that’s an object. You can build many houses from the same plan — each one is a separate object with its own characteristics (one is painted blue, another has a garden), but all follow the same structure.

# The blueprint (class)
class Student:
    pass

# Building instances (objects) from the blueprint
student1 = Student()
student2 = Student()

print(type(student1))         # → <class '__main__.Student'>
print(student1 is student2)   # → False — two separate objects

The init method — the constructor

__init__ is the method that Python calls automatically every time you create a new object. It’s where you initialise the object’s attributes — the data each individual instance will carry.

class Student:
    def __init__(self, name, age, degree):
        self.name = name
        self.age = age
        self.degree = degree
# Creating objects — Python calls __init__ automatically
s1 = Student('Sergio', 20, 'GCID')
s2 = Student('María', 22, 'Informatics')

print(s1.name)     # → Sergio
print(s2.name)     # → María
print(s1.degree)   # → GCID

__init__ has two special characteristics. First, it always has self as the first parameter — but you never pass it when calling. Python passes it automatically. Second, it has double underscores before and after the name (__init__) — this is Python’s convention for “magic methods” or “dunder methods” that Python calls automatically in certain situations.

What is self?

self is the reference to the current object — the instance being created or used. When you write self.name = name, you’re saying “store this value in the name attribute of this specific object”.

class Student:
    def __init__(self, name, age):
        self.name = name    # self.name → this object's name attribute
        self.age = age      # self.age  → this object's age attribute
s1 = Student('Sergio', 20)
s2 = Student('María', 22)

# s1.name and s2.name are different variables in different objects
print(s1.name)    # → Sergio
print(s2.name)    # → María
s1.name = 'Carlos'
print(s1.name)    # → Carlos — only s1 changed
print(s2.name)    # → María — s2 is untouched

The name self is a convention, not a keyword — you could name it anything, but using self is so universal in Python that deviating from it would confuse anyone reading your code.

Instance methods

Instance methods are functions defined inside the class that operate on the object’s data. They always receive self as the first parameter:

class Student:
    def __init__(self, name, age, degree):
        self.name = name
        self.age = age
        self.degree = degree
        self.grades = []

    def add_grade(self, grade):
        if 0 <= grade <= 10:
            self.grades.append(grade)
        else:
            raise ValueError(f'Grade must be between 0 and 10, got {grade}')

    def average(self):
        if not self.grades:
            return 0
        return round(sum(self.grades) / len(self.grades), 2)

    def is_passing(self):
        return self.average() >= 5.0

    def introduce(self):
        status = 'passing' if self.is_passing() else 'failing'
        return (f'I am {self.name}, {self.age} years old, '
                f'studying {self.degree}. '
                f'Average: {self.average()} — {status}.')
s = Student('Sergio', 20, 'GCID')
s.add_grade(7.5)
s.add_grade(8.0)
s.add_grade(6.5)

print(s.average())      # → 7.33
print(s.is_passing())   # → True
print(s.introduce())
# → I am Sergio, 20 years old, studying GCID. Average: 7.33 — passing.

Notice how introduce calls self.is_passing() and self.average() — methods can call other methods on the same object through self.

Private attributes — encapsulation

In Python there are no truly private attributes (unlike Java’s private keyword), but there are conventions that signal “this is internal — don’t access it directly”:

class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner       # public — anyone can access
        self._balance = balance  # protected — single underscore: "be careful"
        self.__pin = 1234        # private — double underscore: "don't touch"

Single underscore _ — a convention saying “this is internal, use it with care”. Python doesn’t enforce anything — it’s a signal to other programmers.

Double underscore __ — Python applies name mangling: __pin becomes _BankAccount__pin. It’s still accessible but deliberately awkward:

account = BankAccount('Sergio', 1000)

print(account.owner)        # → Sergio (public — fine)
print(account._balance)     # → 1000 (works but discouraged)
print(account.__pin)        # → AttributeError (name mangling hides it)
print(account._BankAccount__pin)  # → 1234 (still reachable but ugly)

The philosophy: Python trusts programmers. It signals “don’t touch this” through naming conventions rather than enforcing it mechanically. If you really want to access a _private attribute you can — but you’ve been warned.

Getters and setters — controlling access

If you want controlled access to attributes (validation on write, computed values on read), you can write explicit getter and setter methods:

class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self._balance = balance

    def get_balance(self):
        return self._balance

    def set_balance(self, amount):
        if amount < 0:
            raise ValueError('Balance cannot be negative')
        self._balance = amount

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError('Deposit amount must be positive')
        self._balance += amount

    def withdraw(self, amount):
        if amount <= 0:
            raise ValueError('Withdrawal amount must be positive')
        if amount > self._balance:
            raise ValueError('Insufficient funds')
        self._balance -= amount
account = BankAccount('Sergio', 1000)
account.deposit(500)
print(account.get_balance())    # → 1500

account.set_balance(-100)       # → ValueError: Balance cannot be negative

@property — the Pythonic way

@property makes getter and setter methods look like regular attribute access — the most Pythonic approach:

class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self._balance = balance

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

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

    @property
    def status(self):
        if self._balance >= 1000:
            return 'Premium'
        elif self._balance >= 100:
            return 'Standard'
        else:
            return 'Low funds'
account = BankAccount('Sergio', 1500)

# Reads like an attribute — actually calls the getter method
print(account.balance)     # → 1500
print(account.status)      # → Premium

# Writes like an attribute — actually calls the setter method
account.balance = 500
print(account.balance)     # → 500
print(account.status)      # → Standard

# Setter validation triggers
account.balance = -100     # → ValueError: Balance cannot be negative

The status property has no setter — it’s read-only. Trying to assign to it raises AttributeError. This is the @property decorator’s power: you get clean attribute-style access with full control over reading and writing.

Class attributes vs instance attributes

Instance attributes (defined with self.) belong to each individual object. Class attributes are defined directly in the class body and are shared by all instances:

class Student:
    # Class attribute — shared by all students
    university = 'ULPGC'
    student_count = 0

    def __init__(self, name, degree):
        # Instance attributes — unique per object
        self.name = name
        self.degree = degree
        Student.student_count += 1    # increment the shared counter

    @classmethod
    def get_count(cls):
        return cls.student_count

s1 = Student('Sergio', 'GCID')
s2 = Student('María', 'Informatics')

print(s1.university)           # → ULPGC (from class)
print(s2.university)           # → ULPGC (same class attribute)
print(Student.student_count)   # → 2
print(Student.get_count())     # → 2

# If you assign to an instance, it creates an instance attribute
# that shadows the class attribute for that instance only
s1.university = 'UAM'
print(s1.university)    # → UAM (instance attribute — shadows class)
print(s2.university)    # → ULPGC (class attribute — unchanged)

The str and repr magic methods

Without these methods, printing an object gives useless information:

s = Student('Sergio', 20, 'GCID')
print(s)    # → <__main__.Student object at 0x7f...>

__str__ controls what print() and str() show — the human-readable representation:

class Student:
    def __init__(self, name, age, degree):
        self.name = name
        self.age = age
        self.degree = degree

    def __str__(self):
        return f'Student({self.name}, {self.age}, {self.degree})'
s = Student('Sergio', 20, 'GCID')
print(s)         # → Student(Sergio, 20, GCID)
print(str(s))    # → Student(Sergio, 20, GCID)

__repr__ controls the “official” representation — what you see in the Python shell, in logs and in debugging. It should ideally be a string that could recreate the object:

def __repr__(self):
    return f"Student(name='{self.name}', age={self.age}, degree='{self.degree}')"
s = Student('Sergio', 20, 'GCID')
repr(s)    # → "Student(name='Sergio', age=20, degree='GCID')"

The rule of thumb: __str__ is for end users, __repr__ is for developers. If you only define one, define __repr__ — Python uses it as the fallback for str() too.

Other useful magic methods

class Student:
    def __init__(self, name, average):
        self.name = name
        self.average = average

    def __eq__(self, other):
        """Two students are equal if they have the same name."""
        if not isinstance(other, Student):
            return False
        return self.name.lower() == other.name.lower()

    def __lt__(self, other):
        """Allows sorting by average grade."""
        return self.average < other.average

    def __len__(self):
        """Number of characters in the name."""
        return len(self.name)

    def __bool__(self):
        """A student is 'truthy' if they are passing."""
        return self.average >= 5.0
s1 = Student('Sergio', 7.5)
s2 = Student('María', 8.0)
s3 = Student('Sergio', 6.0)

print(s1 == s3)       # → True (same name)
print(s1 == s2)       # → False

students = [s2, s1]
print(sorted(students, key=lambda s: s.average))   # sort by average

print(len(s1))        # → 6 (len of 'Sergio')
print(bool(s1))       # → True (average >= 5)

# bool is used automatically in if statements
if s1:
    print(f'{s1.name} is passing')

A complete class

class Product:
    # Class attribute
    vat_rate = 0.21

    def __init__(self, name, price, stock=0):
        self.name = name
        self._price = price
        self._stock = stock

    @property
    def price(self):
        return self._price

    @price.setter
    def price(self, value):
        if value < 0:
            raise ValueError(f'Price cannot be negative: {value}')
        self._price = round(value, 2)

    @property
    def price_with_vat(self):
        return round(self._price * (1 + self.vat_rate), 2)

    @property
    def stock(self):
        return self._stock

    def add_stock(self, quantity):
        if quantity <= 0:
            raise ValueError('Quantity must be positive')
        self._stock += quantity

    def sell(self, quantity):
        if quantity <= 0:
            raise ValueError('Quantity must be positive')
        if quantity > self._stock:
            raise ValueError(f'Insufficient stock: {self._stock} available')
        self._stock -= quantity
        return quantity * self._price

    def __str__(self):
        return f'{self.name} — €{self._price} (stock: {self._stock})'

    def __repr__(self):
        return (f"Product(name='{self.name}', "
                f"price={self._price}, stock={self._stock})")

    def __bool__(self):
        return self._stock > 0


# Usage
laptop = Product('Laptop', 899.99, 10)
mouse  = Product('Mouse', 29.99, 50)

print(laptop)                      # → Laptop — €899.99 (stock: 10)
print(f'With VAT: €{laptop.price_with_vat}')  # → With VAT: €1088.99

revenue = laptop.sell(2)
print(f'Sale revenue: €{revenue}')  # → Sale revenue: €1799.98
print(laptop)                       # → Laptop — €899.99 (stock: 8)

laptop.price = 849.99
print(laptop.price)                 # → 849.99

if laptop:
    print('Laptop in stock')

try:
    laptop.sell(100)
except ValueError as err:
    print(err)    # → Insufficient stock: 8 available

Visualise with Python Tutor

Copy this code into pythontutor.com and step through it:

class Counter:
    count = 0    # class attribute

    def __init__(self, name):
        self.name = name         # instance attribute
        Counter.count += 1

    def __str__(self):
        return f'Counter({self.name})'

c1 = Counter('first')
c2 = Counter('second')

print(c1)               # __str__ called
print(Counter.count)    # 2
print(c1.count)         # 2 — reads class attribute
c1.count = 99           # creates instance attribute on c1
print(c1.count)         # 99 — instance shadows class
print(c2.count)         # 2 — c2 still sees class attribute

Step through and observe four key moments. When Counter('first') is called, Python creates a new frame for __init__self is the new object being built. Counter.count += 1 increments the class variable shared by all instances. When c1.count = 99 is executed, Python creates a new instance attribute on c1 — it doesn’t modify the class attribute. After that assignment, c1.count returns 99 (instance) while c2.count still returns 2 (class). The two levels — class and instance — coexist independently.

Quick summary

# CLASS DEFINITION
class MyClass:
    class_attr = 'shared'    # class attribute — shared by all

    def __init__(self, value):
        self.value = value   # instance attribute — unique per object

    def method(self):
        return self.value    # access instance attribute

# CREATE OBJECTS
obj1 = MyClass(10)
obj2 = MyClass(20)

# PRIVATE ATTRIBUTES
self._protected = value     # convention: be careful
self.__private = value      # name mangling: _MyClass__private

# @property — controlled access
@property
def value(self):
    return self._value       # getter

@value.setter
def value(self, v):
    if v < 0:
        raise ValueError('Must be positive')
    self._value = v          # setter with validation

# No setter → read-only property
@property
def computed(self):
    return self._value * 2

# MAGIC METHODS
def __str__(self):   return 'human readable'  # print(), str()
def __repr__(self):  return 'developer repr'  # shell, repr()
def __eq__(self, other): return self.value == other.value
def __lt__(self, other): return self.value < other.value
def __len__(self):   return len(self._items)
def __bool__(self):  return self.value > 0

# CLASS vs INSTANCE ATTRIBUTES
MyClass.class_attr        # access class attribute on class
obj1.class_attr           # access class attribute on instance
obj1.instance_attr        # access instance attribute
obj1.class_attr = 'new'   # creates instance attribute — shadows class

# SELF
# self → the current instance
# always first parameter of instance methods
# Python passes it automatically — you never pass it yourself

# COMMON MISTAKES
# 1. Forgetting self in method definitions → TypeError
# 2. self.attr = value vs attr = value (local variable)
# 3. Accessing __private without name mangling → AttributeError
# 4. Forgetting @property getter before adding setter
# 5. Using class attribute as instance attribute accidentally

# DUNDER NAMING
# __init__  → constructor
# __str__   → str() and print()
# __repr__  → repr() and shell
# __eq__    → ==
# __lt__    → < (enables sorting)
# __len__   → len()
# __bool__  → bool() and if statements

In the next article we practice Python classes with real programs — a product catalogue, a student registry and a task manager.

Similar Posts

4 Comments

Leave a Reply

Your email address will not be published. Required fields are marked *