Python
Completed
-
Section 1: Getting Started
-
Section 2: Core Syntax and Data Types
-
Section 3: Collections
-
39: Set Operations: Union, Intersection, Difference
-
Section 4: Control Flow
-
Section 5: Functions
-
Section 6: Turtle Graphics and Early Practice Projects
-
Section 7: Working with Files and I/O
-
Section 8: Regular Expressions
-
Section 9: Object-Oriented Python
-
Section 10: Error Handling
-
Section 11: Modules and Packages
-
Section 12: Iterators, Generators, and Functional Tools
-
Section 13: Decorators and Metaprogramming
-
Section 14: Concurrency and Parallelism
-
Section 15: Working with Dates, Times, and Numbers
-
Section 16: Standard Library Deep Dive I: Data Structures
-
Section 17: Standard Library Deep Dive II: System and Introspection
-
Section 18: Standard Library Deep Dive III: Security and Encoding
-
Section 19: Standard Library Deep Dive IV: Text and Data Utilities
-
Section 20: Networking and Web Basics
-
Section 21: Working with Databases
-
Section 22: Testing and Quality
-
Section 23: Advanced Typing
-
Section 24: Context Managers and Resource Handling
-
Section 25: Text, Unicode, and Binary Data
-
Section 26: More Functional and Iteration Tools
-
Section 27: Data Validation and Configuration
-
Section 28: Working with Images and Media
-
Section 29: Property-Based and Documentation Testing
-
Section 30: Packaging and Deployment
-
Section 31: Performance and Internals
-
Section 32: Design Patterns in Python
-
Section 33: GUI Programming
-
Section 34: Security Basics
-
Section 35: Data Structures and Algorithms
-
Section 36: Practical Projects
-
Section 37: Capstone Projects
-
Section 38: Interview and Algorithm Practice
-
Section 39: Writing Idiomatic Python
187: Writing Your First Decorator
Wait, is a decorator just a function that takes another function as an argument?
Pretty much. In Python, functions are "first-class objects," which is a fancy way of saying you can pass them around just like you would a string or an integer. A decorator is simply a function that accepts a function, adds some extra behavior to it, and then returns a version of that function that does the new thing.
I like to think of it like a physical wrapper. If you have a gift (your original function), the decorator is the wrapping paper and the bow. The gift inside hasn't changed, but now it has this extra layer around it that changes how it's presented or handled before it's actually opened.
Why do I have to define a second function inside the first one?
This is usually where people get tripped up. If you just wrote a function that took another function and ran it, the code would execute immediately when you decorated it, not when you actually called the function later. We use a nested "wrapper" function to create a closure.
Let's look at a real example. Say we want to log every time a specific function is called so we can track it in our console. Here is how that looks:
def debug_log(func):
def wrapper():
print(f"Calling function: {func.__name__}...")
func()
print(f"Finished calling {func.__name__}!")
return wrapper
@debug_log
def say_hello():
print("Hello, world!")
say_hello()
By returning the wrapper function instead of calling func() right away, we're essentially telling Python: "Don't run this yet. Instead, replace the original say_hello with this new version that includes the print statements."
What happens if my function needs to take arguments?
The example above works for say_hello() because it takes no arguments. But the moment you try to use that decorator on a function like add(a, b), it'll crash because the wrapper doesn't expect any inputs.
To fix this, we use *args and **kwargs. This allows the wrapper to swallow any number of positional and keyword arguments and pass them straight through to the original function. I use this pattern in almost every decorator I write—it's the industry standard for making your decorators reusable.
def debug_log(func):
def wrapper(*args, **kwargs):
print(f"Executing {func.__name__} with arguments {args} and {kwargs}")
result = func(*args, **kwargs)
print(f"{func.__name__} returned: {result}")
return result
return wrapper
@debug_log
def multiply(x, y):
return x * y
# This now works perfectly regardless of the inputs
multiply(5, 10)
Notice that I also captured the result. If you forget to return the value from the wrapper, your decorated function will always return None, which is a bug that has cost me more hours of debugging than I'd care to admit.
Is the @ symbol actually necessary?
Short answer: no. Long answer: you really want it.
The @debug_log syntax is what we call "syntactic sugar." It's a shortcut. Writing @debug_log above your function is exactly the same as writing say_hello = debug_log(say_hello) after the function is defined. It just looks cleaner and makes it immediately obvious to anyone reading your code that the function's behavior is being modified by a decorator.
📋 Practical Task
Build a Function Execution Timer
In real-world software engineering, we often need to know how long a piece of code takes to run to find performance bottlenecks. Your task is to create a decorator called time_it.
- Import the
timemodule. - The
time_itdecorator should record the time immediately before and immediately after the wrapped function executes. - It should print the total elapsed time (e.g., "Function 'process_data' took 1.2 seconds to run").
- Ensure the decorator supports functions with any number of arguments.
- Apply the decorator to a function that simulates a heavy task (you can use
time.sleep(1)inside a function calledprocess_data) and call it to verify the output.
There are no comments for now.