Style
Please Log In for full access to the web site.
Note that this link will take you to an external site (https://shimmer.mit.edu) to authenticate, and then you will be redirected back to this page.
Table of Contents
1) Introduction
People often refer to "good style" when talking about programs/code, but, interestingly, that word can mean a lot of different things depending on context. In this document, we'll try to explain what "good style" means in the context of 6.101, as well as provide some suggestions for incorporating elements of "good style" into your own programs.
One notion of style relates to detailed character-by-character decisions about how code is formatted, like how much whitespace to use when indenting, where parentheses should go, etc. The Python Software Foundation does publish/maintain an official style guide called PEP 8, and this convention is probably the most widely-used style convention for Python code, so it's not a bad idea to get into the habit of following those guidelines.
That said, while formatting conventions are perhaps worth knowing and caring about, we are not going to focus on them in 6.101. You are welcome to put your parentheses wherever you like, and choose your own indentation level, so long as your programs still work.
But we will be concerned with style from a higher-level perspective that focuses on how your code is structured, rather than how it is formatted. So rather than focusing on writing code that looks pretty, we'll focus on writing code that is as easy to read, write, understand, and debug as possible.
2) Guidelines
What constitutes good style and good design is, in some ways, a subjective question, and it depends somewhat on the particular program you're trying to write. However, what follows are some more-or-less general guidelines, each of which is detailed in a following section. While there may be reasons not to follow some of this advice in particular cases, they are generally good things to keep in mind when writing code.
- Don't Repeat Yourself (DRY): Avoid multiple fragments of code that describe redundant logic.
- Names Matter: Choose concise, descriptive names for functions, parameters, and other variables.
- Documentation Matters: Use docstrings and comments to describe assumptions and document non-obvious features of the code.
- Generality Wins: Define logic, functions, and programs as generally as possible.
- Plan for Change: Where possible, make programs that are (relatively) easy to change, should the need arise.
Following these guidelines will help you to:
- improve the readability of your code (other people will read your code, even if those other people are all either 6.101 staff or future versions of yourself)
- reduce the number of errors in your code, and make it easier to spot/fix them
- make it easier to make changes to your program if you need to
- minimize the amount of code you write (the less code we have, the fewer opportunities there are for bugs!)
Note that you do not need to come up with a stylistically perfect design on your first try! Designing for style takes time, experience, and practice. When starting, it is likely a good idea to get things to work first, but to keep an eye out for opportunities to improve stylistically as you're working (and after you are "done"), and to be open to making changes that improve the style of your code. This kind of iterative approach of successively refining a program can be really productive; and, with more time and experience, you will be able to "see" more of these stylistic improvements even before implementing a first solution, and your plans will start to include these ideas from the get-go.
With that in mind, let's explore some of these ideas in a bit more detail.
2.1) Don't Repeat Yourself
Modern text editors have a nice feature called copy/paste, which makes it easy to duplicate a piece of code you've already written. It may often be tempting to do this when you encounter a problem that is similar to another problem you've already solved (the voice in your head says, Hey! I've already written something that's almost right here; let's just copy/paste the code, tweak it a little bit, and then we've solved the new problem!). Every experienced developer has heard this Siren song before, but it's important to avoid this temptation.
The main fundamental risk that is intrinsic to duplicated code is that, if you have identical code (or very similar code) in multiple places in your program, then noticing an error (or some other opportunity for improvement) in one copy requires making appropriate changes to all copies. Not only is this a pain, but it's really easy to forget to make the change in one or more of the copies, and it introduces the possibility that the change is made incorrectly in one or more of the copies.
You should avoid duplication of code like you would avoid crossing the street without looking. Copy/paste is an enormously tempting programming tool, and you should feel a frisson of danger run down your spine every time you use it. The longer the chunk of code is that you're copying, the riskier it is.
Keeping an eye out for duplicated code as you're writing code (as well as after), and rearranging things to reduce the amount of code that is duplicated, can save a lot of trouble later on.
2.1.1) The Rule of Three
Not all code duplication comes from copy/paste, though. Some cases of duplicated code arise from manually typing the same (or very similar) things multiple times.
If you find yourself re-writing the same short expression over and over, that might be a good sign that you can store that in a variable as an intermediate result, or construct a loop to repeatedly compute related values. Along those same lines, if you find yourself copy/pasting a block of code to compute a result, that might be an opportunity to define a "helper" function for that behavior, and to call that function multiple times rather than duplicating the same structure multiple times.
Some people follow the "rule of three" (if a piece of code or a computation is repeated three or more times, abstract that computation into a function or a loop). That's a reasonable approach for small pieces of code, but a better approach for larger chunks might be the rule of two.
2.1.2) Boolean Laundering
One specific case where repeated code often shows up is so-called "Boolean laundering," which is perhaps best demonstrated by example. Consider the following piece of code:
def is_negative(x):
if (x < 0) == True:
return True
else:
return False
It may not look like it since each line is actually unique, but there is actually an awful lot of redundancy in this little example. Let's look a little closer and see how we could achieve the same result in a clearer and more efficient way.
Importantly, the expression x < 0 will always evaluate to a Boolean, so it
will either be True or False. If it is True, then we will enter the
first block of the conditional (since True == True evaluates to True), and,
if not, then we will enter the second block of the conditional (since
False == True evaluates to False).
If the parenthesized portions of that last sentence sounded a little
silly/redundant to you, that's a good sign, as we're perhaps already seeing one
place to improve the code above. Because x < 0 is a Boolean,
(x < 0) == True will always evaluate to exactly the same thing as x < 0.
So we could rewrite this function as follows:
def is_negative(x):
if x < 0:
return True
else:
return False
This is progress! We've removed a bit of redundant computation. But, it turns
out that we can go quite a bit further. Remember that x < 0 will evaluate to
a Boolean, so it will either evaluate to True or to False. In the case
where it evaluates to True, we enter the first block of the conditional and
return True; and, if not, we enter the second branch and instead return
False. So, in fact, we could write the following:
def is_negative(x):
return x < 0
which is much simpler and clearer.
Boolean laundering happens whenever you do a if test and return (or set a variable to) simply True or False.
Does the following function have boolean laundering in it?
def is_odd(x):
if x % 2 == 0:
return False
else:
return True
Yes, it does -- because we can just negate the if test to flip the return statements around:
def is_odd(x):
if x % 2 != 0:
return True
else:
return False
And then we recognize the same pattern as is_negative() above, where the if test is exactly the boolean value we want to return. So we should eliminate the boolean laundering by just returning the value of that expression:
def is_odd(x):
return x % 2 != 0:
2.2) Names Matter
Choosing good names for functions, parameters, and other variables will help make your code much easier to understand. Names of functions should describe what they do, and variable names should describe what they represent. Single letter names are okay in some situations, but use such names sparingly.
2.2.1) Concise, Descriptive Names
There is a lot of freedom in choosing variable names in the programs you write, but it is a good idea to try to balance choosing descriptive variable names with choosing short variable names that are reasonably easy to type.
Avoid single-character variable names entirely except where they are easily understood by convention.
For example, x and y make sense for Cartesian coordinates, and i and j as integer variables for indexing into lists.
But if your code is full of variables like e, f, g, and h, because you're just picking them from the alphabet, then it will be incredibly hard to read.
Lazy generic names like tmp, temp, and data should also be avoided.
Many variables are temporary, and technically every
variable is a type of data, so those names do not mean anything. It is better
to use a longer, more descriptive name, so that your code reads clearly by
itself. It is also generally a good idea to avoid abbreviations, since, while
their meanings may be evident to you, they may not be to someone else.
Good, descriptive variable names make it easier to understand code at a glance. For example, it takes a while to figure out what the following program does, primarily because of poorly chosen names:
def a(x):
return 2 * A * x
def b(x):
return A * x**2
def c(x):
return 4 * b(x)
def d(x):
return 4/3 * A * x**3
Its readability could be drastically improved by choosing variable names more carefully:
def circle_circumference(radius):
return 2 * PI * radius
def circle_area(radius):
return PI * radius**2
def sphere_surface_area(radius):
return 4 * circle_area(radius)
def sphere_volume(radius):
return 4/3 * PI * radius**3
Of course, it is possible to go too far in the direction of descriptive variable names. The following code, which borders on the absurd, is maybe even more difficult to decipher than the first example:
def circumference_of_a_circle_given_its_radius(the_radius_of_the_shapes_for_which_we_want_to_compute_values):
return 2 * the_ratio_between_the_circumference_and_the_diameter_of_a_circle * the_radius_of_the_shapes_for_which_we_want_to_compute_values
def area_of_a_circle_given_its_radius(the_radius_of_the_shapes_for_which_we_want_to_compute_values):
return the_ratio_between_the_circumference_and_the_diameter_of_a_circle * the_radius_of_the_shapes_for_which_we_want_to_compute_values**2
def surface_area_of_a_sphere_given_its_radius(the_radius_of_the_shapes_for_which_we_want_to_compute_values):
return 4 * area_of_a_circle_given_its_radius(the_radius_of_the_shapes_for_which_we_want_to_compute_values)
def volume_of_a_sphere_given_its_radius(the_radius_of_the_shapes_for_which_we_want_to_compute_values):
return 4/3 * the_ratio_between_the_circumference_and_the_diameter_of_a_circle * the_radius_of_the_shapes_for_which_we_want_to_compute_values**3
Interestingly, Python will happily run any of these pieces of code, and the results will be the same if we call any of the functions! But it's important to write programs not only with an eye toward correctness, but also with an eye toward clarity; one of these is far easier to understand than the others.
2.2.2) Magic Numbers
Whenever possible, you should avoid the use of hard-coded constant values, often called "Magic Numbers" because they show up out of nowhere with no explanation, as if by magic. Magic numbers can make your code more prone to bugs, as well as more difficult to read and understand.
For example, the following piece of code has a magic number in it:
def total_cost(raw_cost):
return raw_cost * 1.0625
Here, it's really not clear what 1.0625 means. One way to clear this up
would be with a comment describing the nature of that number, but
if that number shows up in multiple places, a far better way is to declare that
number as a named variable with a good, clear name, and to
refer to it by that name instead.
In this case, making that kind of change might lead to the following code:
MASSACHUSETTS_TAX_RATE = 1.0625
def total_cost(raw_cost):
return raw_cost * MASSACHUSETTS_TAX_RATE
This kind of a change has a few advantages over using a magic number. Among other things:
-
A number is less readable than a name. It's more difficult to glance at the first version of this function and understand its purpose.
-
Constants may need to change in the future. Using a named variable instead of repeating a magic number makes it easier to respond to those kinds of changes in the future.
-
Constants may depend on other constants! In fact, some magic numbers are the result of computations done by hand by a programmer and involving other magic numbers. Code is far clearer and more adaptable if we represent these numbers as named variables, and if we compute the related constants using explicit expressions in terms of other variables.
It's perhaps also worth noting that if you have multiple magic numbers in your code, and those numbers are related to each other in some way, it may make sense to store them in a list or a dictionary, rather than storing each as its own variable.
Constants are often named using all uppercase letters, like MASSACHUSETTS_TAX_RATE or PI, to distinguish them from actually-changing variables, which are conventionally lowercase.
But this convention isn't essential; what matters most is giving a clear, understandable name.
After reading this section, you may ask yourself: What about things like \pi or the gravitational constant, that I know will never change? Surely that's fine to hard-code, since it's definitely not going to change...
While you are right that the value associated with \pi is not ever going to change, there are still at least a few reasons to prefer using a named variable to refer to \pi, as opposed to referring to it by value:
-
As with certain other magic numbers, \pi is complicated to express, so repeating it multiple times may actually provide an opening for bugs to sneak into your code. For example, perhaps I put
3.141592653589793238462in one part of my code, but3.141592853589793238462in another place. Not only is there a bug there (the two values are actually different), but it's an extraordinarily difficult bug to detect. -
Along those same lines, hard-coding these values can make it difficult to detect when you've used the wrong magic number in a given expression, particularly when you have multiple magic numbers in your code.
-
Depending on how complicated these values are, they can clutter the code. For example, which of the following two expressions is easier to glance at and understand?
3.141592653589793238462*r**2, orPI*r**2? -
It may not seem like it, but these values actually also encode other assumptions we're making, which may change later. For example, encoded in that magic number is the degree of precision we use to represent it; if I wanted to change that later, I would need to change it in multiple places.
It is also important to keep in mind, when refactoring code to remove magic
numbers, that some names are better than others. For example, the following is
not any more useful than using 7 as a magic number:
seven = 7
but the following could be useful:
days_per_week = 7
cards_in_hand = 7
max_number_of_players = 7
This is also especially relevant for looping variables that aren't simply counters. While it can be tempting to use generic one-character names for any looping variables, it is often possible to clarify things by using more descriptive names. For example, which of the following two pieces of code is easier to read?
def longest_word_in_file(fname):
longest = 0
with open(fname) as f:
for i in f:
for j in i.split():
if len(j) > longest:
longest = len(j)
return longest
def longest_word_in_file(fname):
longest = 0
with open(fname) as f:
for line in f:
for word in line.split():
if len(word) > longest:
longest = len(word)
return longest
2.2.3) One Purpose For Each Variable
In general, within each function or module, distinct logical entities should be given distinct names. It is generally a bad idea to reuse the same name to mean different things within a single function, for example.
As a general rule, don't reuse parameters, and don't reuse variable names. Variables are not a scarce resource in Python, so you can/should introduce them freely, give them good names, and just stop using them when you stop needing them.
In general, someone reading your code will likely be confused if a name that used to mean one thing suddenly starts meaning something different a few lines later (or if the name of a parameter is reused within the body of a function to mean something else).
An important special case of this rule: avoid reusing Python keywords or built-in names.
Never use list as a variable name, for example, because list() is already built into Python as the way to construct a list.
If you change its meaning in your code, other programmers will be confused (or introduce bugs).
2.3) Documentation Matters
The kindest thing you can do for future people reading your code (which includes not only your 6.101 staff, but also your future self!) is to document your code, and to do so judiciously.
In Python, there are at least two different types of documentation (docstrings and comments), which we'll discuss in more detail below. While these structures can, in some sense, be used interchangeably, they are often used for different things in practice.
Good documentation should not only make the code easier to understand (by documenting non-obvious features of the code), but also safer against bugs (because important assumptions have also been documented).
2.3.1) Docstrings
One important kind of documentation is a docstring,
which is a regular Python string on the first line of a function, and which
serves as documentation for that function (classes and modules can also have
docstrings, but here we'll focus on functions). While any string is fine, it
is conventional to use triple-quotes (""") around docstrings, even for
one-line docstrings.
Here is a function with a (relatively) useful docstring:
def zeller(year, month, day):
"""
Compute the day of the week on which a particular date fell.
Uses Zeller's algorithm to compute the result
(see https://en.wikipedia.org/wiki/Zeller%27s_congruence)
Parameters:
* year (int) : the year of interest; requires year != 0; negative values
correspond to years BC, and positive values to years AD.
* month (int) : the month of interest; requires 1<=month<=12; January is
represented by 1, and December by 12
* day (int) : the day of interest, taken as a 1-indexed value within
the given month; must be a day that exists within the given month
Returns:
A 1-character string representing the day of the week on which the given
date fell:
* 'M' for Monday
* 'T' for Tuesday
* 'W' for Wednesday
* 'R' for Thursday
* 'F' for Friday
* 'S' for Saturday
* 'U' for Sunday
"""
# code here ...
Docstrings are an important type of documentation, as they are used to document
not only the behavior/purpose of the function/module/class, but also
assumptions about the inputs and outputs. Docstrings are also used internally
by Python; they are stored as an attribute called __doc__ inside the object,
and they are displayed when a user invokes the help function on the object.
For example, print(zeller.__doc__) would print the docstring, and
help(zeller) would display the docstring.
The example above provides a lot of useful information about that function. By contrast, the following docstring (for the same function) is not quite as useful:
def zeller(year, month, date):
"""Return the day of the week."""
# code here ...
While we aren't going to care too much about the particular formatting of these docstrings (there are many different conventions), including them is really important from a stylistic perspective, and the best docstrings will generally contain:
- a brief one-sentence description of the function's behavior
- if necessary, a longer description of the behavior
- a description of inputs (including types as well as any other expectations about those values)
- a description of effects (including return value/type, any exceptions that might be raised, or other side effects of the function such as mutating inputs, printing to the terminal, or producing graphics or sound)
2.3.2) Comments
Inline comments can also be useful, particularly when they describe aspects of the code that are not obvious. It is often reasonable to assume that the reader can figure out what the code does; it is more useful to explain why (though in the case of really non-standard structures or design decisions, it is good to leave a comment explaining how that structure accomplishes its task, in addition to your justification for structuring things that way).
As you are writing code, you should document the pieces of the code that are non-obvious, but let the code speak for itself where it can.
Some comments are not useful at all. For example, direct translations of code into English do nothing to improve understanding. Don't do this:
v = 5 # set v to 5
while n != 1: # test whether n is 1
i += 1 # increment i
mylist.append(n) # add n to mylist
You should assume that the reader knows Python and doesn't need you to explain the literal behavior of the code. Instead, use comments to add information that isn't already there. The following comments all provide useful information that does not otherwise exist in the code:
velocity = 5 # meters / second
sum = n*(n+1)/2 # Gauss's formula for the sum of 1...n
# here we're using the approximation that sin(x) ~= x when x is small
moon_diameter_meters = moon_distance_meters * apparent_angle_radians
And the comments in the following function describe high-level organization that may not be apparent from the code itself:
def flatten_list(input_list):
"""
Create a new "flat" list from a nested list.
For example:
>>> flatten_list([1, 2, [3, [4, [[[5]]]], 6, [7, 8]], 9])
[1, 2, 3, 4, 5, 6, 7, 8, 9]
Parameters:
* input_list (list): a list of elements, possibly containing
arbitrarily-nested lists
Returns:
A list containing all of the non-list elements from the given input_list,
but with any nesting of lists removed.
"""
# make a (shallow) copy to avoid accidentally mutating input_list
flat_list = input_list[:]
while True:
for ix, element in enumerate(flat_list):
if isinstance(element, list):
# break out of the for loop, to update flat_list
break
else:
# if we're here, we did not break out of the for loop (i.e., we did
# not find a list inside of flat_list), so we are done!
return flat_list
# because of the for/else structure, if we reach this point, we found a
# nested list (ix contains the index of the nested list within
# flat_list, and element contains the list itself). So we'll replace
# that list with its contents.
flat_list[ix:ix+1] = element
return flat_list
Good variable names can reduce the need for certain kinds of comments. For example, choosing the second of the following variable names is preferred, and makes the comment from the first version unnecessary:
n = 60*60*24 # number of seconds in a day
seconds_per_day = 60*60*24
2.3.3) Commented-out Code
During the development process, it is common to "comment out" various sections of code while working, in order to try new things or to help with debugging.
This is a really great thing to do, and you should definitely do it! But, when your code is ready (in our context, this means ready to be submitted to the web site; in other contexts, it might mean that it's ready to be released to the world), you should remove sections of unused code that have been commented out.
2.4) Generality Wins
It is best to define functions and programs generally where possible, and to handle specific cases by providing the proper inputs to the function.
For example, the square function is not defined in the math module, nor is
cube. This is because they are both specific cases for the pow function
(exponentiation), which is in the math module.
Writing a single general-purpose function results in code that is easier to read, write, and debug, in part because it helps to avoid the duplicate code that often results from creating several specific (but related) functions.
2.4.1) Avoid Superfluous Special Cases
Programmers are often tempted to write special code to deal with what seem like special cases (parameters that are 0, for example, or empty lists, or empty strings). However, it is often the case that an existing general procedure, or a slight variation thereof, could handle the task just as well.
For example, consider the following piece of code, designed to return the length of the longest string in its input:
def longest_string_length(words):
if len(words) == 0:
return 0
longest = 0
for word in words:
if len(word) > longest:
longest = len(word)
return longest
If we look closely at this function, we find that the first if statement is
unnecessary. The function would behave the same way, with or without it: if
the words list is empty, then the for loop simply does nothing, and 0 is
returned anyway. As such, it would be better to omit that first if statement
altogether.
This issue is also especially relevant in recursive functions, which we will discuss a lot in this course. It can be tempting to define more base cases than are actually necessary. For example, consider the following function, designed to compute the sum of all elements in a list recursively:
def sum_all(values):
if len(values) == 0:
return 0
elif len(values) == 1:
return values[0]
else:
return values[0] + sum_all(values[1:])
The first two cases (for length-0 and length-1 lists) are base cases; the last case, the else, is the recursive case.
But it turns out that the recursive case would also handle the length-1 case just fine!
So that length-1 case is unnecessary, and we could instead have only included the first base case, for length-0 lists.
If you find yourself writing an if statement for a special case, stop what you're doing, and think about the general-case code, either to confirm that it can actually already handle the special case you're worrying about (which is often true!), or try to think of a way to make your general case handle that special case as well.
Writing broader, general-case code pays off. It results in shorter functions, which is easier to understand and has fewer places for bugs to hide, and it is also likely to be safer from bugs because it makes fewer assumptions about the values it is working with.
2.4.2) Special Cases for the Sake of Efficiency
Programmers often justify handling special cases separately with a belief that it increases the overall performance of the method, by returning a hardcoded answer for a special case right away. For example, when writing a sort algorithm, it can be tempting to check whether the size of the list is 0 or 1 at the very start of the method, since you can then return immediately with no need to sort at all. Or if the size is 2, just do a comparison and a possible swap.
These optimizations might indeed make sense, but it is a good idea only to implement such things when you have evidence that they would actually make an appreciable difference in the speed of the program.
Continuing the example from above, if our sorting method is almost never called with these special cases, then adding code for them just adds complexity, overhead, and hiding-places for bugs, without any practical improvement in performance. As a general rule, you should try to write a clean, simple, general-case algorithm first, and optimize it later, only if it would actually help.
To quote Donald Knuth:
"We should forget about small efficiencies, say about 97% of the time: premature optimization is the root of all evil. Yet we should not pass up our opportunities in that critical 3%"
2.5) Plan for Change
Oftentimes, when writing programs, you may find that the requirements for the program you're writing may change (or you may find that the problem you actually wanted to solve isn't the one solved by the program you're writing). As such, whenever possible, it is important to attempt to make programs that are as easy to change as possible, should the need arise.
The following are some general ideas that you can keep in mind as you work, in order to make sure that your code is amenable to change.
2.5.1) Avoid Monolithic Functions
It can be tempting, when writing a complicated program, to implement the entire program in a single function. However, it is a good idea to try to separate logically-independent suboperations from each other (usually, into smaller helper functions), for a few reasons:
-
Doing so usually makes the larger program easier to reason about, since, if these helper functions have good names, we are able to describe the large program in terms of higher-level operations.
-
Doing so also means that, if we find another use for one of the suboperations, we can make use of that helper function elsewhere in our program without needing to duplicate that code.
-
Smaller programs are generally easier to debug than larger programs, so testing and debugging several small functions is generally more straightforward than testing and debugging a single more complicated function.
2.5.2) Store Local Information Locally
In general, when information is only needed in one small portion of a program, it is a good idea to make sure that that information is only available in that one portion of the code. Variables that are local to a function are one example of making good use of this principle.
This also suggests that it is a good idea to avoid using global variables altogether. This page has some good examples of the dangers of global variables.
Global variables are often used so that functions can have access to the same piece of data across multiple calls; but there are usually better ways of implementing this that manage to avoid some or all of the dangers associated with global variables. For example, if you want a single function to have access to the same piece of data across multiple calls to that function, you could consider using a closure. If you want multiple functions to have access to the same piece of data, you could consider creating a class.
2.5.3) Return Results, Don't Print Them
Consider the following function:
def count_long_words(word_list):
num_long_words = 0
for word in word_list:
if len(word) > LONG_WORD_LENGTH:
num_long_words += 1
print(num_long_words)
This function is not particularly amenable to change (and, consequently,
programs making use of it are not particularly amenable to change), mostly
because it sends its output to the terminal via print. That means that, if
you want to use it in another context (where the number is needed for some
other purpose, like computation rather than human eyes), it would have to be
rearranged.
In general, only the highest-level parts of a program should interact with the human user or the console. Lower-level parts should take their input as parameters and return their output as results. The sole exception here is debugging output, which can of course be printed to the console. But, that kind of output shouldn't be a part of your design, only a part of how you debug your design.
6.s090