The source code of a program should be readable to human. Making it run correctly is only half of its purpose. Without a properly commenting code, it would be difficult for one, including the future you, to understand the rationale and intent behind the code. It would also make the code impossible to maintain. In Python, there are multiple ways to add descriptions to the code to make it more readable or make the intent more explicit. In the following, we will see how we should properly use comments, docstrings, and type hints to make our code easier to understand. After finishing this tutorial, you will know
- What is the proper way of using comments in Python
- How string literal or docstring can replace comments in some cases
- What is type hints in Python and how it can help us understand the code better
Let’s get started.
Comments, docstrings, and type hints in Python code. Photo by Rhythm Goyal. Some rights reserved
Overview
This tutorial is in 3 parts, they are
- Adding comments to Python code
- Using docstrings
- Using type hints in Python code
Almost all programming languages have dedicated syntax for comments. Comments are to be ignored by compilers or interpreters and hence they have no effect to the programming flow or logic. But with comments, we are easier to read the code.
In languages like C++, we can add “inline comments” with a leading double slash (//
) or add comment blocks enclosed by /*
and */
. However, in Python we only have the “inline” version and they are introduced by the leading hash character (#
).
It is quite easy to write comments to explain every line of code but usually that is a waste. When people read the source code, quite often comments are easier to catch attention and hence putting too much comments would distract the reading. For example, the following is unnecessary and distracting:
timestamp = datetime.datetime.now() # Get the current date and time
x = 0 # initialize x to zero
import datetime
timestamp = datetime.datetime.now() # Get the current date and time x = 0 # initialize x to zero |
Comments like these is merely repeating what the code does. Unless the code is obscured, these comments added no value to the code. The example below might be a marginal case, in which the name “ppf” (percentage point function) is less well-known than the term “CDF” (cumulative distribution function):
z_alpha = scipy.stats.norm.ppf(0.975) # Call the inverse CDF of standard normal
import scipy.stats
z_alpha = scipy.stats.norm.ppf(0.975) # Call the inverse CDF of standard normal |
Good comments should be telling why we are doing something. Let’s look at the following example:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | def adadelta(objective, derivative, bounds, n_iter, rho, ep=1e–3): # generate an initial point solution = bounds[:, 0] + rand(len(bounds)) * (bounds[:, 1] – bounds[:, 0]) # lists to hold the average square gradients for each variable and # average parameter updates sq_grad_avg = [0.0 for _ in range(bounds.shape[0])] sq_para_avg = [0.0 for _ in range(bounds.shape[0])] # run the gradient descent for it in range(n_iter): gradient = derivative(solution[0], solution[1]) # update the moving average of the squared partial derivatives for i in range(gradient.shape[0]): sg = gradient[i]**2.0 sq_grad_avg[i] = (sq_grad_avg[i] * rho) + (sg * (1.0–rho)) # build a solution one variable at a time new_solution = list() for i in range(solution.shape[0]): # calculate the step size for this variable alpha = (ep + sqrt(sq_para_avg[i])) / (ep + sqrt(sq_grad_avg[i])) # calculate the change and update the moving average of the squared change change = alpha * gradient[i] sq_para_avg[i] = (sq_para_avg[i] * rho) + (change**2.0 * (1.0–rho)) # calculate the new position in this variable and store as new solution value = solution[i] – change new_solution.append(value) # evaluate candidate point solution = asarray(new_solution) solution_eval = objective(solution[0], solution[1]) # report progress print(‘>%d f(%s) = %.5f’ % (it, solution, solution_eval)) return [solution, solution_eval] |
The function above is implementing AdaDelta algorithm. At the first line, when we assign something to the variable solution
, we do not write comments like “a random interpolation between bounds[:,0] and bounds[:,1]” because that is just repeating the code literally. We say the intent of this line is to “generate an initial point”. Similarly for the other comments in the function, we mark one of the for loop as the gradient descent algorithm rather than just saying iterate for certain times.
One important issue we want to remember when writing the comment or modifying code is to make sure the comment accurately describe the code. If they are contradicting, it would be confusing to the readers. If we should not put the comment on the first line of the above example to “set initial solution to the lowerbound” while the code obviously is randomizing the initial solution, or vice versa. If this is what you intented to do, you should update the comment and the code at the same time.
An exception would be the “to-do” comments. From time to time, when we have an idea on how to improve the code but not yet changed it, we may put a to-do comments on the code. We can also use it to mark incomplete implementations. For example,
model = Sequential()
model.add(Conv2D(1, (3,3), strides=(2, 2), input_shape=(8, 8, 1)))
model.summary()
…
# TODO replace Keras code below with Tensorflow from keras.models import Sequential from keras.layers import Conv2D
model = Sequential() model.add(Conv2D(1, (3,3), strides=(2, 2), input_shape=(8, 8, 1))) model.summary() ... |
This is a common practice and many IDE will highlight the comment block differently when the keyword TODO
is found. However, it suppposed to be temporary and we should not abuse it as an issue tracking system.
In summary, some common “best practice” on commenting code as listed as follows:
- Comments should not restate the code, but to explain it
- Comments should not cause confusion, but to eliminate it
- Put comments on code that is not trivial to understand, for example, state the unidiomatic use of syntax, name the algorithm being used, or explain the intent or assumptions
- Comments should be concise and simple
- Keep a consistent style and use of language in commenting
- Always prefer to have a better written code that needs no additional comment
Using docstrings
In C++, we may write a large block of comments such as in the following:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | TcpSocketBase::~TcpSocketBase (void) { NS_LOG_FUNCTION (this); m_node = nullptr; if (m_endPoint != nullptr) { NS_ASSERT (m_tcp != nullptr); /* * Upon Bind, an Ipv4Endpoint is allocated and set to m_endPoint, and * DestroyCallback is set to TcpSocketBase::Destroy. If we called * m_tcp->DeAllocate, it will destroy its Ipv4EndpointDemux::DeAllocate, * which in turn destroys my m_endPoint, and in turn invokes * TcpSocketBase::Destroy to nullify m_node, m_endPoint, and m_tcp. */ NS_ASSERT (m_endPoint != nullptr); m_tcp->DeAllocate (m_endPoint); NS_ASSERT (m_endPoint == nullptr); } if (m_endPoint6 != nullptr) { NS_ASSERT (m_tcp != nullptr); NS_ASSERT (m_endPoint6 != nullptr); m_tcp->DeAllocate (m_endPoint6); NS_ASSERT (m_endPoint6 == nullptr); } m_tcp = 0; CancelAllTimers (); } |
But in Python, we do not have the equivalent to the delimiters /*
and */
, but we can write multi-line comments like the following instead:
“””Create the “process pool” of 4 and run asyncio.
The processes will execute the worker function
concurrently with each file path as parameter
“””
loop = asyncio.get_running_loop()
with concurrent.futures.ProcessPoolExecutor(max_workers=4) as executor:
futures = [loop.run_in_executor(executor, func, f) for f in filepaths]
for fut in asyncio.as_completed(futures):
try:
filepath = await fut
print(filepath)
except Exception as exc:
print(“failed one job”)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | async def main(indir): # Scan dirs for files and populate a list filepaths = [] for path, dirs, files in os.walk(indir): for basename in files: filepath = os.path.join(path, basename) filepaths.append(filepath)
“”“Create the “process pool” of 4 and run asyncio. The processes will execute the worker function concurrently with each file path as parameter ““” loop = asyncio.get_running_loop() with concurrent.futures.ProcessPoolExecutor(max_workers=4) as executor: futures = [loop.run_in_executor(executor, func, f) for f in filepaths] for fut in asyncio.as_completed(futures): try: filepath = await fut print(filepath) except Exception as exc: print(“failed one job”) |
This works because Python supports to declare a string literal spanning across multiple lines if it is delimited with triple quotation marks ("""
). And a string literal in the code is merely a string declared with no impact. Therefore it is functionally no different to the comments.
One reason we want to use string literals is to comment out a large block of code. For example,
clf = LogisticRegression(random_state=0).fit(X, y)
…
from sklearn.linear_model import LogisticRegression from sklearn.datasets import make_classification “”“ X, y = make_classification(n_samples=5000, n_features=2, n_informative=2, n_redundant=0, n_repeated=0, n_classes=2, n_clusters_per_class=1, weights=[0.01, 0.05, 0.94], class_sep=0.8, random_state=0) ““” import pickle with open(“dataset.pickle”, “wb”) as fp: X, y = pickle.load(fp)
clf = LogisticRegression(random_state=0).fit(X, y) ... |
The above is a sample code that we may develop with experimenting on a machine learning problem. While we generated a dataset randomly at the beginning (the call to make_classification()
above), we may want to switch to a different dataset and repeat the same process at a later time (e.g., the pickle part above). Rather than removing the block of code, we may simply comment those lines so we can store the code later. It is not in a good shape for the finalized code but convenient while we are developing our solution.
The string literal in Python as comment has a special purpose if it is at the first line under a function. The string literal in that case is called the “docstring” of the function. For example,
Args:
x (int or float): A numerical value
Returns:
int or float: The square of x
“””
return x * x
def square(x): “”“Just to compute the square of a value
Args: x (int or float): A numerical value
Returns: int or float: The square of x ““” return x * x |