10 minutes read

You already know about docstrings and the role of function inputs and outputs in code clarity. Is there an easier and more concise way to add a document to your code? The answer is positive. This is where function annotations come in handy.

def square_area(side: 'length of square side') -> 'result of side ** 2':
    return side ** 2

print(square_area.__annotations__)  # call annotations attribute

# {'side': 'length of square side', 'return': 'result of side ** 2'}

Introduced in PEP-3107, the function annotations are a Python 3 feature that lets you add arbitrary metadata to function arguments and a return value. According to the PEP, the function annotations are arbitrary Python expressions that can be associated with various function parts. Python does not attach any meaning to these annotations by itself.

Importance of annotations

Function annotations require minimal effort, but they can have a huge impact on your code:

  • They improve the way you write your code — it becomes more concise;

  • They encourage you to think outside the box;

  • By normalizing the documentation of inputs and outputs, they help you and other people understand the code easier;

  • They help you identify type-related issues.

Enough said, let's see what the annotations are!

Syntax of annotations

First of all, we'll discuss the main syntactic structures of annotations. Mind the structures below:

We will often use the word "expression" in the examples. In function annotations, an expression is any valid Python expression from a string (Bob drives a nice car.) to object types (str, int, dict, etc.) and mathematic expressions (5 + 2).

  1. Annotations for simple parameters. An argument is followed by : and an expression. The annotation syntax runs like this:

    def func(argument: "expression", default_argument: "expression"=5):
        ...

    The default arguments are specified after the annotation expression.

  2. Annotations for excess parameters. The excess parameters like *args and **kwargs allow passing an arbitrary number of arguments in the function call. The syntax is very similar to the one with simple parameters:

    def func(*args: "expression", **kwargs: "expression"):
        ...
  3. Annotations for the return type. Annotation of the return type is quite different from argument annotation. The return value is annotated with -> followed by an annotation expression. Mind the example below:

    def func(argument: "expression") -> "expression":
       ...

With Python 3, the feature: Nested parameters, where a tuple is passed in a function call and automatically unpacked, is removed.

It's important to understand that annotations are completely optional, and Python doesn't provide any semantic significance for annotations. It only provides nice syntactic support for associating metadata and an easy way to access it. We've discussed the first part, the syntax, so let's talk about accessing annotations.

Accessing annotations

All annotations are stored in a dictionary named __annotations__ that is an attribute of the function:

def func(x:'annotating x', y: 'annotating y', z: int) -> float:
    return x + y + z

print(func.__annotations__)
# {'x': 'annotating x', 'y': 'annotating y', 'z': <class 'int'>, 'return': <class 'float'>}

As you can understand, annotations are not typed declarations. They are just arbitrary expressions that allow arbitrary values to be stored in the __annotations__ dictionary.

Case studies

As we recall from PEP-3107, annotations have no standard meaning or semantics. However, there're certain cases that we will discuss in more depth.

One of the biggest advantages of function annotations is that you can move an argument and a return value from a docstring. Let's take a look at the two functions below. The first one employs a docstring, while the second one is annotated according to PEP-3107. As you can see, the second option is more concise and easy to read:

def multiplication(a, b):
    """Multiply a by b 
    args:
        a - the multiplicand
        b - the multiplier
    return:
        the result of multiplying a by b
    """
    return a * b
    
def multiplication(a: 'the multiplicand', b: 'the multiplier') -> 'the result of multiplying a by b':
    """Multiply a by b"""
    return a * b

There are several other benefits of annotations over docstrings. First of all, when an argument is renamed, the docstring may remain out of date, so don't forget to update them. It is also much easier to see whether an argument is documented or not. Finally, the __annotations__ attribute provides a direct, standard mechanism to access metadata.

Another benefit of annotations over docstring is that you can specify different types of metadata, such as tuples or dictionaries, without any special parsing methods or external modules. Let's say you want to annotate arguments and a return value with both type and a description string. You can do that by annotating with a dict that has two keys: type and description:

def multiplication(a: dict(description='the multiplicand', type=int), 
                   b: dict(description='the multiplier', type=int)) 
                   -> dict(description='the result of multiplying a by b', type=int):
    """Multiply a by b"""
    return a * b


print(multiplication.__annotations__)

#{'a': {'description': 'the multiplicand', 'type': <class 'int'>},
# 'b': {'description': 'the multiplier', 'type': <class 'int'>},
# 'return': {'description': 'the result of multiplying a by b',
#            'type': <class 'int'>}}

Another case where function annotations can be helpful is optional typing. Even though Python is dynamically typed, and any object can be passed as a function argument, there are many cases when functions require arguments of a specific type. With annotations, you can specify the type right next to the argument in a very natural way:

def addition(a: int, b: float) -> float:
	return a + b

Remember that solely specifying the type is not going to enforce it. For example, there will be no errors if the function is called with incorrect argument types. But still, even specifying the type in annotations can make the intent more readable than specifying the type in the docstring. It can help users understand how to call the function.

Conclusion

In this topic, we have found out what function annotations are, how to write them, and how to access them. Let's briefly go through the main points we have discussed:

  • Python doesn't provide any semantics with annotations.

  • They are stored in a dictionary and can be accessed by calling the __annotations__ method.

  • Annotations are completely optional, but they have various use cases.

110 learners liked this piece of theory. 5 didn't like it. What about you?
Report a typo