We closed module 6 with an uncomfortable feeling: EasyTask already knows how to search, sort, walk trees and measure its own cost, but all of that rests on a list of dictionaries that nobody polices. Nothing stops one dictionary from missing the completed key, another from having priority written as "High", or a third from dragging along an invented field; and the functions that work with tasks —show_card, total_days, sort_key— are scattered loose around the file, far from the data they manipulate. In this lesson we start fixing both problems at once.
The tool is called object-oriented programming, and we already mentioned it in passing in Programming languages as one of the major paradigms. Here you will use it for the first time with a very concrete goal: stop having data on one side and functions on the other and start having things —tasks, agendas— that carry inside them both what they are and what they know how to do. We will begin with the essentials: what a class is, what an instance is, and how they differ from a dictionary.
Contents
- The fragile agenda: an uncomfortable demonstration
- What an object is: data and behaviour together
- Class and instance: the mould and the copies
- The minimum syntax:
classand creating instances - Attributes assigned from outside (and why that is still not enough)
type(),isinstance()and "in Python everything is an object"- Class attributes versus instance attributes
- The object's namespace:
__dict__,getattr,setattrandhasattr - Dictionary or object: when each one pays off
- The four pillars of OOP, in one table
- Common mistakes and tips
- Exercises
- Conclusion
- The fragile agenda: an uncomfortable demonstration
Before presenting the solution, let us watch the problem happen. This is an agenda like the ones we have been using since 05-04, but with three tasks that arrived from different places: one was typed by hand by a programmer, another came from an old JSON file, and another was created by a colleague who did not know the format.
agenda = [
{"title": "Book fair poster", "assignee": "Luis",
"priority": "high", "days": 3, "completed": False},
{"title": "Sole Bakery logo", "assignee": "Nuria",
"priority": "High", "days": 5, "completed": False}, # <-- 'High' capitalised
{"title": "Vidal quote", "assignee": "Marta",
"priority": "medium", "days": 2, "urgent": True}, # <-- 'completed' missing
] # and 'urgent' left over
def show_list(agenda):
for number, task in enumerate(agenda, start=1):
mark = "[X]" if task["completed"] else "[ ]"
print(f"{number:>2}. {mark} {task['title']:<26}{task['priority']}")
show_list(agenda)
# 1. [ ] Book fair poster high
# 2. [ ] Sole Bakery logo High
# Traceback (most recent call last): ... KeyError: 'completed'Look closely at the exact shape of the disaster, because it is what justifies this whole module:
- The program printed half the list before blowing up, and the error appears far from its cause: the
KeyErrorfires insideshow_list, but the mistake was made much earlier, when somebody created that dictionary withoutcompleted. In a large program hundreds of lines and several days can separate the two. - The second problem did not even raise an error.
"High"was printed quite happily. The damage will show up later, whensort_keyevaluatesPRIORITY_ORDER["High"], or when a filtertask["priority"] == "high"leaves that task out without warning anyone. This is the worst case: an incorrect result that looks correct. - The
urgentfield is silent rubbish. Nobody reads it or updates it, but there it is, it gets saved into the JSON file and it confuses whoever opens that file six months from now.
The common root is that nowhere in the program is it written down what a task is. The format only exists in our heads and in habit. A dictionary accepts any key and any value: that flexibility, which back in 05-03 was a virtue, is here exactly the problem.
- What an object is: data and behaviour together
An object is an element of the program that brings together two things we have kept apart until now:
| Part | Technical name | In EasyTask |
|---|---|---|
| What the object is (its data) | Attributes | title, assignee, priority, days, completed |
| What the object knows how to do | Methods | display itself, mark itself completed, say whether it is urgent |
Until now our program kept the data in dictionaries and the behaviour in loose functions spread around easytask.py. Nothing connected show_card with the dictionary it knows how to draw, apart from the hope that whoever calls it passes in the right kind of data.
The curious part is that you have been using objects throughout the course without calling them that. When you write title.upper() or title.count("e"), title is not "just" a piece of text: it is an object that contains some characters and also knows how to turn itself into uppercase, count letters or split itself into pieces. That .upper() is a method: a function that lives inside the object and acts on its own data. That is why you do not have to write upper(title): the object already knows which text to work on. The same goes for lists (agenda.append(...)) and dictionaries (task.get("days")). The dot notation you have been using since module 2 is, literally, object-oriented notation: object.whatItKnowsHowToDo().
What you will learn in this module is how to create your own types of object, so that a task at Alba Studio makes as much sense to Python as a string or a list does.
- Class and instance: the mould and the copies
Here come the two central words of the module, and it is worth never confusing them. A class is the definition: it describes what data a task holds and what it knows how to do, it is written once, and it is not a concrete task but the idea of a task. An instance (or object) is each copy created from that class: you create as many as you need and each one has its own values.
The analogy that works best is the paper form. The class is the printed template: it defines the boxes "title", "assignee", "priority", "days" and "completed". The instance is each filled-in form: one says "Book fair poster / Luis / high / 3 / no", another says "Solé Bakery logo / Nuria / high / 5 / no". There is one template and many forms, and changing the template changes every future form.
flowchart TD
T["CLASS Task (the mould)<br/>title, assignee, priority, days, completed"]
T -->|instance of| C["poster<br/>Book fair poster / Luis / high / 3"]
T -->|instance of| L["logo<br/>Sole Bakery logo / Nuria / high / 5"]
T -->|instance of| P["quote<br/>Vidal quote / Marta / medium / 2"]
Two practical consequences are worth nailing down from the start: the class is written once and serves a million tasks (if tomorrow every task needs a client field, you touch the class, not the thousand calls spread around the program), and instances are independent of each other (marking one task as completed does not touch the rest, just as filling in one form does not fill in the others in the folder).
- The minimum syntax:
class and creating instances
class and creating instancesThe simplest form of a class in Python, and the creation of two instances from it, look like this:
class Task:
"""A task at the Alba Studio design agency."""
pass
poster = Task()
logo = Task()
print(poster) # <__main__.Task object at 0x7f8b1c0d5e50>
print(poster is logo) # False: they are two different objectsThree syntax details, which are the ones that trip people up at first:
- The keyword is
class, followed by the name and a colon, and the body is indented, just as withdefor anif. - Class names are written by convention in
CamelCase:Task,RecurringTask,Agenda. Functions and variables stay insnake_case(register_task). Seeing it written that way tells you at a glance whether a name is a class. passis the filler for an empty body; the docstring on the first line serves the same purpose here as it does in functions.
Writing Task() —with parentheses, like a function call— manufactures a new object of that class and returns a reference to it. That operation is called instantiating. Each call creates a different object in memory, which is why poster is logo is False, even though for now both are equally empty: it is the same behaviour of lists you saw in 05-01, where [] is [] was also False.
That <__main__.Task object at 0x...> printed by print is the default representation: it says which class the object belongs to and at which memory address it lives. It is ugly on purpose; in Attributes, methods and constructor you will learn to replace it with something readable using __str__.
- Attributes assigned from outside (and why that is still not enough)
You can add attributes to an instance with dot notation, without declaring them beforehand:
poster = Task()
poster.title = "Book fair poster"
poster.assignee = "Luis"
poster.priority = "high"
poster.days = 3
poster.completed = False
print(f"{poster.title} ({poster.days}d)") # Book fair poster (3d)Compare how it reads against the equivalent dictionary:
| Operation | Dictionary | Object |
|---|---|---|
| Read a field | task["title"] |
task.title |
| Write a field | task["days"] = 4 |
task.days = 4 |
| Non-existent field | KeyError |
AttributeError |
| Possible fields | any, uncontrolled | the ones you define (see 07-02) |
Something is already gained: poster.title reads better than poster["title"], and the editor can suggest the available attributes as you type. But let us be honest: this still does not solve the problem from section 1. Nothing forces us to fill in all five fields:
logo = Task()
logo.title = "Sole Bakery logo"
logo.priority = "High" # capitalised again, nobody complains
print(logo.completed) # ... and we had forgotten that field:
# AttributeError: 'Task' object has no attribute 'completed'We have swapped a KeyError for an AttributeError, and little more: the failure still shows up late and there is still no single place that guarantees that every task is born complete and with valid values. That place exists and it is called the constructor: a block that Python runs automatically when each instance is created, which demands the essential data and can validate it before storing it. It is the __init__ we will study in 07-02, and it is what turns the class from a mere container into a guarantee. Hold on to the idea: defining the class is the first step; making it impossible to create an invalid task is the second.
type(), isinstance() and "in Python everything is an object"
type(), isinstance() and "in Python everything is an object"type(), which we used in 02-01 to discover data types, also works with our own classes, and to ask whether an object belongs to a certain class you use isinstance():
poster = Task()
print(type(poster)) # <class '__main__.Task'>
print(type(poster).__name__) # Task
print(isinstance(poster, Task)) # True
print(isinstance(poster, dict)) # False
print(isinstance(3.5, (int, float))) # True: it accepts a tuple of typesThe correct way to check a type is isinstance(x, Class), not type(x) == Class: besides reading better, it recognises derived classes, something we will see at the end of Collections of objects.
Now the statement that makes sense of everything: in Python, absolutely everything is an object. It is not a textbook slogan, it can be checked in four lines:
print(type(5)) # <class 'int'> -> 5 is an instance of the class int
print(type("hello")) # <class 'str'> -> "hello" is an instance of str
print(type([1, 2])) # <class 'list'> -> the list, of list
print(type(Task)) # <class 'type'> -> even classes are objects| Expression | What it proves |
|---|---|
"hello".upper() |
str is a class and upper is one of its methods |
(255).bit_length() |
even an integer has methods of its own |
[3, 1].sort() |
sort is a method of the class list |
In other words: you are not learning some exotic technique bolted onto the language, you are learning how Python is built on the inside, and the only new part is that now you define the moulds yourself. Everything you know about str or list —that they are passed by reference, that they have methods, that type() identifies them— applies equally to Task.
- Class attributes versus instance attributes
There are two places where an attribute can live, and the difference matters. An instance attribute belongs to one specific object and each instance has its own: it is the poster.title from section 5. A class attribute is defined inside the body of the class and is shared by all instances: there is a single copy, in the class. The classic use case is a value common to every task and a counter of how many have been created:
class Task:
"""A task at Alba Studio."""
STUDIO = "Alba Studio" # class attribute: the same for all
created = 0 # class attribute: shared counter
poster, logo = Task(), Task()
Task.created += 2 # normally done in the constructor (07-02)
print(Task.created) # 2
print(poster.created) # 2 <- the instance sees the class attribute
print(logo.STUDIO) # Alba Studio
poster.created = 99 # CAREFUL: this creates an attribute OWNED by poster
print(poster.created) # 99 <- its own
print(logo.created) # 2 <- still sees the class one
print(Task.created) # 2 <- the class has not been touchedAn instance that does not have an attribute of its own looks for it in its class: that is why poster.created works even though we never assigned it to poster. It is a lookup order similar to the LEGB rule from module 4: first what is yours, then what is inherited from the mould. And there lies the trap: assigning through the instance does not modify the class, it creates an instance attribute that shadows it. That is why the counter is always incremented by naming the class, Task.created += 1.
The important warning: never put a mutable object as a class attribute if you expect each instance to have its own. It is the aliasing of 05-01 in its most treacherous form:
class Task:
notes = [] # WRONG: a single list for every task
poster, logo = Task(), Task()
poster.notes.append("The council logo is missing")
print(logo.notes) # ['The council logo is missing'] <- another task's noteSince poster.notes finds no list of its own, it climbs to the class and modifies the shared list, which is the very same one logo sees. The practical rule is simple:
| Kind of data | Does it work as a class attribute? |
|---|---|
Immutable constants (str, int, tuple) |
Yes: STUDIO, PRIORITIES = ("high", "medium", "low") |
| Counters you want to share on purpose | Yes, updating them with Class.name |
| Lists, dictionaries and sets per instance | No: create them in the constructor (07-02) |
- The object's namespace:
__dict__, getattr, setattr and hasattr
__dict__, getattr, setattr and hasattrUnder the bonnet, instance attributes are stored in... a dictionary. Every object has its own, reachable as __dict__:
poster = Task()
poster.title = "Book fair poster"
poster.days = 3
print(poster.__dict__) # {'title': 'Book fair poster', 'days': 3}
print(vars(poster)) # the same: vars() is the elegant way to ask for itThis explains several things at once: why attributes can be added on the fly, why reaching an attribute costs O(1) like any dictionary key (module 6), and why converting an object into a dictionary to save it as JSON will be so direct in 07-03. Note that __dict__ only contains instance attributes: STUDIO and created do not appear because they live in the class.
When the attribute name is held in a variable, there are three functions for working with it:
print(hasattr(poster, "days")) # True: the object has that attribute
print(hasattr(poster, "completed")) # False: it does not
print(getattr(poster, "completed", False)) # False: default value, no failure
setattr(poster, "completed", True) # same as poster.completed = True| Function | Equivalent to | What it is really for |
|---|---|---|
getattr(obj, "x") |
obj.x |
Reading an attribute whose name is in a variable |
getattr(obj, "x", default) |
— | Reading without risking an AttributeError |
setattr(obj, "x", v) |
obj.x = v |
Writing an attribute with a computed name |
hasattr(obj, "x") |
— | Checking before reading |
Day to day you will write poster.days, not getattr(poster, "days"): these functions are for the case where the name is not known until the program runs. An example with EasyTask, taking advantage of the constant FIELDS that has existed since 05-05:
FIELDS = ("title", "assignee", "priority", "days", "completed")
def review(task):
"""Report which mandatory fields a task is missing."""
return [field for field in FIELDS if not hasattr(task, field)]
print(review(poster)) # ['assignee', 'priority']It is a useful patch, but it is still a check made after the fact: it detects the problem once the invalid task already exists. The real solution is to stop it from being born that way, and for that we need the constructor.
- Dictionary or object: when each one pays off
Creating a class is not always the right answer. Dictionaries are still the appropriate tool in many cases, and this is the honest comparison:
| Criterion | Dictionary | Object (your own class) |
|---|---|---|
| Definition of the format | Does not exist: everyone puts in what they like | Written once in the class |
| Access | t["title"] (fails at run time) |
t.title (the editor autocompletes and warns) |
| Guarantee of fields | None | The constructor demands them (07-02) |
| Validation of values | Manual, in every place | Centralised at creation (07-02) |
| Associated behaviour | Loose functions around the file | Methods next to the data |
| Dynamic keys | Yes, that is its strong point | No: attributes are fixed |
| Saving to JSON | Direct | Has to be converted first (07-03) |
| Cost of writing it | Zero lines | A few lines of class |
Two practical rules come out of that. Use a dictionary when the data arrives from outside with a variable shape (a downloaded JSON file, a CSV row), when the keys are not known in advance (an assignee → tasks index, a word counter, the decision table from 05-04) or when it is a temporary structure that lives for three lines inside a function.
Use a class when the concept repeats throughout the program with the same shape (a task, a client, an invoice), when there are invariants to uphold (the priority can only be high, medium or low; the days, between 1 and 365), when there is behaviour that belongs to that data (knowing whether it is overdue, marking itself completed, printing itself) or when the concept is going to grow: today it is five fields, tomorrow it will be eight and three rules.
The EasyTask task meets all four criteria on the second list, so it gets a class. The agenda that arrives from JSON will still be, at the moment of reading, a list of dictionaries: we will convert it into objects right afterwards.
- The four pillars of OOP, in one table
Object orientation is traditionally summed up in four ideas. Here they are by name, so you recognise the terms when you read them outside this course:
| Pillar | In one line | Where it appears in this course |
|---|---|---|
| Abstraction | Representing a real concept with the essentials and nothing else | This lesson and 07-02 |
| Encapsulation | Keeping data and behaviour together and controlling access | 07-02 and 07-03 |
| Inheritance | Creating a class from another one, reusing what it does | 07-03, basics only |
| Polymorphism | Treating different objects that share an interface in the same way | 07-03, basics only |
A word about expectations: this is a fundamentals course and it goes as far as the foundations. You will see abstraction and encapsulation in detail because they are the ones that solve the problem we have; inheritance and polymorphism will be presented in their simplest form and with the recommendation not to overuse them. Everything else —abstract classes, multiple inheritance, design patterns— is material for a dedicated OOP course, and you do not need it to write correct, tidy programs.
Common Mistakes and Tips
- Confusing the class with the instance.
Taskis the mould;Task()manufactures a copy. If you writeposter = Task(without parentheses),posteris not a task: it is the class itself, andposter.title = "..."would modify the mould for the whole program. Check withtype(poster): it must say<class '__main__.Task'>, not<class 'type'>. - Expecting the class to validate anything by itself. An empty class protects you from nothing; it only gives you a name. The guarantee arrives with the constructor in 07-02.
- Putting a list or a dictionary as a class attribute, or assigning to
instance.counterbelieving you are updating the class. They are the two faces of the mistake in section 7: shared mutables leak between instances, and what is assigned per instance never reaches the class. Mutables, always per instance; the shared counter, always asTask.created += 1. - Writing classes for everything. A class with two fields, no behaviour and used in a single place is worse than a dictionary: more lines for the same result. Apply the table from section 9.
- Unclear names. A class is named in the singular and describes one thing (
Task, notTasksorTaskManager), inCamelCase. The plural is reserved for collections. - Tip: try classes out in the interactive console. Create an instance, assign attributes to it, look at its
__dict__, askisinstance. Seeing the object from the inside is what turns these concepts into something concrete.
Exercises
Exercise 1: Detect malformed tasks
Starting from the dictionary agenda in section 1, write a function validate_agenda(agenda) that walks the list and returns a list of strings describing every problem found: missing fields, leftover fields and priorities that are not exactly in ("high", "medium", "low"). It must review the whole agenda, without stopping at the first failure.
Exercise 2: From dictionary to object
Define a class Client with a class attribute STUDIO = "Alba Studio" and a counter created. Create two instances (Solé Bakery and client Vidal), assign them the attributes name, contact and active from outside, increment the counter on each creation and display the __dict__ of each one along with the total number of clients created. Then assign bakery.created = 50 and explain in writing what Client.created prints and why.
Exercise 3: Choosing the structure
For each case, decide whether you would use a dictionary or a class, and justify it in one sentence based on the table in section 9: (a) the result of counting how many times each word appears in the agenda titles; (b) an invoice from the studio, with number, client, lines and amount, which is issued, sent and collected; (c) the configuration data read from a JSON file at start-up; (d) a team member, with name, weekly hours available and the ability to say whether they are overloaded.
Solutions
Solution 1.
FIELDS = ("title", "assignee", "priority", "days", "completed")
PRIORITIES = ("high", "medium", "low")
def validate_agenda(agenda):
"""Return the list of problems found in a dictionary agenda."""
problems = []
for number, task in enumerate(agenda, start=1):
for field in FIELDS: # missing fields
if field not in task:
problems.append(f"Task {number}: missing field '{field}'")
for key in task: # leftover fields
if key not in FIELDS:
problems.append(f"Task {number}: unknown field '{key}'")
priority = task.get("priority") # get: no failure if absent
if priority is not None and priority not in PRIORITIES:
problems.append(f"Task {number}: invalid priority '{priority}'")
return problems
for warning in validate_agenda(agenda):
print(warning)
# Task 2: invalid priority 'High'
# Task 3: missing field 'completed'
# Task 3: unknown field 'urgent'Three details: .get("priority") is used instead of task["priority"] because the key might be missing and we do not want a KeyError inside the validator itself; results are accumulated in a list rather than printed, so the function stays pure and separate from output (module 4); and enumerate(..., start=1) numbers the tasks the way the user sees them. Now notice how uncomfortable the result is: you have to remember to call this function, and if somebody creates a task afterwards, nobody checks it. With the constructor in 07-02 that check becomes automatic and unavoidable.
Solution 2.
class Client:
"""A client of the studio."""
STUDIO = "Alba Studio"
created = 0
def register(name, contact, active):
"""Create a client assigning its attributes from outside and count it."""
client = Client()
client.name, client.contact, client.active = name, contact, active
Client.created += 1
return client
bakery = register("Sole Bakery", "[email protected]", True)
vidal = register("Client Vidal", "[email protected]", False)
print(bakery.__dict__)
print(vidal.__dict__)
print(f"{Client.STUDIO} has {Client.created} registered clients.")
bakery.created = 50
print(bakery.created, Client.created) # 50 2Client.created is still 2. The assignment bakery.created = 50 does not touch the class: it creates an instance attribute inside bakery's __dict__ which from now on shadows the class one for that particular object. vidal.created still reads the class attribute and is 2. Direct check: print(bakery.__dict__) now includes 'created': 50, while vidal's does not.
Solution 3.
| Case | Choice | Reason |
|---|---|---|
| (a) Word count | Dictionary | Dynamic keys not known in advance; temporary structure with no behaviour |
| (b) Invoice | Class | Repeated concept, with invariants (the amount must add up) and behaviour of its own (issue, send, collect) |
| (c) JSON configuration | Dictionary | It arrives from outside with a variable shape and is only read; turning it into a class adds work and gains nothing |
| (d) Team member | Class | Fixed shape, repeats throughout the program and has behaviour of its own: is_overloaded() |
The criterion that carries most weight is the one in the last column: if the concept does things, it wants to be a class; if it only carries data of a variable shape, a dictionary is enough.
Conclusion
An object brings together in one place the data (attributes) and the behaviour (methods) that belong together, and you have been using them since module 2 without knowing it: "hello".upper() and agenda.append(...) are exactly that. The class is the mould that defines what objects of a type look like, it is written once with class Task: in CamelCase, and each instance is manufactured by calling it like a function, Task(). Attributes are read and written with dot notation and really live in the object's __dict__, with getattr, setattr and hasattr for when the name is not known until run time. Class attributes are shared by every instance —perfect for constants and counters, dangerous if they are mutable—, while instance attributes belong to a single object and shadow the class ones when they collide. type() and isinstance() identify which class each thing belongs to and prove that in Python everything is an object, including classes themselves. And the choice between dictionary and class is not a matter of fashion but of judgement: dictionary for external data, dynamic keys and temporary structures; class when there are invariants, behaviour and a concept that repeats throughout the program.
But what we have built today is still an unfinished mould. A Task whose attributes are assigned from outside can still be born without completed and with its priority as "High": we have swapped the KeyError for an AttributeError and improved readability, little more. What is missing is the piece that turns the class into a guarantee: a block that runs automatically when each instance is created, that demands the essential data, validates it against PRIORITIES and TEAM, and that also lets us store alongside the data the functions that today wander loose around easytask.py. In Attributes, methods and constructor comes __init__, we finally explain what that self in every example on the internet actually is, and EasyTask makes the jump to v0.15: a real Task class, with validation in the constructor and methods of its own, and a program that stops handling dictionaries and starts handling objects.
Fundamentals of Programming
Module 1: Introduction to Programming
- What is programming?
- History of programming
- Programming languages
- Development environments
- From problem to algorithm
Module 2: Core Concepts
- Variables and data types
- Operators and expressions
- Input and output
- Type conversion and data validation
Module 3: Control Structures
Module 4: Functions and Procedures
- Defining and using functions
- Parameters and return values
- Variable scope
- Breaking a program down into functions
- Functions as values: lambda and higher order
Module 5: Data Structures
- Lists and arrays
- Strings
- Dictionaries and sets
- Tuples and nested structures
- Saving data to files: text, CSV and JSON
Module 6: Basic Algorithms
Module 7: Objects and Code Organisation
- From data to objects: classes and instances
- Attributes, methods and the constructor
- Collections of objects
- Modules, packages and imports
Module 8: Good Practices and Tools
- Documentation and comments
- Debugging and error handling
- Version control
- Automated testing
- Style, readability and refactoring
