Free Online Code Editor
Go beyond the basics of Python classes. Learn dataclasses, properties, __repr__, and why composition often beats inheritance, with short runnable examples.
You know what a class is and how to write __init__. The next step is writing classes that are pleasant to use, hard to misuse, and easy to change later.
This guide covers the handful of habits that make the biggest difference: readable output, dataclasses, properties, and thinking in composition instead of deep inheritance.
By default, printing an object gives you something useless:
class Book:
def __init__(self, title, pages):
self.title = title
self.pages = pages
b = Book("Dune", 412)
print(b)<__main__.Book object at 0x7f...>Add a __repr__ method and debugging gets much easier. It should return a string that shows what the object contains.
class Book:
def __init__(self, title, pages):
self.title = title
self.pages = pages
def __repr__(self):
return f"Book(title={self.title!r}, pages={self.pages})"
print(Book("Dune", 412))Book(title='Dune', pages=412)This one habit saves a surprising amount of time when you're hunting for bugs.
Most small classes just hold data. Writing __init__, __repr__, and __eq__ by hand for each of them gets old fast. The dataclass decorator generates all three:
from dataclasses import dataclass
@dataclass
class Book:
title: str
pages: int
in_stock: bool = True
a = Book("Dune", 412)
b = Book("Dune", 412)
print(a)
print(a == b)Book(title='Dune', pages=412, in_stock=True)
TrueYou get a readable repr and value-based equality for free. Two books with the same fields are equal, which usually matches what you'd expect.
A few options worth knowing:
@dataclass(frozen=True) makes instances read-only, which is safer for values that shouldn't change.@dataclass(order=True) lets you compare and sort objects.field(default_factory=list). Never write tags: list = [].from dataclasses import dataclass, field
@dataclass
class Shelf:
name: str
books: list = field(default_factory=list)
s1 = Shelf("Fiction")
s2 = Shelf("Science")
s1.books.append("Dune")
print(s1.books)
print(s2.books)['Dune']
[]Each shelf gets its own list. A plain shared [] default would be reused across every instance, a classic Python bug.
In some languages you write get_price() and set_price() for every field. In Python you start with a plain attribute. If you later need a rule, convert it to a property and nobody using your class has to change their code.
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 can't be negative")
self._price = value
p = Product("Mug", 12.5)
p.price = 15
print(p.price)
try:
p.price = -3
except ValueError as error:
print("Rejected:", error)15
Rejected: price can't be negativeProperties are also handy for values calculated on the fly, like full_name built from first and last. The caller reads it like a normal attribute.
Inheritance says "X is a kind of Y." That works when it's true:
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
return "..."
class Dog(Animal):
def speak(self):
return f"{self.name} says woof"
class Cat(Animal):
def speak(self):
return f"{self.name} says meow"
for pet in [Dog("Rex"), Cat("Tia")]:
print(pet.speak())Rex says woof
Tia says meowEach subclass changes one method, and the loop doesn't care which type it's dealing with. That's polymorphism, and it's the good side of inheritance.
The bad side shows up when hierarchies grow deep. If you have Vehicle → Car → ElectricCar → SportsElectricCar, any change at the top ripples down through everything. Debugging means jumping between four files.
Composition builds a class out of smaller parts. Instead of asking "what is this?", you ask "what does it have?"
class Engine:
def start(self):
return "engine running"
class Battery:
def start(self):
return "battery powering on"
class Car:
def __init__(self, power_source):
self.power_source = power_source
def start(self):
return f"Car: {self.power_source.start()}"
print(Car(Engine()).start())
print(Car(Battery()).start())Car: engine running
Car: battery powering onCar doesn't care what kind of power source it has, as long as it has a start() method. You can add a hybrid or a hydrogen engine later without touching Car.
A decent rule of thumb: reach for composition first. Use inheritance when you have a clear "is a" relationship and a stable base class.
Python doesn't force you to declare interfaces. If it walks like a duck and quacks like a duck, it's treated as one. That's why Car above works with any object that has start().
When you do want to enforce a contract, use an abstract base class:
from abc import ABC, abstractmethod
class Exporter(ABC):
@abstractmethod
def export(self, data):
...
class CsvExporter(Exporter):
def export(self, data):
return ",".join(str(x) for x in data)
print(CsvExporter().export([1, 2, 3]))
try:
Exporter()
except TypeError as error:
print("Can't create it:", error)1,2,3
Can't create it: Can't instantiate abstract class Exporter without an implementation for abstract method 'export'Trying to create an abstract class directly, or a subclass that forgot to implement a required method, fails immediately with a clear message.
items = [] at the class level, every instance shares it. Set lists and dicts inside __init__.__repr__, or use @dataclass, so objects print clearly.@property when you need a rule.Reading helps, but typing the code is what makes it stick. Open the editor, paste any example from this article, and change it until it breaks.
Open the Python editorA clear order for learning Python from zero, the topics you can safely ignore for now, and the mistakes that trip up almost every beginner.
Learn how Python list comprehensions work, with filtering, nested loops, dict and set versions, and clear rules for when a plain loop is the better choice.
Understand Python decorators by building a timer, a logger, a retry wrapper, and a cache, with the functools.wraps detail most tutorials skip.