|
| 1 | +PEP: 839 |
| 2 | +Title: PyFrozenSetWriter and PyFrozenDictWriter C API |
| 3 | +Author: Donghee Na <donghee.na@python.org> |
| 4 | +Status: Draft |
| 5 | +Type: Standards Track |
| 6 | +Created: 15-Jul-2026 |
| 7 | +Python-Version: 3.16 |
| 8 | + |
| 9 | + |
| 10 | +Abstract |
| 11 | +======== |
| 12 | + |
| 13 | +Add two builder ("writer") C APIs, ``PyFrozenSetWriter`` and |
| 14 | +``PyFrozenDictWriter``, following the design of ``PyBytesWriter`` |
| 15 | +(:pep:`782`). A writer collects items internally; |
| 16 | +``*_Finish()`` produces the immutable object — a ``frozenset`` or a |
| 17 | +``frozendict`` (:pep:`814`) — in a single pass, without ever exposing |
| 18 | +a mutable intermediate object. |
| 19 | + |
| 20 | +In addition, calling ``PySet_Add()`` on a frozenset is soft |
| 21 | +deprecated (:pep:`387`) in favor of ``PyFrozenSetWriter``. |
| 22 | + |
| 23 | + |
| 24 | +Motivation |
| 25 | +========== |
| 26 | + |
| 27 | +The C API offers no way to build a ``frozenset`` or a ``frozendict`` |
| 28 | +item by item without either an intermediate container or mutating |
| 29 | +the object after creation: |
| 30 | + |
| 31 | +``frozenset`` |
| 32 | +------------- |
| 33 | + |
| 34 | +There are only two ways to build a frozenset in C today: |
| 35 | + |
| 36 | +1. ``PyFrozenSet_New(iterable)``: works well when all items already |
| 37 | + sit in one iterable. When items are produced one at a time in C, |
| 38 | + or come from more than one collection, callers must first collect |
| 39 | + them into an intermediate mutable container (set, list, tuple) |
| 40 | + and then copy it, which costs a second allocation and a second |
| 41 | + iteration. |
| 42 | + |
| 43 | +2. The documented pattern of calling ``PySet_Add()`` on a newly |
| 44 | + created frozenset before it is exposed to other code. This |
| 45 | + mutates an object of an immutable type after creation and forces |
| 46 | + the implementation to keep frozensets mutable internally. |
| 47 | + |
| 48 | +``frozendict`` |
| 49 | +-------------- |
| 50 | + |
| 51 | +:pep:`814` added the ``frozendict`` builtin type, which can be |
| 52 | +created in C with ``PyFrozenDict_New(iterable)``. As with |
| 53 | +``PyFrozenSet_New()``, code that produces items one at a time, or |
| 54 | +merges more than one mapping, must first build an intermediate dict |
| 55 | +and then copy it. |
| 56 | + |
| 57 | +CPython itself does not build frozendicts this way: the |
| 58 | +``frozendict()`` constructor fills the new object directly, using |
| 59 | +private dict functions, before exposing it. Extension modules |
| 60 | +cannot use this path. The writer API makes it public. |
| 61 | + |
| 62 | + |
| 63 | +Rationale |
| 64 | +========= |
| 65 | + |
| 66 | +Applying the writer pattern of :pep:`782` to the two immutable |
| 67 | +containers based on hash tables gives: |
| 68 | + |
| 69 | +* **Construction in a single pass** — no intermediate container, no |
| 70 | + copy. |
| 71 | +* **Exact sizing** — ``Finish()`` knows the final number of items and |
| 72 | + can build a table of exactly the right size with no resizing. |
| 73 | +* **A real immutability guarantee** — the returned object was never |
| 74 | + reachable while mutable, so ``Finish()`` may compute and cache the |
| 75 | + hash, decide GC tracking at creation time, and the implementation |
| 76 | + may trust that the object never changes after creation. |
| 77 | +* **A way to replace the pattern of calling ``PySet_Add()`` on a |
| 78 | + frozenset**, the last documented API in the set C API that mutates |
| 79 | + an immutable object. |
| 80 | + |
| 81 | + |
| 82 | +Specification |
| 83 | +============= |
| 84 | + |
| 85 | +PyFrozenSetWriter |
| 86 | +----------------- |
| 87 | + |
| 88 | +.. code-block:: c |
| 89 | +
|
| 90 | + typedef struct PyFrozenSetWriter PyFrozenSetWriter; |
| 91 | +
|
| 92 | + PyAPI_FUNC(PyFrozenSetWriter *) PyFrozenSetWriter_Create( |
| 93 | + Py_ssize_t size_hint); |
| 94 | + PyAPI_FUNC(int) PyFrozenSetWriter_Add( |
| 95 | + PyFrozenSetWriter *writer, |
| 96 | + PyObject *item); |
| 97 | + PyAPI_FUNC(int) PyFrozenSetWriter_Update( |
| 98 | + PyFrozenSetWriter *writer, |
| 99 | + PyObject *iterable); |
| 100 | + PyAPI_FUNC(PyObject *) PyFrozenSetWriter_Finish( |
| 101 | + PyFrozenSetWriter *writer); |
| 102 | + PyAPI_FUNC(void) PyFrozenSetWriter_Discard( |
| 103 | + PyFrozenSetWriter *writer); |
| 104 | +
|
| 105 | +``PyFrozenSetWriter_Create(size_hint)`` |
| 106 | + Create a writer. *size_hint* is the expected number of items |
| 107 | + (``0`` is allowed); it is a hint, not a limit. Return ``NULL`` |
| 108 | + with an exception set on error. |
| 109 | + |
| 110 | +``PyFrozenSetWriter_Add(writer, item)`` |
| 111 | + Add *item* (hashable) to the writer. Duplicate items are |
| 112 | + ignored, as with ``set.add``. The writer holds a strong |
| 113 | + reference to *item*. Return ``0`` on success, ``-1`` with an |
| 114 | + exception set on error; on error the writer remains valid. |
| 115 | + |
| 116 | +``PyFrozenSetWriter_Update(writer, iterable)`` |
| 117 | + Add all items of *iterable*. Same error handling as ``Add``. |
| 118 | + ``Update`` can be called any number of times and mixed with |
| 119 | + ``Add``, so a frozenset can be built from several collections in |
| 120 | + one pass — something ``PyFrozenSet_New()`` cannot do without an |
| 121 | + intermediate mutable set. |
| 122 | + |
| 123 | +``PyFrozenSetWriter_Finish(writer)`` |
| 124 | + Return a new ``frozenset`` containing the collected items and |
| 125 | + destroy the writer. ``Finish`` does not copy the items again. |
| 126 | + On failure, return ``NULL`` with an exception set; the writer is |
| 127 | + destroyed in all cases, matching ``PyBytesWriter_Finish``. |
| 128 | + |
| 129 | +``PyFrozenSetWriter_Discard(writer)`` |
| 130 | + Destroy the writer and release all references it holds, without |
| 131 | + producing an object. ``Discard(NULL)`` does nothing. |
| 132 | + |
| 133 | +PyFrozenDictWriter |
| 134 | +------------------ |
| 135 | + |
| 136 | +.. code-block:: c |
| 137 | +
|
| 138 | + typedef struct PyFrozenDictWriter PyFrozenDictWriter; |
| 139 | +
|
| 140 | + PyAPI_FUNC(PyFrozenDictWriter *) PyFrozenDictWriter_Create( |
| 141 | + Py_ssize_t size_hint); |
| 142 | + PyAPI_FUNC(int) PyFrozenDictWriter_SetItem( |
| 143 | + PyFrozenDictWriter *writer, |
| 144 | + PyObject *key, |
| 145 | + PyObject *value); |
| 146 | + PyAPI_FUNC(int) PyFrozenDictWriter_Update( |
| 147 | + PyFrozenDictWriter *writer, |
| 148 | + PyObject *mapping); |
| 149 | + PyAPI_FUNC(PyObject *) PyFrozenDictWriter_Finish( |
| 150 | + PyFrozenDictWriter *writer); |
| 151 | + PyAPI_FUNC(void) PyFrozenDictWriter_Discard( |
| 152 | + PyFrozenDictWriter *writer); |
| 153 | +
|
| 154 | +Creation, error handling, ``Finish`` and ``Discard`` behave the same |
| 155 | +as ``PyFrozenSetWriter``. ``PyFrozenDictWriter_Finish()`` returns a |
| 156 | +new ``frozendict``. ``SetItem`` requires a hashable key and |
| 157 | +overwrites an existing key, keeping the position of the first |
| 158 | +insertion, like ``frozendict``. ``Update`` accepts anything |
| 159 | +``PyFrozenDict_New()`` accepts. |
| 160 | + |
| 161 | +Soft deprecation of ``PySet_Add()`` on frozensets |
| 162 | +------------------------------------------------- |
| 163 | + |
| 164 | +Calling ``PySet_Add()`` on a ``frozenset`` is *soft deprecated* |
| 165 | +(:pep:`387`): the documentation recommends ``PyFrozenSetWriter`` |
| 166 | +instead; no warning is emitted and no removal is scheduled. |
| 167 | +``PySet_Add()`` on ``set`` objects remains fully supported. |
| 168 | + |
| 169 | +Removing frozenset support from ``PySet_Add()``, which would allow |
| 170 | +the implementation to assume that frozensets never change after |
| 171 | +creation, is left to a future PEP. |
| 172 | + |
| 173 | +Common rules |
| 174 | +------------ |
| 175 | + |
| 176 | +* A writer is not a ``PyObject`` and must never be exposed to Python |
| 177 | + code. |
| 178 | +* A writer must not be used from multiple threads at the same time, |
| 179 | + like ``PyBytesWriter``. |
| 180 | +* Using a writer after ``Finish()`` or ``Discard()`` is undefined |
| 181 | + behavior. |
| 182 | +* Every successful ``Create()`` must be paired with exactly one |
| 183 | + ``Finish()`` or ``Discard()``. |
| 184 | +* Both APIs are excluded from the limited API at first, as |
| 185 | + ``PyBytesWriter`` is. |
| 186 | + |
| 187 | +Example |
| 188 | +------- |
| 189 | + |
| 190 | +.. code-block:: c |
| 191 | +
|
| 192 | + PyObject * |
| 193 | + build_keywords(const char *const *names, Py_ssize_t n) |
| 194 | + { |
| 195 | + PyFrozenSetWriter *w = PyFrozenSetWriter_Create(n); |
| 196 | + if (w == NULL) { |
| 197 | + return NULL; |
| 198 | + } |
| 199 | + for (Py_ssize_t i = 0; i < n; i++) { |
| 200 | + PyObject *s = PyUnicode_FromString(names[i]); |
| 201 | + if (s == NULL || PyFrozenSetWriter_Add(w, s) < 0) { |
| 202 | + Py_XDECREF(s); |
| 203 | + PyFrozenSetWriter_Discard(w); |
| 204 | + return NULL; |
| 205 | + } |
| 206 | + Py_DECREF(s); |
| 207 | + } |
| 208 | + return PyFrozenSetWriter_Finish(w); |
| 209 | + } |
| 210 | +
|
| 211 | +
|
| 212 | +Backwards Compatibility |
| 213 | +======================= |
| 214 | + |
| 215 | +Only new APIs are added. The soft deprecation of ``PySet_Add()`` on |
| 216 | +frozensets is limited to documentation: existing extensions keep |
| 217 | +compiling and running unchanged. |
| 218 | + |
| 219 | + |
| 220 | +Security Implications |
| 221 | +===================== |
| 222 | + |
| 223 | +None known. |
| 224 | + |
| 225 | + |
| 226 | +How to Teach This |
| 227 | +================= |
| 228 | + |
| 229 | +Both APIs will be documented in the `C API reference |
| 230 | +<https://docs.python.org/3/c-api/>`_, with example code. |
| 231 | + |
| 232 | + |
| 233 | +Rejected Ideas |
| 234 | +============== |
| 235 | + |
| 236 | +Hard deprecation of ``PySet_Add()`` on frozensets |
| 237 | +------------------------------------------------- |
| 238 | + |
| 239 | +Emitting a ``DeprecationWarning`` would break extensions using the |
| 240 | +documented pattern. This PEP limits itself to soft deprecation; |
| 241 | +removal is left to a future PEP. |
| 242 | + |
| 243 | + |
| 244 | +Appendix: Migration candidates in CPython |
| 245 | +========================================= |
| 246 | + |
| 247 | +CPython's own C code contains all three patterns this PEP replaces. |
| 248 | +These sites would be migrated as part of the reference |
| 249 | +implementation. |
| 250 | + |
| 251 | +Pattern 1 — ``PySet_Add()`` on a newly created frozenset |
| 252 | +-------------------------------------------------------- |
| 253 | + |
| 254 | +* ``Python/marshal.c`` (``TYPE_FROZENSET``): also needs delayed |
| 255 | + reference registration to keep the frozenset hidden while it is |
| 256 | + mutated. |
| 257 | +* ``Modules/_hashopenssl.c`` (``openssl_md_meth_names``) |
| 258 | +* ``Modules/_ssl.c`` (``ssl_enum_certificates``) |
| 259 | +* ``Modules/_abc.c`` (``__abstractmethods__``) |
| 260 | +* ``Modules/_asynciomodule.c`` (``_asyncio_awaited_by`` getter) |
| 261 | + |
| 262 | +Pattern 2 — intermediate container copied by ``PyFrozenSet_New()`` |
| 263 | +------------------------------------------------------------------ |
| 264 | + |
| 265 | +* ``Python/initconfig.c`` (``PyConfig_Names``): via a list |
| 266 | +* ``Objects/codeobject.c``, ``Python/compile.c``, |
| 267 | + ``Python/flowgraph.c`` (constant interning and folding): via a |
| 268 | + tuple |
| 269 | +* ``Modules/_pickle.c`` (``load_frozenset``): via a list |
| 270 | + |
| 271 | +Pattern 3 — mutable dict copied by ``PyFrozenDict_New()`` |
| 272 | +--------------------------------------------------------- |
| 273 | + |
| 274 | +* ``Python/marshal.c`` (``TYPE_FROZENDICT``): fills a dict, then |
| 275 | + copies the entire table with ``PyFrozenDict_New()``. |
| 276 | + |
| 277 | +``Objects/dictobject.c`` already builds frozendicts in a single pass |
| 278 | +internally; this PEP makes that construction path available through a |
| 279 | +supported API. |
| 280 | + |
| 281 | +Example migration (``Python/marshal.c``, ``TYPE_FROZENDICT``): |
| 282 | + |
| 283 | +.. code-block:: c |
| 284 | +
|
| 285 | + // Before: build a dict, then copy it into a frozendict |
| 286 | + v = PyDict_New(); |
| 287 | + for (;;) { |
| 288 | + ... PyDict_SetItem(v, key, val) ... |
| 289 | + } |
| 290 | + Py_SETREF(v, PyFrozenDict_New(v)); |
| 291 | +
|
| 292 | + // After: build the frozendict directly, one pass, exact size |
| 293 | + PyFrozenDictWriter *w = PyFrozenDictWriter_Create(n); |
| 294 | + for (;;) { |
| 295 | + ... PyFrozenDictWriter_SetItem(w, key, val) ... |
| 296 | + } |
| 297 | + v = PyFrozenDictWriter_Finish(w); |
| 298 | +
|
| 299 | +
|
| 300 | +References |
| 301 | +========== |
| 302 | + |
| 303 | +* :pep:`782` — Add PyBytesWriter C API |
| 304 | +* :pep:`814` — Add frozendict built-in type |
| 305 | + |
| 306 | + |
| 307 | +Copyright |
| 308 | +========= |
| 309 | + |
| 310 | +This document is placed in the public domain or under the |
| 311 | +CC0-1.0-Universal license, whichever is more permissive. |
0 commit comments