Getting the Gist: Avoiding Function Side Effects
One day the team got into a virtual discussion about mutable argument defaults in Python. This GtG is not about that, but you can read one of the numerous articles about it here. The common thread in all the articles/blog posts about the danger is that they all use functions with side effects.
In computer science, an operation, function or expression is said to have a side effect if it modifies some state variable value(s) outside its local environment, which is to say if it has any observable effect other than its primary effect of returning a value to the invoker of the operation. Example side effects include modifying a non-local variable, modifying a static local variable, modifying a mutable argument passed by reference, performing I/O or calling other functions with side-effects.
(Source: Wikipedia)
Since I use mypy, I thought I'd look at how it can help catch functions with side effects.
First, I put together a simple, nonsense function with side effects. This function includes append
, pop
and indexed assignment on a List
- a selection of common ways a list might be altered.
# A simple function with deliberate side effects
def foo(val: List[int]) -> List[int]:
val.append(1)
val.pop(0)
val[0] = 5
return val
Running mypy against this function returns no warnings: not the desired result. Changing the type from List
to Sequence
does get the attention of the type checker.
def bar(val: Sequence[int]) -> Sequence[int]:
# mypy will flag errors on the next three lines
val.append(1)
val.pop(0)
val[0] = 5
return val
Mypy is fine taking a List as an input argument in the example above, but it will catch any attempt to assign the return value to a List. This is not ideal since the calling function (and presumably the owner of the original List) will have to cast or be stuck with a type that it can't change. (Note: I am using can't loosely. There is still no runtime type checking, but mypy will flag the assignment.) In one final version of the function these two issues can be addressed:
# We can create a custom type to show our intent
T = TypeVar("T")
ImmutableList = Union[List[T], Sequence[T]]
# Using our own type for readability and casting to a list on return
# This prevents the caller from dealiing with our custom type
def baz(val: ImmutableList[int]) -> List[int]:
# mypy will flag errors on the next three lines
val.append(1)
val.pop(0)
val[0] = 5
return list(val)
Now we have a function with a clear declaration: it should not have side effects and still allows the caller to own and mutate the return value as a List
.
And that is the gist of it.
Play around with this code on replit. mypy
is available in the Shell tab.
Subscribe to my newsletter
Read articles from Joshua Goldie directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by