1. Code
  2. Python

Python 3-Typhinweise und statische Analyse

In Python 3.5 wurde das neue Typisierungsmodul eingeführt, das Standardbibliotheksunterstützung für die Nutzung von Funktionsanmerkungen für optionale Typhinweise bietet. Dies öffnet die Tür zu neuen und interessanten Tools für die statische Typprüfung wie mypy und in Zukunft möglicherweise für die automatisierte typbasierte Optimierung. Typhinweise sind in PEP-483 und PEP-484 angegeben.
Scroll to top

German (Deutsch) translation by Nikol Angelowa (you can also view the original English article)

In Python 3.5 wurde das neue Typisierungsmodul eingeführt, das Standardbibliotheksunterstützung für die Nutzung von Funktionsanmerkungen für optionale Typhinweise bietet. Dies öffnet die Tür zu neuen und interessanten Tools für die statische Typprüfung wie mypy und in Zukunft möglicherweise für die automatisierte typbasierte Optimierung. Typhinweise sind in PEP-483 und PEP-484 angegeben.

In diesem Tutorial untersuche ich die Möglichkeiten, mit denen Hinweise vorhanden sind, und zeige Ihnen, wie Sie mit mypy Ihre Python-Programme statisch analysieren und die Qualität Ihres Codes erheblich verbessern können.

Geben Sie Hinweise ein

Typhinweise basieren auf Funktionsanmerkungen. Kurz gesagt, mit Funktionsanmerkungen können Sie die Argumente und den Rückgabewert einer Funktion oder Methode mit beliebigen Metadaten versehen. Typhinweise sind ein Sonderfall von Funktionsanmerkungen, die Funktionsargumente und den Rückgabewert mit Standardtypinformationen versehen. Funktionsanmerkungen im Allgemeinen und Typhinweise im Besonderen sind völlig optional. Schauen wir uns ein kurzes Beispiel an:

1
def reverse_slice(text: str, start: int, end: int) -> str:
2
    return text[start:end][::-1]
3
    
4
reverse_slice('abcdef', 3, 5)
5
'ed'

Die Argumente wurden mit ihrem Typ sowie dem Rückgabewert kommentiert. Es ist jedoch wichtig zu wissen, dass Python dies vollständig ignoriert. Die Typinformationen werden über das Annotations-Attribut des Funktionsobjekts verfügbar gemacht.

1
reverse_slice.__annotations
2
{'end': int, 'return': str, 'start': int, 'text': str}

Um zu überprüfen, ob Python die Typhinweise wirklich ignoriert, lassen Sie uns die Typhinweise völlig durcheinander bringen:

1
def reverse_slice(text: float, start: str, end: bool) -> dict:
2
    return text[start:end][::-1]
3
    
4
reverse_slice('abcdef', 3, 5)
5
'ed'

Wie Sie sehen können, verhält sich der Code unabhängig von den Typhinweisen gleich.

Motivation für Typhinweise

Es ist OK. Tipphinweise sind optional. Typhinweise werden von Python völlig ignoriert. Was ist der Sinn von ihnen? Nun, es gibt mehrere gute Gründe:

  • statische Analyse
  • IDE-Unterstützung
  • Standarddokumentation

Ich werde später mit Mypy in die statische Analyse eintauchen. Die IDE-Unterstützung begann bereits mit der Unterstützung von PyCharm 5 für Typhinweise. Die Standarddokumentation eignet sich hervorragend für Entwickler, die die Art der Argumente und den Rückgabewert einfach anhand einer Funktionssignatur ermitteln können, sowie automatisierte Dokumentationsgeneratoren, die die Typinformationen aus den Hinweisen extrahieren können.

Das typing-Modul

Das typing-Modul enthält Typen, die zur Unterstützung von Typhinweisen entwickelt wurden. Warum nicht einfach vorhandene Python-Typen wie int, str, list und dict verwenden? Sie können diese Typen definitiv verwenden, aber aufgrund der dynamischen Typisierung von Python erhalten Sie über die Basistypen hinaus nicht viele Informationen. Wenn Sie beispielsweise angeben möchten, dass ein Argument eine Zuordnung zwischen einer Zeichenfolge und einer Ganzzahl sein kann, gibt es keine Möglichkeit, dies mit Standard-Python-Typen zu tun. Mit dem Schreibmodul ist es so einfach wie:

1
Mapping[str, int]

Schauen wir uns ein vollständigeres Beispiel an: eine Funktion, die zwei Argumente akzeptiert. Eines davon ist eine Liste von Wörterbüchern, in denen jedes Wörterbuch Schlüssel enthält, die Zeichenfolgen sind, und Werte, die Ganzzahlen sind. Das andere Argument ist entweder eine Zeichenfolge oder eine Ganzzahl. Das Typisierungsmodul ermöglicht die genaue Spezifikation derart komplizierter Argumente.

1
from typing import List, Dict, Union
2
3
def foo(a: List[Dict[str, int]],
4
        b: Union[str, int]) -> int:
5
    """Print a list of dictionaries and return the number of dictionaries
6
    """
7
    if isinstance(b, str):
8
        b = int(b)
9
    for i in range(b):
10
        print(a)
11
12
13
x = [dict(a=1, b=2), dict(c=3, d=4)]
14
foo(x, '3')
15
16
[{'b': 2, 'a': 1}, {'d': 4, 'c': 3}]
17
[{'b': 2, 'a': 1}, {'d': 4, 'c': 3}]
18
[{'b': 2, 'a': 1}, {'d': 4, 'c': 3}]

Nützliche Typen

Sehen wir uns einige der interessanteren Typen aus dem Typisierungsmodul an.

Mit dem Typ Callable können Sie die Funktion angeben, die als Argumente übergeben oder als Ergebnis zurückgegeben werden kann, da Python Funktionen als erstklassige Bürger behandelt. Die Syntax für aufrufbare Dateien besteht darin, ein Array von Argumenttypen (wiederum vom Typisierungsmodul) bereitzustellen, gefolgt von einem Rückgabewert. Wenn das verwirrend ist, hier ein Beispiel:

1
def do_something_fancy(data: Set[float], on_error: Callable[[Exception, int], None]):
2
    ...
3
    

Die Rückruffunktion on_error wird als eine Funktion angegeben, die eine Ausnahme und eine Ganzzahl als Argumente verwendet und nichts zurückgibt.

Der Typ Any bedeutet, dass eine statische Typprüfung jede Operation sowie die Zuordnung zu einem anderen Typ zulassen sollte. Jeder Typ ist ein Subtyp von Any.

Der Union-Typ, den Sie zuvor gesehen haben, ist nützlich, wenn ein Argument mehrere Typen haben kann, was in Python sehr häufig vorkommt. Im folgenden Beispiel akzeptiert die Funktion verify_config() ein Konfigurationsargument, das entweder ein Konfigurationsobjekt oder ein Dateiname sein kann. Wenn es sich um einen Dateinamen handelt, ruft es eine andere Funktion auf, um die Datei in ein Konfigurationsobjekt zu analysieren und zurückzugeben.

1
def verify_config(config: Union[str, Config]):
2
    if isinstance(config, str):
3
        config = parse_config_file(config)
4
    ...
5
    
6
def parse_config_file(filename: str) -> Config:
7
    ...
8
    

Der optionale Typ bedeutet, dass das Argument möglicherweise auch None ist. Optional[T] entspricht Union[T, None]

Es gibt viele weitere Typen, die verschiedene Funktionen kennzeichnen, z. B. Iterable, Iterator, Reversible, SupportsInt, SupportsFloat, Sequence, MutableSequence und IO. Die vollständige Liste finden Sie in der Dokumentation zum Schreibmodul.

Die Hauptsache ist, dass Sie den Typ der Argumente auf sehr feinkörnige Weise angeben können, die das Python-Typsystem mit hoher Wiedergabetreue unterstützt und auch generische und abstrakte Basisklassen zulässt.

Referenzen weiterleiten

Manchmal möchten Sie auf eine Klasse in einem Typhinweis innerhalb einer ihrer Methoden verweisen. Nehmen wir beispielsweise an, dass Klasse A eine Zusammenführungsoperation ausführen kann, die eine andere Instanz von A übernimmt, mit sich selbst zusammenführt und das Ergebnis zurückgibt. Hier ist ein naiver Versuch, Typhinweise zu verwenden, um ihn anzugeben:

1
class A:
2
    def merge(other: A) -> A:
3
        ...
4
5
      1 class A:
6
----> 2         def merge(other: A = None) -> A:
7
      3                 ...
8
      4
9
10
NameError: name 'A' is not defined

Was ist passiert? Die Klasse A ist noch nicht definiert, wenn der Typhinweis für die merge()-Methode von Python überprüft wird, sodass die Klasse A an dieser Stelle (direkt) nicht verwendet werden kann. Die Lösung ist recht einfach und wurde bereits von SQLAlchemy verwendet. Sie geben einfach den Typhinweis als Zeichenfolge an. Python wird verstehen, dass es sich um eine Vorwärtsreferenz handelt, und das Richtige tun:

1
class A:
2
    def merge(other: 'A' = None) -> 'A':
3
        ...

Geben Sie Aliase ein

Ein Nachteil der Verwendung von Typhinweisen für lange Typspezifikationen besteht darin, dass der Code unübersichtlich und weniger lesbar wird, selbst wenn er viele Typinformationen enthält. Sie können Alias-Typen wie jedes andere Objekt verwenden. Es ist so einfach wie:

1
Data = Dict[int, Sequence[Dict[str, Optional[List[float]]]]
2
3
def foo(data: Data) -> bool:
4
    ...

Die Hilfsfunktion get_type_hints()

Das Typisierungsmodul stellt die Funktion get_type_hints() bereit, die Informationen zu den Argumenttypen und dem Rückgabewert bereitstellt. Während das Attribut annotations Typhinweise zurückgibt, da es sich nur um Annotationen handelt, empfehle ich dennoch, die Funktion get_type_hints() zu verwenden, da sie Vorwärtsreferenzen auflöst. Wenn Sie für eines der Argumente den Standardwert None angeben, gibt die Funktion get_type_hints() automatisch ihren Typ als Union [T, NoneType] zurück, wenn Sie gerade T angegeben haben. Sehen wir uns den Unterschied mit der Methode A.merge() an früher definiert:

1
print(A.merge.__annotations__)
2
3
{'other': 'A', 'return': 'A'}

Das Annotationsattribut gibt den Annotationswert einfach so zurück, wie er ist. In diesem Fall ist es nur die Zeichenfolge "A" und nicht das A-Klassenobjekt, auf das "A" nur eine Vorwärtsreferenz ist.

1
print(get_type_hints(A.merge))
2
3
{'return': <class '__main__.A'>, 'other': typing.Union[__main__.A, NoneType]}

Die Funktion get_type_hints() konvertierte den Typ des anderen Arguments aufgrund des Standardarguments None in eine Union von A (der Klasse) und NoneType. Der Rückgabetyp wurde ebenfalls in die Klasse A konvertiert.

Die Dekorateure

Typhinweise sind eine Spezialisierung von Funktionsanmerkungen und können auch neben anderen Funktionsanmerkungen verwendet werden.

Zu diesem Zweck stellt das Typisierungsmodul zwei Dekoratoren zur Verfügung: @no_type_check und @no_type_check_decorator. Der Dekorator @no_type_check kann entweder auf eine Klasse oder eine Funktion angewendet werden. Es fügt der Funktion (oder jeder Methode der Klasse) das Attribut no_type_check hinzu. Auf diese Weise können Typprüfer Anmerkungen ignorieren, bei denen es sich nicht um Typhinweise handelt.

Dies ist etwas umständlich, da Sie beim Schreiben einer Bibliothek, die häufig verwendet wird, davon ausgehen müssen, dass eine Typprüfung verwendet wird. Wenn Sie Ihre Funktionen mit nicht typbezogenen Hinweisen versehen möchten, müssen Sie sie auch mit @no_type_check dekorieren.

Ein häufiges Szenario bei der Verwendung von regulären Funktionsanmerkungen besteht darin, dass ein Dekorator über sie arbeitet. In diesem Fall möchten Sie auch die Typprüfung deaktivieren. Eine Möglichkeit besteht darin, den Dekorator @no_type_check zusätzlich zu Ihrem Dekorator zu verwenden, aber das wird alt. Stattdessen kann der @no_Type_check_decorator verwendet werden, um Ihren Dekorator so zu dekorieren, dass er sich auch wie @no_type_check verhält (fügt das Attribut no_type_check hinzu).

Lassen Sie mich all diese Konzepte veranschaulichen. Wenn Sie versuchen, eine Funktion, die mit einer regulären Zeichenfolgenanmerkung versehen ist, mit get_type_hint() zu versehen (wie dies bei jeder Typprüfung der Fall ist), interpretiert get_type_hints() sie als Vorwärtsreferenz:

1
def f(a: 'some annotation'):
2
    pass
3
4
print(get_type_hints(f))
5
6
SyntaxError: ForwardRef must be an expression -- got 'some annotation'

Um dies zu vermeiden, fügen Sie den Dekorator @no_type_check hinzu, und get_type_hints gibt einfach ein leeres Diktat zurück, während das Attribut __annotations__ die Anmerkungen zurückgibt:

1
@no_type_check
2
def f(a: 'some annotation'):
3
    pass
4
    
5
print(get_type_hints(f))
6
{}
7
8
print(f.__annotations__)
9
{'a': 'some annotation'}

Angenommen, wir haben einen Dekorateur, der das Diktat der Anmerkungen druckt. Sie können es mit dem @no_Type_check_decorator dekorieren und dann die Funktion dekorieren, ohne sich Sorgen machen zu müssen, dass eine Typprüfung get_type_hints() aufruft und verwirrt wird. Dies ist wahrscheinlich eine bewährte Methode für jeden Dekorateur, der Anmerkungen bearbeitet. Vergessen Sie nicht die @functools.wraps, da sonst die Anmerkungen nicht in die dekorierte Funktion kopiert werden und alles auseinander fällt. Dies wird in Python 3-Funktionsanmerkungen ausführlich behandelt.

1
@no_type_check_decorator
2
def print_annotations(f):
3
    @functools.wraps(f)
4
    def decorated(*args, **kwargs):
5
        print(f.__annotations__)
6
        return f(*args, **kwargs)
7
    return decorated

Jetzt können Sie die Funktion nur mit @print_annotations dekorieren. Bei jedem Aufruf werden die Anmerkungen gedruckt.

1
@print_annotations
2
def f(a: 'some annotation'):
3
    pass
4
    
5
f(4)
6
{'a': 'some annotation'}

Das Aufrufen von get_type_hints() ist ebenfalls sicher und gibt ein leeres Dikt zurück.

1
print(get_type_hints(f))
2
{}

Statische Analyse mit Mypy

Mypy ist eine statische Typprüfung, die als Inspiration für Typhinweise und das Typisierungsmodul diente. Guido van Rossum selbst ist Autor von PEP-483 und Co-Autor von PEP-484.

Mypy installieren

Mypy befindet sich in einer sehr aktiven Entwicklung und zum jetzigen Zeitpunkt ist das Paket auf PyPI veraltet und funktioniert nicht mit Python 3.5. Um Mypy mit Python 3.5 zu verwenden, holen Sie sich das Neueste aus dem Mypy-Repository auf GitHub. Es ist so einfach wie:

1
pip3 install git+git://github.com/JukkaL/mypy.git

Mit Mypy spielen

Sobald Sie Mypy installiert haben, können Sie Mypy einfach auf Ihren Programmen ausführen. Das folgende Programm definiert eine Funktion, die eine Liste von Zeichenfolgen erwartet. Anschließend wird die Funktion mit einer Liste von Ganzzahlen aufgerufen.

1
from typing import List
2
3
def case_insensitive_dedupe(data: List[str]):
4
    """Converts all values to lowercase and removes duplicates"""
5
    return list(set(x.lower() for x in data))
6
7
8
print(case_insensitive_dedupe([1, 2]))

Beim Ausführen des Programms schlägt es zur Laufzeit offensichtlich mit folgendem Fehler fehl:

1
python3 dedupe.py
2
Traceback (most recent call last):
3
  File "dedupe.py", line 8, in <module>
4
    print(case_insensitive_dedupe([1, 2, 3]))
5
  File "dedupe.py", line 5, in case_insensitive_dedupe
6
    return list(set(x.lower() for x in data))
7
  File "dedupe.py", line 5, in <genexpr>
8
    return list(set(x.lower() for x in data))
9
AttributeError: 'int' object has no attribute 'lower'

Was ist das Problem damit? Das Problem ist, dass selbst in diesem sehr einfachen Fall nicht sofort klar ist, was die Grundursache ist. Ist es ein Problem mit dem Eingabetyp? Oder vielleicht ist der Code selbst falsch und sollte nicht versuchen, die lower()-Methode für das 'int'-Objekt aufzurufen. Ein weiteres Problem ist, dass, wenn Sie keine 100% ige Testabdeckung haben (und, um ehrlich zu sein, keiner von uns), solche Probleme in einem ungetesteten, selten verwendeten Codepfad lauern und zum schlechtesten Zeitpunkt in der Produktion erkannt werden können.

Die statische Typisierung, unterstützt durch Typhinweise, bietet Ihnen ein zusätzliches Sicherheitsnetz, indem sichergestellt wird, dass Sie Ihre Funktionen (mit Typhinweisen versehen) immer mit den richtigen Typen aufrufen. Hier ist die Ausgabe von Mypy:

1
(N) > mypy dedupe.py
2
dedupe.py:8: error: List item 0 has incompatible type "int"
3
dedupe.py:8: error: List item 1 has incompatible type "int"
4
dedupe.py:8: error: List item 2 has incompatible type "int"

Dies ist unkompliziert, weist direkt auf das Problem hin und erfordert nicht viele Tests. Ein weiterer Vorteil der statischen Typprüfung besteht darin, dass Sie die dynamische Typprüfung überspringen können, wenn Sie sich dazu verpflichten, es sei denn, Sie analysieren externe Eingaben (Lesen von Dateien, eingehende Netzwerkanforderungen oder Benutzereingaben). Es schafft auch viel Vertrauen in Bezug auf das Refactoring.

Abschluss

Tipphinweise und das Typisierungsmodul sind völlig optionale Ergänzungen zur Ausdruckskraft von Python. Obwohl sie möglicherweise nicht jedem Geschmack entsprechen, können sie für große Projekte und große Teams unverzichtbar sein. Der Beweis ist, dass große Teams bereits die statische Typprüfung verwenden. Nachdem die Typinformationen standardisiert sind, ist es einfacher, Code, Dienstprogramme und Tools, die sie verwenden, gemeinsam zu nutzen. IDEs wie PyCharm nutzen es bereits, um eine bessere Entwicklererfahrung zu bieten.