Function Hygiene in Python#
Function headers and docstrings#
Consider again the abs_diff
function defined in the prior chapter.
def abs_diff(x: int, y: int) -> int:
"""Absolute value of the difference between x and y."""
if x > y:
return x - y
else:
return y - x
The name of the new function will be abs_diff
. Following
Python naming conventions, it
is made of lower case letters (no capital letters), with parts
separated by underscore (”_
”).
The triple-quoted string immediately following the function header
is the docstring comment.
Names matter#
We have chosen
abs_diff
rather than absolute_difference
to keep it short.
We have not shortened it further to a_d
, nor chosen an arbitrary
name like theta
, because it is important for the name to be
mnemonic and suggestive of its purpose.
How long is long enough? How descriptive must a name be?
Kernighan and Plauger provide nuanced and pragmatic guidance to
naming in their classic book
The Elements of Program Style.
Although the original version of that book uses examples from older
programming languages that you may never encounter, the essential
principles remain valid. One of these is that distinctiveness
(avoiding names that can be confused with each other) is paramount.
For example, hours_weekday_worked
and hours_weekend_worked
are a
bad pair, because they are distinguished only by middle words
that look similar. weekend_hours
and
weekday_hours
, or even wkend_hours
and wkday_hours
, are
easier to distinguish because their differences are more
prominent.
A second principle, which like distinctiveness is rooted in properties of human memory, is that a name which is defined in one place and then referenced far away needs to be more descriptive than a name that is defined and then used just once nearby (e.g., within the span of code that is likely to be visible on the same screen of text).
Arguments#
Function abs_diff
has two arguments, also called formal
parameters, x
and y
, and returns a single
int
value. This information comprises the signature of
function abs_diff
, which is (int, int) -> int. This is enough
to tell us that x = abs_diff(5, 7)
is a legal assignment that
assigns an int
value to x
, but abs_diff("cats", "dogs")
is
an error, as is abs_diff(3, 2) + "turtles"
.
Less obviously, abs_diff(3.2, 5.4)
is a programming error, even
though it will return 2.2. This is because the header of a function
together with its docstring
is a contract between the author of the function and anyone
who calls that function (even if they are the same person). Passing
a floating point number like 3.2 to abs_diff
breaks that contract.
Why do we care about this “contract”, if the code works? Because the contract determines what the author of the function is permitted to change without notice. Calling a function in a way that violates the contract might work today, but fail sometime later when the author of the function makes a perfectly acceptable change to the body of the function. The user of a function must only rely on the contract as given in the function header and docstring. This applies even if the author and the user are the same programmer! It is all too easy to make a small and seemingly harmless change to one part of a program and accidentally break a completely different part because of some forgotten dependency.
Information hiding#
We treat the body of the function as if it were invisible to the programmer who writes calls to that function, and subject to change without notice, even if it is the same programmer. This is called information hiding. Students and beginning programmers often find information hiding unintuitive and bothersome. Understandably so, because as a beginner they write most code individually, and seldom work on the same project for more than a few weeks. This is apt to change as you tackle larger and more complex projects.
The software systems that matter to people are typically collaborative or change hands over time, and they last much longer than you might imagine. Even the original developer of a function will find themselves essentially an outsider when they return to it after a few months working on other parts of an application.
There is another reason for clear, simple contracts and information hiding. Programs are written by humans. Human brains are amazing, but one thing they do not do well is maintain a large number of details in working memory.
We solve complex problems by decomposing them into smaller problems, then composing simple solutions of sub-problems to solve the overall problem. This is only possible if we can ignore and abstract away details of some of the sub-problems while working on other parts. If we need to understand how a function works to understand what it does, we can’t suppress that detail in any part of the program that uses the function. Information hiding is an essential tool for controlling complexity by giving us permission to ignore most details most of the time, focusing in on just a few at a time.
Docstrings#
As useful as the signature of a function is in telling us what
kind of values we can pass to it and what kind of value we can
expect back from it, the signature alone cannot tell us everything
we need to know. The name can help, but it’s not enough: We can
guess that abs_diff
probably does not give us the sum or the
product of its arguments, but the docstring comment immediately
following the function header gives us a more complete description.
The Python interpreter makes the docstring comment available through its built-in help system:
help(abs_diff)
Help on function abs_diff in module __main__:
abs_diff(x: int, y: int) -> int
Absolute value of the difference between x and y.
As with choosing names, writing docstring comments that are clear but concise is an art that requires care and practice.
Argument names#
What about those formal argument names, x
and y
? Should they be
longer? Would it help? If there were particular meanings
associated with them (e.g., if the first argument should be a height
in centimeters and second should be an angle in degrees), then x
and y
would be poor names. Consider the following function,
in which longer names are needed:
def relative_error(est: float, expected: float) -> float:
"""Relative error of estimate (est) as non-negative fraction
of expected value.
"""
Here the formal parameters are est
(for estimate) and expected
(for expected value, i.e., for the value that est
should be close
to). These have been chosen to be mnemonic. You can probably
guess that est
means “estimate” even without context. Without
context it is less likely that you would guess that expected
means
“the value we expected to get”, but in the context of a function
called relative_error
it is clear.
These formal parameters are not interchangeable;
relative_error(3.5, 3.8)
will
not give the same result as relative_error(3.8, 3.5)
. If we give
them meaningless names like x
and y
, we are likely to reverse
them and get the wrong answers. Ambiguous names would be dangerous
in that case! In the case of abs_diff
, on the other hand, x
and y
are just numbers, nothing more, which is what their generic
names communicate.
Names that are distinguished only by suffixes, like v1
, v2
, v3
,
etc., are never acceptable, because they are too easy to confuse.
Arbitrary names like your favorite colors or your pets are also not
acceptable, even for generic values for which x
and y
would be
acceptable, because they falsely appear to have some meaning.
Results, effects, and side effects#
A function should normally either return a well-documented result or have some well-documented effect on the arguments it is passed, but not both. For example, here is a function that returns a list containing just the positive elements from a list of integers:
def select_pos(m: list[int]) -> list[int]:
"""Returns a list of the positive elements from m."""
result = []
for el in m:
if el > 0:
result.append(el)
return result
li = [1, 0, 2, 0, 3, 0]
li_selection = select_pos(li)
print(f"li after selection is {li}")
print(f"selection is {li_selection}")
li after selection is [1, 0, 2, 0, 3, 0]
selection is [1, 2, 3]
select_pos
is ok … it returns a well-documented result and it
does not have effects on anything outside its local scope.
We could also write a function that appends the positive elements of
one list to another:
def append_pos(src: list[int], dest: list[int]):
"""Positive elements of src are appended to dest."""
for el in src:
if el > 0:
dest.append(el)
li = [1, 0, 2, 0, 3, 0]
li_selection = []
append_pos(li, li_selection)
print(f"li after selection is {li}")
print(f"li_selection is {li_selection}")
li after selection is [1, 0, 2, 0, 3, 0]
li_selection is [1, 2, 3]
append_pos
is also ok … it has a well-documented effect, and it
does not return a result. (Technically it returns a special value
called None
.)
We generally avoid creating functions or methods that both
return a result and have a result. It is easier to understand just
one or the other. For example, the built-in
function sorted
returns a sorted list without changing the list it
is given, while the list method sort
puts the list into sorted
order but does not return a result.
In the rare cases that doing both makes a program
significantly shorter, clearer, or faster, we must document that
combination particularly well. A rare example of a built-in method
that has both an effect and a result is the list
method pop
,
which both removes an item from the list and returns it.
Side effects#
When a function or method affects something other than its arguments or the object on which a method is called, it is called a side effect. Side effects make it easy to create program bugs and hard to find and correct them. One case where side effects may be justifiable is in output, e.g., printing something, logging something to a file, or presenting a graphic. The more obvious and well-documented such a side effect is, the less chance it will lead to a frustrating debugging session as unexpected effects seem to come from nowhere.
Summary of function hygiene guidelines#
Make the function header and docstring a contract between the author and user of the function. The user should not depend on details of the body of the function, or even have to read the body of the function to know how to use it correctly.
Use names that are sufficiently clear and distinct. They don’t have to be long, but they must be understandable in the context in which they will be used.
Generic names like
x
are ok only for generic purposes like “some number”, never for something specific like “the number of sides of the polygon.” The names of your pets or siblings are only acceptable if the program computing something about your pets or siblings.A function (or method) should either return a result or have an effect. Very seldom should a function or method have both an effect and a result, and those rare cases must be carefully documented.
Side effects are particularly dangerous. Avoid them when practical, and particularly avoid side effects that could go unnoticed, unless you love long and confusing debugging sessions.
You may be tempted to ignore these guidelines because you will be the only user of the functions you write. The you of next week will be angry at the you of this week if you do.