Every Ticket Looks the Same, but Every Ticket Is Different
Look at any train ticket. It always has the same kind of details: a PNR number, a passenger name, a seat, a fare and a status such as “Confirmed”. Yet your ticket and your friend’s ticket have different values in those boxes. The railway does not design a new ticket for every passenger. It has one design and fills it in millions of times.
Programming has the same idea. A class is the design: it says what details something has and what it can do. An object (also called an instance) is one filled-in copy, like one real ticket. Writing programs this way is called object-oriented programming, or OOP.
You have already used objects without knowing it. Every string is an object of the class str, and "hello".upper() calls one of its methods. In this lesson you will create your own classes for train tickets, a bank account and a train coach.
Your First Class
A class starts with the keyword class and a name written in CapitalWords style, such as Ticket or BankAccount. Inside it, the special method __init__ (two underscores on each side, said “dunder init”) runs automatically every time you create a new object. Its job is to store the starting details.
class Ticket:
def __init__(self, pnr, name, seat):
self.pnr = pnr
self.name = name
self.seat = seat
ticket1 = Ticket("2401556789", "Ananya Gupta", "B2-34")
ticket2 = Ticket("2401556790", "Rahul Menon", "B2-35")
print(ticket1.name) # Ananya Gupta
print(ticket2.name) # Rahul Menon
print(ticket1.seat) # B2-34
ticket2.seat = "B2-40" # objects can be changed
print(ticket2.seat) # B2-40
Here is what happens on the line ticket1 = Ticket("2401556789", "Ananya Gupta", "B2-34"):
- Python creates a new, empty
Ticketobject. - It calls
__init__, passing the new object asselfand your three values aspnr,nameandseat. - Lines like
self.name = namestore each value on that object. These stored values are called attributes. - The finished object is saved in
ticket1.
What is self?
self means “this particular object”. When __init__ runs for Ananya’s ticket, self is Ananya’s ticket; when it runs for Rahul’s, self is Rahul’s. This is how one design can fill in many different tickets. You never pass self yourself: Python passes it for you. The name self is a convention, not a keyword, but every Python programmer uses it, so you should too.
Methods: What an Object Can Do
A method is a function written inside a class. Its first parameter is always self, so it can read and change that object’s attributes. You call it with a dot: ticket.cancel().
class Ticket:
def __init__(self, pnr, name, seat, fare):
self.pnr = pnr
self.name = name
self.seat = seat
self.fare = fare
self.status = "Confirmed"
def summary(self):
return f"PNR {self.pnr}: {self.name}, seat {self.seat}, {self.status}"
def cancel(self):
self.status = "Cancelled"
refund = self.fare * 0.75
return refund
ticket = Ticket("2401556789", "Ananya Gupta", "B2-34", 1240)
print(ticket.summary())
refund = ticket.cancel()
print(f"Refund: Rs {refund}") # Refund: Rs 930.0
print(ticket.summary())
Notice that __init__ can also set attributes that are not passed in, like self.status = "Confirmed". Then cancel() changes the status only for this ticket and returns the refund. The data (attributes) and the actions (methods) live together in one place. That is the main idea of OOP.
Creating Many Objects
Once you have a class, you can create as many objects as you need and keep them in a list, just like numbers or strings. Each one keeps its own data.
class Ticket:
def __init__(self, pnr, name, seat):
self.pnr = pnr
self.name = name
self.seat = seat
bookings = [
Ticket("2401556789", "Ananya Gupta", "B2-34"),
Ticket("2401556790", "Rahul Menon", "B2-35"),
Ticket("2401556791", "Sunita Devi", "S4-12"),
]
for t in bookings:
print(t.pnr, t.name, t.seat)
def find_ticket(pnr):
for t in bookings:
if t.pnr == pnr:
return t
return None
found = find_ticket("2401556791")
print("Found:", found.name) # Found: Sunita Devi
A Bank Account with Rules
Classes are perfect for things that must follow rules. A bank account must never accept a negative deposit or allow you to withdraw more than you have. If the checks live inside the methods, every part of your program gets them for free. When a rule is broken, the method raises a ValueError, which you learned to catch in the Error Handling lesson.
class BankAccount:
def __init__(self, holder, balance=0):
self.holder = holder
self.balance = balance
def deposit(self, amount):
if amount <= 0:
raise ValueError("Deposit must be more than zero")
self.balance += amount
def withdraw(self, amount):
if amount <= 0:
raise ValueError("Withdrawal must be more than zero")
if amount > self.balance:
raise ValueError(f"Not enough balance. Available: Rs {self.balance}")
self.balance -= amount
account = BankAccount("Kiran Patil", 5000)
account.deposit(2000)
account.withdraw(1500)
print(account.balance) # 5500
try:
account.withdraw(9000)
except ValueError as error:
print("Refused:", error) # Refused: Not enough balance. Available: Rs 5500
try:
account.deposit(-100)
except ValueError as error:
print("Refused:", error) # Refused: Deposit must be more than zero
Notice balance=0 in __init__: default values work in methods just as in normal functions, so BankAccount("Kiran Patil") starts with Rs 0.
Class Attributes and Instance Attributes
Some details are different on every ticket, like the name. Others are the same for all tickets, like the name of the railway. Python lets you store both:
| Instance attribute | Class attribute | |
|---|---|---|
| Where you create it | Inside a method, with self.name = ... | Directly in the class body |
| Belongs to | One object | The class, shared by all objects |
| Example | self.pnr, self.seat | railway, tickets_booked |
class Ticket:
railway = "Indian Railways" # class attribute: shared by all tickets
tickets_booked = 0 # class attribute: one counter for the class
def __init__(self, pnr, name):
self.pnr = pnr # instance attributes: different for each ticket
self.name = name
Ticket.tickets_booked += 1
t1 = Ticket("2401556789", "Ananya Gupta")
t2 = Ticket("2401556790", "Rahul Menon")
print(t1.railway, "|", t2.railway) # Indian Railways | Indian Railways
print(t1.name, "|", t2.name) # Ananya Gupta | Rahul Menon
print("Booked so far:", Ticket.tickets_booked) # Booked so far: 2
To change a class attribute, write it through the class name: Ticket.tickets_booked += 1. If you wrote self.tickets_booked += 1, Python would create a new instance attribute on that one ticket, and the shared counter would stay at 0.
Friendly Printing with __str__
If you print an object from your own class, Python shows something like <__main__.Ticket object at 0x7f3a...>. That is not useful. Add a __str__ method that returns a string, and Python uses it whenever you call print() or str() on the object.
class Ticket:
def __init__(self, pnr, name, seat):
self.pnr = pnr
self.name = name
self.seat = seat
def __str__(self):
return f"Ticket {self.pnr} | {self.name} | Seat {self.seat}"
ticket = Ticket("2401556789", "Ananya Gupta", "B2-34")
print(ticket) # Ticket 2401556789 | Ananya Gupta | Seat B2-34
message = "Your booking: " + str(ticket)
print(message)
Dataclasses: Less Typing for Simple Classes
Many classes mainly hold data, and writing self.x = x for every attribute gets boring. The dataclasses module can write __init__, a readable printout and an equality check for you. You list the attributes with their types, and you can still add your own methods.
from dataclasses import dataclass
@dataclass
class Passenger:
name: str
age: int
berth: str = "No preference"
def is_senior(self):
return self.age >= 60
p1 = Passenger("Lakshmi Iyer", 64, "Lower")
p2 = Passenger("Aman Joshi", 21)
print(p1) # Passenger(name='Lakshmi Iyer', age=64, berth='Lower')
print(p2.berth) # No preference
print(p1.is_senior()) # True
print(p1 == Passenger("Lakshmi Iyer", 64, "Lower")) # True
The @dataclass line above the class is a decorator. You will learn how decorators work in the Decorators lesson. For now, just remember that dataclasses are great for simple records such as passengers, products or students.
Real-Life Example: Booking Seats in a Coach
Now let us model something closer to a real booking system. Each Coach knows its code, how many seats it has, the fare, who has booked and who is on the waiting list. When the coach is full, new passengers go on the waiting list, just like WL1 and WL2 on a real ticket. We use a tiny coach with only 3 seats so you can see the waiting list quickly.
class Coach:
def __init__(self, code, total_seats, fare):
self.code = code
self.total_seats = total_seats
self.fare = fare
self.booked = {} # seat number -> passenger name
self.waiting = [] # names on the waiting list
def seats_left(self):
return self.total_seats - len(self.booked)
def book(self, name):
if self.seats_left() == 0:
self.waiting.append(name)
return f"{name}: waiting list WL{len(self.waiting)}"
seat = len(self.booked) + 1
self.booked[seat] = name
return f"{name}: confirmed, seat {self.code}-{seat}, Rs {self.fare}"
def __str__(self):
return (f"Coach {self.code}: {len(self.booked)} booked, "
f"{self.seats_left()} free, {len(self.waiting)} waiting")
sleeper = Coach("S1", 3, 485)
ac_coach = Coach("B1", 2, 1240)
for name in ["Ananya", "Rahul", "Sunita", "Farhan"]:
print(sleeper.book(name))
print(ac_coach.book("Meena"))
print(sleeper)
print(ac_coach)
Ananya, Rahul and Sunita get seats S1-1, S1-2 and S1-3, and Farhan gets WL1. The two coaches are separate objects, so booking in the sleeper coach does not change the AC coach.
Try these changes:
- Add a
cancel(seat)method that removes a booking and gives the seat to the first person on the waiting list. - Add a class attribute
train_name = "Festival Special"and show it in__str__. - Add a
total_collected()method that returns the number of booked seats multiplied by the fare.
Common Mistakes
- Forgetting
selfin a method. Python always passes the object as the first argument, so a method with no parameters gets one argument it did not expect:
class Ticket:
def __init__(self, pnr):
self.pnr = pnr
def show():
print("PNR:", self.pnr)
ticket = Ticket("2401556789")
ticket.show() # ERROR: TypeError
Fix: add self as the first parameter of every method:
class Ticket:
def __init__(self, pnr):
self.pnr = pnr
def show(self):
print("PNR:", self.pnr)
ticket = Ticket("2401556789")
ticket.show() # PNR: 2401556789
- Forgetting
self.when saving an attribute. Writingname = nameinside__init__only creates a local variable that disappears. Later,ticket.nameraises anAttributeError. Fix: writeself.name = name. - Misspelling
__init__._init_(one underscore) or__int__is just a normal method, so Python never calls it. Creating the object then fails with “takes no arguments”. Fix: two underscores, the wordinit, two underscores. - Using a list as a class attribute. If you write
waiting = []in the class body, all coaches share one list, and a name added to one coach appears in every coach. Fix: create lists and dictionaries in__init__withself.waiting = []. __str__that prints instead of returning.__str__mustreturna string. If it only prints,print(ticket)raises aTypeError.
Practice Task: A Mobile Recharge Wallet
Build a RechargeWallet class for a mobile recharge app. Complete the TODO steps in the starter code.
__init__stores the owner, the balance and an emptyhistorylist.add_money(amount)refuses zero or negative amounts with aValueError.recharge(mobile_plan, price)refuses a plan that costs more than the balance.- Both methods add a tuple to
history. print(wallet)showsWallet of Neha: Rs 201after the example calls.
class RechargeWallet:
def __init__(self, owner, balance=0):
# TODO 1: save owner and balance, and start an empty list called history
pass
def add_money(self, amount):
# TODO 2: refuse amounts of zero or less with ValueError
# TODO 3: increase the balance and add ("Added", amount) to history
pass
def recharge(self, mobile_plan, price):
# TODO 4: refuse if price is more than the balance (ValueError)
# TODO 5: reduce the balance and add (mobile_plan, price) to history
pass
# TODO 6: write __str__ so print(wallet) shows "Wallet of Neha: Rs 201"
# When you have finished, remove the # from the lines below and run:
# wallet = RechargeWallet("Neha")
# wallet.add_money(500)
# wallet.recharge("28-day plan", 299)
# print(wallet)
# print(wallet.history)
Show one possible answer (try it yourself first!)
class RechargeWallet:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
self.history = []
def add_money(self, amount):
if amount <= 0:
raise ValueError("Amount must be more than zero")
self.balance += amount
self.history.append(("Added", amount))
def recharge(self, mobile_plan, price):
if price > self.balance:
raise ValueError(f"Not enough balance for {mobile_plan}")
self.balance -= price
self.history.append((mobile_plan, price))
def __str__(self):
return f"Wallet of {self.owner}: Rs {self.balance}"
wallet = RechargeWallet("Neha")
wallet.add_money(500)
wallet.recharge("28-day plan", 299)
print(wallet) # Wallet of Neha: Rs 201
print(wallet.history) # [('Added', 500), ('28-day plan', 299)]
try:
wallet.recharge("84-day plan", 719)
except ValueError as error:
print("Refused:", error) # Refused: Not enough balance for 84-day plan
Key Takeaways
- A class is a design; an object is one filled-in copy. Create one with
ClassName(...). __init__runs when an object is created and stores its attributes withself.name = value.selfis the object a method is working on. Every method needs it as its first parameter.- Put rules inside methods (like checks in
withdraw) so the object can protect its own data. - Class attributes are shared by all objects; instance attributes belong to one object.
- Add
__str__for friendly printing, and use@dataclassfor simple data-holding classes.