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
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 normalendictan 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 übergebenencollection-Objekt. Das übergebenecollection-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.
- ein
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 einemFrozenMapCopy-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
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.
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.MutableMappingmit O(1)-Get- und Set-Operationen (frozenmaphat 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.
Urheberrecht
Dieses Dokument wird in die Public Domain oder unter die CC0-1.0-Universal-Lizenz gestellt, je nachdem, welche Lizenz permissiver ist.
Quelle: https://github.com/python/peps/blob/main/peps/pep-0603.rst
Zuletzt geändert: 2025-02-01 08:59:27 GMT