Chapter 13: Classes and Objects
Section 1
A class is a blueprint for creating objects, and an object is a bundle of data and behaviour that belong together. When you have worked with lists, strings, and dictionaries, you have already used objects: a list holds items and knows how to append, sort, and count them, while a string holds characters and knows how to upper-case, split, and join them. A class lets you define your own kind of object, with its own data, called attributes, and its own behaviour, called methods. That ability to define new kinds of things, tailored to a problem, is one of the most powerful ideas in programming.
Think of a class as a recipe and an object as a dish made from that recipe. The recipe describes what ingredients are needed and what steps to follow, but it is not itself a meal. Each object made from the class is a separate instance, with its own data, even though all instances share the same structure and behaviour. A Book class, for example, might describe a title, an author, and a page count, and each book object would hold a different title, author, and page count, while all of them could be described, compared, or shelved by the same methods. That distinction, between the class and its instances, is fundamental to understanding object-oriented programming.
Classes help organise programs that deal with entities in the real world, or in a problem domain, such as books, tasks, users, or bank accounts. Instead of keeping related data in separate variables, or in parallel lists that must be kept in sync, a class gathers the data into one object, and gathers the operations on that data into methods that belong to the object. That gathering makes code easier to read, because the data and its operations are in one place, and easier to maintain, because a change to how a book works can be made in the Book class, rather than in many scattered places that manipulate book data.
You do not need classes for every program. Small scripts that perform a single calculation, or that process a file line by line, are often clearer with plain functions and data structures. Classes become valuable when a program has several kinds of entities, when those entities have both data and behaviour, or when the same entity is used in many places and needs a consistent interface. Learning when to use a class, and when a simpler structure will do, is part of learning to design programs, and this chapter will help you develop that judgement, starting with the basics of defining and using classes.
class Book:
def __init__(self, title, author):
self.title = title
self.author = author
book = Book("Dune", "Frank Herbert")
print(book.title, book.author)
Objects also appear in everyday programming in forms you may not have recognised as objects, such as file objects, which hold a path and a position and know how to read and write, or datetime objects, which hold a date and time and know how to format themselves. Recognising those familiar values as objects helps the class concept feel less abstract, because you have already been using objects successfully, and defining your own is simply extending a pattern you already know to a new kind of value.
Check: What is the difference between a class and an object created from that class?
Section 2
Defining a class in Python uses the class keyword, followed by the class name, conventionally written in CapWords, with each word capitalised and no underscores, such as Book or TaskManager. Inside the class body, you define methods, which are functions that belong to the class, and the most important method is usually __init__, the initialiser, which runs automatically when a new object is created. The __init__ method receives the new object as its first argument, conventionally named self, followed by any arguments passed when creating the object, and it sets up the object's initial attributes by assigning to self.
The self parameter is a source of confusion for beginners, but its role is straightforward: it refers to the particular object being created or used. When you write book.title, Python calls the method with book as self, so that the method can access and modify that object's data, rather than some other object's. Inside a method, self.title means the title of this object, while title alone would mean a local variable or a parameter, not an attribute. That distinction, between attributes accessed through self and local variables, is essential, and it is why self appears explicitly in method definitions, even though you do not pass it explicitly when calling the method.
Attributes are created by assignment to self inside methods, most commonly in __init__, but also in other methods when an object's state changes. An object can gain a new attribute at any time, simply by assigning to it, but creating attributes outside __init__ can make it hard to know what attributes an object has, because they depend on which methods have been called. A good practice is to initialise all attributes in __init__, even if some start as None or as an empty value, so that every object of the class has the same set of attributes from the start, and a reader can see the object's shape by reading __init__ alone.
Creating an object, called instantiation, looks like calling the class as if it were a function: Book("Dune", "Frank Herbert"). Python creates a new, empty object, calls __init__ with that object and the given arguments, and returns the initialised object. That returned object can then be stored in a variable, passed to functions, stored in a list, or used in any other way a value can be used. Objects are values, just like numbers and strings, but they are values with structure and behaviour, defined by their class, which makes them a powerful way to represent complex ideas in a program.
class Dog:
def __init__(self, name):
self.name = name
def greet(self):
return f"Woof, I am {self.name}"
print(Dog("Bingo").greet())
The initialiser also gives you a place to validate arguments, so that an object cannot be created in an invalid state. A Book with an empty title, or a Counter with a negative starting count, could be rejected in __init__ by raising an exception, forcing callers to provide valid data. That validation at creation is powerful, because it guarantees that every object that exists has passed the checks, which simplifies the rest of the code, because methods can assume they are working with a valid object.
Check: Why does a method definition include self as its first parameter, even though you do not pass it when calling the method?
Section 3
Methods are functions defined inside a class, and they give objects their behaviour. A method that operates on an object's data, such as returning a description, updating a count, or checking a condition, is called an instance method, because it is tied to a particular instance through self. When you call book.describe(), you are asking that specific book to describe itself, using its own title and author, not some other book's. That tying of behaviour to data is what makes objects feel like entities that can act, rather than passive containers that must be acted upon by external functions.
Methods can also take additional arguments, beyond self, just like ordinary functions. A deposit method for a bank account might take an amount, add it to the balance, and return the new balance, while a rename method might take a new name and update the object's name attribute. Those arguments are passed after the object, in the call, and they are received after self in the definition. Keeping methods focused, each doing one clear thing, makes objects easier to understand and to test, because each method can be checked independently, with a known object and known arguments, and its effect on the object's state can be observed directly.
Methods can return values, modify the object's attributes, or both, and the distinction matters for how callers use them. A method that returns a description does not change the object, so it can be called safely at any time, while a method that updates a balance changes the object, so its effect must be understood before calling it. Documenting whether a method changes state, and what it returns, helps callers use it correctly, and it helps readers understand the object's lifecycle, from creation through its various states to, eventually, its disposal. That lifecycle thinking is part of object design.
You can also define methods that do not need an instance, but those are special cases covered later, and for now, instance methods are the norm. The key habit is to ask, for each operation on an entity, whether it belongs inside the class as a method, or outside as a function. If the operation uses the object's data, changes its state, or is conceptually part of what the entity does, it usually belongs inside, as a method. If it operates on many different kinds of data, or is a general utility, it may belong outside, as a function. That placement decision shapes how a program is organised, and it becomes more intuitive with practice.
class Counter:
def __init__(self):
self.count = 0
def increment(self):
self.count += 1
return self.count
c = Counter()
print(c.increment(), c.increment())
Methods also make it easier to change how an object works internally without changing how callers use it. If a Counter stores its count as a number today and as a list of events tomorrow, its increment method can be updated to maintain the new representation, while callers who use increment continue to work unchanged. That freedom to change internals, while keeping the interface stable, is a major benefit of methods, and it is why well-designed classes can evolve without breaking the programs that use them.
Check: What makes a method an instance method, and how does calling it on one object differ from calling it on another?
Section 4
Attributes hold an object's state, and methods change or report that state, and together they define how an object behaves over time. An object's state is the set of values its attributes currently hold, and it changes as methods are called, in response to events in the program. A bank account's balance changes when deposits and withdrawals are made, a task's status changes when it is completed, and a book's availability changes when it is borrowed or returned. Tracking state through attributes, and changing it only through methods, gives a program a clear model of how its entities evolve, which is easier to reason about than scattered variables that change from many places.
Encapsulation is the idea that an object's state should be managed by the object itself, through its methods, rather than being manipulated directly from outside. In Python, encapsulation is by convention, not by strict enforcement: attributes are accessible from outside, but a leading underscore, such as _balance, signals that an attribute is intended for internal use, and that external code should use methods instead of touching it directly. That convention, sometimes called a private-by-convention attribute, relies on cooperation, not on locks, and it works because programmers respect it, knowing that direct manipulation can break an object's invariants, the conditions that its methods maintain.
Invariants are conditions that should always be true for an object, such as a balance never being negative, or a task's status always being one of a small set of values. Methods maintain invariants by validating inputs and by updating attributes in a consistent way, so that an object never enters an invalid state, even if a method fails partway. If external code bypasses methods and sets attributes directly, it can break an invariant without the object knowing, leading to a state that methods do not expect and cannot handle correctly. Respecting encapsulation, by using methods, therefore protects invariants, and it makes objects more trustworthy.
Python's approach to encapsulation, based on convention rather than enforcement, reflects a philosophy of trusting programmers, while giving them tools to signal intent. The underscore is a signal, not a barrier, and it is accompanied by properties, covered later, which allow controlled access to attributes through methods that look like attribute access. For now, the lesson is to use methods to change state, to respect underscore attributes as internal, and to design methods that maintain invariants, so that objects remain in valid states throughout their lives.
class BankAccount:
def __init__(self):
self._balance = 0
def deposit(self, amount):
if amount > 0:
self._balance += amount
def balance(self):
return self._balance
Encapsulation also helps when several programmers work on the same program, because it defines a boundary between the class's author and its users. The author is free to change internal attributes, knowing that users only depend on methods, while users are free to use methods without understanding internals. That boundary reduces coordination costs, because changes on one side do not require changes on the other, provided the interface remains stable, which is essential for larger teams.
Check: Why is it better to change an object's state through its methods rather than by setting its attributes directly from outside?
Section 5
When you create several objects from the same class, each has its own attributes, independent of the others. Changing one object's title does not change another's, and calling a method on one object does not affect another's state, because each object stores its own data. That independence is essential: it is what allows a program to have many books, many tasks, or many users, each with their own information, while sharing the same code for how books, tasks, or users behave. Without independence, objects would interfere with each other, and a program with many entities would be impossible to manage.
Class attributes are a different kind of attribute, shared by all instances of a class, rather than belonging to a single instance. A class attribute is defined directly in the class body, outside any method, and it is accessed through the class or through an instance, with the same value for all instances, unless an instance shadows it by assigning an attribute of the same name to itself. Class attributes are useful for values that are the same for every instance, such as a default setting, a category name, or a counter of how many instances have been created, though the last use requires care, because updating it must be done through the class, not through an instance, to avoid shadowing.
The distinction between instance and class attributes is a common source of confusion, especially with mutable class attributes, such as a list defined at class level. If a method appends to such a list through an instance, it modifies the shared list, affecting all instances, which is usually not intended, and which can cause surprising bugs where one object's change appears in another's data. The remedy is to initialise mutable attributes in __init__, as instance attributes, so that each object gets its own list, while keeping only truly shared, usually immutable, values as class attributes. That rule, mutable in __init__, shared only when intended, prevents a whole class of shared-state bugs.
Understanding instance versus class attributes also helps you read other people's classes, because you can tell, from where an attribute is defined, whether it is meant to be per-object or shared. An attribute in __init__, assigned through self, is per-object; an attribute in the class body, without self, is shared. That reading skill, combined with the habit of initialising per-object data in __init__, will make your own classes clearer and your reading of others' classes faster, because the structure of the class will tell you its data model at a glance.
class Book:
kind = "book"
def __init__(self, title):
self.title = title
a, b = Book("A"), Book("B")
print(a.kind, b.kind, a.title, b.title)
Class attributes also serve as a place for constants that belong to the class, such as a maximum length, a default value, or a set of allowed statuses, which callers can read through the class without creating an instance. That use, as a namespace for related constants, keeps those values organised with the class they belong to, rather than scattered as global variables. It also makes the constants easy to find, because a reader looking at the class will see them alongside the methods that use them.
Check: What is the difference between an instance attribute and a class attribute, and why should mutable data usually be an instance attribute?
Section 6
Objects can be stored in collections, passed to functions, and returned from functions, just like other values, and that composability is what makes them useful in larger programs. A list of Book objects can represent a library, a dictionary mapping names to Task objects can represent a task manager, and a function that takes a Book and returns a formatted description can be reused for any book. That ability means techniques for lists and dictionaries apply directly to objects.
Sorting objects, for example, can be done by a key function that extracts an attribute, such as sorting books by title with sorted(books, key=lambda b: b.title), or by defining a method that returns the sorting key. Filtering can be done with a comprehension that checks an attribute or calls a method, such as selecting only completed tasks. Those patterns are familiar from earlier chapters, applied to a new kind of value, and they show that objects do not require a whole new set of techniques; they extend the techniques you already know, by giving you richer values to work with, values that carry both data and behaviour.
Passing objects to functions also raises the question of mutation: if a function modifies an object passed to it, the change is visible to the caller, because the function and the caller share the same object, not a copy. That sharing is powerful, because it allows functions to update objects in place, but it also requires care, because a function that modifies its argument unexpectedly can surprise its caller. Documenting whether a function modifies its object arguments, and designing functions to modify only when that is their purpose, helps callers reason about side effects, and it prevents bugs where an object changes in a place the caller did not expect.
Returning objects from functions is equally common. A function that creates a new object, based on input data, and returns it, is a factory in miniature, and it encapsulates the creation logic in one place, so that callers do not need to know the details of __init__ or of validation. That encapsulation of creation is useful when creation involves validation, defaults, or transformation of input, because it ensures that every object created through the function meets the same standards, rather than depending on each caller to initialise it correctly. Factories and constructors together give you control over how objects come into being, which is the first step in ensuring they start in a valid state.
class Task:
def __init__(self, title, done=False):
self.title = title
self.done = done
tasks = [Task("Read"), Task("Write", True)]
print([t.title for t in tasks if not t.done])
Collections of objects also benefit from methods that summarise or filter, because the logic for deciding whether an object matches a criterion can live inside the object, as a method, rather than in the code that filters the list. A Task with an is_overdue method, for example, encapsulates the overdue logic, so that filtering code can simply call that method, without knowing how overdue is calculated. That encapsulation of criteria makes filtering code shorter, clearer, and less likely to duplicate logic in several places.
Check: If a function modifies an object passed to it, why is that change visible to the caller?
Section 7
Every object in Python has a string representation, and you can control it by defining special methods, which are introduced here and covered in depth in a later chapter. The __str__ method returns a human-friendly string, used by print and by string formatting, while __repr__ returns a developer-friendly string, used in interactive sessions and in debugging, that should ideally show how the object could be recreated. Defining those methods makes objects easier to work with, because printing a list of books will show their titles, rather than cryptic memory addresses, and debugging will show meaningful information, rather than opaque object references.
The default representation, which shows the class name and a memory address, is not useless: it tells you the object's type and that it is a distinct object, but it does not tell you its state, which is usually what you need when debugging. A good __repr__ includes the class name and the key attributes, in a form that resembles a constructor call, such as Book('Dune', 'Frank Herbert'), so that a reader can see both the type and the state at a glance, and can even copy the representation to recreate a similar object. A good __str__, by contrast, is for end users, and it might return just the title and author in a readable form, without the class name or quotes, because users do not need to know how the object was constructed.
Defining these methods is a small investment with a large payoff in usability, because objects are printed, logged, and inspected constantly during development, and a clear representation makes each of those activities faster. It also makes tests easier to read, because a failing test that prints objects will show their contents, rather than their addresses, helping you see why the test failed without adding extra debugging code. That readability benefit alone justifies defining __repr__ for almost every class you write, even a simple one, because you will look at its objects many times during development.
You should be careful, however, that __str__ and __repr__ do not themselves raise exceptions or change the object's state, because they may be called in contexts where an exception would be confusing, such as during logging or debugging, when the program is already in an unusual state. Keeping those methods simple, returning a string built from attributes without side effects, ensures that they are safe to call at any time, which is exactly when you need them most, during debugging of a problem that may have left the object in an unexpected state.
class Book:
def __init__(self, title):
self.title = title
def __repr__(self):
return f"Book({self.title!r})"
print([Book("Dune"), Book("Emma")])
String representations also matter when objects are stored in logs or in error messages, because a log that shows Book('Dune') is far more useful than one that shows a memory address. That usefulness extends to testing, where a test failure that prints objects with clear representations helps you see the difference between expected and actual values immediately. Investing in representations is therefore an investment in debuggability, which pays off every time you need to understand what a program was doing.
Check: What is the difference between __str__ and __repr__, and who is each one mainly for?
Section 8
Objects often need to be compared, and Python's default comparison, based on identity, checks whether two variables refer to the same object, not whether they have the same values. That default is useful for checking identity, with is, but it is not what most programs mean when they ask whether two books are equal. Two distinct Book objects with the same title and author are, in most senses, equal as books, even though they are different objects in memory, and a program that checks equality should reflect that sense, by comparing attributes, not identities. Defining __eq__ lets you specify what equality means for your class, by comparing the relevant attributes.
Equality should be consistent with other operations, such as hashing, if objects are to be used in sets or as dictionary keys, but that is a later topic, and for now, the key point is to define equality in a way that matches the domain. For a book, title and author might define equality; for a task, title and status might; for a user, a unique identifier might, even if other attributes differ. Choosing the attributes that define equality is a design decision, and it should be documented, because callers will rely on it when they compare objects, deduplicate lists, or test for membership. An equality definition that is surprising, such as comparing only a title when two books can share a title, will cause bugs that are hard to trace, because the comparison will report equality where the domain would not.
You should also be careful about comparing objects of different types. An __eq__ method that assumes the other object is of the same class may raise an error when compared with a different type, whereas returning NotImplemented for unknown types allows Python to try the other object's comparison, and ultimately to report inequality, which is usually the correct result. Returning NotImplemented rather than raising makes equality robust in mixed-type comparisons.
Finally, remember that equality is not the same as identity, and that Python provides both, with == for equality and is for identity, for a reason. Using is when you mean == will fail for objects that are equal but distinct, while using == when you mean is may succeed for objects that are equal but should be treated as distinct, such as two separate bank accounts with the same balance. Choosing the right comparison, based on whether you care about sameness of value or sameness of object, is a fundamental programming judgement, and defining __eq__ for your classes makes that judgement explicit for your domain.
class Point:
def __init__(self, x, y):
self.x, self.y = x, y
def __eq__(self, other):
if not isinstance(other, Point):
return NotImplemented
return self.x == other.x and self.y == other.y
print(Point(1, 2) == Point(1, 2))
Equality also interacts with sorting and with membership tests, because a list that contains an object equal to a searched value will report membership, even if the searched object is a different instance. That behaviour is usually desired, because it means equality is about values, not identities, but it requires that __eq__ be defined consistently, so that equal objects behave the same in all contexts. Inconsistent equality, where two objects compare equal in one context but not in another, is a source of subtle bugs.
Check: Why does Python's default object equality check identity, and when would you define __eq__ to compare values instead?
Section 9
Methods that belong to a class, rather than to an instance, are useful for operations that do not need a particular object's data. A class method, marked with the @classmethod decorator, receives the class itself as its first argument, conventionally named cls, rather than an instance, and it can create new instances, access class attributes, or perform operations that are conceptually tied to the class as a whole. A common use is an alternative constructor, such as a method that creates a Book from a string, or from a dictionary, by parsing the input and calling the normal constructor, giving callers a convenient, named way to create objects from different kinds of data.
A static method, marked with @staticmethod, receives neither an instance nor a class, and it behaves like an ordinary function that happens to live inside a class for organisational reasons. Static methods are useful for helper functions that are conceptually related to the class, such as a validation function for a book title, or a calculation that does not need object state, but that belongs with the class in the reader's mind. The distinction between class methods and static methods is subtle, and for beginners, the key point is that both exist, that class methods can create instances and access class-level data, while static methods cannot, and that most methods you write will be ordinary instance methods, with class and static methods reserved for specific, well-defined purposes.
Choosing between a class method, a static method, and an ordinary function outside the class is a design decision. If the operation is tied to the class, creates instances, or uses class attributes, a class method is appropriate. If the operation is a helper that is only meaningful in the context of the class, but does not need the class or an instance, a static method keeps it organised with the class, without implying a connection it does not have. If the operation is general, and not tied to the class at all, an ordinary function outside the class is clearer, because it does not suggest a relationship that does not exist. That placement thinking, like the placement of instance methods, shapes how a program is organised and how easy it is to navigate.
For now, knowing they exist and recognising them in other people's code is enough. As your own classes grow more complex, you will find natural uses for them, such as a from_string constructor for a class that is often created from text, and at that point, the concepts will feel like a natural extension of what you already know about methods, rather than a new, separate topic.
class Book:
def __init__(self, title):
self.title = title
@classmethod
def from_string(cls, text):
return cls(text.strip())
print(Book.from_string(" Dune ").title)
Alternative constructors also make code more readable at the call site, because Book.from_string('Dune') says more about how the object is being created than Book('Dune') does when the argument is ambiguous. That readability at the call site is valuable, because code is read far more often than it is written, and a named constructor communicates its input format and its purpose in a way that a bare constructor call cannot, especially when a class can be created from several different kinds of input.
Check: What is the difference between a class method and a static method, in terms of what each receives as its first argument?
Section 10
Designing a class starts with asking what entities your program deals with, what data each entity needs, and what operations it should support. That design process, sometimes called modelling, is more important than the syntax of class definition, because a well-modelled class, with clear attributes and focused methods, will be easy to use, while a poorly modelled class, with unclear data or unfocused methods, will be hard to use no matter how correct its syntax is. A good starting point is to list the nouns in your problem description, such as book, task, or user, and to consider each noun as a candidate class, with its adjectives as attributes and its verbs as methods. That noun-and-verb analysis is a simple, effective way to begin modelling.
A class should have a single, clear responsibility, a principle sometimes called cohesion. A Book class should manage book data and book operations, not also manage library loans, user accounts, and file storage, because a class that does too much becomes hard to understand, hard to test, and hard to change, as a change to one responsibility risks breaking another. When a class grows too large, or its methods start to fall into unrelated groups, it is a sign that it should be split into smaller classes, each with a focused responsibility, that collaborate through method calls. That splitting, like splitting a long function into smaller ones, is a refactoring skill that grows with experience, and it is driven by noticing when a class's responsibilities diverge.
A class's interface, the set of methods and attributes that callers use, should be as small and clear as possible, hiding implementation details behind methods, as discussed for encapsulation. A small interface is easier to learn, easier to test, and easier to change, because fewer callers depend on its details. When you add a method, ask whether it is needed by callers, or whether it is an implementation detail that should remain internal, and when you add an attribute, ask whether callers need direct access, or whether a method should mediate it. Those questions, asked consistently, lead to classes with clean interfaces, which are a pleasure to use and to maintain.
Finally, design is iterative. Your first version of a class will rarely be perfect, and that is normal. As you use it in a program, you will discover attributes you forgot, methods that are awkward, or responsibilities that belong elsewhere, and you will refactor, moving methods, renaming attributes, and splitting classes, to improve the design. That iteration is how good design emerges, through use and feedback.
class Task:
def __init__(self, title):
self.title = title
self.done = False
def complete(self):
self.done = True
t = Task("Read")
t.complete()
print(t.title, t.done)
Modelling also benefits from sketching a class on paper, or in a comment, before writing code, listing its attributes and methods and checking that each one is needed and that none is missing. That sketch, even if it is rough, forces you to think about the class as a whole, rather than adding methods one by one as problems arise, which can lead to a sprawling, unfocused class. A short planning step, before coding, often saves a longer refactoring step afterwards.
Check: What does it mean for a class to have a single, clear responsibility, and why does that make it easier to maintain?
Section 11
Properties allow you to control access to an attribute while keeping the simple syntax of attribute access. A property is defined with the @property decorator, on a method that returns a value, and it is accessed like an attribute, without parentheses, even though a method runs behind the scenes. That combination, method behaviour with attribute syntax, is useful when reading an attribute should compute a value, validate it, or return a stored value through a controlled path, rather than exposing the stored value directly. For example, a full_name property might combine a first and last name, or a balance property might return a stored value after checking that it is still valid.
Properties also allow you to define a setter, a method that runs when an attribute is assigned, so that assignment can validate the new value, transform it, or update related state, rather than simply storing it. A setter for a balance, for example, could reject negative values, while a setter for a title could strip whitespace, ensuring that the object always holds clean data, even when callers assign directly. That validation on assignment is a form of encapsulation that feels natural to callers, because they use ordinary assignment syntax, while the object maintains its invariants behind the scenes, without requiring callers to remember to call a special method.
The most common pattern is to store the actual value in a private attribute, such as _balance, and to expose it through a property named balance, with a getter that returns _balance and a setter that validates and then assigns to _balance. That pattern separates the public interface, the property name, from the storage, the private attribute, so that the storage can change later without changing the interface. It also prevents infinite recursion, which would occur if the getter or setter tried to access the property itself, rather than the private attribute, a common beginner mistake that leads to a recursion error and a confusing traceback.
Properties should be used judiciously, not for every attribute. A simple attribute that needs no validation, computation, or control can remain a plain attribute, because adding a property for it adds code without benefit, and it may mislead readers into thinking something special happens on access. Properties are for attributes where access has meaning beyond storage, such as a computed value, a validated value, or a value that must stay synchronised with other state. Using them only where they add value keeps classes clear, while using them where they are needed gives you control without sacrificing the simplicity of attribute syntax.
class Person:
def __init__(self, first, last):
self.first, self.last = first, last
@property
def full_name(self):
return f"{self.first} {self.last}"
print(Person("Ada", "Lovelace").full_name)
Properties also interact with inheritance, because a subclass can override a property to change how an attribute is computed or validated, while keeping the same public name. That override allows a subclass to specialise behaviour without changing how callers access the attribute, which is a powerful form of polymorphism. For beginners, the key point is that properties are part of the interface, and overriding them should preserve the interface's meaning, even if the implementation changes.
Check: How does a property let an object control attribute access while still allowing simple attribute syntax?
Section 12
When an object is created, __init__ sets it up, and when it is no longer needed, Python's garbage collector reclaims its memory, but there is no guaranteed, timely cleanup method that you should rely on for important resources. The __del__ method, sometimes called a destructor, runs when an object is about to be destroyed, but its timing is not guaranteed, and it may not run at all in some situations, such as circular references or interpreter shutdown. Relying on __del__ to close files, release locks, or save data is therefore risky, and the recommended pattern for resource cleanup is the with statement and context managers, covered in a later chapter, which guarantee timely release.
That limitation does not mean object lifecycle is unimportant; it means lifecycle should be managed explicitly, through methods that callers invoke, or through context managers that guarantee cleanup, rather than through a destructor that may run late or not at all. A class that holds a file, for example, should provide a close method, and should be used in a with block, so that closing happens at a known point, rather than hoping that destruction will close it. That explicit lifecycle, create, use, close, is clearer than an implicit one, because callers can see when each stage happens, and they can handle errors at each stage appropriately.
Initialisation, by contrast, is reliable: __init__ runs every time an object is created, and it is the right place to set up attributes, validate arguments, and establish invariants. A well-written __init__ leaves the object in a valid, usable state, so that methods can assume validity, as discussed earlier. If initialisation cannot succeed, because arguments are invalid or a required resource cannot be obtained, __init__ should raise an exception, rather than creating a half-initialised object that methods cannot safely use. That fail-fast initialisation prevents a class of bugs where an object exists but is not really ready, and where methods fail in confusing ways because they assume a setup that never completed.
You should also think about what happens when an object is copied. Assigning an object to a new variable does not copy it; it creates a second reference to the same object, so changes through one variable are visible through the other. Copying, when needed, must be done explicitly, with the copy module, which provides shallow and deep copies, with different meanings for nested objects. Understanding that assignment shares, rather than copies, is essential for working with objects in collections and functions, because it explains why a change in one place can appear in another, and it helps you decide when an explicit copy is needed to isolate changes.
class Box:
def __init__(self, value):
self.value = value
a = Box(1)
b = a
b.value = 2
print(a.value, b.value)
Lifecycle thinking also applies to objects that are created and discarded frequently, such as temporary objects used in a calculation. Those objects should be cheap to create, should not hold resources that need explicit cleanup, and should not rely on destruction for any important effect. Keeping temporary objects simple and resource-free makes programs faster and safer, because their creation and disposal can happen freely without side effects that must be managed.
Check: Why should you not rely on __del__ to close files or save important data?
Section 13
Objects are often used to represent data that also needs to be saved to a file or loaded from one, and designing that conversion is part of class design. A to_dict method, for example, might return a dictionary of an object's key attributes, which can then be saved as JSON or as a simple text format, while a from_dict class method might create an object from such a dictionary, validating its fields as it does so. That pair of methods, one for exporting and one for importing, encapsulates the conversion logic inside the class, so that callers do not need to know which attributes are saved, in what format, or how they are validated on loading.
That encapsulation also makes it easier to change the saved format later, because only the conversion methods need to change, while the rest of the program, which works with objects, continues unchanged. If conversion logic were scattered through the program, with each part of the code extracting attributes directly, changing the format would require changing many places, and missing one would create a bug. Concentrating conversion in the class, where the object's data is already understood, is a practical application of the principle that a class should own knowledge about its own data, including how that data is represented outside the object.
Validation on loading is particularly important, because data from a file may be old, corrupted, or created by a different version of the program, and an object created from bad data may violate its invariants. A from_dict method that checks for required fields, converts types, and rejects invalid values, with clear error messages, protects the program from bad data, in the same way that __init__ protects it from bad arguments. That protection is worth the extra code, because it turns a file problem into a clear, early failure, rather than a subtle, later failure when a method encounters an attribute it did not expect.
For now, you do not need to implement full file persistence for every class; the lesson is to recognise that objects and files are connected, and that the class is the natural place to manage that connection. Later chapters will cover JSON and databases in depth, and the conversion methods you design now, even in simple form, will prepare you for those topics, because they introduce the idea that an object's in-memory form and its stored form are related but distinct, and that converting between them is a responsibility that deserves its own methods, not an afterthought in the code that happens to save or load.
class Book:
def __init__(self, title):
self.title = title
def to_dict(self):
return {"title": self.title}
@classmethod
def from_dict(cls, data):
return cls(data["title"])
print(Book.from_dict({"title": "Dune"}).title)
Conversion methods also help when an object's data needs to be displayed in different formats, such as a short summary for a list and a detailed view for a single object. A to_summary method and a to_detail method, for example, can each select the relevant attributes and format them appropriately, keeping display logic inside the class, where the data is understood, rather than scattering it through display code. That concentration of display logic makes it easier to change a format consistently.
Check: Why should conversion between an object and a dictionary live inside the class, rather than scattered through the program?
Section 14
A class can also define how its objects behave in common Python operations, through special methods, which are introduced here and explored in the next-but-one chapter. For example, defining __len__ lets you use len() on an object, defining __contains__ lets you use in, and defining __iter__ lets you loop over an object, as if it were a collection. Those methods allow a custom class to feel like a built-in type, integrating smoothly with Python's syntax and with functions that expect collections, which makes objects more natural to use and reduces the need for special-case code that knows about the class's internals.
That integration should be used when it matches the object's meaning. A library class that holds books should support len and iteration, because it is conceptually a collection of books, and callers will expect to be able to count and loop over it. A book class itself, by contrast, should not support len unless length has a clear meaning for a book, such as its page count, because adding a method that does not match the domain can mislead callers into using an object in a way that does not make sense. Special methods are powerful because they change how syntax works for your objects, and that power should be used only when the syntax's meaning aligns with the object's meaning.
A related point is that special methods are looked up on the class, not on the instance, which has implications for how they can be defined and changed, but for beginners, the practical point is simpler: define special methods in the class body, like other methods, and they will be used automatically by Python's built-in operations. You do not call __len__ directly in normal code; you call len(), and Python calls __len__ for you. That indirection, where a built-in function dispatches to a special method, is how Python's data model works, and it is what allows custom classes to participate in the language's syntax, rather than standing apart from it.
As you learn more special methods, you will see that they form a coherent system, where each method corresponds to a specific operation, and where defining a consistent set of them makes a class feel complete. For now, the lesson is to be aware that such methods exist, to recognise them when you see them, and to use the simplest ones, such as __repr__ and __str__, in your own classes, while leaving the more advanced ones for the dedicated chapter that follows inheritance. That staged learning, basics first and protocols later, keeps each chapter focused and prevents overload.
class Shelf:
def __init__(self, books):
self.books = books
def __len__(self):
return len(self.books)
print(len(Shelf(["Dune", "Emma"])))
Special methods also include methods for comparison and for truth testing, which allow objects to participate in sorting, in membership tests, and in boolean contexts, in ways that match their domain meaning. A task that is complete might be truthy in one sense and falsy in another, depending on the domain, so defining truth testing requires care about what truth means for that kind of object. As with length, the rule is to define a special method only when its meaning is clear and matches the domain.
Check: Why should you only define a special method like __len__ when its meaning matches your object's domain?
Section 15
Testing classes is similar to testing functions, but with an added focus on state: you create an object, call its methods, and check that its attributes and return values are as expected, both after each step and after a sequence of steps. A test for a Counter, for example, might create a counter, check that its count starts at zero, increment it, check that it is one, increment again, and check that it is two, verifying not just individual methods, but the object's behaviour over time. That sequence testing is important, because an object that works for a single method call may fail when methods are called in combination, due to state that is not updated correctly, or to an invariant that is broken by a particular sequence.
A useful testing pattern is to create a fresh object for each test, so that tests do not interfere with each other through shared state. If one test modifies an object and the next test uses the same object, the second test may fail, not because its code is wrong, but because it started from a state left by the first test. Fresh objects per test, created in a setup step or at the start of each test, isolate tests from each other, making failures easier to diagnose, because each test's starting state is known and independent. That isolation is a general testing principle, but it is especially important for objects, whose state persists across method calls by design.
Tests should also cover edge cases for objects, such as creating an object with boundary values, calling a method with invalid arguments and checking that it raises the expected exception, and calling methods in unusual orders, such as completing a task that is already complete, or depositing a negative amount. Those edge cases reveal whether methods maintain invariants, handle invalid input gracefully, and behave sensibly when used in ways that are not part of the happy path. A class that passes only happy-path tests is not yet trustworthy, because real use will include edge cases, and those are where bugs in state management most often appear.
Finally, tests for classes should be readable, with clear names that state what behaviour is being tested, such as test_counter_increments or test_task_complete_twice_is_still_done, so that a failing test immediately suggests what broke. That readability, combined with fresh objects and sequence testing, makes a test suite for a class a valuable document of its intended behaviour, which future maintainers can read to understand what the class is supposed to do, even before reading its implementation. Tests, in that sense, are not just a safety net; they are a specification, in executable form, of how an object should behave.
class Counter:
def __init__(self):
self.count = 0
def increment(self):
self.count += 1
c = Counter()
c.increment()
assert c.count == 1
print("counter test passed")
Testing classes also benefits from testing the object's representation, because a clear __repr__ makes test failures easier to understand, as discussed earlier. A test that compares two objects and fails will print their representations, and if those representations are clear, the difference between expected and actual will be visible immediately. That visibility rewards defining representations early.
Check: Why should each test for a class start with a fresh object, rather than reusing an object from a previous test?
Section 16
A common beginner mistake with classes is to use them where a simpler structure would be clearer, such as creating a class with only data attributes and no methods, when a dictionary or a named tuple would do, or creating a class with only static methods, when a module of functions would do. Those overuses add complexity without benefit, because they require readers to understand class syntax, instantiation, and method calls, for a problem that could be expressed with a familiar data structure or function. Recognising when a class adds value, by combining data and behaviour, or by providing a clear interface for an entity, versus when it merely wraps data, is a judgement that improves with experience, and it is worth reflecting on for each new class you consider.
Another common mistake is to make a class too large, by adding every operation that could conceivably relate to it, rather than focusing on its core responsibility. A Book class that also handles file storage, user authentication, and email notifications is not a book class; it is a program in disguise, and it will be hard to test, hard to reuse, and hard to change, because its responsibilities are entangled. The remedy, as discussed for design, is to split such a class, moving unrelated operations to other classes or to functions, and keeping each class focused. That splitting may feel like extra work, but it pays off in clarity, because each resulting class can be understood, tested, and changed independently.
A third mistake is to expose too much internal state, by making every attribute public and by allowing callers to set them directly, without validation or through methods. That exposure breaks encapsulation, as discussed, and it makes it hard to maintain invariants, because any caller can put an object into an invalid state, and the class has no way to know or to prevent it. The remedy is to use methods and properties to mediate access, at least for attributes where validity matters, and to treat direct attribute assignment as a privilege for simple, safe data, not as a default for all data. That mediation may seem like extra code, but it is the code that keeps objects trustworthy.
A fourth mistake is to forget that objects are mutable and shared, and to be surprised when a change through one reference appears through another, or when a method modifies an object that the caller expected to remain unchanged. That surprise is a consequence of Python's object model, where assignment shares, not copies, and it is avoided by documenting mutation, by copying when isolation is needed, and by designing methods to be clear about whether they modify the object or return a new one. A method that returns a new object, rather than modifying the original, is sometimes called immutable-style, and it can be clearer in some contexts, because callers can see that the original is unchanged, though it may be less efficient for large objects.
# A simple record may not need a class:
book_data = {"title": "Dune", "author": "Frank Herbert"}
print(book_data["title"])
Overuse also appears when a program defines a class for a single use, with one instance and no need for encapsulation, where a function and a dictionary would be clearer.
Check: When is a dictionary a better choice than a class with only data attributes and no methods?
Section 17
Classes and functions are complementary, not competing, and a well-designed program uses both, with functions for operations that are not tied to a particular entity, and classes for entities that combine data and behaviour. A function that formats a date, calculates a total, or validates an input, and that does not need to maintain state between calls, is naturally a function, because it is a transformation from inputs to outputs, without an entity to model. A class that represents a task, with a title, a status, and methods to complete it, is naturally a class, because it is an entity whose state evolves over time, and whose operations are tied to that state. Recognising which kind of problem you have, transformation or entity, helps you choose the right tool.
A program that uses only functions can become hard to manage when it deals with many entities, because the data for each entity must be passed around explicitly, and the operations on that data must be kept in sync across many functions. A program that uses only classes can become hard to manage when it deals with many transformations, because each transformation must be forced into a class, even when it has no state to maintain, creating classes that are merely containers for a single method. The balance, using functions for transformations and classes for entities, keeps each part of the program in the form that best matches its nature, and it makes the program easier to read, because the form signals the kind of problem being solved.
That balance also applies within a class: a class may have helper functions outside it, for operations that are used by several classes, or for general utilities that are not tied to one class. Those helpers should not be forced inside a class merely because the class uses them; they belong outside, where they can be reused, and where their generality is visible. Conversely, a function that is only ever used with one class's objects, and that accesses their internals, probably belongs inside that class as a method, where its connection to the class is explicit. That placement reasoning, applied consistently, leads to a program whose structure reflects its problem, rather than a structure imposed by habit or by a rule that everything must be a class, or everything must be a function.
For beginners, a practical rule is to start with functions and simple data structures, and to introduce a class when you notice that several functions always operate on the same data, that the data's structure is becoming complex, or that you are passing the same group of values around together in many places. Those signs indicate that the data and its operations belong together, as a class, and that grouping them will simplify the program, by reducing the number of arguments passed around and by giving the group a name. Starting simple and introducing classes when the need emerges is a sustainable path, because each class is motivated by a concrete problem, not by a premature decision to be object-oriented.
def format_title(title):
return title.strip().title()
print(format_title(" dune "))
Functions also interact with classes through duck typing, where a function calls an object's methods without knowing its exact class, relying only on the methods it provides.
Check: How can you tell, in a program, that a group of functions and data should be grouped into a class?
Section 18
Documentation for classes should explain, in a few sentences, what the class represents, what its main attributes mean, and how its main methods are used, so that a new reader can understand it without reading every method in detail. A class docstring, placed just inside the class definition, is the natural place for that overview, and method docstrings, placed inside each method, should explain what the method does, what arguments it takes, what it returns, and whether it changes the object's state. That documentation, written as you design the class, helps you clarify your own thinking, because explaining a class in words often reveals attributes or methods that are unclear, missing, or unnecessary, which you can then fix before the class grows.
Documentation should also include examples, showing how to create an object and how to use its main methods, because examples are often clearer than prose descriptions, and they give readers a starting point they can copy and adapt. An example in a docstring, or in a separate example file, that creates a book, describes it, and updates it, teaches the class's interface by demonstration, which is especially helpful for beginners, who may not yet be fluent in reading class definitions. Examples also serve as informal tests, because a reader can run them and check that they work as described, which builds confidence that the documentation matches the code.
As a class evolves, its documentation must evolve with it, or it will become misleading, which is worse than having no documentation, because readers will trust it and be misled. When you add a method, update the class docstring if the class's purpose has changed; when you change a method's behaviour, update its docstring to reflect the new behaviour, including any new exceptions it may raise or any new state changes it may cause. That maintenance, done as part of each change, keeps documentation accurate with little extra effort, while documentation updated only occasionally, in a separate effort, tends to drift and to be forgotten.
Finally, documentation for classes should note any important invariants, limitations, or assumptions, such as a balance that must never be negative, a title that must not be empty, or a method that is not safe to call concurrently, because those are the details that callers need to know to use the class correctly, and that are not obvious from method signatures alone. Noting invariants in the class docstring, where they are visible to every reader, prevents misuse that might otherwise occur because a caller did not know a constraint existed. Documentation, in that sense, is part of the class's interface, not an optional extra, because it communicates the contract that the code alone cannot fully express.
class Book:
"""A book with a title and an author."""
def __init__(self, title, author):
self.title = title
self.author = author
Documentation should also note sharing or mutation concerns, such as whether a method modifies the object or returns a new one.
Check: What should a class docstring explain, beyond simply listing its methods?
Section 19
Inheritance is previewed here, because it is the next chapter's topic, but a basic understanding helps you place this chapter's classes in context. Inheritance allows a new class to reuse and extend an existing class, by inheriting its attributes and methods, and by adding or overriding some of them. For example, a Ebook class might inherit from Book, reusing its title and author handling, while adding a file size and a download method. That reuse can save code, but it also creates a relationship between classes, where changes to the parent can affect the child, and where the child must respect the parent's interface and invariants. That relationship should be used when it genuinely models a kind of relationship, such as an ebook being a kind of book, not merely as a way to reuse code, because code reuse without a genuine relationship leads to fragile, confusing hierarchies.
Composition, also previewed here and covered next chapter, is an alternative to inheritance, where a class contains objects of other classes as attributes, rather than inheriting from them. A Library class, for example, might contain a list of Book objects, rather than inheriting from Book, because a library is not a kind of book, but a collection that has books. Composition is often more flexible than inheritance, because the contained objects can be changed, replaced, or combined in different ways, without the constraints of a hierarchy, and because it does not create a tight coupling between classes. Many experienced programmers prefer composition by default, using inheritance only when the kind-of relationship is clear and stable, a guideline that will be explored in depth next chapter.
For now, the lesson is to design classes that can be reused, whether by inheritance or by composition, by keeping their interfaces clear, their responsibilities focused, and their invariants documented, so that future classes can build on them safely. A class that is well-designed in isolation, with a clear purpose and a small interface, is easy to extend, whether by inheriting from it or by containing it, while a class that is poorly designed, with a large, unclear interface, is hard to extend in either way, because its users cannot tell what is safe to rely on. Good class design in this chapter is therefore preparation for good inheritance and composition in the next, and the habits you build now, such as encapsulation and clear interfaces, will pay off when you start combining classes.
class Book:
def __init__(self, title):
self.title = title
class Ebook(Book):
pass
print(Ebook("Dune").title)
Inheritance and composition also interact with testing, because a subclass should be tested not only for its own new behaviour, but also for the behaviour it inherits, to ensure that overriding a method has not broken a parent behaviour that callers rely on. That inherited-behaviour testing is part of the next chapter's work, but it is worth anticipating now, because it explains why a clear parent interface, with documented invariants, is essential: without it, a subclass author cannot know which behaviours must be preserved.
Check: What is the difference between inheritance, where a class extends another, and composition, where a class contains another?
Section 20
This chapter's closing theme is that classes are a modelling tool, and like any modelling tool, they are judged by how well they help you think about a problem, not by how much syntax they use. A good class model makes a problem clearer, by giving names to its entities, by gathering related data and behaviour, and by hiding details that are not relevant to the current task. A poor model makes a problem harder, by introducing entities that do not match the domain, by scattering related behaviour, or by exposing details that distract from the main logic. The goal is not to use classes everywhere, but to use them where they clarify, and to use simpler tools where they do not, with the choice guided by clarity, not by fashion or by a rule that a program must be object-oriented to be good.
A practical way to evaluate a class model is to try to explain it in plain language, without code, to someone who understands the problem domain but not programming. If you can say, a book has a title and an author, and it can be described and shelved, and that explanation matches your class, then your model is probably sound. If you struggle to explain a class, or if your explanation involves implementation details, such as lists, dictionaries, or file formats, rather than domain concepts, then your model may be mixing levels, and it may need refactoring to separate domain concepts from implementation details. That plain-language test is a simple, powerful check that you can apply to any class you design, at any stage of its development.
As you move to inheritance and composition, you will build larger models, with classes related to each other, and the plain-language test will become even more valuable, because it will help you decide whether a relationship is a kind-of relationship, suitable for inheritance, or a has-a relationship, suitable for composition, or merely a coincidence that should not be modelled as a relationship at all. Those decisions, made in plain language first and in code second, lead to models that are understandable to domain experts as well as to programmers, which is important, because programs are ultimately about domains, not about code for its own sake. A model that domain experts can recognise and critique is a model that is more likely to be correct, because it has been checked against real understanding, not just against syntax.
Keep practising class design in small projects, starting with a single class, such as a task or a book, and growing to several classes that collaborate, such as a library with books and members, or a task manager with tasks and projects. Each project will teach you something new about modelling, about interfaces, and about the balance between classes and functions, and that learning, accumulated over many small projects, is how you become fluent in object-oriented design, not by memorising rules, but by experiencing the trade-offs in real, if small, programs.
class Task:
def __init__(self, title):
self.title = title
self.done = False
print(Task("Learn classes").title)
Plain-language modelling also helps domain experts validate a model without reading code, catching nuances programmers might miss.
Check: How can explaining a class in plain, domain language help you judge whether its design is sound?
Section 21
When you design a class, it helps to write down, before coding, the questions that objects of that class will need to answer, and the changes they will need to undergo. A Task object, for example, should be able to answer whether it is done, what its title is, and when it was created, and it should be able to change from pending to done, and perhaps back again. Listing those questions and changes, in plain language, gives you a draft interface: each question becomes a method or a property that returns a value, and each change becomes a method that updates state. That draft, refined as you code, prevents a class from growing by accident, because new methods are added only when they answer a question or perform a change that the class genuinely needs to support.
That question-and-change approach also helps you decide what data to store. An object needs to store the data required to answer its questions and to perform its changes, and no more. A task needs a title and a status to answer whether it is done, but it does not need to store a formatted display string, if that string can be computed from the title and status when needed. Storing only essential data, and computing derived values on demand, keeps objects smaller, avoids inconsistency between stored and derived values, and makes it easier to change how a value is computed later, because only the computing method needs to change, not every place that stored the derived value.
A related design question is whether a method should return a value, modify the object, or do both. A method that both modifies and returns can be convenient, but it can also surprise callers who expected only one effect, especially if the return value is ignored. A clearer convention is for query methods, which answer questions, to return values without changing state, and for command methods, which perform changes, to change state and return either nothing or a simple success indicator, rather than a computed value that might be mistaken for the object's new state. That separation, sometimes called command-query separation, is not a strict rule in Python, but it is a useful guideline for making methods predictable, because callers can tell, from a method's name and documentation, whether calling it will change anything.
Finally, design should consider how objects will be created in different situations. A task created by a user typing a title needs a different creation path from a task loaded from a file, where the status is already known, and a class that provides only one constructor may force callers to create an object and then modify it to match the loaded data, which is awkward and can temporarily violate invariants. Providing alternative constructors, as discussed for class methods, or providing a creation function that handles loading, gives callers a clean way to create objects in each situation, with the object starting in a valid state in every case, which is a hallmark of a well-designed class.
class Task:
def __init__(self, title):
self.title = title
self.done = False
def is_done(self):
return self.done
def complete(self):
self.done = True
Check: Why is it useful, before coding a class, to list the questions its objects should answer and the changes they should undergo?
Section 22
Objects often work together, and the relationships between them are part of a program's design. A task might belong to a project, a book might belong to a library, and a user might have many tasks, and those relationships can be represented by attributes that refer to other objects, or by collections that hold them. For example, a Project object might hold a list of Task objects, and a method to add a task, to list tasks, and to count completed tasks, delegating the details of each task to the Task class itself. That delegation, where a container class relies on the contained class's methods, rather than manipulating its internals directly, is a form of composition in practice, even before the formal chapter on composition, and it is a natural way to build larger structures from smaller, well-tested parts.
Relationships also raise questions about ownership and lifecycle: if a project is deleted, should its tasks be deleted too, or should they survive independently? If a book is removed from a library, should the book object itself be destroyed, or merely removed from the library's list? Those questions have no universal answer; they depend on the domain, and they should be decided explicitly, because the code's behaviour will imply an answer, whether you intended it or not. A project that holds tasks in a list, and that is the only reference to those tasks, will effectively own them, because deleting the project will make the tasks unreachable, while a project that holds references to tasks that are also held elsewhere will share ownership, and deleting the project will not destroy the tasks. Understanding that distinction, between owning and merely referencing, helps you predict a program's behaviour when objects are removed.
Another relationship question is whether an object should know about its container, such as a task knowing which project it belongs to, or whether only the container should know about its contents. A two-way relationship, where each knows about the other, can be convenient, because either object can navigate to the other, but it also creates a coupling that must be maintained: when a task moves from one project to another, both the old project's list and the new project's list, and the task's project reference, must be updated consistently, or the model will become inconsistent. A one-way relationship, where only the container knows, is simpler to maintain, because there is only one place to update, though it means a task cannot, on its own, say which project it belongs to. Choosing between one-way and two-way relationships is a design trade-off between convenience and simplicity, and it should be made deliberately.
For beginners, the practical lesson is to start with one-way relationships, where a container holds its contents, and to add a reverse reference only when a genuine need arises, such as a task needing to report its project in a display, and when the maintenance cost of keeping both directions consistent is acceptable. That conservative approach keeps models simple, and it avoids a class of bugs where two directions disagree, which can be hard to diagnose because each direction looks correct in isolation, while together they contradict each other.
class Project:
def __init__(self, name):
self.name = name
self.tasks = []
def add_task(self, task):
self.tasks.append(task)
Check: Why is a one-way relationship, where only the container knows its contents, often simpler than a two-way relationship?
Section 23
Naming is a crucial part of class design, because class, method, and attribute names are the vocabulary with which a program talks about its domain. A class name should be a noun, or a noun phrase, that clearly names the entity, such as Book, Task, or BankAccount, while method names should be verbs, or verb phrases, that clearly state the action, such as complete, deposit, or describe. Attribute names should be nouns that clearly state the data, such as title, balance, or due_date. That noun-and-verb consistency makes code read like a description of the domain, which helps readers understand it quickly, and it helps writers choose names, because the grammatical role suggests the kind of name to use.
Naming should also be specific, not generic. A method named do or process tells the reader almost nothing, while a method named complete_task or calculate_total tells them exactly what happens. An attribute named data or value is similarly vague, while title or balance is specific. Specific names cost a few extra characters, but they save readers from having to infer meaning from context, and they make code searchable, because a reader can find all uses of complete_task by searching for that name, whereas a generic name appears in many unrelated contexts. Specificity in naming is a form of documentation, built into the code itself, and it is one of the cheapest ways to improve readability.
Consistency in naming across a program is equally important. If one class uses complete for finishing a task, another class should not use finish for the same concept, unless there is a genuine difference in meaning, because inconsistency forces readers to learn two words for one idea, and it makes code harder to search and to refactor. A team or a project should agree on a vocabulary for common concepts, such as complete versus finish, create versus add, and remove versus delete, and then use that vocabulary consistently, so that the program's language is coherent. That coherence, like consistency in error messages, reduces cognitive load and makes the program feel professionally designed, even when it is small.
Finally, naming should avoid abbreviations and jargon that are not widely understood, unless they are standard in the domain, because a name that is clear to its author may be cryptic to a future reader. A method named calc_tot might be obvious to its author, but calculate_total is obvious to everyone, and the extra characters are a small price for clarity. Similarly, a class named Mgr is less clear than Manager, and a variable named tmp is less clear than temporary_file, unless the context makes the abbreviation obvious. Clarity in naming is a courtesy to future readers, including your future self, who will not remember the abbreviations you used today.
class Task:
def __init__(self, title):
self.title = title
def complete(self):
pass
Check: Why should class names be nouns and method names be verbs, and what does that consistency give readers?
Section 24
A class's methods should be tested not only individually, but also in combination, because objects evolve through sequences of calls, and a bug may only appear after a particular sequence. For example, a bank account that allows a deposit, then a withdrawal, then another deposit, should end with the correct balance after each step, and a task that is completed, then reopened, then completed again, should end in the correct state, with its history, if any, consistent. Testing sequences means writing tests that perform several operations in order, checking the state after each one, rather than testing each operation in isolation from a fresh object, which may miss bugs that arise from interaction between operations, such as a counter that is not reset, or a flag that is not cleared.
Sequence testing also helps you discover whether methods are order-dependent in ways they should not be. A method that works only if another method has been called first, without documenting that dependency, is fragile, because callers may not know the required order, and they may call methods in a different order, causing a failure that is hard to diagnose. A well-designed class either makes its methods order-independent, where possible, or documents the required order clearly, and raises a clear error if methods are called out of order, rather than failing silently or producing a wrong result. That documentation of order, where it matters, is part of the class's contract, and it should be stated in method docstrings, not left for callers to discover by trial and error.
Another testing consideration is that objects may be used in unexpected orders by real programs, especially when they are passed to generic code, such as sorting, filtering, or display functions, that call methods in an order the class author did not anticipate. A class that assumes a particular call order, without enforcing it, may work in its author's tests, which follow the expected order, but fail in a real program that uses it differently. Designing methods to be robust to call order, by checking state before acting and by handling already-completed or already-initialised cases gracefully, makes classes more reusable, because they can be used in contexts the author did not foresee, without breaking.
Finally, sequence testing is a way to document a class's lifecycle, by showing, in test code, the typical sequences of operations that an object undergoes, from creation through use to completion. A reader who looks at those tests can see, at a glance, how an object is meant to be used, in what order, and what states it passes through, which is often clearer than a prose description of the lifecycle. That documentation value, combined with the bug-finding value, makes sequence tests a worthwhile investment for any class with non-trivial state, and it is a habit that will serve you well as your classes grow more complex.
class Counter:
def __init__(self):
self.count = 0
def increment(self):
self.count += 1
def reset(self):
self.count = 0
Check: Why should classes be tested with sequences of method calls, not just with individual methods in isolation?
Section 25
When a class is used in a larger program, its objects may need to be compared, sorted, or stored in collections, and those uses impose requirements on the class's methods. Sorting, for example, requires either a key function, as discussed earlier, or a comparison method, such as __lt__, which defines what it means for one object to be less than another. Defining __lt__ allows objects to be sorted directly with sorted(), without a key function, which can be convenient, but it also commits the class to a single, natural ordering, which may not exist for all kinds of objects. A book, for example, could be ordered by title, by author, or by publication date, and choosing one as the natural ordering may surprise callers who expected another, so a key function, which makes the ordering explicit at the call site, is often clearer than a built-in ordering.
Storing objects in sets or as dictionary keys requires a hash, defined by __hash__, which must be consistent with equality, as defined by __eq__: objects that compare equal must have the same hash, or set and dictionary operations will behave incorrectly, finding one equal object but not another. That consistency requirement is a subtle but important part of class design, and it is why defining __eq__ without considering __hash__ can cause problems, because Python may set __hash__ to None when __eq__ is defined, making objects unhashable, unless you define __hash__ explicitly or design the class to be unhashable deliberately. For beginners, the lesson is to be aware of the relationship between equality and hashing, and to test objects in sets and dictionaries if they are meant to be used there, rather than assuming they will work.
Collections of objects also raise the question of identity versus equality in membership tests, as discussed for equality. A list membership test, using in, uses equality, so it will find an object equal to a searched value, even if it is a different instance, while a set membership test uses hashing and equality together, with the same result if hashing is consistent. Understanding those mechanisms helps you predict how collections will behave with your objects, and it helps you debug cases where an object that should be found is not, or where a duplicate that should be detected is missed, because the cause is often an inconsistent or missing equality or hash definition, rather than a bug in the collection code itself.
For now, you do not need to define comparison or hash methods for every class; you need to know that they exist, that they have consistency requirements, and that they should be defined only when the class's domain gives them a clear meaning. A class that is never sorted or stored in a set does not need __lt__ or __hash__, and adding them unnecessarily adds complexity and risk. A class that is meant to be sorted or hashed, by contrast, should define those methods carefully, with tests that check consistency, because those uses are part of its interface, and getting them wrong will cause bugs that are hard to trace to their source in the class definition.
class Book:
def __init__(self, title):
self.title = title
books = [Book("Emma"), Book("Dune")]
print([b.title for b in sorted(books, key=lambda b: b.title)])
Check: Why must objects that compare equal also have the same hash if they are to be used in sets or as dictionary keys?
Section 26
A class can be documented by its tests, by its docstrings, and by examples, but it can also be documented by its name and its method names alone, if those names are chosen well. A reader who sees task.complete() understands, without reading documentation, that the task is being marked as complete, because the names convey the meaning. That self-documenting quality, achieved through clear naming, is the most effective form of documentation, because it is always present, always accurate, and never drifts out of date, unlike prose documentation, which can become stale. Investing in names, therefore, is investing in documentation that maintains itself, as long as the names are kept accurate when behaviour changes, which is a smaller burden than maintaining separate prose.
Self-documenting code does not eliminate the need for docstrings, especially for complex methods, for methods with non-obvious side effects, or for classes with invariants that are not visible from names alone. A method named complete is clear, but a method named process is not, and even a clear method may need a docstring to explain its arguments, its return value, its exceptions, or its effect on state, details that names alone cannot convey. The balance is to use names for the obvious, and docstrings for the non-obvious, so that readers can understand the common case at a glance, and can find details when they need them, without wading through prose for every method.
Another form of documentation is the class's example usage, which can be placed in a docstring, in a test, or in a separate example file, and which shows, in code, how the class is meant to be used. An example that creates an object, calls its main methods, and prints the result, teaches the interface by demonstration, which is often faster than reading a list of method descriptions, especially for beginners who learn by example. Examples also serve as a check on the design: if an example is awkward to write, with many steps or confusing calls, that awkwardness may indicate that the interface itself is awkward, and that it should be simplified, rather than documented around. Writing an example before finalising a class, therefore, is a design technique as well as a documentation technique.
Finally, documentation should be written for the reader who will maintain the code, not just for the reader who will use it. A maintainer needs to know why a class is designed as it is, what alternatives were considered and rejected, and what invariants must be preserved when changing it, information that is not needed by a user, but that is essential for safe maintenance. Noting those design decisions in comments or in a design document, separate from user-facing docstrings, helps future maintainers avoid breaking the class by changing something that looks incidental but is actually load-bearing. That maintainer-focused documentation is often overlooked, but it is what allows a class to evolve safely over time.
class Task:
"""A task that can be completed."""
def complete(self):
"""Mark this task as done."""
self.done = True
Check: How can clear method names act as documentation, and what details still require a docstring?
Section 27
Objects can be used to model not just things, but also processes, such as a game, a workflow, or a simulation, where the object's state represents the current stage of the process, and its methods represent the steps that advance it. A game object, for example, might hold a score, a level, and a list of players, with methods to start, to advance, and to end the game, each updating the state in a controlled way. Modelling a process as an object gives the process a clear lifecycle, with methods that enforce the order of steps, such as preventing a game from ending before it starts, or preventing a level from advancing beyond its maximum, which is harder to enforce with scattered functions and variables, where the process's state is implicit and its order is not checked.
Process objects also benefit from encapsulation, because the process's rules, such as how scores are calculated or how levels advance, can be hidden inside methods, while callers interact with a simple interface, such as start, advance, and score. That hiding makes the process easier to use, because callers do not need to know the rules in detail, and easier to change, because a rule change affects only the method that implements it, not every caller. It also makes the process easier to test, because each step can be tested in isolation, with a known starting state, and its effect on the state can be checked directly, without needing to run the whole process from start to finish for every test.
A process object should also handle invalid transitions gracefully, by raising a clear exception or by returning a clear failure signal, when a method is called in a state where it does not make sense, such as ending a game that has not started. That handling prevents the process from entering an inconsistent state, where, for example, a game is both started and ended, or a level is beyond its maximum, and it gives callers a clear signal that they have used the object incorrectly, rather than allowing them to proceed with a corrupted process. Designing for invalid transitions, by checking state at the start of each method, is a form of defensive programming applied to objects, and it is essential for process models, where order matters.
For beginners, modelling a process as an object is a useful exercise, because it forces you to think about states, transitions, and rules, in a concrete way, rather than as abstract concepts. A simple process, such as a task that moves from pending to in-progress to done, or a book that moves from available to borrowed to returned, can be modelled as an object with a status attribute and methods for each transition, each checking that the transition is valid from the current status. That small model teaches state-machine thinking, which is a valuable skill for larger processes, such as workflows, games, and simulations, where state and transitions are central to the problem.
class Game:
def __init__(self):
self.started = False
def start(self):
self.started = True
def is_started(self):
return self.started
Check: Why is an object a good way to model a process, such as a game, with states and transitions?
Section 28
When you work with objects, you will often need to convert between objects and other representations, such as strings, dictionaries, or files, and those conversions should be designed as part of the class, as discussed earlier for dictionaries. A string representation, through __str__, is for humans, a dictionary representation, through a method, is for storage or for passing to other systems, and a file representation, through save and load methods, is for persistence, and each representation should be consistent with the object's state, so that converting and converting back, a round trip, returns an equivalent object, not a different one. Testing round trips, by creating an object, converting it, converting it back, and checking that the result equals the original, is a powerful test for conversion methods, because it checks both directions at once, and it reveals inconsistencies that one-direction tests might miss.
Round-trip testing also reveals whether a conversion loses information, such as a dictionary representation that omits an attribute, or a string representation that cannot be parsed back, which may be acceptable for a display format, but not for a storage format, where loss is a bug. For storage, the representation must be complete, capturing every attribute needed to recreate the object, while for display, it may be selective, showing only the attributes relevant to the reader. Distinguishing complete from selective representations, and documenting which is which, prevents a display format from being mistakenly used for storage, where its incompleteness would cause data loss, and it prevents a storage format from being mistakenly used for display, where its completeness would overwhelm the reader with detail.
Conversion methods should also handle versioning, as discussed for files, because a stored representation created by an older version of a class may lack fields that a newer version expects, or may contain fields that a newer version no longer uses. A loading method that fills in defaults for missing fields, and ignores unknown fields, is more robust than one that fails on any difference, because it allows old data to be used with new code, which is important for user data that should not be lost when a program is updated. That robustness, like backward compatibility for files, should be designed in, by thinking about how representations will evolve, and by including a version marker, or by making loading tolerant of variation, rather than strict about exact matches.
For now, the lesson is to think about conversions as part of class design, not as an afterthought, and to test them with round trips, because conversions are where objects meet the outside world, whether that world is a file, a user, or another system, and where mismatches between the object's model and the world's expectations most often appear. A class with well-designed conversions is a class that can be saved, shared, and displayed, without losing its meaning, which is a large part of what makes objects useful in real programs, where data must move between memory, disk, and display, and where each move is a conversion that must be correct.
class Point:
def __init__(self, x, y):
self.x, self.y = x, y
def to_dict(self):
return {"x": self.x, "y": self.y}
print(Point(1, 2).to_dict())
Check: Why is testing a round trip, converting an object and converting it back, a powerful test for conversion methods?
Section 29
A class's interface should be designed for its most common uses, making those uses easy, while still allowing less common uses, though they may require more steps. That principle, sometimes called making the common case easy, means that the methods and arguments that callers use most often should be simple, with sensible defaults, while rarer operations can be more verbose, because they are used less often and their verbosity is less of a burden. For example, creating a task with just a title should be easy, with other attributes defaulting sensibly, while creating a task with a detailed schedule, priority, and notes should be possible, though it may require more arguments or a separate method. That balance, easy for the common, possible for the rare, makes a class pleasant to use in everyday code, without sacrificing flexibility for special cases.
Defaults play a key role in that balance. A constructor with sensible defaults for optional attributes allows callers to provide only the essential information, while still creating a valid object, and it reduces the number of arguments that must be remembered for a simple use. Defaults should be chosen carefully, to represent the most common or safest value, not merely a convenient placeholder, because a default that is wrong for most uses will cause bugs, as callers who omit an argument will get a value they did not expect. Documenting defaults, in the constructor's docstring, helps callers know what they will get when they omit an argument, and it helps maintainers know why a particular default was chosen, which is important when the default's appropriateness changes over time.
Another aspect of designing for common uses is to provide convenience methods for frequent operations, such as a method that completes a task and records the completion date in one step, rather than requiring callers to call two methods in sequence, with a risk of forgetting the second. Convenience methods should not duplicate logic, but should combine existing methods in a way that matches a common workflow, making that workflow a single, named operation, rather than a sequence that must be remembered and repeated. That naming of workflows, as methods, makes code more readable, because a call to complete_with_date says more than two separate calls, and it makes workflows easier to change, because a change to the workflow affects only the convenience method, not every caller that repeats the sequence.
Finally, designing for common uses should not come at the expense of clarity for rare uses. A class that is easy for the common case, but confusing or impossible for a rare case that is still legitimate, will frustrate users who need that rare case, and may force them to work around the class, breaking encapsulation or duplicating logic. The goal is a balance, where the common case is easy, the rare case is possible and documented, and the interface as a whole remains coherent, with methods that fit together as a system, rather than as a collection of unrelated conveniences. Achieving that balance requires thinking about who will use the class, for what, and how often, which is a design activity, not just a coding one.
class Task:
def __init__(self, title, priority="normal"):
self.title = title
self.priority = priority
print(Task("Read").priority)
Check: How do sensible defaults in a constructor make the common case easy while still allowing less common cases?
Section 30
This section returns to the idea that classes are not an end in themselves, but a means to organise programs, and that their value should be judged by the programs they enable, not by their complexity or by their adherence to a particular style. A program that uses classes well will be easier to understand, easier to test, and easier to change, because its entities are clear, its operations are grouped, and its state is managed through methods that maintain invariants. A program that uses classes poorly, with large, unfocused classes, with exposed state, and with unclear interfaces, will be harder to understand, test, and change, than a simpler program that uses functions and data structures well, because the class syntax adds complexity without adding clarity. The goal is not to use classes, but to use them well, where they help, and to use simpler tools where they help more.
A useful way to judge a class is to ask whether it makes a program's logic clearer to a reader who is not familiar with the code. If a reader can understand, from the class and method names, what the program does, without reading every method's implementation, then the class is doing its job as an abstraction, hiding details and presenting a clear interface. If a reader must read every method to understand what the program does, then the class is not abstracting effectively, because its interface does not convey enough meaning, or its methods are doing too much, or its responsibilities are unclear. That readability test, applied to your own classes, is a practical way to evaluate them, and it often reveals improvements, such as renaming a method, splitting a class, or adding a convenience method, that make the program clearer without changing its behaviour.
As you continue to the next chapters, on inheritance, composition, and special methods, you will build on the foundations in this chapter, creating larger, more interconnected models, and the principles here, clear naming, focused responsibility, encapsulation, and testing, will become even more important, because larger models have more places where confusion can arise, and more dependencies that can break. Keeping those principles in mind, and applying them consistently, will help you build models that scale, from a single class for a task, to a system of classes for a library, a game, or a business process, without losing clarity as the system grows. That scaling, from small to large, while preserving clarity, is the real challenge of object-oriented design, and it is a challenge that is met through discipline, not through syntax alone.
Keep practising, by building small class-based programs, by reviewing your classes for clarity and focus, and by refactoring them when they become unclear, because that practice, more than any single rule, is what develops good design judgement. Each class you design, test, and refactor teaches you something about modelling, about interfaces, and about the balance between classes and functions, and that learning accumulates, making each next class a little clearer, a little more focused, and a little easier to use. That gradual improvement, through practice and reflection, is how programming skill grows, in object-oriented design as in every other area.
class Task:
def __init__(self, title):
self.title = title
self.done = False
def complete(self):
self.done = True
Check: How can you judge whether a class is a good abstraction, by what a reader can understand from its names alone?
Section 31
A class that represents a collection, such as a library, a playlist, or a shopping basket, needs methods that manage the collection as a whole, not just individual items. Those methods might add an item, remove an item, count items, find an item by a criterion, or list all items, and they should maintain the collection's invariants, such as preventing duplicates where duplicates are not allowed, or keeping items in a particular order where order matters. Designing a collection class means thinking about what operations a collection should support, how those operations interact, and what guarantees the collection provides to its callers, such as whether adding an item that is already present is an error, a no-op, or a replacement, and documenting those guarantees so callers can rely on them.
Collection classes also benefit from supporting Python's collection protocols, such as length, iteration, and membership, through special methods, so that callers can use familiar syntax, such as len(library), for book in library, and book in library, rather than learning collection-specific method names for those basic operations. That familiarity reduces the learning burden, because callers can transfer knowledge of built-in collections to your custom collection. Supporting protocols is not just a convenience; it is a way of integrating your class into the language's ecosystem, so that it works with generic functions that expect collections, such as sorting, filtering, and summarising tools, without special handling.
A collection class should also decide how it handles items that are removed or that fail validation. Removing an item that is not present might raise an error, return a failure signal, or do nothing, and each choice has different implications for callers, who must know what to expect. Adding an item that fails validation, such as a book with an empty title, might raise an exception, preventing the collection from entering an invalid state, or it might be rejected with a message, depending on whether the collection's role is to enforce validity or merely to store what it is given. Those decisions, like the decisions for individual objects, should be documented, because they define the collection's contract, and callers will write code that depends on them, whether they realise it or not.
Finally, a collection class should be tested with the same rigour as an individual object class, but with a focus on interactions between items, such as adding then removing, adding duplicates, finding a missing item, and iterating over an empty collection. An empty collection is an important edge case, because many collection methods behave differently when there are no items, and a collection that fails on empty, by raising an error where it should return an empty result, will frustrate callers who expect a collection to be usable from the moment it is created, before any items have been added. Testing empty, single-item, and multi-item collections, in that order, covers the most important cases, and it reveals bugs that only appear when a collection transitions between those states, such as a count that is not updated on removal, or an order that is not maintained on addition.
class Shelf:
def __init__(self):
self.books = []
def add(self, book):
self.books.append(book)
def __len__(self):
return len(self.books)
s = Shelf()
s.add("Dune")
print(len(s))
Check: Why should a custom collection class support familiar operations like len, iteration, and membership?
Section 32
When an object holds a reference to another object, changes to the second object are visible through the first, because they share the same object, not a copy, and that sharing can be both useful and surprising. For example, a library that holds a list of books shares those book objects with any other code that holds them, so a change to a book's title, made through one reference, will be visible through the library's list, because it is the same book object. That sharing is efficient when the book is meant to be shared, but surprising when a caller expects a private copy. Documenting sharing, and providing a copy method when isolation is needed, helps callers understand whether they are sharing or owning an object.
Sharing also affects how objects should be passed to constructors. A constructor that stores a list passed to it, without copying it, shares that list with the caller, so later changes to the caller's list will affect the object's internal list, which may violate the object's invariants or surprise its users. A constructor that copies the list, by contrast, isolates the object from later changes to the caller's list, at the cost of a copy, which is usually small for the lists in learning programs. The choice between sharing and copying in a constructor should be deliberate, and it should be documented, because it determines whether an object is independent of its creation arguments, or whether it remains linked to them, with all the implications that linkage carries for later behaviour.
A related issue is default arguments that are mutable, such as a list or a dictionary, which are shared across all calls to a function or method, because the default is created once, when the function is defined, not once per call. A constructor with a default list argument, such as def __init__(self, books=[]), will share that list between all objects created without an explicit list, so adding a book to one object's list will add it to every object's list, which is almost never intended, and which is a classic Python pitfall. The remedy is to use None as the default, and to create a new list inside the constructor when the argument is None, ensuring that each object gets its own list, independent of other objects. That pattern, None default plus creation inside, should be used for every mutable default, without exception, because the shared-default bug is subtle, hard to diagnose, and easy to prevent with this simple habit.
Understanding sharing, copying, and defaults is essential, because objects are shared by reference in Python. A programmer who understands sharing can design objects that share efficiently where sharing is intended, and copy where isolation is needed, while a programmer who does not understand sharing will be surprised by changes that appear in unexpected places, and will struggle to debug them, because the cause, a shared reference, is not visible in the code that makes the change, but in the code that created the sharing in the first place. That understanding, developed through practice with objects, is a core part of Python fluency.
class Shelf:
def __init__(self, books=None):
self.books = list(books) if books else []
s = Shelf(["Dune"])
print(s.books)
Check: Why should a constructor use None as the default for a list argument, rather than an empty list?
Section 33
Objects can be used to represent values that have both a magnitude and a unit, or a value and a context, such as a measurement, a date, or a money amount, where the value alone is not sufficient, and the context matters for interpretation and for operations. A measurement object, for example, might hold a number and a unit, such as metres or centimetres, with methods to convert between units, to compare measurements in different units, and to add measurements only when their units are compatible, raising an error otherwise. That encapsulation of value plus unit, with operations that respect the unit, prevents a whole class of errors where numbers with different units are added as if they were comparable, which is a common mistake when values are stored as bare numbers without their units.
Value objects, as such objects are sometimes called, should often be immutable, meaning that their attributes cannot be changed after creation, and that operations on them return new objects, rather than modifying the original. Immutability makes value objects safer to share, because a caller cannot accidentally change a value that another caller is using, and it makes them easier to reason about, because a value, once created, stays the same, like a number or a string, which are also immutable in Python. Designing a value object as immutable requires discipline, such as not providing methods that modify attributes, and returning new objects from operations, but that discipline pays off in predictability, especially when value objects are used as dictionary keys or in sets, where mutation after insertion can break hashing and equality, as discussed earlier.
A value object's equality should be based on its value, not its identity, so that two measurements with the same number and unit compare equal, even if they are different objects, which is the natural meaning of equality for values. That value-based equality, combined with immutability, makes value objects behave like numbers, where 2 always equals 2, regardless of how it was created, and where changing a value means creating a new value, not modifying an old one. That behaviour is intuitive for users, because it matches how they think about values in everyday life, and it is a reason why value objects are a good model for measurements, dates, money, and other quantities where the value, not the object identity, is what matters.
For beginners, the lesson is to recognise when an object represents a value, versus when it represents an entity with an identity that persists over time, such as a user or a book, whose identity matters even if its attributes change. A user remains the same user even if their name changes, while a measurement of five metres is a different value from a measurement of six metres, even if both are measurements. That distinction, entity versus value, guides decisions about mutability, equality, and identity, and it helps you design objects that match how the domain thinks about them, rather than forcing every object into the same mould, which would make some objects harder to use than they need to be.
class Measurement:
def __init__(self, value, unit):
self.value, self.unit = value, unit
def __eq__(self, other):
return isinstance(other, Measurement) and (self.value, self.unit) == (other.value, other.unit)
print(Measurement(5, "m") == Measurement(5, "m"))
Check: What is the difference between a value object, like a measurement, and an entity object, like a user?
Section 34
A class's methods can be organised into groups, such as methods that query state, methods that change state, methods that convert to other representations, and methods that manage relationships, and that organisation, reflected in the order methods appear in the class body, helps readers navigate the class. A common order is to start with the constructor, followed by properties, then by query methods, then by command methods, then by conversion methods, and finally by special methods, though the exact order is less important than consistency within a program and within a class. Consistent ordering means a reader can find a method by knowing its kind, rather than by searching the whole class, which saves time, especially in larger classes, where methods may number in the dozens and where a predictable layout is a significant aid to navigation.
Grouping methods also helps you notice when a class has too many methods of one kind, which may indicate that it has too many responsibilities, or that a group of methods belongs in a separate class. A class with many conversion methods, for example, might be trying to handle too many formats, and those conversions might belong in a separate converter class, while a class with many relationship methods might be managing too many kinds of relationships, and some of those might belong in the related classes themselves. Noticing those groupings, through the act of organising methods, is a design activity, not just a formatting one, because it reveals the class's structure and its potential refactorings, in a way that a disordered list of methods would hide.
Method organisation also interacts with documentation, because a class docstring that lists its methods by group, with a brief description of each group, gives readers an overview that a simple list of methods does not. That overview, sometimes called a table of contents for the class, helps readers decide which methods are relevant to their task, without reading every method, and it helps maintainers see the class's shape at a glance, which is useful when deciding where to add a new method, or whether a new method belongs in this class at all. Writing that overview, even informally in a comment at the top of the class, is a small investment that pays off in navigability, especially for classes that will be used by others.
Finally, method organisation should be maintained as a class evolves, by placing new methods in the appropriate group, rather than appending them at the end regardless of their kind, because a class whose organisation decays over time becomes harder to navigate, even if each individual method is clear. That maintenance, like documentation maintenance, is part of the discipline of keeping a class clean, and it should be done as part of each change, so that the class's layout remains a reliable guide to its structure, rather than a historical accident of the order in which methods were added. A well-organised class is a pleasure to read, and that pleasure is not cosmetic; it is what allows readers to understand and maintain the class efficiently.
class Counter:
def __init__(self):
self.count = 0
def value(self):
return self.count
def increment(self):
self.count += 1
Check: Why does grouping and consistently ordering a class's methods help readers navigate a larger class?
Section 35
When you create an object, you may need to ensure that it is used correctly, and one way to do that is to make invalid states unrepresentable, by designing the class so that it is impossible, or at least difficult, to create an invalid object, rather than possible but discouraged. For example, a task class might require a non-empty title in its constructor, raising an exception for an empty title, so that no task object can ever exist with an empty title, and methods can assume a title is present, without checking. That design, sometimes called making illegal states unrepresentable, shifts the burden of validation from every method, which would each need to check, to the constructor, which checks once, at creation, and guarantees validity thereafter, which simplifies methods and makes the class more trustworthy.
Making invalid states unrepresentable can also be achieved through the choice of types, such as using an enumeration for a status, rather than a free-form string, so that a status can only be one of a small set of allowed values, and a method that tries to set an invalid status will fail at assignment, rather than creating an object with a status that no other method understands. That type-based restriction, like constructor validation, prevents a whole class of errors, by making them impossible to express, rather than possible but handled, and it is a powerful design technique, though it requires thinking carefully about what values are valid, and about how to represent them in a way that the type system, or the class's own validation, can enforce.
A related technique is to use separate classes for separate states, such as a PendingTask and a CompletedTask, rather than a single Task with a status attribute that can be any value, so that methods that only make sense for one state can be defined on that state's class, and methods that do not make sense for that state are simply not available, rather than being available but raising an error when called in the wrong state. That state-specific class design, sometimes called the state pattern in its full form, is more elaborate, and it is not needed for simple cases, but it illustrates the principle: design can prevent errors, not just handle them, by making the wrong thing hard to do, and the right thing easy to do, which is a goal that applies to all design, not just to classes.
For beginners, the practical lesson is to validate in the constructor, to use restricted types where appropriate, and to think, when designing a class, about what invalid states are possible, and whether any of them can be prevented by design, rather than merely checked for in methods. That thinking, applied consistently, leads to classes that are harder to misuse, because misuse is not just discouraged by documentation, but prevented by construction, and that prevention is more reliable than documentation alone, because it works even when a caller does not read the documentation, or forgets it in the moment of writing code.
class Task:
def __init__(self, title):
if not title.strip():
raise ValueError("title must not be empty")
self.title = title
self.done = False
print(Task("Read").title)
Check: What does it mean to make an invalid state unrepresentable, and how does constructor validation help achieve it?
Section 36
Objects and functions can be combined in a pattern where a function creates and configures an object, performs an operation with it, and then discards it, without the caller ever needing to know about the object directly. That pattern, sometimes called a helper function that wraps an object, is useful when the object is an implementation detail, such as a parser, a formatter, or a temporary accumulator, and when callers only care about the result, not about the object itself. Wrapping the object in a function hides its complexity, gives callers a simple interface, and allows the implementation to change, from an object to a different structure, without changing callers, which is a form of encapsulation at the function level, complementing the encapsulation inside the class itself.
That wrapping pattern also helps when an object's lifecycle is short and its setup is fiddly, such as an object that needs several configuration steps before it can be used, where a function can perform those steps in the correct order, with defaults, and then return the result, sparing callers from repeating the setup each time. A function that reads a file, creates a parser object, configures it, parses the file, and returns the parsed data, for example, is easier to use than requiring callers to create and configure the parser themselves, especially if the parser's configuration is an implementation detail that callers should not need to understand. That simplification, through a wrapping function, is a common design pattern, and it shows that classes and functions are not alternatives, but partners, with functions often providing a simpler interface over a more complex object-based implementation.
A related pattern is to use an object as a namespace for related functions, where the object holds configuration that those functions share, such as a formatter object that holds a style, with methods to format different kinds of data in that style. That object-as-namespace pattern is useful when several functions share configuration, because it avoids passing the configuration as an argument to every function, and it groups the functions with the configuration they use, making their relationship explicit. It is distinct from an entity object, because the namespace object does not represent a thing in the domain, but a collection of related operations with shared settings, and recognising that distinction helps you decide whether a class is modelling an entity, a process, or a namespace, each of which has different design considerations.
For beginners, the lesson is to not be dogmatic about classes versus functions, but to use each where it fits, and to combine them when a combination gives a simpler interface than either alone. A program that is entirely functions may be hard to manage when it deals with entities, while a program that is entirely classes may be hard to use when it deals with simple transformations, and a program that mixes them thoughtfully, with functions wrapping objects where appropriate, and objects grouping data and behaviour where appropriate, will be clearer than either extreme. That thoughtful mixing, guided by the problem rather than by a rule, is the hallmark of experienced design.
def total_price(items):
total = 0
for item in items:
total += item
return total
print(total_price([10, 20, 30]))
Check: When is a function that creates, uses, and discards an object internally a better interface than exposing the object directly?
Section 37
A class can be used to implement a small domain-specific language, or a fluent interface, where method calls are chained together to build up an operation, such as a query builder that adds conditions one by one, or a report builder that adds sections one by one, with each method returning the object itself, so that the next method can be called on the result. That chaining, sometimes called a fluent interface, makes code read like a sentence, with each method call adding a piece to the overall operation, and it can be more readable than a long list of arguments or a series of separate statements, especially for building complex objects or queries where the number of parts varies. Fluent interfaces should be used sparingly, however, because they can make debugging harder, as a failure in a chain does not clearly indicate which call failed, and because they can encourage long chains that are hard to read when they exceed a few calls.
A fluent interface requires methods to return self, which must be documented, because callers may otherwise be surprised. That surprise can be mitigated by naming methods clearly, and by documenting that they return self for chaining, so that callers know they can chain, but are not forced to, because a method that returns self can also be called as a standalone statement, with its return value ignored, without harm. That flexibility, chaining when useful and standalone when not, makes fluent methods versatile, but it also requires discipline, to ensure that chaining does not obscure the object's state changes, which should remain clear even when calls are chained.
For most learning programs, fluent interfaces are not necessary, and ordinary methods, called as separate statements, are clearer, because each statement's effect is visible on its own line, and debugging is easier, as each call can be inspected separately. Fluent interfaces become valuable when building complex structures, such as a query with many optional parts, where a chain expresses the building process more naturally than a constructor with many optional arguments, or a series of assignments that must be remembered in order. Recognising when a fluent interface genuinely helps, versus when it merely looks elegant, is a design judgement that grows with experience, and it should be based on readability for the specific use case, not on a desire to use a fashionable pattern for its own sake.
The broader lesson is that method return values are part of a class's interface, and they should be chosen deliberately, whether they return a value, return self for chaining, or return nothing, because callers will depend on that choice, and changing it later will break their code. Documenting return values, in method docstrings, is therefore essential, especially for methods where the return value is not obvious from the method's name, such as a method that both updates state and returns a value, or a method that returns self for chaining. Clear return-value documentation prevents misuse, and it helps readers understand a method's purpose at a glance, without reading its implementation to discover what it returns.
class Builder:
def __init__(self):
self.parts = []
def add(self, part):
self.parts.append(part)
return self
print(Builder().add("a").add("b").parts)
Check: What is a fluent interface, and why can long method chains make debugging harder?
Section 38
When a program grows to many classes, organising those classes into modules becomes important, because a single file with dozens of classes is hard to navigate, hard to test, and hard to maintain. A module, as covered in an earlier chapter, is a file that groups related classes and functions, and a package is a folder of modules, and organising classes into modules by their domain, such as a module for tasks, a module for projects, and a module for users, mirrors the domain's structure, making the program's organisation understandable at the file level, before any code is read. That file-level organisation, like method organisation within a class, helps readers find what they need, by knowing which module to look in, rather than searching a large file for a class among many unrelated ones.
Module organisation also affects imports, because a class in one module that needs a class from another module must import it, and those imports create dependencies between modules, which should be kept simple and acyclic, meaning that modules should not import each other in a cycle, because cycles make code hard to understand and hard to test, and they can cause import errors. A simple dependency structure, where higher-level modules import lower-level ones, but not vice versa, is easier to reason about, and it reflects a layered design, where lower layers provide basic entities, and higher layers combine them into more complex behaviours. Designing modules with clear dependencies is a larger-scale version of designing classes with clear interfaces, and it requires the same attention to boundaries and to responsibilities.
Testing also benefits from module organisation, because a module can be tested in isolation, with its classes and functions, without needing to load the entire program, which makes tests faster and more focused. A test for the task module, for example, can import only that module, create task objects, and check their behaviour, without needing the project or user modules, unless the task module genuinely depends on them, in which case that dependency should be explicit and minimal. That isolation, like fresh objects per test, makes failures easier to diagnose, because a failing test points to a specific module, rather than to the program as a whole, and it allows tests to run in parallel or in any order, without interference through shared module state.
For beginners, the practical lesson is to start with classes in a single file for a small program, and to split that file into modules when it becomes hard to navigate, or when a group of classes is clearly related and reusable in another program, rather than splitting prematurely, before the program's structure is clear. Premature splitting, like premature class creation, adds indirection without benefit, because readers must jump between files to understand a simple program, while splitting at the right time, when a file is large or a group is reusable, improves navigability without obscuring the program's logic. That timing, like many design decisions, is a judgement that improves with experience, and it should be guided by readability, not by a rule about file size or number of classes.
# In a module, a class would be defined and imported elsewhere:
# from tasks import Task
class Task:
def __init__(self, title):
self.title = title
Check: Why should related classes be grouped into modules, and what problem do circular imports between modules cause?
Section 39
A class's behaviour can be extended by adding methods, but it can also be extended by allowing callers to provide custom behaviour, such as a function that is called at a certain point, or a strategy object that implements a particular algorithm, which the class uses without knowing its details. That extension by customisation, sometimes called the strategy pattern in its full form, allows a class to be reused in different contexts, with different behaviours, without changing the class itself, by delegating a variable part of its behaviour to an object or function supplied by the caller. For example, a sorter class might accept a comparison function, or a report generator might accept a formatter object, so that the same class can sort by different criteria, or generate reports in different formats, without being modified for each new criterion or format.
Customisation through supplied behaviour requires careful interface design, because the class and the supplied behaviour must agree on what arguments will be passed, what values will be returned, and what side effects are allowed, and that agreement should be documented, so that callers can provide behaviour that works correctly with the class. Without that documentation, a caller might provide a function that returns the wrong type, or that modifies state it should not, causing a failure that is hard to trace, because the failure occurs inside the class, but its cause is in the supplied behaviour, which the class author did not write and cannot control. Documenting the customisation interface, with examples of correct supplied behaviour, is therefore essential for this pattern to work safely, and it is a reason why customisation should be used only when the benefit, reuse across contexts, outweighs the cost, added interface complexity.
For most learning programs, ordinary methods are clearer than customisation through supplied behaviour. Customisation becomes valuable when a class is used in many contexts, with behaviour that cannot be captured by a simple parameter. In those cases, allowing callers to supply behaviour, rather than adding a parameter for every possible variation, keeps the class focused, while still allowing flexibility, and it is a design pattern that you will encounter in libraries, where classes are designed to be reused by many different programs, each with its own needs.
The broader lesson is that a class's design should consider not just its current uses, but also its likely future uses, and should provide extension points, such as methods that can be overridden, or behaviour that can be supplied, where extension is genuinely likely, while keeping the interface simple where extension is unlikely. That foresight, balanced against simplicity, is a design skill that grows with experience, and it is guided by asking, for each part of a class's behaviour, whether it is likely to vary, and if so, how callers should be able to vary it, without breaking the class's invariants or complicating its interface for the common case. A class that is designed with thoughtful extension points is a class that can grow with its uses, rather than needing to be rewritten when a new use arises.
def by_title(book):
return book.title
class Book:
def __init__(self, title):
self.title = title
books = [Book("Emma"), Book("Dune")]
print([b.title for b in sorted(books, key=by_title)])
Check: How does allowing callers to supply a function, such as a sorting key, let a class be reused in different contexts without changing it?
Section 40
This chapter has covered classes from definition to design, and it is worth reviewing the journey, from the basic idea of a blueprint and its instances, through attributes, methods, encapsulation, and properties, to design principles such as focused responsibility, clear naming, and testing. Each topic built on the previous one, starting with syntax, moving to behaviour, and ending with design judgement, which is the real skill that this chapter aims to develop, because syntax can be looked up, but judgement about when to use a class, how to model an entity, and how to design an interface, must be developed through practice and reflection. That judgement will continue to develop in the next chapters, on inheritance and composition, where classes are combined into larger systems, and where the principles here will be tested by more complex relationships and by the need to maintain clarity as systems grow.
A good next step, after this chapter, is to build a small program that uses several classes together, such as a task manager with tasks and projects, or a library with books and members, where each class has a clear responsibility, and where the classes collaborate through method calls, rather than through direct manipulation of each other's attributes. That program, even if small, will teach you more about class design than reading about it, because it will force you to make decisions about relationships, about interfaces, and about where behaviour belongs, and it will reveal, through testing and use, whether those decisions were good, or whether they need refactoring. Building, testing, and refactoring a multi-class program is the practical counterpart to this chapter's theory, and it is the step that turns knowledge into skill.
As you build, keep the habits emphasised in this chapter: initialise all attributes in __init__, validate inputs, maintain invariants through methods, respect private attributes, define clear representations, test sequences and edge cases, and document interfaces and invariants. Those habits, applied consistently, will make your classes trustworthy, even when they are simple, and they will scale to larger programs, where trustworthiness matters more, because a bug in a class used in many places can affect the whole program, while a bug in a single-use function affects only its immediate caller. Trustworthy classes are the foundation of trustworthy programs, and they are built through disciplined design, not through clever syntax.
Finally, remember that classes are a tool, not a goal, and that the goal is always a program that is clear, correct, and maintainable, whether it uses classes, functions, or a thoughtful combination of both. A program that achieves that goal with simple tools is better than one that uses classes everywhere but is hard to understand, because clarity and correctness are what users and maintainers need, not adherence to a particular style. Keep that goal in mind as you learn inheritance and composition, and as you encounter new patterns and techniques, asking always, does this make the program clearer, more correct, or easier to maintain, and if not, why use it. That question, asked consistently, will guide you to good design, in this chapter and in all the chapters that follow.
class Task:
def __init__(self, title):
self.title = title
self.done = False
def complete(self):
self.done = True
def __repr__(self):
return f"Task({self.title!r}, done={self.done})"
print(Task("Read"))
Check: What habits from this chapter most help a class remain trustworthy as a program grows larger?
Section 41
A class can be used to encapsulate a small piece of state that would otherwise be scattered across variables, such as the state of a game, a timer, or a counter with a limit, where the state must be updated consistently and where invalid transitions must be prevented. Encapsulating that state in an object, with methods that update it in a controlled way, makes the state easier to reason about, because all updates go through methods that can validate, log, or enforce rules, rather than through direct assignments from many places, which can leave the state inconsistent.
State encapsulation also makes testing easier, because a test can create an object, perform a sequence of operations, and check the state after each one, without needing to set up many separate variables or to simulate the program's overall state. That focused testing, on a single object's state, is more precise than testing a program's global state, because it isolates the object's behaviour from other parts of the program, and it makes failures easier to diagnose, because a failing test points to a specific object's method, rather than to a diffuse interaction between variables. That isolation is a general benefit of objects, but it is most visible for stateful objects, where state is the central concern, and where encapsulation turns a diffuse problem, managing state across a program, into a focused one, managing state inside an object.
A stateful object should also provide a way to inspect its state, through properties or query methods, without allowing callers to change it directly, because inspection is often needed for display, for logging, or for testing, while direct change would bypass validation and rules. That inspection, through read-only properties, gives callers the information they need, while preserving the object's control over its state, which is a balance between transparency and encapsulation that is essential for trustworthy objects. A stateful object that hides its state entirely, with no way to inspect it, is hard to debug and hard to use, because callers cannot tell what state it is in, while one that exposes its state for direct change is hard to trust, because its rules can be bypassed. Read-only inspection is the middle path that serves both needs.
Finally, a stateful object should be designed with its lifecycle in mind, from creation, through its various states, to its eventual disposal, with methods that correspond to lifecycle events, such as start, stop, pause, and resume, each checking that the transition is valid from the current state. That lifecycle design, like process modelling discussed earlier, makes the object's behaviour predictable, because callers can see, from its methods and their state checks, what transitions are allowed, and what happens when an invalid transition is attempted, rather than discovering the lifecycle by trial and error. A well-designed lifecycle is a form of documentation, built into the code, that guides callers toward correct use and away from invalid use, which is the ultimate goal of encapsulation.
class Timer:
def __init__(self):
self.running = False
def start(self):
self.running = True
def stop(self):
self.running = False
Check: Why is encapsulating stateful data, like a timer's running status, in an object better than using scattered variables?
Section 42
Bulk creation also raises the question of partial success, as discussed for file processing: if one data item fails validation, should the whole bulk creation fail, or should the valid items be created and the invalid ones reported? The answer depends on the program, as before, but for objects, the stakes are similar: a task manager that loads tasks from a file might skip invalid tasks and report them, so that valid tasks remain usable, while a configuration loader that creates a settings object might fail entirely on one invalid setting, because a partial settings object could cause wrong behaviour. Making that decision explicitly, and documenting it in the loader's docstring, helps callers know what to expect, and it prevents a loader that silently drops invalid items from being mistaken for one that guarantees all items were loaded successfully.
A loader should also handle the case where the input data is empty, by returning an empty list, rather than raising an error, because an empty input is often a valid case, such as a new user with no tasks yet, or a new library with no books, and a loader that fails on empty input forces callers to handle that case specially, even though it is ordinary. That empty-case handling, like handling a missing file as an empty list, is a small detail that makes a loader more usable, because it allows callers to treat empty and non-empty inputs uniformly, without a special case, which simplifies their code and reduces the chance of a bug where empty input is mishandled. Testing a loader with empty input, therefore, is an important edge case, just as testing a file program with an empty file is.
Finally, a loader's performance should be considered, though it is rarely a problem for learning programs, because creating many objects can be slower than processing raw data, especially if each object's constructor does validation or other work. For ordinary sizes, that cost is negligible, and clarity should take precedence over speed, but for very large inputs, a loader that creates objects lazily, through a generator, rather than all at once, can save memory, by creating each object only when it is needed, and discarding it after use. That lazy loading, using generators covered in a later chapter, is an advanced optimisation, and it should only be used when memory is genuinely a concern, because it complicates the loader's interface, by returning a generator rather than a list, which callers must handle differently. For now, a simple loader that returns a list is the right choice for most programs.
class Task:
def __init__(self, title):
self.title = title
def load_tasks(titles):
return [Task(t) for t in titles]
print([t.title for t in load_tasks(["Read", "Write"])])
Check: When loading many objects from data, why should the loader handle an empty input by returning an empty list?
Section 43
Consistency in ordering also means that the ordering should be stable and transitive, meaning that if A is less than B, and B is less than C, then A should be less than C, and that sorting the same list twice should give the same result, which are properties that most sorting algorithms assume, and that are violated by orderings based on mutable attributes that change during sorting, or on random values, or on attributes that are not comparable. An ordering based on a title, for example, is stable if titles do not change during sorting, but it becomes unstable if titles are changed by another part of the program while sorting is in progress, which is a rare but real risk in concurrent or complex programs. For learning programs, the lesson is simpler: choose an ordering based on a stable, comparable attribute, document it, and test it with a few examples, to ensure that it behaves as expected for equal values, for different values, and for edge cases such as empty strings or missing attributes.
Another ordering consideration is that some objects have no natural ordering, such as a user, where ordering by name, by age, or by join date are all plausible, but none is obviously the natural one, and defining a single __lt__ for such a class may mislead callers into thinking that ordering is inherent, rather than a choice made by the class author. In those cases, it is better not to define __lt__ at all, and to require callers to specify a key function when sorting, making the ordering explicit at the call site, where the caller's intention is clear, rather than implicit in the class, where it may be misunderstood. That explicitness, like explicitness in other areas of Python, prevents a class from imposing a choice that callers did not make, and it keeps the class's interface honest about what it does and does not define.
Finally, ordering methods should handle comparison with other types gracefully, by returning NotImplemented for unknown types, as discussed for equality, rather than raising an error, so that Python can try the other object's comparison, or report that the objects are not comparable, in a way that is consistent with the language's behaviour for built-in types. That graceful handling, like graceful handling in equality, makes classes more robust in generic code, where they may be compared with values of many types, and it prevents a comparison that should simply report incomparability from becoming a crash that stops the program. Robust comparison methods, like robust equality methods, are a hallmark of well-designed classes that are meant to be used in diverse contexts.
from functools import total_ordering
@total_ordering
class Book:
def __init__(self, title):
self.title = title
def __eq__(self, other):
return isinstance(other, Book) and self.title == other.title
def __lt__(self, other):
return isinstance(other, Book) and self.title < other.title
print(sorted([Book("Emma"), Book("Dune")], key=lambda b: b.title))
Check: Why is it important that a class's ordering methods be consistent with each other and with equality?
Section 44
A class can define a method that is called when an object is used in a boolean context, such as in an if statement, through __bool__, which should return True if the object is considered truthy, and False otherwise, based on its state. For a collection, truthiness usually means non-emptiness, so an empty shelf is falsy and a shelf with books is truthy, which matches the behaviour of built-in collections, where an empty list is falsy. For other objects, truthiness should have a clear meaning in the domain, such as a task being truthy when it is pending, or a bank account being truthy when it has a positive balance, though those meanings are less standard, and defining them may surprise callers who expect truthiness to mean existence, not a domain-specific condition. As with length and ordering, the rule is to define __bool__ only when its meaning is clear and matches the domain, and to document it, so that callers are not surprised by a boolean test that behaves differently from what they expected.
If __bool__ is not defined, Python falls back to __len__, if defined, treating an object as falsy when its length is zero, and truthy otherwise, which is a sensible default for collections, and which means that a collection class that defines __len__ automatically gets sensible truthiness, without needing __bool__. If neither is defined, every object is truthy, regardless of its state, which is the default for most objects, and which means that a test such as if book: will always succeed, even for a book with an empty title, unless the class defines a truthiness method. That default, always truthy, is appropriate for entities, where existence is the relevant test, but not for collections, where emptiness is relevant, and understanding the fallback chain, __bool__ then __len__ then always truthy, helps you predict how your objects will behave in boolean contexts, and helps you decide whether to define a truthiness method at all.
Truthiness should also be consistent with other methods, such as length and equality, so that an object that is falsy because it is empty also has a length of zero, and an object that compares equal to an empty object is also falsy, where those concepts apply. Inconsistent truthiness, where an object is falsy but has a non-zero length, or truthy but compares equal to a falsy object, can confuse callers and can cause bugs in generic code that relies on truthiness to mean emptiness, as many Python idioms do, such as if not items: to check for an empty collection. Keeping truthiness, length, and equality consistent, therefore, is part of designing a coherent class, where different operations give the same answer to the same underlying question, whether an object is empty, present, or equal to another.
class Shelf:
def __init__(self, books):
self.books = books
def __len__(self):
return len(self.books)
print(bool(Shelf([])), bool(Shelf(["Dune"])))
Check: How does Python decide an object's truthiness when __bool__ is not defined but __len__ is?
Section 45
When a class is designed, it is helpful to consider how it will be used in interactive sessions, such as in a Python REPL, where a programmer explores objects by creating them, printing them, and calling their methods, to understand their behaviour. An interactive-friendly class has a clear __repr__, so that objects print meaningfully, has methods with clear names and sensible defaults, so that they can be called with minimal arguments, and has docstrings that can be read with help(), so that a programmer can learn its interface without leaving the session.
Interactive exploration also benefits from classes that are forgiving of small mistakes, such as accepting a title with extra whitespace and stripping it, or accepting a number as a string and converting it, where the conversion is unambiguous, rather than raising an error for a minor formatting difference. That forgiveness, like tolerance in file parsing, makes exploration smoother, because a programmer trying an example does not need to get every detail exactly right on the first try, and it makes the class more usable in real programs, where input may vary in formatting. Forgiveness should not extend to genuinely invalid input, such as an empty title or a negative balance, where raising is appropriate, because forgiving those would hide errors, but for formatting variations, where the intended value is clear, normalising is helpful and user-friendly.
A class that is good for interactive use is also good for testing, because tests, like interactive sessions, create objects, call methods, and check results, and a class that is easy to explore is easy to test, with minimal setup and clear assertions. That similarity, between interactive exploration and testing, means that designing for one often helps the other, and that a class that is painful to use interactively, requiring many setup steps or precise arguments, will also be painful to test, because each test will need the same setup, which is a sign that the class's interface may be too complex, or that its defaults may be poorly chosen. Simplifying the interface, with better defaults or with convenience constructors, helps both interactive use and testing, which is a win for the class's overall usability.
Finally, interactive use reveals design flaws quickly, because a programmer exploring a class will try methods in orders and with arguments that the class author did not anticipate, and will encounter edge cases, such as empty objects, missing attributes, or invalid transitions, that scripted use might not reveal until later. That early revelation, through exploration, is valuable, because it allows flaws to be fixed before the class is used in a larger program, where they would be harder to diagnose and more costly to fix. Encouraging interactive exploration, by making classes easy to create and inspect, is therefore a design strategy, not just a convenience, because it turns exploration into a form of informal testing that improves the class before formal testing even begins.
class Book:
"""A book with a title."""
def __init__(self, title):
self.title = title.strip()
def __repr__(self):
return f"Book({self.title!r})"
print(Book(" Dune "))
Check: Why does designing a class to be easy to explore interactively also make it easier to test?
Section 46
A class's methods should be robust to being called with unexpected arguments, by validating them and raising clear exceptions, rather than by failing with a confusing error deep inside the method, or by silently accepting a wrong value and producing a wrong result. Validation, as discussed for constructors, applies to all methods, not just to __init__, because a method that receives a negative amount for a deposit, or a non-string title for a rename, should reject it clearly, at the start of the method, before changing any state, so that the object remains in a valid state even when a method call fails.
Method validation should also be consistent with constructor validation, so that the same rules apply wherever a value enters the object, whether at creation or through a later method, because inconsistent rules, where a constructor rejects an empty title but a rename method accepts it, create a loophole through which invalid state can enter, defeating the purpose of constructor validation. That consistency requires that validation logic be shared, either by calling a common validation function from both the constructor and the method, or by having the method use the same property setter that the constructor uses, so that the rule is defined once and enforced everywhere. Sharing validation logic, like sharing other logic, prevents duplication and inconsistency, and it makes it easier to change a rule later, because only one place needs to change.
A method that validates should also give a clear error message, naming the argument, the value received, and the rule that was violated, so that the caller can fix the call without guessing, which is the same standard as for error messages in general, applied to method arguments. A message such as deposit amount must be positive, got -5 is far more helpful than invalid amount, because it tells the caller exactly what was wrong and what would have been acceptable, and it helps them distinguish a bug in their code, where they passed a wrong value, from a bug in the method, where the method's rule is too strict or incorrectly implemented. Clear validation messages, therefore, are part of a method's interface, not an afterthought, because they are how the method communicates its contract to callers who violate it.
Finally, method validation should be tested, with tests that call methods with invalid arguments and check that they raise the expected exceptions, without changing the object's state, because a method that claims to validate but actually changes state before failing, or that raises the wrong exception, or that accepts an invalid value, will cause bugs that are hard to trace, as they manifest later, when the invalid state is used. Testing validation, like testing constructors, ensures that the object's invariants are maintained not just at creation, but throughout its life, which is the ultimate goal of validation, to keep objects valid at all times, not just at the moment they are created.
class Account:
def __init__(self):
self.balance = 0
def deposit(self, amount):
if amount <= 0:
raise ValueError("deposit amount must be positive")
self.balance += amount
Check: Why should a method validate its arguments before changing any of the object's state?
Section 47
Objects can be used to implement a simple form of event handling, where an object notifies other objects, or functions, when its state changes, by calling callbacks that have been registered with it. That notification pattern, sometimes called the observer pattern in its full form, allows a program to react to changes, such as updating a display when a task is completed, or logging when a balance changes, without the object needing to know about the display or the log directly, because it merely calls the registered callbacks, and the callbacks handle the reaction. That decoupling, between the object that changes and the code that reacts, is valuable, because it allows new reactions to be added without changing the object, by registering a new callback, and it allows the object to be reused in contexts where different reactions are needed, without modifying its code for each context.
Event handling through callbacks requires care about the callback interface, about what arguments will be passed, about whether callbacks can modify the object, and about what happens if a callback raises an exception, because a failing callback should not corrupt the object's state or prevent other callbacks from running, unless that is the intended behaviour. A robust event system should handle callback failures gracefully, by logging them and continuing with other callbacks, or by propagating them clearly, depending on the program's needs, and it should document that behaviour, so that callback authors know what to expect. For learning programs, a simple callback system, where an object holds a list of functions and calls each one on a change, is enough to illustrate the pattern, without needing the full complexity of a production event system, which may involve threading, ordering, and unsubscribe mechanisms.
A simpler alternative to callbacks, for many programs, is to have the caller check the object's state after an operation, and react accordingly, rather than having the object notify automatically, because that polling approach is simpler to understand and to debug, as the reaction happens in the caller's code, at a known point, rather than inside the object, at a time that may be surprising. Polling is appropriate when reactions are infrequent, or when the caller already checks state regularly, while callbacks are appropriate when reactions must happen immediately, or when many different parts of a program need to react to the same change, without each polling separately. Choosing between polling and callbacks, like choosing between other patterns, depends on the program's needs, and it should be based on simplicity and clarity, not on a desire to use a pattern for its own sake.
class Task:
def __init__(self, title):
self.title = title
self.done = False
self.listeners = []
def complete(self):
self.done = True
for fn in self.listeners:
fn(self)
Check: How does notifying registered callbacks when an object changes decouple the object from the code that reacts to the change?
Section 48
A class can be designed to work with Python's built-in functions and operators, through special methods, as previewed earlier, and that integration should be guided by the principle of least surprise, meaning that an object should behave, in built-in contexts, as a user would expect from its domain, not in a way that is technically possible but surprising. For example, a shelf object that supports len should return the number of books, not the number of shelves, and a book object that supports comparison should compare by a meaningful attribute, not by an arbitrary one, because a surprising behaviour, even if documented, will cause bugs when a caller assumes the expected behaviour, which is more likely than a caller reading documentation for every operation. Least surprise is a design principle that applies to all interfaces, but it is especially important for special methods, because they change how familiar syntax behaves, and familiar syntax carries strong expectations from built-in types.
Least surprise also applies to method names and to argument order, where a method that takes arguments in an unexpected order, or that has a name that suggests one behaviour but implements another, will cause bugs, even if its documentation is correct, because callers will rely on their expectations, not on documentation, when writing code quickly. A method named add that actually removes, or a constructor that takes arguments in the order author, title when most callers expect title, author, are examples of surprising design that will cause errors, even though the code itself is correct. Avoiding surprise requires thinking from the caller's perspective, asking what they would expect, given the names and the domain, and designing to match those expectations, rather than to match an internal implementation convenience that callers do not see.
Finally, least surprise applies to error behaviour, where a method that fails should fail in a way that is consistent with similar methods, both in your class and in built-ins, by raising the same type of exception for the same kind of failure, with a similar message structure, so that callers can handle failures uniformly, without learning a new exception for each method. That consistency in errors, like consistency in names and in behaviour, reduces the learning burden and makes programs more robust, because handlers can be written once, for a category of failures, rather than once per method, with different expectations for each. Least surprise, in all its forms, is a principle that serves users, by making interfaces predictable, and that serves maintainers, by making code easier to reason about, because its behaviour matches its appearance.
class Shelf:
def __init__(self, books):
self.books = books
def __len__(self):
return len(self.books)
def __contains__(self, book):
return book in self.books
Check: What does the principle of least surprise mean for a custom class that mimics a built-in collection?
Section 49
As you finish this chapter, it is worth considering how classes fit into the broader landscape of Python programming, where they are one tool among many, alongside functions, modules, data structures, and, in later chapters, generators, decorators, and context managers, each of which solves a different kind of problem, and each of which can be combined with classes in powerful ways. A class that uses a generator method to iterate over its items, for example, combines classes with iteration, while a class that is used as a context manager, through special methods, combines classes with resource management, and those combinations, covered in later chapters, show that classes are not an isolated topic, but a foundation on which other Python features build, by allowing custom types to participate in the language's protocols, such as iteration, context management, and decoration.
That foundation role means that the skills in this chapter, defining classes, managing state, designing interfaces, and testing objects, will recur in every later chapter, in new forms, as you learn to make classes iterable, context-managed, decorated, and inherited, each of which adds a new capability to the basic class model, while relying on the same principles of clear naming, focused responsibility, and encapsulation.
The broader landscape also includes other programming paradigms, such as functional programming, where data is immutable and functions are pure, and procedural programming, where programs are sequences of steps, and Python, as a multi-paradigm language, allows you to mix those paradigms, using classes where they help, functions where they help, and immutable data where it helps, without forcing you into a single style. That flexibility is a strength, because different problems suit different paradigms, and a program that uses the right paradigm for each part will be clearer than one that forces everything into a single paradigm, whether that paradigm is object-oriented, functional, or procedural. Learning to mix paradigms thoughtfully, guided by clarity rather than by dogma, is a skill that develops over time, and it starts with understanding each paradigm's strengths, as this chapter has begun to do for classes.
Finally, the broader landscape includes the community and the ecosystem, where classes are used in libraries, frameworks, and applications, in styles that vary, but that share the principles in this chapter, such as clear interfaces, documented invariants, and tested behaviour, which are what make a library usable by others, regardless of its specific style. Reading library code, with those principles in mind, will help you learn from experienced designers, by seeing how they model entities, how they design interfaces, and how they handle edge cases, and it will also help you contribute to libraries, by writing classes that meet the same standards of clarity and trustworthiness. That community learning, through reading and contributing, is a continuing part of programming education, beyond any single chapter or book.
class Task:
def __init__(self, title):
self.title = title
self.done = False
def __repr__(self):
return f"Task({self.title!r})"
print(Task("Learn"))
Check: How do classes serve as a foundation for later Python features like iteration and context management?
Section 50
The final message of this chapter is to practise class design in real, small programs, where the stakes are low and the feedback is immediate, because design judgement, like any judgement, improves through experience, not through reading alone. Build a task manager, a library, a bank account system, or a game, starting with one class, testing it, using it, and then adding a second class, testing their interaction, and refactoring when the design becomes unclear, and repeat that cycle, with different domains, to build a repertoire of models, interfaces, and patterns that you can draw on for new problems. Each program you build will teach you something, whether it is a naming choice that worked well, a method that was awkward, a state that was hard to manage, or a test that caught a bug, and that learning, accumulated over many programs, is what turns a beginner who knows class syntax into a programmer who can design classes well.
As you practise, keep a notebook, or a comment file, of design lessons you learn, such as a class that was too large and how you split it, a method that was confusing and how you renamed it, or a bug that was caused by shared state and how you fixed it with copying, because those lessons, written down, become a personal design guide that you can consult for future programs, and that grows with your experience. That guide, like the habits in this chapter, is a tool for improving design over time, by making your learning explicit, rather than leaving it as vague intuition that fades between projects. A programmer who reflects on their design, and records what worked and what did not, improves faster than one who merely builds program after program without reflection, because reflection turns experience into knowledge, and knowledge into better design.
Finally, remember that class design is a creative activity, not just a technical one, where there are often several good solutions to a modelling problem, and where the best solution depends on the program's purpose, its users, and its future evolution, not on a single correct answer that can be looked up. Embrace that creativity, by trying different models for the same problem, by comparing their clarity and their testability, and by discussing your designs with others, who may see a model you missed, or a flaw you overlooked, because design improves through discussion and through trying alternatives, not through following a single rule. That creative, reflective, collaborative approach to class design is what will make you a thoughtful programmer, not just a competent one, and it is the approach this chapter hopes to encourage, as you move on to inheritance, composition, and the other advanced topics that build on the foundations you have now established.
class Task:
def __init__(self, title):
self.title = title
def describe(self):
return f"Task: {self.title}"
print(Task("Practise classes").describe())
Check: Why is building and refactoring small class-based programs a better way to learn design than reading about it alone?
Practical Task
Build a small library program using classes: a Book class (title, author, available flag, with borrow and return_book methods that maintain the flag correctly and reject invalid transitions, such as borrowing an already-borrowed book) and a Library class (a collection of books, with methods to add a book, find a book by title, list available books, and count books). Write tests, using simple asserts, that check: a new book is available; borrowing makes it unavailable and a second borrow raises an error; returning makes it available again; finding a missing title returns None; and listing available books excludes borrowed books. Also test an empty library. Document each class with a docstring explaining what it represents and its main invariants.
Chapter Questions
- What is the difference between a class attribute and an instance attribute, and why should mutable data be initialised in
__init__? - Explain encapsulation in Python, including the role of the leading underscore convention and of properties.
- When is a class a better choice than a dictionary or a set of functions, and when is it overkill?
- Why should a constructor validate its arguments and leave the object in a valid state, rather than creating a half-initialised object?
- Describe how you would test a class that manages state, including sequence tests and edge cases.