Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python Enhancement Proposals

PEP 340 – Anonyme Blockanweisungen

Autor:
Guido van Rossum
Status:
Abgelehnt
Typ:
Standards Track
Erstellt:
27-Apr-2005
Post-History:


Inhaltsverzeichnis

Einleitung

Diese PEP schlägt einen neuen Typ einer zusammengesetzten Anweisung vor, der für die Ressourcenverwaltung verwendet werden kann. Der neue Anweisungstyp wird vorläufig als Blockanweisung bezeichnet, da das zu verwendende Schlüsselwort noch nicht ausgewählt wurde.

Diese PEP konkurriert mit mehreren anderen PEPs: PEP 288 (Generator-Attribute und -Ausnahmen; nur der zweite Teil), PEP 310 (Zuverlässige Erfassungs-/Freigabepaare) und PEP 325 (Ressourcenfreigabestützung für Generatoren).

Ich sollte klarstellen, dass die Verwendung eines Generators zum „Steuern“ einer Blockanweisung ein wirklich separater Vorschlag ist; mit der reinen Definition der Blockanweisung aus der PEP könnten Sie alle Beispiele mit einer Klasse implementieren (ähnlich wie Beispiel 6, das leicht in eine Vorlage umgewandelt werden kann). Aber die Kernidee ist die Verwendung eines Generators zur Steuerung einer Blockanweisung; der Rest ist Ausarbeitung, daher möchte ich diese beiden Teile zusammenhalten.

(PEP 342, Erweiterte Iteratoren, war ursprünglich Teil dieser PEP; aber die beiden Vorschläge sind wirklich unabhängig und mit Stevens Bethards Hilfe habe ich ihn in eine separate PEP verschoben.)

Ablehnungsbescheid

Ich lehne diese PEP zugunsten von PEP 343 ab. Sehen Sie sich den Motivationsabschnitt in dieser PEP für die Begründung dieser Ablehnung an. GvR.

Motivation und Zusammenfassung

(Danke an Shane Hathaway – Hallo Shane!)

Gute Programmierer lagern häufig verwendete Codeblöcke in wiederverwendbare Funktionen aus. Manchmal treten Muster jedoch eher in der Struktur von Funktionen als in der tatsächlichen Anweisungssequenz auf. Viele Funktionen erwerben beispielsweise eine Sperre, führen einen für die Funktion spezifischen Code aus und geben die Sperre bedingungslos frei. Das Wiederholen des Sperrcodes in jeder Funktion, die ihn verwendet, ist fehleranfällig und erschwert das Refactoring.

Blockanweisungen bieten einen Mechanismus zur Kapselung von Strukturmustern. Code innerhalb einer Blockanweisung wird unter der Kontrolle eines Objekts namens Block-Iterator ausgeführt. Einfache Block-Iteratoren führen Code vor und nach dem Code innerhalb der Blockanweisung aus. Block-Iteratoren haben auch die Möglichkeit, den gesteuerten Code mehrmals auszuführen (oder gar nicht), Ausnahmen abzufangen oder Daten aus dem Körper der Blockanweisung zu empfangen.

Eine bequeme Möglichkeit, Block-Iteratoren zu schreiben, ist die Verwendung eines Generators (PEP 255). Ein Generator sieht einer Python-Funktion sehr ähnlich, aber anstatt sofort einen Wert zurückzugeben, pausieren Generatoren ihre Ausführung bei „yield“-Anweisungen. Wenn ein Generator als Block-Iterator verwendet wird, weist die yield-Anweisung den Python-Interpreter an, den Block-Iterator anzuhalten, den Körper der Blockanweisung auszuführen und den Block-Iterator fortzusetzen, wenn der Körper ausgeführt wurde.

Der Python-Interpreter verhält sich wie folgt, wenn er auf eine Blockanweisung stößt, die auf einem Generator basiert. Zuerst instanziiert der Interpreter den Generator und beginnt mit dessen Ausführung. Der Generator führt Setup-Arbeiten durch, die dem gekapselten Muster entsprechen, wie z. B. das Erwerben einer Sperre, das Öffnen einer Datei, das Starten einer Datenbanktransaktion oder das Starten einer Schleife. Dann gibt der Generator die Ausführung an den Körper der Blockanweisung ab und verwendet eine yield-Anweisung. Wenn der Körper der Blockanweisung abgeschlossen ist, eine unbehandelte Ausnahme auslöst oder Daten über eine continue-Anweisung an den Generator zurücksendet, wird der Generator fortgesetzt. An diesem Punkt kann der Generator entweder aufräumen und stoppen oder erneut yielden, wodurch der Körper der Blockanweisung erneut ausgeführt wird. Wenn der Generator beendet ist, verlässt der Interpreter die Blockanweisung.

Anwendungsfälle

Siehe den Abschnitt Beispiele am Ende.

Spezifikation: die __exit__() Methode

Eine optionale neue Methode für Iteratoren wird vorgeschlagen, genannt __exit__(). Sie nimmt bis zu drei Argumente entgegen, die den drei „Argumenten“ der raise-Anweisung entsprechen: Typ, Wert und Traceback. Wenn alle drei Argumente None sind, kann sys.exc_info() konsultiert werden, um geeignete Standardwerte zu liefern.

Spezifikation: die anonyme Blockanweisung

Eine neue Anweisung wird mit der folgenden Syntax vorgeschlagen:

block EXPR1 as VAR1:
    BLOCK1

Hier sind „block“ und „as“ neue Schlüsselwörter; EXPR1 ist ein beliebiger Ausdruck (aber keine Ausdrucksliste) und VAR1 ist ein beliebiges Zuweisungsziel (das eine durch Kommas getrennte Liste sein kann).

Der Teil „as VAR1“ ist optional; wenn er weggelassen wird, werden die Zuweisungen an VAR1 in der folgenden Übersetzung weggelassen (aber die zugewiesenen Ausdrücke werden trotzdem ausgewertet!).

Die Wahl des Schlüsselworts „block“ ist umstritten; viele Alternativen wurden vorgeschlagen, einschließlich der Verwendung überhaupt kein Schlüsselwort (was ich eigentlich mag). PEP 310 verwendet „with“ für ähnliche Semantik, aber ich möchte dies für eine with-Anweisung reservieren, die der in Pascal und VB gefundenen ähnelt. (Obwohl ich gerade festgestellt habe, dass die C#-Designer „with“ nicht mögen [2], und ich stimme ihrer Begründung zu.) Um dieses Problem vorübergehend zu umgehen, verwende ich „block“, bis wir uns auf das richtige Schlüsselwort geeinigt haben, falls überhaupt.

Beachten Sie, dass das Schlüsselwort „as“ nicht umstritten ist (es wird schließlich zum Status eines echten Schlüsselworts erhoben).

Beachten Sie, dass es am Iterator liegt, zu entscheiden, ob eine Blockanweisung eine Schleife mit mehreren Iterationen darstellt; im häufigsten Anwendungsfall wird BLOCK1 genau einmal ausgeführt. Für den Parser ist es jedoch immer eine Schleife; break und continue kehren zum Iterator des Blocks zurück (siehe unten für Details).

Die Übersetzung unterscheidet sich subtil von einer for-Schleife: iter() wird nicht aufgerufen, daher sollte EXPR1 bereits ein Iterator sein (nicht nur ein iterierbares Objekt); und der Iterator erhält garantiert eine Benachrichtigung, wenn die Blockanweisung verlassen wird, unabhängig davon, ob dies aufgrund eines break, return oder einer Ausnahme geschieht.

itr = EXPR1  # The iterator
ret = False  # True if a return statement is active
val = None   # Return value, if ret == True
exc = None   # sys.exc_info() tuple if an exception is active
while True:
    try:
        if exc:
            ext = getattr(itr, "__exit__", None)
            if ext is not None:
                VAR1 = ext(*exc)   # May re-raise *exc
            else:
                raise exc[0], exc[1], exc[2]
        else:
            VAR1 = itr.next()  # May raise StopIteration
    except StopIteration:
        if ret:
            return val
        break
    try:
        ret = False
        val = exc = None
        BLOCK1
    except:
        exc = sys.exc_info()

(Die Variablen „itr“ usw. sind jedoch nicht für den Benutzer sichtbar und die verwendeten integrierten Namen können nicht vom Benutzer überschrieben werden.)

Innerhalb von BLOCK1 gelten die folgenden speziellen Übersetzungen

  • „break“ ist immer legal; es wird übersetzt zu
    exc = (StopIteration, None, None)
    continue
    
  • „return EXPR3“ ist nur legal, wenn die Blockanweisung in einer Funktionsdefinition enthalten ist; sie wird übersetzt zu
    exc = (StopIteration, None, None)
    ret = True
    val = EXPR3
    continue
    

Der Nettoeffekt ist, dass break und return sich weitgehend wie bei einer for-Schleife verhalten, mit dem Unterschied, dass der Iterator die Möglichkeit zur Ressourcenbereinigung erhält, bevor die Blockanweisung verlassen wird, über die optionale __exit__()-Methode. Der Iterator erhält auch eine Chance, wenn die Blockanweisung durch Auslösen einer Ausnahme verlassen wird. Wenn der Iterator keine __exit__()-Methode hat, gibt es keinen Unterschied zu einer for-Schleife (außer dass eine for-Schleife iter() auf EXPR1 aufruft).

Beachten Sie, dass eine yield-Anweisung in einer Blockanweisung nicht anders behandelt wird. Sie suspendiert die Funktion, die den Block enthält, **ohne** den Iterator des Blocks zu benachrichtigen. Der Iterator des Blocks ist sich dieses Yields vollständig unbewusst, da der lokale Kontrollfluss die Blockanweisung nicht tatsächlich verlässt. Mit anderen Worten, es ist **nicht** wie eine break- oder return-Anweisung. Wenn die durch das Yield wiederhergestellte Schleife next() aufruft, wird der Block direkt nach dem Yield fortgesetzt. (Siehe Beispiel 7 unten.) Die unten beschriebenen Generator-Finalisierungssemantik garantiert (innerhalb der Grenzen aller Finalisierungssemantik), dass der Block irgendwann wiederhergestellt wird.

Im Gegensatz zur for-Schleife hat die Blockanweisung keine else-Klausel. Ich denke, das wäre verwirrend und würde die „Schleifigkeit“ der Blockanweisung hervorheben, während ich ihren **Unterschied** zu einer for-Schleife hervorheben möchte. Außerdem gibt es mehrere mögliche Semantiken für eine else-Klausel und nur einen sehr schwachen Anwendungsfall.

Spezifikation: Generator-Exit-Behandlung

Generatoren werden die neue __exit__()-Methoden-API implementieren.

Generatoren dürfen eine yield-Anweisung innerhalb einer try-finally-Anweisung haben.

Das Ausdrucksargument für die yield-Anweisung wird optional (standardmäßig None).

Wenn __exit__() aufgerufen wird, wird der Generator fortgesetzt, aber an der Stelle der yield-Anweisung wird die Ausnahme ausgelöst, die durch die __exit__-Argumente repräsentiert wird. Der Generator kann diese Ausnahme erneut auslösen, eine andere Ausnahme auslösen oder einen anderen Wert yielden, außer dass, wenn die an __exit__() übergebene Ausnahme StopIteration war, StopIteration ausgelöst werden sollte (andernfalls wäre der Effekt, dass ein break in continue umgewandelt wird, was zumindest unerwartet ist). Wenn der **erste** Aufruf, der den Generator fortsetzt, ein __exit__()-Aufruf anstelle eines next()-Aufrufs ist, wird die Ausführung des Generators abgebrochen und die Ausnahme erneut ausgelöst, ohne die Kontrolle an den Körper des Generators weiterzugeben.

Wenn ein Generator, der noch nicht beendet wurde, gesammelt wird (entweder durch Referenzzählung oder durch den zyklischen Garbage Collector), wird seine __exit__()-Methode einmal mit StopIteration als erstem Argument aufgerufen. Zusammen mit der Anforderung, dass ein Generator StopIteration auslösen sollte, wenn __exit__() mit StopIteration aufgerufen wird, garantiert dies die endgültige Aktivierung aller finally-Klauseln, die aktiv waren, als der Generator zuletzt suspendiert wurde. Natürlich kann der Generator unter bestimmten Umständen nie gesammelt werden. Dies unterscheidet sich nicht von den Garantien, die für Finalizer (__del__()-Methoden) anderer Objekte gemacht werden.

Berücksichtigte und abgelehnte Alternativen

  • Viele Alternativen wurden für ‚block‘ vorgeschlagen. Ich habe noch keinen Vorschlag für ein anderes Schlüsselwort gesehen, das mir besser gefällt als ‚block‘. Leider ist ‚block‘ auch keine gute Wahl; es ist ein recht gebräuchlicher Name für Variablen, Argumente und Methoden. Vielleicht ist ‚with‘ doch die beste Wahl?
  • Anstatt zu versuchen, das ideale Schlüsselwort auszuwählen, könnte die Blockanweisung einfach die Form haben
    EXPR1 as VAR1:
        BLOCK1
    

    Dies ist zunächst attraktiv, denn zusammen mit einer guten Wahl von Funktionsnamen (wie in den Beispielen unten) in EXPR1 liest es sich gut und fühlt sich wie eine „benutzerdefinierte Anweisung“ an. Und doch macht es mir (und vielen anderen) Unbehagen; ohne Schlüsselwort ist die Syntax sehr „fade“, schwer in einem Handbuch nachzuschlagen (denken Sie daran, dass ‚as‘ optional ist), und es macht die Bedeutung von break und continue in der Blockanweisung noch verwirrender.

  • Phillip Eby hat vorgeschlagen, dass die Blockanweisung eine völlig andere API als die for-Schleife verwendet, um zwischen den beiden zu unterscheiden. Ein Generator müsste in einen Decorator verpackt werden, um die Block-API zu unterstützen. Meiner Meinung nach fügt dies mehr Komplexität mit sehr wenig Nutzen hinzu; und wir können nicht wirklich leugnen, dass die Blockanweisung konzeptionell eine Schleife ist – sie unterstützt schließlich break und continue.
  • Dies wird immer wieder vorgeschlagen: „block VAR1 = EXPR1“ anstelle von „block EXPR1 as VAR1“. Das wäre sehr irreführend, da VAR1 **nicht** den Wert von EXPR1 zugewiesen bekommt; EXPR1 ergibt einen Generator, der einer internen Variablen zugewiesen wird, und VAR1 ist der Wert, der von aufeinanderfolgenden Aufrufen der __next__()-Methode dieses Iterators zurückgegeben wird.
  • Warum nicht die Übersetzung ändern, um iter(EXPR1) anzuwenden? Alle Beispiele würden weiterhin funktionieren. Aber dies macht die Blockanweisung **mehr** wie eine for-Schleife, während der Schwerpunkt auf dem **Unterschied** zwischen den beiden liegen sollte. Das Nichtaufrufen von iter() fängt eine Reihe von Missverständnissen ab, wie z. B. die Verwendung einer Sequenz als EXPR1.

Vergleich mit Thunks

Alternative Semantiken, die für die Blockanweisung vorgeschlagen wurden, verwandeln den Block in einen Thunk (eine anonyme Funktion, die in den enthaltenden Bereich verschmilzt).

Der Hauptvorteil von Thunks, den ich sehe, ist, dass man den Thunk für später speichern kann, wie einen Callback für ein Button-Widget (der Thunk wird dann zu einem Closure). Dies kann man nicht mit einem yield-basierten Block tun (außer in Ruby, das die yield-Syntax mit einer Thunk-basierten Implementierung verwendet). Aber ich muss sagen, dass ich dies fast als Vorteil sehe: Ich glaube, ich wäre leicht unbehaglich, einen Block zu sehen und nicht zu wissen, ob er im normalen Kontrollfluss oder später ausgeführt wird. Die Definition einer expliziten verschachtelten Funktion für diesen Zweck hat dieses Problem für mich nicht, da ich bereits weiß, dass das Schlüsselwort ‚def‘ bedeutet, dass ihr Körper später ausgeführt wird.

Das andere Problem mit Thunks ist, dass, sobald wir sie als die anonymen Funktionen betrachten, die sie sind, wir ziemlich sicher sagen müssen, dass ein return-Statement in einem Thunk aus dem Thunk und nicht aus der enthaltenden Funktion zurückkehrt. Jede andere Vorgehensweise würde zu größeren Komplikationen führen, wenn der Thunk seine enthaltende Funktion als Closure überlebt (vielleicht könnten Fortsetzungen helfen, aber ich werde mich nicht damit beschäftigen :-)).

Aber dann geht ein meiner Meinung nach wichtiger Anwendungsfall für das Ressourcenbereinigungs-Template-Muster verloren. Ich schreibe routinemäßig Code wie diesen

def findSomething(self, key, default=None):
    self.lock.acquire()
    try:
         for item in self.elements:
             if item.matches(key):
                 return item
         return default
    finally:
       self.lock.release()

und ich wäre enttäuscht, wenn ich das nicht so schreiben könnte

def findSomething(self, key, default=None):
    block locking(self.lock):
         for item in self.elements:
             if item.matches(key):
                 return item
         return default

Dieses spezielle Beispiel kann mit einem break neu geschrieben werden

def findSomething(self, key, default=None):
    block locking(self.lock):
         for item in self.elements:
             if item.matches(key):
                 break
         else:
             item = default
     return item

aber es wirkt erzwungen und die Umwandlung ist nicht immer so einfach; man wäre gezwungen, seinen Code in einem Single-Return-Stil neu zu schreiben, was zu einschränkend ist.

Beachten Sie auch das semantische Dilemma eines Yields in einem Thunk – die einzig vernünftige Interpretation ist, dass dies den Thunk zu einem Generator macht!

Greg Ewing glaubt, dass Thunks „viel einfacher wären, genau das tun, was erforderlich ist, ohne irgendeinen Hokuspokus mit Ausnahmen und break/continue/return-Anweisungen. Es wäre einfach zu erklären, was sie tun und warum sie nützlich sind.“

Aber um die erforderliche lokale Variablenfreigabe zwischen dem Thunk und der enthaltenden Funktion zu erreichen, müsste jede lokale Variable, die im Thunk verwendet oder gesetzt wird, zu einer ‚Zelle‘ werden (unser Mechanismus zur Freigabe von Variablen zwischen verschachtelten Geltungsbereichen). Zellen verlangsamen den Zugriff im Vergleich zu regulären lokalen Variablen: Der Zugriff beinhaltet einen zusätzlichen C-Funktionsaufruf (PyCell_Get() oder PyCell_Set()).

Vielleicht nicht ganz zufällig zeigt das letzte Beispiel oben (findSomething(), neu geschrieben, um einen return innerhalb des Blocks zu vermeiden), dass im Gegensatz zu regulären verschachtelten Funktionen Variablen, die vom Thunk **zugewiesen** werden, auch mit der enthaltenden Funktion geteilt werden sollen, selbst wenn sie außerhalb des Thunks nicht zugewiesen werden.

Greg Ewing wieder: „Generatoren haben sich als leistungsfähiger erwiesen, da man mehrere davon gleichzeitig im Einsatz haben kann. Gibt es dafür einen Nutzen?“

Ich glaube, es gibt definitiv Nutzen dafür; mehrere Leute haben bereits gezeigt, wie man asynchrone, leichtgewichtige Threads mit Generatoren macht (z. B. David Mertz, zitiert in PEP 288, und Fredrik Lundh [3]).

Und schließlich sagt Greg: „Eine Thunk-Implementierung hat das Potenzial, mehrere Blockargumente leicht zu handhaben, wenn eine geeignete Syntax jemals entwickelt werden könnte. Es ist schwer zu sehen, wie das in einem allgemeinen Fall mit der Generatorimplementierung geschehen könnte.“

Die Anwendungsfälle für mehrere Blöcke scheinen jedoch schwer fassbar zu sein.

(Später wurden Vorschläge gemacht, die Implementierung von Thunks zu ändern, um die meisten dieser Einwände zu beseitigen, aber die resultierende Semantik ist ziemlich komplex zu erklären und zu implementieren, so dass dies meiner Meinung nach den Zweck der Verwendung von Thunks von vornherein verfehlt.)

Beispiele

(Mehrere dieser Beispiele enthalten „yield None“. Wenn PEP 342 akzeptiert wird, können diese natürlich zu „yield“ geändert werden.)

  1. Eine Vorlage, um sicherzustellen, dass eine Sperre, die zu Beginn eines Blocks erworben wurde, freigegeben wird, wenn der Block verlassen wird
    def locking(lock):
        lock.acquire()
        try:
            yield None
        finally:
            lock.release()
    

    Verwendet wie folgt

    block locking(myLock):
        # Code here executes with myLock held.  The lock is
        # guaranteed to be released when the block is left (even
        # if via return or by an uncaught exception).
    
  2. Eine Vorlage zum Öffnen einer Datei, die sicherstellt, dass die Datei geschlossen wird, wenn der Block verlassen wird
    def opening(filename, mode="r"):
        f = open(filename, mode)
        try:
            yield f
        finally:
            f.close()
    

    Verwendet wie folgt

    block opening("/etc/passwd") as f:
        for line in f:
            print line.rstrip()
    
  3. Eine Vorlage zum Committen oder Rollback einer Datenbanktransaktion
    def transactional(db):
        try:
            yield None
        except:
            db.rollback()
            raise
        else:
            db.commit()
    
  4. Eine Vorlage, die etwas bis zu n Mal versucht
    def auto_retry(n=3, exc=Exception):
        for i in range(n):
            try:
                yield None
                return
            except exc, err:
                # perhaps log exception here
                continue
        raise # re-raise the exception we caught earlier
    

    Verwendet wie folgt

    block auto_retry(3, IOError):
        f = urllib.urlopen("https://www.example.com/")
        print f.read()
    
  5. Es ist möglich, Blöcke zu verschachteln und Vorlagen zu kombinieren
    def locking_opening(lock, filename, mode="r"):
        block locking(lock):
            block opening(filename) as f:
                yield f
    

    Verwendet wie folgt

    block locking_opening(myLock, "/etc/passwd") as f:
        for line in f:
            print line.rstrip()
    

    (Wenn dieses Beispiel Sie verwirrt, bedenken Sie, dass es äquivalent zur Verwendung einer for-Schleife mit einem Yield in ihrem Körper in einem regulären Generator ist, der einen anderen Iterator oder Generator rekursiv aufruft; siehe zum Beispiel den Quellcode für os.walk().)

  6. Es ist möglich, einen regulären Iterator mit der Semantik von Beispiel 1 zu schreiben
    class locking:
       def __init__(self, lock):
           self.lock = lock
           self.state = 0
       def __next__(self, arg=None):
           # ignores arg
           if self.state:
               assert self.state == 1
               self.lock.release()
               self.state += 1
               raise StopIteration
           else:
               self.lock.acquire()
               self.state += 1
               return None
       def __exit__(self, type, value=None, traceback=None):
           assert self.state in (0, 1, 2)
           if self.state == 1:
               self.lock.release()
           raise type, value, traceback
    

    (Dieses Beispiel kann leicht modifiziert werden, um die anderen Beispiele zu implementieren; es zeigt, wie viel einfacher Generatoren für denselben Zweck sind.)

  7. Stdout temporär umleiten
    def redirecting_stdout(new_stdout):
        save_stdout = sys.stdout
        try:
            sys.stdout = new_stdout
            yield None
        finally:
            sys.stdout = save_stdout
    

    Verwendet wie folgt

    block opening(filename, "w") as f:
        block redirecting_stdout(f):
            print "Hello world"
    
  8. Eine Variante von opening(), die auch eine Fehlerbedingung zurückgibt
    def opening_w_error(filename, mode="r"):
        try:
            f = open(filename, mode)
        except IOError, err:
            yield None, err
        else:
            try:
                yield f, None
            finally:
                f.close()
    

    Verwendet wie folgt

    block opening_w_error("/etc/passwd", "a") as f, err:
        if err:
            print "IOError:", err
        else:
            f.write("guido::0:0::/:/bin/sh\n")
    

Danksagungen

In keiner bestimmten Reihenfolge: Alex Martelli, Barry Warsaw, Bob Ippolito, Brett Cannon, Brian Sabbey, Chris Ryland, Doug Landauer, Duncan Booth, Fredrik Lundh, Greg Ewing, Holger Krekel, Jason Diamond, Jim Jewett, Josiah Carlson, Ka-Ping Yee, Michael Chermside, Michael Hudson, Neil Schemenauer, Alyssa Coghlan, Paul Moore, Phillip Eby, Raymond Hettinger, Georg Brandl, Samuele Pedroni, Shannon Behrens, Skip Montanaro, Steven Bethard, Terry Reedy, Tim Delaney, Aahz und andere. Danke an alle für die wertvollen Beiträge!

Referenzen

[1] https://mail.python.org/pipermail/python-dev/2005-April/052821.html


Quelle: https://github.com/python/peps/blob/main/peps/pep-0340.rst

Zuletzt geändert: 2025-02-01 08:59:27 GMT