OOP I: Classes and Objects

Classes, Objects, and Special Methods in Python

Author

Your Name

Published

November 15, 2025

Review of terminology

Let’s briefly remind ourselves of some terminology:

  • x = 12, we say that x is an integer, i.e. :
    • there is a class integer (or int), also called a type
    • and x is an object belonging to that class
    • we say that x is a member of the class int
    • or that x is an instance of the class int
  • x = "abc", we say that x is a string , i.e. :
    • there is a class string (or str), also called a type
    • and x is an object belonging to that class
    • x is a member of the class str
    • the object x is an instance of the class str

Whenever we have an object x which is an instance of some class, we will receive a number of special functions that we can call with the syntax x.name_of_function(...). These are called the methods of the class.

Some examples:

x = "abcd"
x.startswith("a"), x.startswith("b")
(True, False)

Thus .startswith(...) is a method of the class str.

x = 5
x.to_bytes()
b'\x05'

Thus .to_bytes() is a method of the class int.

To find the class (or type) of an object you can use the builtin type(...) function:

type("abc")
str
type(5)
int

In python everything is an object and therefore everything has a type.

import sys
type(sys.stdout)
ipykernel.iostream.OutStream
Exercise

Investigate the type of as many objects as you can think of. Certainly try None, True and datetime.datetime.now().

Tip

In the before-before times, classes and types were two different things in Python, until the Ancients unified them. A big unification happened in Python 2.2 but throughout Python 2.x there always remained a distinction between “old style” and “new style” classes.

Old style classes were eventually dropped in Python 3.0 and that brings us to the simple system we have today where everything is an object.

The normal way to create an instance of a class is with the syntax name_of_class(...). For most classes, especially the ones you create yourself, the name of the class should follow the camel case convention, so it will look more like NameOfClass(...). But classes built into Python or older parts of python (like int, str or the datetime module) often follow different conventions.

A great way to think about a class is as a way to bundle:

  • A bunch of “data”, often called the fields or datafields of the object
  • A bunch of “methods” to manipulate the data, i.e. functions defined on the class

Both the fields and methods are known as attributes. In fact, everything you could do with an object that follows the syntax obj.something or even MyClass.something is an attribute, wheter it is ultimately a method or a field or something else.

Stuff will become interesting only when we can create our own classes. The simplest classes don’t do anything1:

class EmptyClass:
    pass

empty_obj = EmptyClass()
print(empty_obj)
<__main__.EmptyClass object at 0x70d5488ed400>

Respect the naming convention: CamelCase for classes, snake_case for objects.

Now if we want we can go ahead and assign attributes to our object.

empty_obj.a = 10
empty_obj.b = 20

These attributes can later be retrieved for use in other expressions:

print("Total value stored in the object =", empty_obj.a + empty_obj.b)
Total value stored in the object = 30

We could even make a function to retrieve the total value of an object:

def total(obj):
    return obj.a + obj.b

total(empty_obj)
30

The core idea behind object-oriented programming is to bundle all of that inside of the class: the data itself, and the methods to operate on them.

Creating your own classes

A class with a single method

Our next example is a class with a single method that computes the sum of its fields a and b.

class TotalCounter:
    def total(self):
        return self.a + self.b

This looks very similar to what we did just earlier, but this time the function was nested under the class. We also changed the naming from obj to self. The word self indicates that the method is supposed to act on the object that will be created itself2

We can now create an object like this:

total_counter = TotalCounter()  # Create an object
total_counter.a = 10
total_counter.b = 20

(It’s not ideal to assign attributes like this outside of the class, and we’ll see a better way in the next section.)

We can will call our method like this:

total_counter.total()
30

The real magic is that we didn’t have to pass the total_counter as an argument.

Warning

In older Python code you will sometimes see this:

class Test(object):
    ...

Never do this (unless your codebase must somehow still support Python 2).

Another common mistake:

class Test():
    ...

I think people write parentheses out of confusion with functions. It’s not necessary so don’t do this! Python allows the syntax, but it is used for inheritance, as we will see later.

Exercise

Create a class with two data fields first_name and last_name. Foresee a method full_name which will give a string with first and last name, last name in upper case. (For instance Isaac NEWTON.)

A class with two methods

Now what would be neat is if we could assign self.a = 10 also inside a method. It’s quite straightforward, we just introduce another method to do it!

class TotalCounter:
    def assign_a_b(self, a, b):
        print(f"Assigning a = {a} and b = {b}")
        self.a = a
        self.b = b

    def total(self):
        return self.a + self.b

total_counter = TotalCounter()
total_counter.assign_a_b(10, 20)
total_counter.total()
Assigning a = 10 and b = 20
30

It’s quite weird: we define a method with 3 arguments but when we call it we need only to supply 2 arguments. It’s because the self is again automatically filled in as the first argument. In fact it may help to think of it like this:

total_counter = TotalCounter()
TotalCounter.assign_a_b(total_counter, 10, 20)
Assigning a = 10 and b = 20

You’re not supposed to use it like that in practice though.

Writing a dunder init

What would be even nicer is if we could supply the a and b during the creation of the object already, right into the TotalCounter(10, 20). It turns out that Python has a special method __init__ that allows just that:

class TotalCounter:
    def __init__(self, a, b):
        self.a = a
        self.b = b

    def total(self):
        return self.a + self.b

total_counter = TotalCounter(10, 20)  # will call TotalCounter.__init__
total_counter.total()
30

The __init__ is a method that initializes the object.3

Exercise

Complete this class for computing the interval between two timestamps. How many days until the end of the year?

class TimeInterval:
    def __init__(self, start_time, end_time):
        ...

    def duration(self):
        ...

    def duration_in_days(self):
        ...


interval = TimeInterval(
    datetime.datetime(2025, 9, 23),
    datetime.datetime(2025, 12, 31)
)

interval

Complete this class for maintaining a bank account. Raise exceptions when a person attempts to withdraw more than they have.

class BankAccount:
    def __init__(self, initial_balance=0):
        ...
    
    def deposit(self, amount):
        ...
    
    def withdraw(self, amount):
        ...


account = BankAccount(1000)
print(account.deposit(500))
print(account.withdraw(200))
print(f"Final balance: €{account.balance}")
Warning

Although you can assign attributes anywhere inside and outside the class, you should use this power sparingly. In a lot of code, most attributes are initialized in the __init__ and if you go assigning attributes in unexpected places you are making it a lot harder for other programmers to understand your code.4

Special Methods (Magic Methods)

Special methods, also called “magic methods” or “dunder methods” (double underscore), allow your classes to integrate with Python’s built-in functions and operators.

__repr__ and __str__

  • repr is supposed to be some full representation which, when evaluated, will give back the original object
  • str is a more convenient string representation for printing
class Book:
    def __init__(self, title, author, year):
        self.title = title
        self.author = author
        self.year = year
    
    def __repr__(self):
        return f"Book('{self.title}', '{self.author}', {self.year})"
    
    def __str__(self):
        return f"'{self.title}' by {self.author} ({self.year})"

book = Book("1984", "George Orwell", 1949)
print(repr(book))  # Uses __repr__
print(str(book))   # Uses __str__
print(book)        # Uses __str__ by default
Book('1984', 'George Orwell', 1949)
'1984' by George Orwell (1949)
'1984' by George Orwell (1949)

__add__ and Arithmetic Operations

class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    
    def __add__(self, other):
        return Vector(self.x + other.x, self.y + other.y)
    
    def __repr__(self):
        return f"Vector({self.x}, {self.y})"
    
    def __eq__(self, other):
        return self.x == other.x and self.y == other.y

v1 = Vector(1, 2)
v2 = Vector(3, 4)
v3 = v1 + v2  # Uses __add__
print(f"{v1} + {v2} = {v3}")
Vector(1, 2) + Vector(3, 4) = Vector(4, 6)

Because we can do v1 + v2 we have basically invented a new usecase for the +-operator. This is sometimes called overloading an operator.

__getitem__ and Indexing

class Playlist:
    def __init__(self, name):
        self.name = name
        self.songs = []
    
    def add_song(self, song):
        self.songs.append(song)
    
    def __getitem__(self, index):
        return self.songs[index]
    
    def __len__(self):
        return len(self.songs)
    
    def __repr__(self):
        return f"Playlist('{self.name}', {len(self.songs)} songs)"

playlist = Playlist("My Favorites")
playlist.add_song("Song 1")
playlist.add_song("Song 2")
playlist.add_song("Song 3")

print(playlist[0])      # Uses __getitem__
print(len(playlist))    # Uses __len__
print(playlist)         # Uses __repr__
Song 1
3
Playlist('My Favorites', 3 songs)
Exercise

Write a class to emulate a cash registry. There will be bank notes of 5, 10 and 20. We would like to have nice things like:

  • Print the content of the cash registry nicely, like 10 x 5 + 9 x 10 + 1 x 20
  • Make it easy to manipulate banknotes cash_register[5] += 1
  • Make it easy to add banknotes together

Optional challenge: - Make it easy to extend the concept with new notes - Introduce methods to help compute change.

__call__

This is used to make an object callable, i.e. to allow you to write object(...). This is in fact overloading the parentheses. Here’s an example.

class AdderN:
    def __init__(self, N):
        self.N = N    
        self.times_called = 0

    def __call__(self, x):
        self.times_called += 1
        return x + self.N

add_1 = AdderN(1)
add_2 = AdderN(2)
add_1 = AdderN(1)
add_2 = AdderN(2)
add_1(100)  # does add_1.__call__(100)
101

Predict the output of the following cell!

add_1(add_2(add_1(10)))
add_1.times_called

Properties

Properties are methods that behave like datafields. (The syntax with the @ is called a decorator, more about these later.)

Here’s a simple example:

class Temperature:
    def __init__(self, temperature):
        # Temperatuure assumed in Celcius
        self.temperature = float(temperature)

    @property
    def celcius(self):
        return self.temperature

    @property
    def kelvin(self):
        return self.temperature + 273.15

    @property
    def fahrenheit(self):
        return self.temperature * 9 / 5. + 32.

t = Temperature(25)
t.celcius, t.kelvin, t.fahrenheit
(25.0, 298.15, 77.0)

So for the outside world, we can pretend that kelvin is just a data field, while internally we are actually computing it. This is only possible for a method that has no arguments other than self, of course.

Very often a property shadows an underlying hidden atttribute:

class Taxes:
    def __init__(self, amount):
        self._amount = amount  # hidden attributed, not intended for the final user of the class

    @property
    def amount(self):  # intended to be used 
        return self._amount

t = Taxes(10)
t.amount
10

A good reason for using properties is is to be extensible. For instance, suppose we later find out that there is another form of tax that we hadn’t heard about. The amount that must be reported is actually the sum of the two. We can then easily write:

class Taxes:
    def __init__(self, amount):
        self._amount1 = amount
        self._amount2 = 0

    @property
    def amount(self):
        return self._amount1 + self._amount2

The public interface of our class hasn’t changed much, but in our code we are now ready to deal with the consequences of this new tax.

If you use a property, the user also cannot change this field:

t = Taxes(20)
print(t.amount)
t.amount = 10
20
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[27], line 3
      1 t = Taxes(20)
      2 print(t.amount)
----> 3 t.amount = 10

AttributeError: property 'amount' of 'Taxes' object has no setter

Of course a user can still inspect the inner workings of our class and do something like this:

t._amount1 = 100
t.amount
100

… but that would be bad practice. By starting the name of the attribute _amount1 with an underscore, the author of the class has clearly expressed the intent that it should not be used directly outside of the class.

To allow properties to be set outside of the class you can use this special syntax:

class Taxes:
    def __init__(self, amount):
        self._amount = amount
        self._times_modified = 0

    @property
    def amount(self):
        return self._amount

    @amount.setter
    def amount(self, value):
        # We are going to count how often the field is modified
        self._times_modified += 1
        self._amount = value

    def __str__(self):
        return f'Taxes({self._amount}) [{self._times_modified} times modified]'

t = Taxes(10)
t        
<__main__.Taxes at 0x70d520486510>
Exercise

Make this class above ready for the introduction of another tax with its own amount. The total amount shown should be the sum of both tax amounts.

Class variables, class methods, static methods

Class variables

A variable or data field can also live at the level of the class rather than that of the object. Such a field would be common to all objects in the same class.

class People:
    number = 0  # class variable

    def __init__(self, name):
        self.name = name
        People.number += 1

    def greet(self):
        print(f'Hello {self.name}!')

    def number_of_people(self):
        # Will use the class variable number
        print(f'There are {self.number} people')

frank = People('Frank')
sabine = People('Sabine')

frank.number_of_people()
sabine.number_of_people()
print(People.number)
There are 2 people
There are 2 people
2

However, if we do it like this the result is very different!

class People:
    number = 0  # class variable

    def __init__(self, name):
        self.name = name
        self.number += 1  # <--- different!

    def greet(self):
        print(f'Hello {self.name}!')

    def number_of_people(self):
        print(f'There are {self.number} people')

frank = People('Frank')
sabine = People('Sabine')

frank.number_of_people()
sabine.number_of_people()
print(People.number)
There are 1 people
There are 1 people
0

Although the number = 0 introduces a class variable, assigning to self.number = ... will assign an object-level variable, which is more specific than the class variable.

It’s quite inconvenient that we have to write People in our own class explicitly. Here’s a better way: ## Class methods

Class methods are methods that are oblivious to the object, they only operate on the level of the class. Instead of self they take cls as their first argument:

class People:
    number = 0

    @classmethod
    def increase_number(cls, by=1):
        cls.number += by

    def __init__(self, name):
        self.name = name
        self.increase_number(by=1)

    def greet(self):
        print(f'Hello {self.name}!')

    @classmethod
    def number_of_people(cls):
        print(f'There are {cls.number} people')

frank = People('Frank')
sabine = People('Sabine')
frank.number_of_people()
sabine.number_of_people()
There are 2 people
There are 2 people

Class methods are often used to create objects in an alternative manner:

class Person:
    def __init__(self, first_name, last_name):
        self.first_name = first_name
        self.last_name = last_name

    @classmethod
    def from_name_str(cls, namestr):
        first_name, _, last_name = namestr.partition(' ')
        return cls(first_name, last_name)

    def __repr__(self):
        return f'Person("{self.first_name}", "{self.last_name}")'

person1 = Person("Frank", "Sinatra")
person2 = Person.from_name_str("Frank Sinatra")
Person.from_name_str("Pepin The Short")
Person("Pepin", "The Short")

Static methods

Finally, there also exist static methods which don’t depend on the object or class. They can be used to bundle relevant functions together into a class.

class SecretFormulas:
    @staticmethod
    def magic_sum(a, b):
        return a + 2 * b

    @staticmethod
    def magic_difference(a, b):
        return a - 2 * b

SecretFormulas.magic_sum(3, 4)
11
Tip

Programmers coming from other languages will often overuse static methods for bundling functions into a class. In Python we often prefer to bundle functions together in a module. More on modules later in the course, but they can be thought of as singleton classes with static methods only.

Dataclasses

Dataclasses are an effective way to remove a lot of boilerplate (repetitive code). I strongly recommend going through the documentation.

Let’s go through some examples:

from dataclasses import dataclass
from datetime import date

@dataclass
class Person:
    name: str
    date_of_birth: date

    def approximate_age(self):
        # age plus or minus 1
        today = date.today()
        years = today.year - self.date_of_birth.year
        return years

    @classmethod
    def same_name(cls, first, other):
        return first.name == other.name

person = Person('Isaac', date(1643, 12, 25))
person.approximate_age()
382

As you can see, we didn’t have to write a complicated __init__ method or __eq__ method, the dataclass has done this for us.

Exercise

Write the above class without using dataclasses.

Real-World Example: OpenWebUI Pipe Class

Open WebUI is a modern Python based interface to talk with your favorite AI-assistant. Because it’s Python based it is easy to extend with new functionality under the form of functions. In the docs, they define three types of functions: Pipes, Filters and Actions. Let’s focus on the Pipes. From their documentation:

Introduction to Pipes Imagine Open WebUI as a plumbing system where data flows through pipes and valves. In this analogy:

  • Pipes are like plugins that let you introduce new pathways for data to flow, allowing you to inject custom logic and processing.
  • Valves are the configurable parts of your pipe that control how data flows through it.

By creating a Pipe, you’re essentially crafting a custom model with the specific behavior you want, all within the Open WebUI framework.

What they call a Pipe is essentially a class that satisfies a particular interface:

from pydantic import BaseModel, Field

class Pipe:
    class Valves(BaseModel):
        MODEL_ID: str = Field(default="")

    def __init__(self):
        self.valves = self.Valves()

    def pipe(self, body: dict):
        # Logic goes here
        print(self.valves, body)  # This will print the configuration options and the input body
        return "Hello, World!"

Note that it has some awkwardness under the form of a nested class Valves inside of the Pipe. It is also using Pydantic, which you can think over as dataclasses over-achieving distant cousin. Later on, they explain that there is another important method def pipes(self): that can return a dict with information about the AI-models available through the pipe.

Exercise

Write your own pipe for Open WebUI! Make it so there are two models, and they are really dumb and select their answers randomly from a predefined list of responses.

Additional Resources


Next: Lesson 4: Loose Ends and New Python Features

Footnotes

  1. In fact, this is occasionally useful as a so called sentinel class↩︎

  2. Some programming languages will use the word this for this purpose.↩︎

  3. Sometimes it is incorrectly called a constructor method but this isn’t quite correct. Pythons constructor method is called __new__, as we will see later on.↩︎

  4. And by other programmers we mostly mean: yourself, three weeks from now.↩︎