In dieser langgestreckten Antwort implementieren wir einen Python 3.x-spezifischen Dekorator für die Typprüfung, der auf PEP 484- Typ-Hinweisen in weniger als 275 Zeilen reinen Python basiert (die meisten davon sind erklärende Dokumentationen und Kommentare) - stark optimiert für industrielle Stärke in der py.test
Praxis mit einer gesteuerten Testsuite, die alle möglichen Randfälle ausübt.
Genießen Sie das unerwartete Fantastische Bärentippen :
>>> @beartype
... def spirit_bear(kermode: str, gitgaata: (str, int)) -> tuple:
... return (kermode, gitgaata, "Moksgm'ol", 'Ursus americanus kermodei')
>>> spirit_bear(0xdeadbeef, 'People of the Cane')
AssertionError: parameter kermode=0xdeadbeef not of <class "str">
Wie dieses Beispiel zeigt, unterstützt die Bärentypisierung explizit die Typprüfung von Parametern und Rückgabewerten, die entweder als einfache Typen oder als Tupel solcher Typen bezeichnet werden. Golly!
OK, das ist eigentlich nicht beeindruckend. @beartype
ähnelt jedem anderen Python 3.x-spezifischen Dekorator für die Typprüfung, der auf Typhinweisen im PEP 484- Stil in weniger als 275 Zeilen Pure-Python basiert . Also, was ist das Problem, Bub?
Reine Bruteforce Hardcore-Effizienz
Das Eingeben von Bären ist räumlich und zeitlich erheblich effizienter als alle vorhandenen Implementierungen der Typprüfung in Python nach bestem Wissen und Gewissen. ( Dazu später mehr. )
Effizienz spielt in Python jedoch normalerweise keine Rolle. Wenn dies der Fall wäre, würden Sie Python nicht verwenden. Weicht die Typprüfung tatsächlich von der etablierten Norm ab, eine vorzeitige Optimierung in Python zu vermeiden? Ja. Ja tut es.
Betrachten Sie die Profilerstellung, die jeder interessierenden profilierten Metrik (z. B. Funktionsaufrufe, Zeilen) unvermeidbaren Overhead hinzufügt. Um genaue Ergebnisse zu gewährleisten, wird dieser Overhead durch die Nutzung optimierter C-Erweiterungen (z. B. der _lsprof
vom cProfile
Modul genutzten C-Erweiterung ) anstelle von nicht optimiertem Pure-Python (z. B. dem profile
Modul) verringert . Effizienz ist bei der Profilerstellung wirklich wichtig.
Die Typprüfung ist nicht anders. Die Typprüfung erhöht den Aufwand für jeden von Ihrer Anwendung geprüften Funktionsaufruftyp - im Idealfall für alle . Um zu verhindern, dass gut gemeinte (aber leider kleinmütige) Mitarbeiter die Typprüfung entfernen, die Sie nach dem koffeinhaltigen Allnighter am vergangenen Freitag stillschweigend zu Ihrer geriatrischen Django-Web-App hinzugefügt haben , muss die Typprüfung schnell erfolgen. So schnell, dass niemand merkt, dass es da ist, wenn Sie es hinzufügen, ohne es jemandem zu sagen. Ich mache das die ganze Zeit! Hören Sie auf, dies zu lesen, wenn Sie ein Mitarbeiter sind.
Wenn selbst lächerliche Geschwindigkeit für Ihre gefräßige Anwendung nicht ausreicht, kann die Bärentypisierung durch Deaktivieren von Python-Optimierungen (z. B. durch Übergeben der -O
Option an den Python-Interpreter) global deaktiviert werden :
$ python3 -O
>>> spirit_bear(0xdeadbeef, 'People of the Cane')
(0xdeadbeef, 'People of the Cane', "Moksgm'ol", 'Ursus americanus kermodei')
Nur weil. Willkommen beim Bärentippen.
Was zum...? Warum "Bär"? Du bist ein Nackenbart, oder?
Bei der Bärentypisierung handelt es sich um eine Bare-Metal-Typprüfung, dh eine Typprüfung, die dem manuellen Ansatz der Typprüfung in Python so nahe wie möglich kommt. Die Bärentypisierung soll keine Leistungseinbußen, Kompatibilitätsbeschränkungen oder Abhängigkeiten von Drittanbietern nach sich ziehen (über die ohnehin durch den manuellen Ansatz auferlegten hinaus). Die Bärentypisierung kann ohne Änderung nahtlos in vorhandene Codebasen und Testsuiten integriert werden.
Jeder kennt wahrscheinlich den manuellen Ansatz. Sie übergeben manuell assert
jeden Parameter, der an jede Funktion in Ihrer Codebasis zurückgegeben wird , und / oder geben einen Wert zurück . Welche Kesselplatte könnte einfacher oder banaler sein? Wir haben es alle hundertmal bei Googleplex gesehen und uns jedes Mal ein wenig im Mund übergeben. Wiederholung wird schnell alt. TROCKEN , yo.
Bereiten Sie Ihre Erbrochenenbeutel vor. Nehmen wir der Kürze halber eine vereinfachte easy_spirit_bear()
Funktion an, die nur einen einzigen str
Parameter akzeptiert . So sieht der manuelle Ansatz aus:
def easy_spirit_bear(kermode: str) -> str:
assert isinstance(kermode, str), 'easy_spirit_bear() parameter kermode={} not of <class "str">'.format(kermode)
return_value = (kermode, "Moksgm'ol", 'Ursus americanus kermodei')
assert isinstance(return_value, str), 'easy_spirit_bear() return value {} not of <class "str">'.format(return_value)
return return_value
Python 101, richtig? Viele von uns haben diese Klasse bestanden.
Die Bear-Typisierung extrahiert die vom obigen Ansatz manuell durchgeführte Typprüfung in eine dynamisch definierte Wrapper-Funktion, die automatisch dieselben Überprüfungen durchführt - mit dem zusätzlichen Vorteil, dass TypeError
eher granulare als mehrdeutige AssertionError
Ausnahmen ausgelöst werden . So sieht der automatisierte Ansatz aus:
def easy_spirit_bear_wrapper(*args, __beartype_func=easy_spirit_bear, **kwargs):
if not (
isinstance(args[0], __beartype_func.__annotations__['kermode'])
if 0 < len(args) else
isinstance(kwargs['kermode'], __beartype_func.__annotations__['kermode'])
if 'kermode' in kwargs else True):
raise TypeError(
'easy_spirit_bear() parameter kermode={} not of {!r}'.format(
args[0] if 0 < len(args) else kwargs['kermode'],
__beartype_func.__annotations__['kermode']))
return_value = __beartype_func(*args, **kwargs)
if not isinstance(return_value, __beartype_func.__annotations__['return']):
raise TypeError(
'easy_spirit_bear() return value {} not of {!r}'.format(
return_value, __beartype_func.__annotations__['return']))
return return_value
Es ist langatmig. Aber es ist im Grunde auch * so schnell wie der manuelle Ansatz. * Schielen empfohlen.
Beachten Sie das völlige Fehlen einer Funktionsprüfung oder -iteration in der Wrapper-Funktion, die eine ähnliche Anzahl von Tests wie die ursprüngliche Funktion enthält - allerdings mit den zusätzlichen (möglicherweise vernachlässigbaren) Kosten für das Testen, ob und wie die zu überprüfenden Parameter an die übergeben werden aktueller Funktionsaufruf. Sie können nicht jede Schlacht gewinnen.
Können solche Wrapper-Funktionen tatsächlich zuverlässig generiert werden, um beliebige Funktionen in weniger als 275 Zeilen reinem Python zu überprüfen? Schlange Plisskin sagt: "Wahre Geschichte. Haben Sie einen Rauch?"
Und ja. Ich kann einen Nackenbart haben.
Nein, Srsly. Warum "Bär"?
Bär schlägt Ente. Ente kann fliegen, aber Bär kann Lachs auf Ente werfen. In Kanada kann Sie die Natur überraschen.
Nächste Frage.
Was ist überhaupt so heiß an Bären?
Bestehende Lösungen führen keine Bare-Metal-Typprüfung durch - zumindest keine, auf die ich gestoßen bin. Sie alle überprüfen iterativ die Signatur der typgeprüften Funktion bei jedem Funktionsaufruf . Während der Aufwand für die erneute Überprüfung für einen einzelnen Anruf vernachlässigbar ist, ist er normalerweise nicht vernachlässigbar, wenn er über alle Anrufe hinweg aggregiert wird. Wirklich, wirklich nicht zu vernachlässigen.
Es geht jedoch nicht nur um Effizienz. Bestehende Lösungen berücksichtigen häufig auch keine häufigen Randfälle. Dies schließt die meisten, wenn nicht alle Spielzeugdekorateure ein, die hier und anderswo als Stackoverflow-Antworten bereitgestellt werden. Klassische Fehler sind:
- Fehler beim Eingeben von Check-Keyword-Argumenten und / oder Rückgabewerten (z. B. Sweeneyrods
@checkargs
Dekorator ).
- Keine Unterstützung von Tupeln (dh Gewerkschaften) von Typen, die vom
isinstance()
eingebauten System akzeptiert werden .
- Fehler beim Übertragen des Namens, der Dokumentzeichenfolge und anderer identifizierender Metadaten von der ursprünglichen Funktion auf die Wrapper-Funktion.
- Es ist nicht gelungen, mindestens einen Anschein von Komponententests zu liefern. ( Irgendwie kritisch. )
- Auslösen generischer
AssertionError
Ausnahmen anstelle spezifischer TypeError
Ausnahmen bei fehlgeschlagenen Typprüfungen. Aus Gründen der Granularität und Vernunft sollte die Typprüfung niemals generische Ausnahmen auslösen.
Das Eingeben von Bären ist dort erfolgreich, wo Nichtbären versagen. Alle eins, alle tragen!
Bear Typing Unbared
Durch die Typisierung von Bären werden die Raum- und Zeitkosten für die Überprüfung von Funktionssignaturen von der Funktionsaufrufzeit zur Funktionsdefinitionszeit verschoben, dh von der vom @beartype
Dekorateur zurückgegebenen Wrapper-Funktion in den Dekorator selbst. Da der Dekorateur nur einmal pro Funktionsdefinition aufgerufen wird, bringt diese Optimierung Freude für alle.
Bärentippen ist ein Versuch, Ihren Typ Kuchen überprüfen zu lassen und ihn auch zu essen. Um dies zu tun @beartype
:
- Überprüft die Signatur und Anmerkungen der ursprünglichen Funktion.
- Konstruiert dynamisch den Hauptteil des Wrapper-Funktionstyps und überprüft die ursprüngliche Funktion. Thaaat hat recht. Python-Code, der Python-Code generiert.
exec()
Deklariert diese Wrapper-Funktion dynamisch über das eingebaute System.
- Gibt diese Wrapper-Funktion zurück.
Sollen wir? Tauchen wir in das tiefe Ende ein.
if __debug__:
import inspect
from functools import wraps
from inspect import Parameter, Signature
def beartype(func: callable) -> callable:
'''
Decorate the passed **callable** (e.g., function, method) to validate
both all annotated parameters passed to this callable _and_ the
annotated value returned by this callable if any.
This decorator performs rudimentary type checking based on Python 3.x
function annotations, as officially documented by PEP 484 ("Type
Hints"). While PEP 484 supports arbitrarily complex type composition,
this decorator requires _all_ parameter and return value annotations to
be either:
* Classes (e.g., `int`, `OrderedDict`).
* Tuples of classes (e.g., `(int, OrderedDict)`).
If optimizations are enabled by the active Python interpreter (e.g., due
to option `-O` passed to this interpreter), this decorator is a noop.
Raises
----------
NameError
If any parameter has the reserved name `__beartype_func`.
TypeError
If either:
* Any parameter or return value annotation is neither:
* A type.
* A tuple of types.
* The kind of any parameter is unrecognized. This should _never_
happen, assuming no significant changes to Python semantics.
'''
func_body = '''
@wraps(__beartype_func)
def func_beartyped(*args, __beartype_func=__beartype_func, **kwargs):
'''
func_sig = inspect.signature(func)
func_name = func.__name__ + '()'
for func_arg_index, func_arg in enumerate(func_sig.parameters.values()):
if func_arg.name == '__beartype_func':
raise NameError(
'Parameter {} reserved for use by @beartype.'.format(
func_arg.name))
if (func_arg.annotation is not Parameter.empty and
func_arg.kind not in _PARAMETER_KIND_IGNORED):
_check_type_annotation(
annotation=func_arg.annotation,
label='{} parameter {} type'.format(
func_name, func_arg.name))
func_arg_type_expr = (
'__beartype_func.__annotations__[{!r}]'.format(
func_arg.name))
func_arg_value_key_expr = 'kwargs[{!r}]'.format(func_arg.name)
if func_arg.kind is Parameter.KEYWORD_ONLY:
func_body += '''
if {arg_name!r} in kwargs and not isinstance(
{arg_value_key_expr}, {arg_type_expr}):
raise TypeError(
'{func_name} keyword-only parameter '
'{arg_name}={{}} not a {{!r}}'.format(
{arg_value_key_expr}, {arg_type_expr}))
'''.format(
func_name=func_name,
arg_name=func_arg.name,
arg_type_expr=func_arg_type_expr,
arg_value_key_expr=func_arg_value_key_expr,
)
else:
func_arg_value_pos_expr = 'args[{!r}]'.format(
func_arg_index)
func_body += '''
if not (
isinstance({arg_value_pos_expr}, {arg_type_expr})
if {arg_index} < len(args) else
isinstance({arg_value_key_expr}, {arg_type_expr})
if {arg_name!r} in kwargs else True):
raise TypeError(
'{func_name} parameter {arg_name}={{}} not of {{!r}}'.format(
{arg_value_pos_expr} if {arg_index} < len(args) else {arg_value_key_expr},
{arg_type_expr}))
'''.format(
func_name=func_name,
arg_name=func_arg.name,
arg_index=func_arg_index,
arg_type_expr=func_arg_type_expr,
arg_value_key_expr=func_arg_value_key_expr,
arg_value_pos_expr=func_arg_value_pos_expr,
)
if func_sig.return_annotation not in _RETURN_ANNOTATION_IGNORED:
_check_type_annotation(
annotation=func_sig.return_annotation,
label='{} return type'.format(func_name))
func_return_type_expr = (
"__beartype_func.__annotations__['return']")
func_body += '''
return_value = __beartype_func(*args, **kwargs)
if not isinstance(return_value, {return_type}):
raise TypeError(
'{func_name} return value {{}} not of {{!r}}'.format(
return_value, {return_type}))
return return_value
'''.format(func_name=func_name, return_type=func_return_type_expr)
else:
func_body += '''
return __beartype_func(*args, **kwargs)
'''
local_attrs = {'__beartype_func': func}
exec(func_body, globals(), local_attrs)
return local_attrs['func_beartyped']
_PARAMETER_KIND_IGNORED = {
Parameter.POSITIONAL_ONLY, Parameter.VAR_POSITIONAL, Parameter.VAR_KEYWORD,
}
'''
Set of all `inspect.Parameter.kind` constants to be ignored during
annotation- based type checking in the `@beartype` decorator.
This includes:
* Constants specific to variadic parameters (e.g., `*args`, `**kwargs`).
Variadic parameters cannot be annotated and hence cannot be type checked.
* Constants specific to positional-only parameters, which apply to non-pure-
Python callables (e.g., defined by C extensions). The `@beartype`
decorator applies _only_ to pure-Python callables, which provide no
syntactic means of specifying positional-only parameters.
'''
_RETURN_ANNOTATION_IGNORED = {Signature.empty, None}
'''
Set of all annotations for return values to be ignored during annotation-
based type checking in the `@beartype` decorator.
This includes:
* `Signature.empty`, signifying a callable whose return value is _not_
annotated.
* `None`, signifying a callable returning no value. By convention, callables
returning no value are typically annotated to return `None`. Technically,
callables whose return values are annotated as `None` _could_ be
explicitly checked to return `None` rather than a none-`None` value. Since
return values are safely ignorable by callers, however, there appears to
be little real-world utility in enforcing this constraint.
'''
def _check_type_annotation(annotation: object, label: str) -> None:
'''
Validate the passed annotation to be a valid type supported by the
`@beartype` decorator.
Parameters
----------
annotation : object
Annotation to be validated.
label : str
Human-readable label describing this annotation, interpolated into
exceptions raised by this function.
Raises
----------
TypeError
If this annotation is neither a new-style class nor a tuple of
new-style classes.
'''
if isinstance(annotation, tuple):
for member in annotation:
if not (
isinstance(member, type) and hasattr(member, '__name__')):
raise TypeError(
'{} tuple member {} not a new-style class'.format(
label, member))
elif not (
isinstance(annotation, type) and hasattr(annotation, '__name__')):
raise TypeError(
'{} {} neither a new-style class nor '
'tuple of such classes'.format(label, annotation))
else:
def beartype(func: callable) -> callable:
return func
Und Leycec sprach: Lass den @beartype
Typ schnell prüfen! Und es war so.
Vorsichtsmaßnahmen, Flüche und leere Versprechen
Nichts ist perfekt. Sogar Bären tippen.
Vorsichtsmaßnahme I: Standardwerte deaktiviert
Bei der Bärentypisierung werden keine nicht übergebenen Parameter überprüft, denen Standardwerte zugewiesen wurden. Theoretisch könnte es. Aber nicht in 275 Zeilen oder weniger und schon gar nicht als Stackoverflow-Antwort.
Die sichere (... wahrscheinlich völlig unsichere ) Annahme ist, dass Funktionsimplementierer behaupten, sie wüssten, was sie taten, als sie Standardwerte definierten. Da Standardwerte normalerweise Konstanten sind (... besser! ), Würde eine erneute Überprüfung der Konstantentypen, die sich bei jedem Funktionsaufruf, der einem oder mehreren Standardwerten zugewiesen wurde, nie ändern, gegen den Grundgedanken der Bärentypisierung verstoßen: "Nicht wiederholen dich vorbei und oooover und oooo-oooover wieder. "
Zeigen Sie mir falsch und ich werde Sie mit Upvotes überschütten.
Vorsichtsmaßnahme II: Kein PEP 484
PEP 484 ( "Type Hints" ) formalisierte die Verwendung von Funktionsanmerkungen, die zuerst von PEP 3107 ( "Function Annotations" ) eingeführt wurden. Python 3.5 unterstützt oberflächlich diese Formalisierung mit einem neuen Top-Level - typing
Modul , ein Standard - API für beliebig komplexe Typen von einfacheren Typen Komponieren ( zum Beispiel Callable[[Arg1Type, Arg2Type], ReturnType]
, eine Art , die eine Funktion mit zwei Argumenten des Typs beschreibt , Arg1Type
und Arg2Type
und einen Wert vom Typ der Rückkehr ReturnType
).
Die Bärentippung unterstützt keine von ihnen. Theoretisch könnte es. Aber nicht in 275 Zeilen oder weniger und schon gar nicht als Stackoverflow-Antwort.
Die isinstance()
Bärentypisierung unterstützt jedoch Gewerkschaften von Typen auf dieselbe Weise wie die integrierte Typvereinigung: als Tupel. Dies entspricht oberflächlich dem typing.Union
Typ - mit der offensichtlichen Einschränkung, dass typing.Union
beliebig komplexe Typen unterstützt werden, während Tupel, die von der @beartype
Unterstützung akzeptiert werden , nur einfache Klassen unterstützen. Zu meiner Verteidigung 275 Zeilen.
Tests oder es ist nicht passiert
Hier ist der Kern davon. Erhalten Sie es, Kern ? Ich werde jetzt aufhören.
Wie beim @beartype
Dekorateur selbst können diese py.test
Tests ohne Änderung nahtlos in vorhandene Testsuiten integriert werden. Wertvoll, nicht wahr?
Jetzt hat niemand nach dem obligatorischen Nackenbart-Rant gefragt.
Eine Geschichte der API-Gewalt
Python 3.5 bietet keine tatsächliche Unterstützung für die Verwendung von PEP 484-Typen. wat?
Es ist wahr: keine Typprüfung, keine Typinferenz, keine Typnuss. Stattdessen wird von Entwicklern erwartet, dass sie ihre gesamte Codebasis routinemäßig über schwergewichtige CPython-Interpreter-Wrapper von Drittanbietern ausführen, die ein Faksimile dieser Unterstützung (z . B. mypy ) implementieren . Natürlich schreiben diese Wrapper vor:
- Eine Kompatibilitätsstrafe. Wie die offiziellen mypy-FAQ als Antwort auf die häufig gestellte Frage "Kann ich mit mypy meinen vorhandenen Python-Code überprüfen?" Zugeben: " Das hängt davon ab. Die Kompatibilität ist ziemlich gut, aber einige Python-Funktionen sind noch nicht implementiert oder werden nicht vollständig unterstützt." Eine nachfolgende FAQ-Antwort verdeutlicht diese Inkompatibilität, indem Folgendes angegeben wird:
- "... Ihr Code muss Attribute explizit machen und eine explizite Protokolldarstellung verwenden." Die Grammatikpolizei sieht Ihr "explizites" und bringt Sie zu einem impliziten Stirnrunzeln.
- "Mypy unterstützt die modulare, effiziente Typprüfung, und dies scheint die Typprüfung einiger Sprachfunktionen auszuschließen, z. B. das willkürliche Hinzufügen von Methoden zur Laufzeit. Es ist jedoch wahrscheinlich, dass viele dieser Funktionen in eingeschränkter Form unterstützt werden (z. B.) Die Laufzeitänderung wird nur für Klassen oder Methoden unterstützt, die als dynamisch oder 'patchbar' registriert sind. "
- Eine vollständige Liste der syntaktischen Inkompatibilitäten finden Sie unter "Umgang mit häufig auftretenden Problemen" . Es ist nicht schön. Sie wollten nur die Typprüfung und jetzt haben Sie Ihre gesamte Codebasis überarbeitet und zwei Tage nach der Veröffentlichung des Kandidaten alle Builds gebrochen, und der hübsche HR-Zwerg in lässiger Geschäftskleidung schiebt einen rosa Slip durch den Spalt in Ihrer Kabine. Vielen Dank, mypy.
- Eine Leistungsminderung trotz Interpretation von statisch typisiertem Code. Vierzig Jahre hartgesottener Informatik sagen uns, dass (... alles andere gleich ist ) das Interpretieren von statisch typisiertem Code schneller und nicht langsamer sein sollte als das Interpretieren von dynamisch typisiertem Code. In Python ist up das neue down.
- Zusätzliche nicht triviale Abhängigkeiten, die zunehmen:
- Die fehlerbehaftete Fragilität der Projektbereitstellung, insbesondere plattformübergreifend.
- Die Wartungslast der Projektentwicklung.
- Mögliche Angriffsfläche.
Ich frage Guido: "Warum? Warum eine abstrakte API erfinden, wenn Sie nicht bereit waren, eine konkrete API aufzubauen, die tatsächlich etwas mit dieser Abstraktion macht?" Warum das Schicksal einer Million Pythonisten der arthritischen Hand des freien Open-Source-Marktplatzes überlassen? Warum noch ein Techno-Problem erstellen, das mit einem 275-Zeilen-Dekorator in der offiziellen Python-Stdlib trivial hätte gelöst werden können?
Ich habe kein Python und ich muss schreien.
locals()
wird wahrscheinlich zu einer nutzlosen Komplikation führen - tatsächlich sehe ich keinen Anwendungsfall dafür, da Sie Ihre benannten Parameternamen (offensichtlich <g>) bereits kennen und direkt darauf zugreifen könnenargs
undkwargs
wenn Ihre Funktion sie verwendet. Assertion dient hauptsächlich zum Debuggen. Wenn der Vertrag Ihrer Funktion lautet, dass arg 'a' ein int zwischen 0 und 10 und das Argument 'b' eine nicht leere Zeichenfolge sein muss, lösen Sie die entsprechenden Ausnahmetypen aus, dhTypeError
oderValueError
- tryint('a')
undint(None)
in Ihrer Python-Shell.