Functions
- Item 30: Know That Function Arguments Can Be Mutated
- Item 31: Return Dedicated Result Objects Instead of Requiring Function Callers to Unpack More Than Three Variables
- Item 32: Prefer Raising Exceptions to Returning None
- Item 33: Know How Closures Interact with Variable Scope and nonlocal
- Item 34: Reduce Visual Noise with Variable Positional Arguments
- Item 35: Provide Optional Behavior with Keyword Arguments
- Item 36: Use None and Docstrings to Specify Dynamic Default Arguments
- Item 37: Enforce Clarity with Keyword-Only and Positional-Only Arguments
- Item 38: Define Function Decorators with functools.wraps
- Item 39: Prefer functools.partial over lambda Expressions for Glue Functions
The first organizational tool programmers use in Python is the function. As in other programming languages, functions enable you to break large programs into smaller, simpler components with names to represent their purpose. They improve readability and make code more approachable. They allow for reuse and refactoring.
Functions in Python have a variety of extra features that make a programmer’s life easier. Some are similar to capabilities in other programming languages, but many are unique to Python. These extras can make a function’s interface clearer. They can eliminate noise and reinforce the intention of callers. They can significantly reduce subtle bugs that are difficult to find.
Item 30: Know That Function Arguments Can Be Mutated
Python doesn’t support pointer types (beyond interfacing with C; see Item 95: “Consider ctypes to Rapidly Integrate with Native Libraries”). But arguments passed to functions are all passed by reference. For simple types, like integers and strings, parameters appear to be passed by value because they’re immutable objects. But more complex objects can be modified whenever they’re passed to other functions, regardless of the caller’s intent.
For example, if I pass a list to another function, that function has the ability to call mutation methods on the argument:
def my_func(items): items.append(4) x = [1, 2, 3] my_func(x) print(x) # 4 is now in the list >>> [1, 2, 3, 4]
In this case, you can’t replace the original value of the variable x within the called function, as you might do with a C-style pointer type. But you can make modifications to the list assigned to x.
Similarly, when one variable is assigned to another, it stores a reference, or an alias, to the same underlying data structure. Thus, calling a function with what appears to be a separate variable actually allows for mutation of the original:
a = [7, 6, 5] b = a # Creates an alias my_func(b) print(a) # 4 is now in the list >>> [7, 6, 5, 4]
For lists and dictionaries, you can work around this issue by passing a copy of the container to insulate you from the function’s behavior. Here, I create a copy by using the slice operation with no starting or ending indexes (see Item 14: “Know How to Slice Sequences”):
def capitalize_items(items): for i in range(len(items)): items[i] = items[i].capitalize() my_items = ["hello", "world"] items_copy = my_items[:] # Creates a copy capitalize_items(items_copy) print(items_copy) >>> ['Hello', 'World']
The dictionary built-in type provides a copy method specifically for this purpose:
def concat_pairs(items): for key in items: items[key] = f"{key}={items[key]}" my_pairs = {"foo": 1, "bar": 2} pairs_copy = my_pairs.copy() # Creates a copy concat_pairs(pairs_copy) print(pairs_copy) >>> {'foo': 'foo=1', 'bar': 'bar=2'}
User-defined classes (see Item 29: “Compose Classes Instead of Deeply Nesting Dictionaries, Lists, and Tuples”) can also be modified by callers. Any of their internal properties can be accessed or assigned by any function they’re passed to (see Item 55: “Prefer Public Attributes over Private Ones”):
class MyClass: def __init__(self, value): self.value = value x = MyClass(10) def my_func(obj): obj.value = 20 # Modifies the object my_func(x) print(x.value) >>> 20
When implementing a function that others will call, you shouldn’t modify any mutable value provided unless that behavior is mentioned explicitly in the function name, argument names, or documentation. You might also want to make a defensive copy of any arguments you receive to avoid various pitfalls with iteration (see Item 21: “Be Defensive when Iterating over Arguments” and Item 22: “Never Modify Containers While Iterating over Them; Use Copies or Caches Instead”).
When calling a function, you should be careful about passing mutable arguments because your data might get modified, which can cause difficult-to-spot bugs. For complex objects you control, it can be useful to add helper functions and methods that make it easy to create defensive copies. Alternatively, you can use a more functional style and try to leverage immutable objects and pure functions (see Item 56: “Prefer dataclasses for Creating Immutable Objects”).
Things to Remember
Arguments in Python are passed by reference, meaning their attributes can be mutated by receiving functions and methods.
Functions should make it clear (with naming and documentation) when they will modify input arguments and avoid modifying arguments otherwise.
Creating copies of collections and objects you receive as input is a reliable way to ensure that your functions avoid inadvertently modifying data.