x = "abcd"
x.startswith("a"), x.startswith("b")(True, False)
Classes, Objects, and Special Methods in Python
Your Name
November 15, 2025
Let’s briefly remind ourselves of some terminology:
x = 12, we say that x is an integer, i.e. :
intintx = "abc", we say that x is a string , i.e. :
strstrWhenever 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:
Thus .startswith(...) is a method of the class str.
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:
In python everything is an object and therefore everything has a type.
Investigate the type of as many objects as you can think of. Certainly try None, True and datetime.datetime.now().
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:
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:
<__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.
These attributes can later be retrieved for use in other expressions:
Total value stored in the object = 30
We could even make a function to retrieve the total value of an object:
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.
Our next example is a class with a single method that computes the sum of its fields a and 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:
(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:
The real magic is that we didn’t have to pass the total_counter as an argument.
In older Python code you will sometimes see this:
Never do this (unless your codebase must somehow still support Python 2).
Another common mistake:
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.
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.)
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:
Assigning a = 10 and b = 20
You’re not supposed to use it like that in practice though.
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
Complete this class for computing the interval between two timestamps. How many days until the end of the year?
Complete this class for maintaining a bank account. Raise exceptions when a person attempts to withdraw more than they have.
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, 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__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 defaultBook('1984', 'George Orwell', 1949)
'1984' by George Orwell (1949)
'1984' by George Orwell (1949)
__add__ and Arithmetic Operationsclass 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 Indexingclass 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)
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:
10 x 5 + 9 x 10 + 1 x 20cash_register[5] += 1Optional 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.
Predict the output of the following cell!
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.amount10
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:
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:
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:
… 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>
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.
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")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
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 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.
Write the above class without using dataclasses.
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.
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.
In fact, this is occasionally useful as a so called sentinel class↩︎
Some programming languages will use the word this for this purpose.↩︎
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.↩︎
And by other programmers we mostly mean: yourself, three weeks from now.↩︎
---
title: "OOP I: Classes and Objects"
subtitle: "Classes, Objects, and Special Methods in Python"
author: "Your Name"
date: today
toc: true
execute:
echo: true
output: true
---
# 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:
```{python}
x = "abcd"
x.startswith("a"), x.startswith("b")
```
Thus `.startswith(...)` is a method of the class `str`.
```{python}
x = 5
x.to_bytes()
```
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:
```{python}
type("abc")
```
```{python}
type(5)
```
In python **everything is an object** and therefore *everything has a type*.
```{python}
import sys
type(sys.stdout)
```
::: {.callout-note icon=false}
## Exercise
Investigate the type of as many objects as you can think of. Certainly try `None`, `True` and `datetime.datetime.now()`.
:::
::: {.callout-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](https://www.python.org/download/releases/2.2/descrintro/) 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 anything^[In fact, this is occasionally useful as a so called sentinel class]:
```{python}
class EmptyClass:
pass
empty_obj = EmptyClass()
print(empty_obj)
```
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.
```{python}
empty_obj.a = 10
empty_obj.b = 20
```
These attributes can later be retrieved for use in other expressions:
```{python}
print("Total value stored in the object =", empty_obj.a + empty_obj.b)
```
We could even make a function to retrieve the total value of an object:
```{python}
def total(obj):
return obj.a + obj.b
total(empty_obj)
```
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`.
```{python}
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 `itself`^[Some programming languages will use the word _this_ for this purpose.]
We can now create an object like this:
```{python}
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:
```{python}
total_counter.total()
```
The real magic is that we didn't have to pass the `total_counter` as an argument.
::: {.callout-warning}
In older Python code you will sometimes see this:
```{python}
class Test(object):
...
```
_Never_ do this (unless your codebase must somehow still support Python 2).
Another common mistake:
```{python}
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.
:::
::: {.callout-note icon=false}
## 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!
```{python}
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()
```
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:
```{python}
total_counter = TotalCounter()
TotalCounter.assign_a_b(total_counter, 10, 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:
```{python}
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()
```
The `__init__` is a method that initializes the object.^[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.]
::: {.callout-note icon=false collapse="false"}
## Exercise
Complete this class for computing the interval between two timestamps. How many days until the end of the year?
```{python}
#| eval: false
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
```
:::
::: {.callout-note icon=false collapse="true"}
## Exercise
Complete this class for maintaining a bank account. Raise exceptions when a person attempts to withdraw more than they have.
```{python}
#| eval: false
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}")
```
:::
::: {.callout-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.^[And by other programmers we mostly mean: yourself, three weeks from now.]
:::
# 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
```{python}
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
```
## `__add__` and Arithmetic Operations
```{python}
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}")
```
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
```{python}
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__
```
::: {.callout-note icon=false}
## 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.
```{python}
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)
```
```{python}
add_1 = AdderN(1)
add_2 = AdderN(2)
```
```{python}
add_1(100) # does add_1.__call__(100)
```
Predict the output of the following cell!
```{python}
#| eval: false
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:
```{python}
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
```
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:
```{python}
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
```
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:
```{python}
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:
```{python}
#| error: true
t = Taxes(20)
print(t.amount)
t.amount = 10
```
Of course a user can still inspect the inner workings of our class and do something like this:
```{python}
t._amount1 = 100
t.amount
```
... 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:
```{python}
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
```
::: {.callout-note icon=false}
## 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.
```{python}
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)
```
However, if we do it like this the result is very different!
```{python}
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)
```
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:
```{python}
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()
```
Class methods are often used to create objects in an alternative manner:
```{python}
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")
```
```{python}
Person.from_name_str("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.
```{python}
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)
```
::: {.callout-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](https://docs.python.org/3/library/dataclasses.html).
Let's go through some examples:
```{python}
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()
```
As you can see, we didn't have to write a complicated `__init__` method or `__eq__` method, the dataclass has done this for us.
::: {.callout-note icon=false}
## 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](https://docs.openwebui.com/features/plugin/functions/). In the docs, they define three types of functions: Pipes, Filters and Actions. Let's focus on the Pipes. From [their documentation](https://docs.openwebui.com/features/plugin/functions/pipe#introduction-to-pipes):
> 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:
```{python}
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.
::: {.callout-note icon=false}
## 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
- [Python Official Documentation - Classes](https://docs.python.org/3/tutorial/classes.html)
- [Real Python - Object-Oriented Programming](https://realpython.com/python3-object-oriented-programming/)
- [Python Special Methods Guide](https://docs.python.org/3/reference/datamodel.html#special-method-names)
- [Dataclasses Documentation](https://docs.python.org/3/library/dataclasses.html)
---
**Next**: [Lesson 4: Loose Ends and New Python Features](04-loose-ends.qmd)