Python with AI Tutorial · Chapter 32 of 48
Python classes and objects let you bundle data and the functions that work on it into one named thing. A class is the blueprint (Employee, Invoice, Product); an object is one concrete instance built from it. The __init__ method sets up each new object, self refers to the object being worked on, and methods are functions that live inside the class.
So far you have kept an employee as a dictionary and written separate functions to calculate pay. That works for ten lines and gets messy at a hundred. Object-oriented programming puts the data and its behaviour together, so priya.annual_cost() reads like English and every part of your program agrees on what an Employee is. Everything in Python, from strings to pandas DataFrames, is built this way, which is why understanding classes makes every library easier to use.
Defining a class and creating objects
Use the class keyword with a capitalised name. __init__ runs automatically each time you create an object; its parameters become the values you pass in, and self.name = name stores them on the object as attributes.
class Employee:
def __init__(self, name, dept, salary):
self.name = name
self.dept = dept
self.salary = salary
priya = Employee("Priya Sharma", "Finance", 85000)
rahul = Employee("Rahul Verma", "Sales", 62000)
print(priya.name, "-", priya.dept)
print(rahul.salary)
print(type(priya))
Output: Priya Sharma - Finance 62000 <class '__main__.Employee'>
self is not a keyword, just the conventional name for the first parameter of every method. Python fills it in for you: priya.annual_cost() is really Employee.annual_cost(priya). You never pass it yourself.
Adding methods
A method is a function defined inside the class. It reads and changes the object through self, and it can take extra parameters and default values just like any function.
class Employee:
def __init__(self, name, dept, salary):
self.name = name
self.dept = dept
self.salary = salary
def annual_cost(self, bonus_pct=10):
return self.salary * 12 * (100 + bonus_pct) // 100
def give_raise(self, pct):
self.salary = round(self.salary * (1 + pct / 100))
priya = Employee("Priya Sharma", "Finance", 85000)
print(priya.annual_cost())
priya.give_raise(8)
print(priya.salary)
print(priya.annual_cost(bonus_pct=0))
Output: 1122000 91800 1101600
print(priya.annual_cost) without parentheses prints <bound method Employee.annual_cost of ...> instead of a number. Methods are called with (); attributes are read without. The other classic is defining a method without self, which fails with takes 0 positional arguments but 1 was given.__str__ and __repr__: readable objects
By default print(obj) shows an unhelpful memory address. Define __str__ for the friendly text a user sees and __repr__ for the unambiguous text a developer sees in lists and debuggers.
class Invoice:
def __init__(self, number, customer, amount):
self.number = number
self.customer = customer
self.amount = amount
def __str__(self):
return f"Invoice {self.number} - {self.customer}: {self.amount:,.2f}"
def __repr__(self):
return f"Invoice({self.number!r}, {self.customer!r}, {self.amount})"
inv = Invoice("INV-104", "Acme Ltd", 12500.5)
print(inv)
print(repr(inv))
print([inv])
Output:
Invoice INV-104 - Acme Ltd: 12,500.50
Invoice('INV-104', 'Acme Ltd', 12500.5)
[Invoice('INV-104', 'Acme Ltd', 12500.5)]
| Special method | Triggered by | Typical use |
|---|---|---|
__init__(self, ...) |
Creating an object | Store the starting attributes |
__str__(self) |
print(obj), str(obj) |
Friendly text for users |
__repr__(self) |
repr(obj), showing a list |
Debug text that recreates the object |
__eq__(self, other) |
a == b |
Compare by value rather than identity |
__lt__(self, other) |
a < b, sorted() |
Natural sort order |
__len__(self) |
len(obj) |
Number of items in a container object |
Class attributes versus instance attributes
An attribute set with self. inside __init__ belongs to one object. An attribute defined directly in the class body is shared by every object, which suits constants such as a tax rate and counters that track how many objects exist.
class Invoice:
gst_rate = 0.18 # shared by all invoices
count = 0
def __init__(self, number, amount):
self.number = number # unique to this invoice
self.amount = amount
Invoice.count += 1
def total_with_gst(self):
return round(self.amount * (1 + Invoice.gst_rate), 2)
a = Invoice("INV-104", 10000)
b = Invoice("INV-105", 2500)
print("Invoices created:", Invoice.count)
print(a.total_with_gst(), b.total_with_gst())
Invoice.gst_rate = 0.12
print(a.total_with_gst())
Output: Invoices created: 2 11800.0 2950.0 11200.0
Inspecting, adding and deleting attributes
Objects are flexible: you can attach a new attribute at any time, check for one with hasattr, read one safely with getattr and a default, and remove one with del. The __dict__ attribute shows everything stored on the object.
class Employee:
def __init__(self, name, dept, salary):
self.name = name
self.dept = dept
self.salary = salary
priya = Employee("Priya Sharma", "Finance", 85000)
priya.email = "priya.sharma@neotech.in"
print(priya.__dict__)
print(hasattr(priya, "phone"), getattr(priya, "phone", "not set"))
del priya.email
print("email" in priya.__dict__)
Output:
{'name': 'Priya Sharma', 'dept': 'Finance', 'salary': 85000, 'email': 'priya.sharma@neotech.in'}
False not set
False
Working with a list of objects
The real payoff arrives when you have many objects. Lists of objects sort, filter and sum with the same tools you already know, and attribute names make the intent obvious.
team = [
Employee("Priya Sharma", "Finance", 85000),
Employee("Rahul Verma", "Sales", 62000),
Employee("Anita Desai", "Sales", 71000),
]
print("Monthly payroll:", sum(e.salary for e in team))
print("Highest paid:", max(team, key=lambda e: e.salary).name)
sales_team = [e.name for e in team if e.dept == "Sales"]
print("Sales:", sales_team)
for e in sorted(team, key=lambda e: e.name):
print(f"{e.name:<14}{e.dept}")
Output: Monthly payroll: 218000 Highest paid: Priya Sharma Sales: ['Rahul Verma', 'Anita Desai'] Anita Desai Sales Priya Sharma Finance Rahul Verma Sales
Properties: validation and computed values
A @property looks like an attribute from the outside but runs code when read or assigned. Use a setter to reject bad values at the door, and a read-only property for values that should always be derived from others rather than stored.
class Product:
def __init__(self, name, price):
self.name = name
self.price = price # goes through the setter below
@property
def price(self):
return self._price
@price.setter
def price(self, value):
if value < 0:
raise ValueError("Price cannot be negative")
self._price = round(value, 2)
@property
def price_with_gst(self):
return round(self._price * 1.18, 2)
p = Product("Laptop", 45999.999)
print(p.price, p.price_with_gst)
try:
p.price = -5
except ValueError as e:
print("Error:", e)
Output: 46000.0 54280.0 Error: Price cannot be negative
Less boilerplate with dataclasses
When a class is mostly data, the @dataclass decorator writes __init__, __repr__ and __eq__ for you from the type-annotated fields. You still add ordinary methods underneath.
from dataclasses import dataclass, field
@dataclass
class Order:
order_id: int
customer: str
items: list = field(default_factory=list)
def total(self):
return sum(qty * price for qty, price in self.items)
o = Order(1001, "Acme Ltd", [(2, 1200.0), (1, 4500.0)])
print(o)
print("Total:", o.total())
print(o == Order(1001, "Acme Ltd", [(2, 1200.0), (1, 4500.0)]))
Output: Order(order_id=1001, customer='Acme Ltd', items=[(2, 1200.0), (1, 4500.0)]) Total: 6900.0 True
Ask for a small class design and a critique of your own attempt.
Design a Python class BankAccount for a small business with attributes account_no, holder and balance, methods deposit(amount) and withdraw(amount) that reject negative amounts and overdrafts with ValueError, a transactions list that records every change with a timestamp, and a __str__ that shows the balance formatted with commas. Then explain in plain English what self does in each method.
Convert dictionary-based code into classes.
Here is my Python script that stores each invoice as a dict and has five separate functions that take an invoice dict (add_gst, is_overdue, days_late, mark_paid, summary_line). Refactor it into an Invoice class with those as methods, use a @property for total_with_gst, add __repr__, and show before and after code side by side with a short note on what improved.
Common mistakes
- Leaving
selfout of a method definition, or writingname = nameinstead ofself.name = nameso the value is lost. - Putting a mutable default such as
items=[]in__init__; every object then shares the same list. UseNoneand create the list inside, orfield(default_factory=list)in a dataclass. - Calling a method without parentheses and getting a bound method object instead of a result.
- Changing a class attribute through an instance (
a.gst_rate = 0.12), which creates a new instance attribute and leaves every other object unchanged. - Forgetting that
==compares identity for plain classes unless you define__eq__or use a dataclass.
Exercise
Create a Subscription class with customer, monthly_fee and months. Add a method total() that applies a 10% discount when months is 12 or more, and a __str__ that prints Acme Ltd: 12 months at 2,500.00 = 27,000.00.
Show answer
class Subscription:
def __init__(self, customer, monthly_fee, months):
self.customer = customer
self.monthly_fee = monthly_fee
self.months = months
def total(self):
amount = self.monthly_fee * self.months
if self.months >= 12:
amount *= 0.9
return round(amount, 2)
def __str__(self):
return (f"{self.customer}: {self.months} months at "
f"{self.monthly_fee:,.2f} = {self.total():,.2f}")
print(Subscription("Acme Ltd", 2500, 12))
# Acme Ltd: 12 months at 2,500.00 = 27,000.00
Related chapters
FAQ
What does self mean in a Python class?
self is the object the method is being called on. Python passes it automatically, so priya.give_raise(8) runs give_raise with self set to priya. It is a convention, not a keyword.
What is the difference between __init__ and a constructor?
__init__ is Python’s initialiser: it runs right after the object is created to set its attributes. In everyday speech people call it the constructor, and for practical purposes it plays that role.
When should I use a dataclass instead of a normal class?
Use @dataclass when the class mainly holds data and you want __init__, __repr__ and __eq__ generated for you. Use a normal class when you need custom initialisation logic or properties.
Working with spreadsheets too? Ready-made Excel, Google Sheets and Power BI templates are at NextGenTemplates.com.
Chapter 32 of 48 · Python with AI: all 48 chapters



