Using Types For Better Python
I’ve written Python for some time now and it’s been a go-to for quick scripts on many occasions. It’s not particularly fast, and I’m not a fan of Python for large projects, but it is definitely easy to use. There’s a massive ecosystem for libraries, an easy to use std lib, a REPL, and other attributes that make it simple to jump into quickly.
The reason I prefer to not write large projects in Python is because it is prohibitively difficult for me to reason about Python code. Shared mutable state and, in particular, dynamic types, are a burden to me - I can not quite know what a function really does, what a variable really is, or how the program will behave.
While I can test the code, and I can be reasonably sure that it works in some way, the true cost here is in development. As a code base grows I have to increasingly rely on the code being documented well to be able to jump in and start using it or making changes. Types, for me, are like a free documentation that alleviates the need for boiler plate testing/ comments.
And so, naturally, I’ve been eager to try out MyPy - a static type checker for Python that features a familiar type system and type inference. If you’re coming from a language with types it should be fairly quick to get started.
Essentially, through some type annotations in your Python code, you can use MyPy for static analysis.
def typed(x: int) -> Optional[str]: if x == 0: return "Zero" else if x == 1: return "One" else: return None
The above function takes an integer and returns the union of a ‘str’ and None.
To get started I needed to install the magicpython tool for Atom, since the default language syntax highlighting breaks with Python’s 3.5+ type syntax.
I then installed the mypy-lang package through pip:
pip install --user mypy-lang
And looked online for any tidbits on how to use it.
It turns out that mypy has solid documentation, it was very easy to find a few extra, interesting settings.
The Optional Type
In languages like Python there exists a ‘null’ type. In Go this is ‘nil’, Python has None, Java has ‘null’, the name changes but the meaning is generally the same.
A variable is implicitly always its type, or null. So while Java may tell you that you’re dealing with a String, you may not be - and the same goes for Python.
MyPy has a way of dealing with this - the Optional type. This is not much different from how many languages deal with nullable types. Instead of having the None type implicitly in every other type, we make it explicit.
MyPy will then try to ensure that you properly check this Optional[T] type before you try to treat it like its concrete variants - T or None.
from typing import Optional def f(x: Optional[int]) -> int: if x is None: return 0 else: # The inferred type of x is just int here. return x + 1
If you do not perform the check against None before accessing the Optional value you will encounter an error before the program runs.
To enable this checking just add –strict-optional to your mypy command.
Note that this is an experimental flag and there exist loopholes.
Unfortunately, much of the code you may encounter will not include types. This is something I’ll be talking about a bit later in this post but essentially you have two options:
- Write typed wrappers for the untyped code
Essentially if you know that a function takes T and returns U, write a wrapper function that provides the annotations.
- Use –silent-imports
The silent imports flag will disable automatic type checking of imported modules.
Disallow Untyped Calls and Defs
From the MyPy blog:
--disallow-untyped-defs generates errors for functions without type annotations. Consider using this if you tend to forget to annotate some functions. --disallow-untyped-calls causes mypy to complain about calls to untyped functions. This is a boon for static typing purists, together with --disallow-untyped-defs :-)
Both of these flags are very useful for larger codebases where multiple developers may be working on the code. It’s a strict enforcement of writing typed code.
In the end I wrote an alias:
alias smypy='python3.5 -m mypy --strict-optional --disallow-untyped-defs --disallow-untyped-calls\ --check-untyped-defs'
To analyze a program with these options I just need to run
Here’s a simple program that demonstrates how to use type annotations:
import random from typing import Iterable, Optional def get_rand_list(len: int) -> Iterable[int]: return [int(10*random.random()) for i in range(len)] def index_of(li: Iterable[int], val: int) -> Optional[int]: for index, item in enumerate(li): if item == val: return index if __name__ == '__main__': r_list = get_rand_list(100) # Type is inferred to be iterable[int] opt_ix = index_of(r_list, 19) print(hex(opt_ix))
A list of random numbers between 1 and 10 is generated.
We get the index of a value in that list.
We convert that value into hex and print it out.
But it will crash on hex(opt_ix) because 19 is never going to be in the list, and applying hex to None raises an exception. With MyPy this code does not pass the type checker:
example.py:23: error: Argument 1 to "hex" has incompatible type "Optional[int]"; expected "int"
Instead we have to change the code to look like this:
if opt_ix: print(hex(opt_ix))
MyPy doesn’t complain and the program will no longer crash.
It’s a trivial example but in a large code base it can be easy, especially when working with third party libraries, to not realize that a return value may be of multiple types.
MyPy, to me, eases this burden significantly.
That said, it’s not perfect. Type annotations provide some nice linting and documentation but they can’t actually impact the code that runs. Many languages with static type systems are able to do far more with their types, leading to faster, safer code. With Python we can only scratch the surface - but even with these annotations not being as powerful as other type systems I think they’re well worth implementing in your code.
Note that Python type annotations are cross-version. There is a compatible syntax for Python 2.x+, using comment blocks.
blog comments powered by Disqus