Week 6 Lecture: Decorators & Method Types
Functions as Objects
In Python, functions are objects — just like integers, strings, and lists. In practice, this means you can assign them to variables, pass them as arguments, and return them from other functions.
For example, you can create a function and assign it to a new variable. Notice that we write the function name without parentheses — we are passing the function itself, not calling it:
def greet(name):
return f"hi, {name}!"
# assign function to a variable — no parentheses!
say_hello = greet
print(say_hello("Alisher")) # hi, Alisher!
Think of it like passing a TV remote to someone — you are giving them the remote control, not pressing the button for them.
If you write greet() with parentheses, you would be calling the function and assigning its result to say_hello. By writing greet without parentheses, you are taking the function itself and giving it a second name. As a result, say_hello now points to the same function in memory.
Nested Functions and Returning Functions
You can also define a function inside another function:
def outer():
def inner():
print("I am inside!")
inner()
outer() # I am inside!
The inner function lives only inside outer. You cannot call inner() from outside — it is like a room inside a house; you have to enter the house first.
A function can also return another function:
def outer():
def inner():
print("I am inside!")
return inner # returning the function, not calling it
my_func = outer()
my_func() # I am inside!
Here, outer() is called (with parentheses) because we want the result of its execution, which is the inner function.
This pattern — a function that returns a function — is the foundation of decorators.
Building a Simple Decorator
Suppose we have a simple function:
def say_hi():
print('hi!')
We want to add some behavior — say, printing a border line before and after it runs. We could edit the function manually, but what if we have 50 functions and want the same border around all of them?
The idea is to write a function that takes a function, wraps it with extra behavior, and returns a new function:
def add_border(func):
def wrapper():
print("=" * 30)
func()
print("=" * 30)
return wrapper
add_bordertakes one argument:func— the original function we want to decorate.- Inside, we define
wrapper— the new function that will replace the original. It does three things: prints a border, calls the original function, and prints another border. - We then return
wrapper. We do not call it; we return the function itself.
Now let’s use it:
def say_hi():
print("hi!")
say_hi = add_border(say_hi)
say_hi()
==============================
hi!
==============================
The line say_hi = add_border(say_hi) replaces say_hi with the wrapped version. The original function still exists inside, but now it has a border around it.
The @ Syntax
This reassignment line works, but it is not very clean. Python provides a nicer way to write the same thing — the @ symbol:
@add_border
def say_hi():
print("hi!")
say_hi()
@add_border does the exact same thing as say_hi = add_border(say_hi). It is simply a more readable way to write it.
Key takeaway: When you see @something above a function, it means the function is being passed to something, and the result replaces it.
Revisiting @property
With this understanding, let’s revisit the @property decorator from Week 2. Python takes our method and passes it to the built-in property function.
Here is the normal way of using the property decorator:
class Student:
def __init__(self, name):
self._name = name
@property
def name(self):
return self._name
And this is what happens behind the scenes:
class Student:
def __init__(self, name):
self._name = name
def get_name(self):
return self._name
# We pass the method into the property() function
# and save it to a variable called 'name'
name = property(get_name)
The @name.setter Behind the Scenes
The property function creates a special object that has its own .setter() method. The @name.setter decorator works the same way.
The normal way:
class Student:
def __init__(self, name):
self._name = name
@property
def name(self):
return self._name
@name.setter
def name(self, value):
self._name = value
Behind the scenes:
class Student:
def __init__(self, name):
self._name = name
def name(self):
return self._name
name = property(name)
saved = name.setter
def name(self, value):
self._name = value
name = saved(name)
Decorating Functions with Arguments
What if the function we want to decorate takes arguments?
@add_border
def greet(name):
print(f"Salom, {name}!")
greet("Sevara")
With our current add_border, this would cause an error because the wrapper function takes zero arguments, but greet expects name.
One way to fix it is to add name to wrapper:
def add_border(func):
def wrapper(name):
print("=" * 30)
func(name)
print("=" * 30)
return wrapper
This works for greet, but what about a function that takes two arguments, or three? You would need a different decorator for each case. Python has a solution for this: *args and **kwargs.
*args — Variable Positional Arguments
Normally, functions take a fixed number of arguments. *args lets a function accept any number of positional arguments by collecting them all into a tuple:
def show_all(*args):
print(args)
print(type(args))
show_all(1, 2, 3)
# (1, 2, 3)
# <class 'tuple'>
show_all("Alisher", "Sevara")
# ('Alisher', 'Sevara')
show_all()
# ()
You can pass zero, one, five, or a hundred arguments — *args collects them all. The name args is just a convention; you could write *stuff or *numbers. The magic is in the *, not the name.
**kwargs — Variable Keyword Arguments
**kwargs does the same thing but for keyword arguments (arguments with a key). It collects them into a dictionary:
def show_details(**kwargs):
print(kwargs)
print(type(kwargs))
show_details(name="Jasur", age=19, city="Tashkent")
# {'name': 'Jasur', 'age': 19, 'city': 'Tashkent'}
# <class 'dict'>
Again, the name kwargs is a convention; the important part is **.
Combining *args and **kwargs
You can combine both in a single function:
def flexible(a, b, *args, **kwargs):
print(f"a = {a}")
print(f"b = {b}")
print(f"extra positional: {args}")
print(f"extra keyword: {kwargs}")
flexible(1, 2, 3, 4, 5, color="red", size=10)
# a = 1
# b = 2
# extra positional: (3, 4, 5)
# extra keyword: {'color': 'red', 'size': 10}
a and b get the first two arguments. Everything else goes into args or kwargs.
Unpacking with * and **
You can also go in the opposite direction — unpack a tuple or dictionary when calling a function.
Use * to unpack a tuple into positional arguments:
def add(a, b):
return a + b
numbers = (3, 5)
print(add(*numbers)) # same as add(3, 5) → 8
Think of * as “open the box and take things out.” The tuple numbers is a box containing 3 and 5. Writing *numbers tells Python to take the values out and place them one by one into the function call. Without *, you would be passing the whole tuple as a single argument, causing an error:
add(numbers) # Error! add() got 1 argument (a tuple), expected 2
add(*numbers) # same as add(3, 5)
Use ** to unpack a dictionary into keyword arguments:
details = {"name": "Nodira", "age": 20}
def greet(name, age):
print(f"{name} is {age}")
greet(**details) # same as greet(name="Nodira", age=20)
**details becomes name="Nodira", age=20.
The Universal Decorator Pattern
Now we can write a decorator wrapper that accepts any arguments and passes them to the original function:
def add_border(func):
def wrapper(*args, **kwargs):
print("=" * 30)
func(*args, **kwargs)
print("=" * 30)
return wrapper
wrapper(*args, **kwargs)means “accept absolutely anything.”func(*args, **kwargs)means “pass all of it to the original function.”
This is the standard way to write decorators. Always use *args, **kwargs in the wrapper so it works with any function.
@add_border
def greet(name):
print(f"Salom, {name}!")
@add_border
def add(a, b):
print(f"{a} + {b} = {a + b}")
greet("Jasur")
add(3, 7)
==============================
Salom, Jasur!
==============================
==============================
3 + 7 = 10
==============================
Handling Return Values in Decorators
What if the original function returns something?
@add_border
def multiply(a, b):
return a * b
result = multiply(3, 5)
print(result)
==============================
==============================
None
We get None because our wrapper calls func() but never returns the result — it just throws it away. The fix is simple: save the result and return it.
def add_border(func):
def wrapper(*args, **kwargs):
print("=" * 30)
result = func(*args, **kwargs)
print("=" * 30)
return result
return wrapper
The Standard Decorator Template
This is the pattern you should memorize. Every decorator you write will follow it:
def my_decorator(func):
def wrapper(*args, **kwargs):
# do something before
result = func(*args, **kwargs)
# do something after
return result
return wrapper
Practical Example: A Timer Decorator
Here is a useful decorator that measures how long a function takes to run:
import time
def timer(func):
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
end = time.time()
print(f'{func.__name__} took {end - start:.4f} seconds')
return result
return wrapper
@timer
def slow_function():
time.sleep(2)
@timer
def fast_function():
total = sum(range(1_000_000))
return total
slow_function() # slow_function took 2.0012 seconds
fast_function() # fast_function took 0.0001 seconds
Three Types of Methods in Classes
So far, all our class methods have taken self as the first parameter. Python classes actually support three types of methods:
- Instance methods — regular methods that take
self. They work with a specific object. - Static methods — they do not take
self. They are regular functions that live inside the class for organizational purposes. - Class methods — they take
cls(the class itself) instead ofself. They work with the class, not a specific object.
Instance Methods
class Temperature:
def __init__(self, celsius):
self.celsius = celsius
def describe(self):
print(f"Temperature is {self.celsius}°C") # option shift 8
Nothing new here. describe is an instance method — it uses self to access the specific object’s data. You need an object to call it:
t = Temperature(36.6)
t.describe()
Static Methods (@staticmethod)
Sometimes you have a function that is related to the class topic but does not need access to any instance (self).
For example, converting Celsius to Fahrenheit. Suppose we want a universal method that works with any input, not just the object’s temperature — it simply takes a number and returns a number.
We could write it as a standalone function outside the class:
def celsius_to_fahrenheit(c):
return c * 9/5 + 32
But logically it is about temperature. Python lets you put it inside the class using @staticmethod:
class Temperature:
def __init__(self, celsius):
self.celsius = celsius
@staticmethod
def celsius_to_fahrenheit(c):
return c * 9/5 + 32
@staticmethod
def fahrenheit_to_celsius(f):
return (f - 32) * 5/9
Notice — no self parameter. It is just a regular function living inside the class for organization.
You can call it in two ways:
# through the class directly — no object needed
print(Temperature.celsius_to_fahrenheit(100)) # 212.0
# or through an object — also works, but unnecessary
t = Temperature(0)
print(t.celsius_to_fahrenheit(100)) # 212.0
Both work, but the first way is more common. Since the method does not use the object at all, there is no reason to create one.
Think of @staticmethod like a tool in a toolbox. The toolbox is the class. The tool does not care about the toolbox — it just lives there so you know where to find it.
When to use static methods: When the function is logically related to the class but does not need self.
Class Methods (@classmethod)
@classmethod receives the class itself as the first argument instead of an instance. By convention, we call it cls (similar to how we use self for instances).
The most common use case for class methods is alternative constructors — alternative ways to create an object.
For example, our Temperature class takes Celsius:
t = Temperature(36.6) #celsius
But what if someone has a temperature in Kelvin or Fahrenheit? You cannot have two __init__ methods, so we use @classmethod to create alternative ways to build the object.
Inside a class method, cls(celsius) is the same as calling Temperature(celsius) — it creates and returns a new object:
class Temperature:
def __init__(self, celsius):
self.celsius = celsius
@classmethod
def from_fahrenheit(cls, f):
celsius = (f - 32) * 5/9
return cls(celsius)
@classmethod
def from_kelvin(cls, k):
celsius = k - 273.15
return cls(celsius)
@staticmethod
def celsius_to_fahrenheit(c):
return c * 9/5 + 32
def describe(self):
print(f"Temperature is {self.celsius:.1f}°C")
Why cls Instead of Using the Class Name Directly?
Technically you can write Temperature(...) instead of cls(...), but if someone creates a subclass later, cls will automatically refer to the subclass. It is more flexible.
Example of what goes wrong with the class name directly:
class Temperature:
def __init__(self, celsius):
self.celsius = celsius
@classmethod
def from_fahrenheit(cls, f):
# BAD: using Temperature directly instead of cls
return Temperature((f - 32) * 5/9)
class SmartTemperature(Temperature):
def warning(self):
if self.celsius > 50:
print("Too hot!")
else:
print("OK")
t = SmartTemperature.from_fahrenheit(212)
print(type(t)) # <class 'Temperature'> — not SmartTemperature!
t.warning() # ERROR! Temperature has no method 'warning'
Because we wrote Temperature(...) directly, the method always makes a Temperature object, even when called from SmartTemperature. So t.warning() crashes. Using cls(...) instead makes cls equal to SmartTemperature, and everything works correctly.
Using the Alternative Constructors
t1 = Temperature(100) # from celsius
t2 = Temperature.from_fahrenheit(212) # from fahrenheit
t3 = Temperature.from_kelvin(373.15) # from kelvin
t1.describe() # Temperature is 100.0°C
t2.describe() # Temperature is 100.0°C
t3.describe() # Temperature is 100.0°C
All three objects end up with the same value — three different ways to create the same thing.
Comparison Table
| Feature | Instance Method | @staticmethod |
@classmethod |
|---|---|---|---|
| First argument | self (the object) |
nothing special | cls (the class) |
| Can access instance data? | Yes | No | No |
| Can access class data? | Yes | Yes (using class name) | Yes |
| Needs an object to call? | Yes | No | No |
| Common use | Regular behavior | Helper functions | Other ways to create objects |
If your method never touches self, it should probably be a @staticmethod.
Full Example: Using All Three Method Types
class Student:
university = 'Al-Khwarizmi university'
_count = 0
def __init__(self, name, student_id):
self.name = name
self.student_id = student_id
Student._count += 1
def introduce(self):
print(f'hi, i am {self.name} ({self.student_id})')
@staticmethod
def is_valid_id(student_id: str):
return len(student_id) == 7 and student_id.isnumeric()
@classmethod
def from_string(cls, data_string):
name, student_id = data_string.split('-')
return cls(name, student_id)
@classmethod
def get_count(cls):
return cls._count
Each method type has its job:
introduceneedsselfbecause it talks about a specific student — so it is a regular instance method.is_valid_iddoes not need any object — it just checks a string format — so it is a static method.from_stringcreates a new student from a different input format — so it is a class method (alternative constructor).get_countaccesses the class variable_count— so it is a class method.
s1 = Student('nodira', '2612345')
s2 = Student.from_string('alisher-2654321')
print(Student.is_valid_id('2500000'))
print(Student.is_valid_id('123'))
print(Student.get_count())
s1.introduce()
s2.introduce()
Custom Decorators on Class Methods
You can use custom decorators on class methods too. Here is a decorator that logs every time a method is called:
def debug_info(func):
def wrapper(*args, **kwargs):
print(f'calling {func.__name__}...')
result = func(*args, **kwargs)
print(f'{func.__name__} returned {result}')
return result
return wrapper
class Calculator:
@debug_info
def add(self, a, b):
return a + b
@debug_info
def multiply(self, a, b):
return a * b
calc = Calculator()
calc.add(3, 5)
calc.multiply(4, 7)
calling add...
add returned 8
calling multiply...
multiply returned 28