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

Python Enhancement Proposals

PEP 603 – Hinzufügen eines frozenmap-Typs zu collections

Autor:
Yury Selivanov <yury at edgedb.com>
Discussions-To:
Discourse thread
Status:
Entwurf
Typ:
Standards Track
Erstellt:
12. September 2019
Post-History:
12. September 2019

Inhaltsverzeichnis

Zusammenfassung

Eine persistente Datenstruktur ist eine Datenstruktur, die die vorherige Version der Daten beibehält, wenn die Daten modifiziert werden. Solche Datenstrukturen sind effektiv unveränderlich, da Operationen darauf die Struktur nicht vor Ort aktualisieren, sondern immer eine neue aktualisierte Struktur liefern (siehe [0] für weitere Details).

Diese PEP schlägt die Hinzufügung eines neuen, vollständig persistenten und unveränderlichen Mapping-Typs namens frozenmap zum Modul collections vor.

Der Großteil der Referenzimplementierung von frozenmap wird bereits in CPython zur Implementierung des Moduls contextvars verwendet.

Begründung

Python hat zwei unveränderliche Sammlungstypen: tuple und frozenset. Diese Typen können zur Darstellung unveränderlicher Listen und Mengen verwendet werden. Es gibt jedoch noch keine Möglichkeit, unveränderliche Mappings darzustellen, und diese PEP schlägt einen frozenmap vor, um ein unveränderliches Mapping zu implementieren.

Der vorgeschlagene Typ frozenmap

  • implementiert das Protokoll collections.abc.Mapping,
  • unterstützt Pickling und
  • bietet eine API zur effizienten Erstellung von „modifizierten“ Versionen.

Die folgenden Anwendungsfälle verdeutlichen, warum ein unveränderliches Mapping wünschenswert ist

  • Unveränderliche Mappings sind hashable, was ihre Verwendung als Dictionary-Schlüssel oder Set-Elemente ermöglicht.

    Diese Hashable-Eigenschaft erlaubt es Funktionen, die mit @functools.lru_cache() dekoriert sind, unveränderliche Mappings als Argumente zu akzeptieren. Im Gegensatz zu einem unveränderlichen Mapping führt die Übergabe eines normalen dict an eine solche Funktion zu einem Fehler.

  • Unveränderliche Mappings können komplexe Zustände speichern. Da unveränderliche Mappings per Referenz kopiert werden können, kann eine transaktionale Mutation von Zuständen effizient implementiert werden.
  • Unveränderliche Mappings können verwendet werden, um Dictionaries sicher über Thread- und asynchrone Task-Grenzen hinweg zu teilen. Die Unveränderlichkeit erleichtert die Nachvollziehbarkeit von Threads und asynchronen Tasks.

Zuletzt enthält CPython [1] bereits den Hauptteil des C-Codes, der für die Implementierung von frozenmap erforderlich ist. Der C-Code existiert bereits zur Implementierung des Moduls contextvars (siehe PEP 567 für weitere Details). Die Bereitstellung dieses C-Codes über einen öffentlichen Sammlungstyp erhöht drastisch die Anzahl der Benutzer des Codes. Dies führt zu einer erhöhten Codequalität durch das Entdecken von Fehlern und die Verbesserung der Leistung, was ohne eine frozenmap-Sammlung sehr schwierig wäre, da die meisten Programme das Modul contextvars indirekt verwenden.

Spezifikation

Ein neuer öffentlicher unveränderlicher Typ frozenmap wird dem Modul collections hinzugefügt.

Konstruktion

frozenmap implementiert eine dict-ähnliche Konstruktions-API

  • frozenmap() erstellt ein neues leeres unveränderliches Mapping;
  • frozenmap(**kwargs) erstellt ein Mapping aus **kwargs, z. B. frozenmap(x=10, y=0, z=-1)
  • frozenmap(collection) erstellt ein Mapping aus dem übergebenen collection-Objekt. Das übergebene collection-Objekt kann sein
    • ein dict,
    • ein weiteres frozenmap,
    • ein Objekt mit einer items()-Methode, das eine Reihe von Schlüssel/Wert-Tupeln zurückgeben soll, oder
    • ein Iterable von Schlüssel/Wert-Tupeln.

Datenzugriff

frozenmap implementiert das Protokoll collection.abc.Mapping. Daher funktionieren Getter, Mitgliedsprüfungen und Iteration genauso wie bei einem dict.

m = frozenmap(foo='bar')

assert m['foo'] == 'bar'
assert m.get('foo') == 'bar'
assert 'foo' in m

assert 'baz' not in m
assert m.get('baz', 'missing') == 'missing'

assert m == m
assert m != frozenmap()  # m is not equal to an empty frozenmap

assert len(m) == 1

# etc.

Mutation

frozenmap-Instanzen sind unveränderlich. Dennoch ist es möglich, mutierte Kopien der unveränderlichen Instanz effizient zu erzeugen.

Die Komplexität von Mutationsoperationen beträgt O(log N), und die resultierenden frozenmap-Kopien verbrauchen aufgrund der Verwendung von strukturellem Teilen oft nur sehr wenig zusätzlichen Speicher (siehe [6] für weitere Details).

frozenmap.including(key, value)

Die Methode erstellt eine neue frozenmap-Kopie mit einem neuen Schlüssel/Wert-Paar.

m = frozenmap(foo=1)
m2 = m.including('bar', 100)

print(m)   # will print frozenmap({'foo': 1})
print(m2)  # will print frozenmap({'foo': 1, 'bar': 100})

frozenmap.excluding(key)

Die Methode erzeugt eine Kopie des frozenmap, die einen gelöschten Schlüssel nicht enthält.

m = frozenmap(foo=1, bar=100)

m2 = m.excluding('foo')

print(m)   # will print frozenmap({'foo': 1, 'bar': 100})
print(m2)  # will print frozenmap({'bar': 1})

m3 = m.excluding('spam')  # will throw a KeyError('spam')

frozenmap.union(mapping=None, **kw)

Die Methode erzeugt eine Kopie des frozenmap und fügt mehrere Schlüssel/Werte hinzu oder ersetzt sie für die erstellte Kopie. Die Signatur der Methode entspricht der Signatur des frozenmap-Konstruktors.

m = frozenmap(foo=1)

m2 = m.union({'spam': 'ham'})
print(m2)  # will print frozenmap({'foo': 1, 'spam': 'ham'})

m3 = m.union(foo=100, y=2)
print(m3)  # will print frozenmap({'foo': 100, 'y': 2})

print(m)   # will print frozenmap({'foo': 1})

Das Aufrufen der Methode union() zum Hinzufügen/Ersetzen von N Schlüsseln ist effizienter als das N-malige Aufrufen der Methode including().

frozenmap.mutating()

Die Methode ermöglicht das effiziente Kopieren einer frozenmap-Instanz mit mehreren angewandten Modifikationen. Diese Methode ist besonders nützlich, wenn die betreffende frozenmap Tausende von Schlüssel/Wert-Paaren enthält und viele davon in einem leistungsbeschränkten Teil des Codes aktualisiert werden müssen.

Die Methode frozenmap.mutating() gibt eine veränderbare, dict-ähnliche Kopie des frozenmap-Objekts zurück: eine Instanz von collections.FrozenMapCopy.

Die Objekte FrozenMapCopy

  • sind Copy-on-Write-Ansichten der Daten von frozenmap-Instanzen, von denen sie erstellt wurden;
  • sind veränderlich, obwohl keine Änderungen an ihnen die frozenmap-Instanzen beeinflussen, von denen sie erstellt wurden;
  • können an den frozenmap-Konstruktor übergeben werden; die Erstellung einer frozenmap aus einem FrozenMapCopy-Objekt ist eine O(1)-Operation;
  • haben eine Komplexität von O(log N) für Get/Set-Operationen; ihre Erstellung ist eine O(1)-Operation;
  • haben eine Methode FrozenMapCopy.close(), die jeden weiteren Zugriff/jede weitere Mutation der Daten verhindert;
  • können als Context Manager verwendet werden.

Das folgende Beispiel veranschaulicht, wie mutating() mit einem Context Manager verwendet werden kann.

numbers = frozenmap((i, i ** 2) for i in range(1_000_000))

with numbers.mutating() as copy:
    for i in numbers:
        if not (numbers[i] % 997):
            del copy[i]

    numbers_without_997_multiples = frozenmap(copy)

    # at this point, *numbers* still has 1_000_000 key/values, and
    # *numbers_without_997_multiples* is a copy of *numbers* without
    # values that are multiples of 997.

    for i in numbers:
        if not (numbers[i] % 593):
            del copy[i]

    numbers_without_593_multiples = frozenmap(copy)

    print(copy[10])  # will print 100.

print(copy[10])  # This will throw a ValueError as *copy*
                 # has been closed when the "with" block
                 # was executed.

Iteration

Da frozenmap das Standardprotokoll collections.abc.Mapping implementiert, werden alle erwarteten Iterationsmethoden unterstützt.

assert list(m) == ['foo']
assert list(m.items()) == [('foo', 'bar')]
assert list(m.keys()) == ['foo']
assert list(m.values()) == ['bar']

Die Iteration in frozenmap bewahrt, im Gegensatz zu dict, nicht die Einfügungsreihenfolge.

Hashing

frozenmap-Instanzen können, ähnlich wie tuple-Objekte, hashable sein.

hash(frozenmap(foo='bar'))  # works
hash(frozenmap(foo=[]))     # will throw an error

Typisierung

Es ist möglich, die Standard-Typisierungsschreibweise für frozenmaps zu verwenden.

m: frozenmap[str, int] = frozenmap()

Implementierung

Der vorgeschlagene unveränderliche Typ frozenmap verwendet eine Hash Array Mapped Trie (HAMT) Datenstruktur. Funktionale Programmiersprachen wie Clojure verwenden HAMTs zur effizienten Implementierung unveränderlicher Hash-Tabellen, Vektoren und Mengen.

HAMT

Der Hauptvertragsbestandteil von HAMT ist die Garantie eines vorhersagbaren Werts bei gegebenem Hash eines Schlüssels. Für ein Paar aus Schlüssel und Wert kann der Hash des Schlüssels verwendet werden, um den Speicherort des Werts im Hash-Map-Baum zu bestimmen.

Unveränderliche Mappings, die mit HAMT implementiert sind, haben eine O(log N) Leistung für set()- und get()-Operationen. Diese Effizienz ist möglich, da Mutationsoperationen nur einen Zweig des Baumes beeinflussen, was die Wiederverwendung nicht mutierter Zweige und somit die Vermeidung von Kopien unveränderter Daten ermöglicht.

Lesen Sie mehr über HAMT in [5]. Die CPython-Implementierung [1] enthält ebenfalls eine recht detaillierte Beschreibung des Algorithmus.

Performance

../_images/pep-0603-hamt_vs_dict.png

Abbildung 1. Benchmark-Code finden Sie hier: [3].

Die obige Grafik zeigt, dass

  • frozenmap, implementiert mit HAMT, eine nahezu O(1)-Leistung für alle gemessenen Dictionary-Größen aufweist.
  • dict.copy() wird bei etwa 100-200 Elementen weniger effizient.
../_images/pep-0603-lookup_hamt.png

Abbildung 2. Benchmark-Code finden Sie hier: [4].

Abbildung 2 vergleicht die Lookup-Kosten von dict mit denen eines HAMT-basierten unveränderlichen Mappings. Die HAMT-Lookup-Zeit ist im Durchschnitt etwa 30 % langsamer als Python-Dict-Lookups. Dieser Leistungsunterschied besteht, da das Durchlaufen eines flachen Baumes weniger effizient ist als ein Lookup in einem flachen, kontinuierlichen Array.

Darüber hinaus, zitierend [6]: „[die Verwendung von HAMT] bedeutet, dass in der Praxis, obwohl Einfügungen, Löschungen und Lookups in einem persistenten Hash Array Mapped Trie eine rechnerische Komplexität von O(log n) haben, sie für die meisten Anwendungen effektiv konstante Zeit haben, da eine extrem große Anzahl von Einträgen erforderlich wäre, um einer Operation mehr als ein Dutzend Schritte abzuverlangen.“

Designüberlegungen

Warum „frozenmap“ und nicht „FrozenMap“

Das Kleinbuchstaben-„frozenmap“ resoniert gut mit dem integrierten frozenset sowie mit Typen wie collections.defaultdict.

Warum „frozenmap“ und nicht „frozendict“

„Dict“ hat eine sehr spezifische Bedeutung in Python

  • ein dict ist eine konkrete Implementierung von abc.MutableMapping mit O(1)-Get- und Set-Operationen (frozenmap hat O(log N)-Komplexität);
  • Python-Dicts bewahren die Einfügungsreihenfolge.

Der vorgeschlagene frozenmap hat nicht diese genannten Eigenschaften. Stattdessen hat frozenmap Kosten von O(log N) für Set/Get-Operationen und implementiert nur das Protokoll abc.Mapping.

Implementierung

Die vollständige Implementierung des vorgeschlagenen Typs frozenmap ist unter [2] verfügbar. Das Paket enthält C- und reine Python-Implementierungen des Typs.

Siehe auch die HAMT-Sammlungsimplementierung als Teil des CPython-Projektbaums hier: [1].

Referenzen

Danksagungen

Ich danke Carol Willing, Łukasz Langa, Larry Hastings und Guido van Rossum für ihr Feedback, ihre Ideen, Edits und Diskussionen zu dieser PEP.


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

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