Если вам трудно читать на английском языке можно сразу перейти на русский перевод
сразу приносим свои извинения за машинный перевод.
Introduction
With the release of version 3.5, Python has introduced type hints: code annotations that, through additional tooling, can check if you’re using your code correctly.
Long-time Python users might cringe at the thought of new code needing type hinting to work properly, but we need not worry: Guido himself wrote in PEP 484, “no type checking happens at runtime.”
The feature has been proposed mainly to open up Python code for easier static analysis and refactoring.
For data science–and for the data scientist– type hinting is invaluable for a couple of reasons:
- It makes it much easier to understand the code, just by looking at the signature, i.e. the first line(s) of the function definition;
- It creates a documentation layer that can be checked with a type checker, i.e. if you change the implementation, but forget to change the types, the type checker will (hopefully) yell at you.
Of course, as is always the case with documentation and testing, it’s an investment: it costs you more time at the beginning, but saves you (and your co-worker) a lot in the long run.
Note: Type hinting has also been ported to Python 2.7 (a.k.a Legacy Python). The functionality, however, requires comments to work. Furthermore, no one should be using Legacy Python in 2019: it’s less beautiful and only has a couple more months of updates before it stops receiving support of any kind.
Getting started with types
The code for this article may be found at Kite’s Github repository.
The hello world of type hinting is
# hello_world.py
def hello_world(name: str = 'Joe') -> str:
return f'Hello { name } '
We have added two type hint elements here. The first one is : str after name and the second one is -> str towards the end of the signature.
The syntax works as you would expect: we’re marking name to be of type str and we’re specifying that the hello_world function should output a str. If we use our function, it does what it says:
> hello_world(name='Mark')
'Hello Mark'
Since Python remains a dynamically unchecked language, we can still shoot ourselves in the foot:
> hello_world(name=2)
'Hello 2'
What’s happening? Well, as I wrote in the introduction, no type checking happens at runtime.
So as long as the code doesn’t raise an exception, things will continue to work fine.
What should you do with these type definitions then? Well, you need a type checker, or an IDE that reads and checks the types in your code (PyCharm, for example).
Type checking your program
There are at least four major type checker implementations: Mypy, Pyright, pyre, and pytype:
- Mypy is actively developed by, among others, Guido van Rossum, Python’s creator.
- Pyright has been developed by Microsoft and integrates very well with their excellent Visual Studio Code;
- Pyre has been developed by Facebook with the goal to be fast (even though mypy recently got much faster);
- Pytype has been developed by Google and, besides checking the types as the others do, it can run type checks (and add annotations) on unannotated code.
Since we want to focus on how to use typing from a Python perspective, we’ll use Mypy in this tutorial. We can install it using pip (or your package manager of choice):
$ pip install mypy
$ mypy hello_world.py
Right now our life is easy: there isn’t much that can go wrong in our hello_world function. We’ll see later how this might not be the case anymore.
More advanced types
In principle, all Python classes are valid types, meaning you can use str, int, float, etc. Using dictionary, tuples, and similar is also possible, but you need to import them from the typing module.
# tree.py
from typing import Tuple, Iterable, Dict, List, DefaultDict
from collections import defaultdict
def create_tree(tuples: Iterable[Tuple[int, int]]) -> DefaultDict[int, List[int]]:
"""
Return a tree given tuples of (child, father)
The tree structure is as follows:
tree = { node_1: [node_2, node_3],
node_2: [node_4, node_5, node_6],
node_6: [node_7, node_8] }
"""
tree = defaultdict(list)
for child, father in tuples:
if father:
tree[father].append(child)
return tree
print(create_tree([(2.0,1.0), (3.0,1.0), (4.0,3.0), (1.0,6.0)]))
# will print
# defaultdict( 'list'="">, { 1.0: [2.0, 3.0], 3.0: [4.0], 6.0: [1.0] }
While the code is simple, it introduces a couple of extra elements:
- First of all, the
Iterabletype for thetuplesvariable. This type indicates that the object should conform to thecollections.abc.Iterablespecification (i.e. implement__iter__). This is needed because we iterate overtuplesin theforloop; - We specify the types inside our container objects: the
IterablecontainsTuple, theTuplesare composed of pairs ofint, and so on.
Ok, let’s try to type check it!
$ mypy tree.py
tree.py:14: error: Need type annotation for 'tree'
Uh-oh, what’s happening? Basically Mypy is complaining about this line:
tree = defaultdict(list)
While we know that the return type should be DefaultDict[int, List[int]], Mypy cannot infer that tree is indeed of that type. We need to help it out by specifying tree’s type. Doing so can be done similarly to how we do it in the signature:
tree: DefaultDict[int, List[int]] = defaultdict(list)
If we now re-run Mypy again, all is well:
$ mypy tree.py
$
Type aliases
Sometimes our code reuses the same composite types over and over again. In the above example, Tuple[int, int] might be such a case. To make our intent clearer (and shorten our code), we can use type aliases. Type aliases are very easy to use: we just assign a type to a variable, and use that variable as the new type:
Relation = Tuple[int, int]
def create_tree(tuples: Iterable[Relation]) -> DefaultDict[int, List[int]]:
"""
Return a tree given tuples of (child, father)
The tree structure is as follow:
tree = { node_1: [node_2, node_3],
node_2: [node_4, node_5, node_6],
node_6: [node_7, node_8] }
"""
# convert to dict
tree: DefaultDict[int, List[int]] = defaultdict(list)
for child, father in tuples:
if father:
tree[father].append(child)
return tree
Generics
Experienced programmers of statically typed languages might have noticed that defining a Relation as a tuple of integers is a bit restricting. Can’t create_tree work with a float, or a string, or the ad-hoc class that we just created?
In principle, there’s nothing that prevents us from using it like that:
# tree.py
from typing import Tuple, Iterable, Dict, List, DefaultDict
from collections import defaultdict
Relation = Tuple[int, int]
def create_tree(tuples: Iterable[Relation]) -> DefaultDict[int, List[int]]:
...
print(create_tree([(2.0,1.0), (3.0,1.0), (4.0,3.0), (1.0,6.0)]))
# will print
# defaultdict( 'list'="">, { 1.0: [2.0, 3.0], 3.0: [4.0], 6.0: [1.0] } )
However if we ask Mypy’s opinion of the code, we’ll get an error:
$ mypy tree.py
tree.py:24: error: List item 0 has incompatible type 'Tuple[float, float]'; expected 'Tuple[int, int]'
...
There is a way in Python to fix this. It’s called TypeVar, and it works by creating a generic type that doesn’t require assumptions: it just fixes it throughout our module. Usage is pretty simple:
from typing import TypeVar
T = TypeVar('T')
Relation = Tuple[T, T]
def create_tree(tuples: Iterable[Relation]) -> DefaultDict[T, List[T]]:
...
tree: DefaultDict[T, List[T]] = defaultdict(list)
...
print(create_tree([(2.0,1.0), (3.0,1.0), (4.0,3.0), (1.0,6.0)]))
Now, Mypy will no longer complain, and programmers will be happy as the type hints for create_tree correctly reflect that create_tree works for more than just integers.
Note that it’s important that the ‘T’ inside TypeVar is equal to the variable name T.
Generic classes: Should I have used a TypeVar?
What I said about create_tree at the beginning of this section is not 100% accurate. Since T will be used as a key to a dictionary, it needs to be hashable.
This is important as the key lookup in Python works by computing the hash of the key. If the key is not hashable, the lookup will break.
Such properties are encountered enough that Python offers a few types which can indicate that an object should have certain properties (e.g. it should be hashable if we want it to be a key to a dictionary).
Some examples:
typing.Iterablewill indicate that we expect the object to be an iterable;typing.Iteratorwill indicate that we expect the object to be an iterator;typing.Reversiblewill indicate that we expect the object to be reversible;typing.Hashablewill indicate that we expect the object to implement__hash__;typing.Sizedwill indicate that we expect the object to implement__len__;typing.Sequencewill indicate that we expect the object to beSized,Iterable,Reversible, and implementcount,index.
These are important, because sometimes we expect to use those methods on our object, but don’t care which particular class they belong to as long as they have the methods needed. For example, if we’d like to create our own version of chain to chain sequences together, we could do the following:
from typing import Iterable, TypeVar
T = TypeVar('T')
def return_values() -> Iterable[float]:
yield 4.0
yield 5.0
yield 6.0
def chain(*args: Iterable[T]) -> Iterable[T]:
for arg in args:
yield from arg
print(list(chain([1, 2, 3], return_values(), 'string')))
[1, 2, 3, 4.0, 5.0, 6.0, 's', 't', 'r', 'i', 'n', 'g']
The return_values function is a bit contrived but it illustrates the point: the function chain doesn’t care about who we are as long as we’re iterable!
Any, Union, and Optional
Python provides another couple of features that are handy when writing code with type hints:
- Any does what you think it does, marking the object to not have any specific type
- Union can be used as
Union[A, B]to indicate that the object can have type A or B - Optional is used as
Optional[A]to indicate that the object is either of type A or None. Contrary to real functional languages, we can’t expect safety when sending Optionals around, so beware. It effectively works as aUnion[A, None]. Functional programming lovers will recognize their beloved Option (if you come from Scala) or Maybe (if you come from Haskell).
Callables
Python supports passing functions as arguments to other functions, but how should we annotate them?
The solution is to use Callable, Mypy не может сделать вывод, что дерево действительно относится к этому типу. Нам нужно помочь ему, указав тип дерева. Это можно сделать аналогично тому, как мы делаем это в подписи:
tree: DefaultDict[int, List[int]] = defaultdict(list)
Если мы сейчас снова запустим Mypy, все будет хорошо:
$ mypy tree.py
$
Псевдонимы типов
Иногда наш код повторно использует одни и те же составные типы снова и снова. В приведенном выше примере, Tuple[int, int]возможно, будет такой случай. Чтобы сделать наше намерение более ясным (и сократить наш код), мы можем использовать псевдонимы типов. Псевдонимы типов очень просты в использовании: мы просто назначаем тип переменной и используем эту переменную в качестве нового типа:
Relation = Tuple[int, int]
def create_tree(tuples: Iterable[Relation]) -> DefaultDict[int, List[int]]:
"""
Return a tree given tuples of (child, father)
The tree structure is as follow:
tree = { node_1: [node_2, node_3],
node_2: [node_4, node_5, node_6],
node_6: [node_7, node_8] }
"""
# convert to dict
tree: DefaultDict[int, List[int]] = defaultdict(list)
for child, father in tuples:
if father:
tree[father].append(child)
return tree
Дженерик
Опытные программисты статически типизированных языков могли бы заметить, что определение a Relation как кортеж целых чисел немного ограничивает. Не может create_tree работать с float, строкой или специальным классом, который мы только что создали?
В принципе, нет ничего, что мешает нам использовать его таким образом:
# tree.py
from typing import Tuple, Iterable, Dict, List, DefaultDict
from collections import defaultdict
Relation = Tuple[int, int]
def create_tree(tuples: Iterable[Relation]) -> DefaultDict[int, List[int]]:
...
print(create_tree([(2.0,1.0), (3.0,1.0), (4.0,3.0), (1.0,6.0)]))
# will print
# defaultdict( 'list'="">, { 1.0: [2.0, 3.0], 3.0: [4.0], 6.0: [1.0] } )
Однако если мы спросим мнение Mypy о коде, мы получим ошибку:
$ mypy tree.py
tree.py:24: error: List item 0 has incompatible type 'Tuple[float, float]'; expected 'Tuple[int, int]'
...
Есть способ в Python, чтобы исправить это. Это называетсяTypeVar, и он работает, создавая универсальный тип, который не требует предположений: он просто фиксирует его по всему нашему модулю. Использование довольно простое:
from typing import TypeVar
T = TypeVar('T')
Relation = Tuple[T, T]
def create_tree(tuples: Iterable[Relation]) -> DefaultDict[T, List[T]]:
...
tree: DefaultDict[T, List[T]] = defaultdict(list)
...
print(create_tree([(2.0,1.0), (3.0,1.0), (4.0,3.0), (1.0,6.0)]))
Теперь Mypy больше не будет жаловаться, и программисты будут счастливы, поскольку подсказки типа create_treeправильно отражают, что create_treeработает больше, чем просто целые числа.
Обратите внимание, что важно, чтобы ‘T’внутренняя TypeVarчасть была равна имени переменной T.
Общие классы: должен ли я использовать a TypeVar?
То, что я сказал create_treeв начале этого раздела, не является 100% точным. Поскольку Tон будет использоваться в качестве ключа к словарю, он должен быть хэшируемым .
Это важно, поскольку поиск ключа в Python работает путем вычисления хэша ключа. Если ключ не хэшируется, поиск будет прерван.
Такие свойства встречаются достаточно часто, что Python предлагает несколько типов, которые могут указывать, что объект должен иметь определенные свойства (например, он должен быть хэшируемым, если мы хотим, чтобы он был ключом к словарю).
Примеры:
typing.Iterableбудет указано, что мы ожидаем, что объект будет итерационным;typing.Iteratorбудет указано, что мы ожидаем, что объект будет итератором;typing.Reversibleбудет указывать, что мы ожидаем, что объект будет обратимым;typing.Hashableбудет указано, что мы ожидаем объект для реализации__hash__;typing.Sizedбудет указано, что мы ожидаем объект для реализации__len__;typing.Sequenceбудет указывать, что мы ожидаем, что объект будетSized,IterableReversible, и реализоватьcount,index.
Они важны, потому что иногда мы ожидаем использовать эти методы на нашем объекте, но не важно, к какому конкретному классу они принадлежат, пока у них есть необходимые методы. Например, если бы мы хотели создать нашу собственную версию chainto chain sequences вместе, мы могли бы сделать следующее:
from typing import Iterable, TypeVar
T = TypeVar('T')
def return_values() -> Iterable[float]:
yield 4.0
yield 5.0
yield 6.0
def chain(*args: Iterable[T]) -> Iterable[T]:
for arg in args:
yield from arg
print(list(chain([1, 2, 3], return_values(), 'string')))
[1, 2, 3, 4.0, 5.0, 6.0, 's', 't', 'r', 'i', 'n', 'g']
Эта return_valuesфункция немного надуманна, но она иллюстрирует суть: функция chainне заботится о том, кто мы, пока мы повторяемы!
Любой, соединение, и опционное
Python предоставляет еще несколько функций, которые удобны при написании кода с подсказками типа:
- Любой делает то, что вы думаете, что он делает, помечая объект, чтобы не иметь никакого определенного типа
- Объединение может использоваться как
Union[A, B]указание на то, что объект может иметь тип A или B - Необязательно используется
Optional[A]для указания того, что объект относится к типу A или нет . В отличие от реальных функциональных языков, мы не можем ожидать безопасности при отправке Optionals вокруг, так что будьте осторожны. Оно эффектно работает какUnion[A, None]А. Любители функционального программирования узнают свой любимый вариант (если вы пришли из Scala) или, возможно, (если вы пришли из Haskell).
Вызываемые объекты
Python поддерживает передачу функций в качестве аргументов другим функциям, но как мы должны их аннотировать?
Решение заключается в использовании Callable CoderNet