diff --git a/Include/internal/pycore_dict_state.h b/Include/internal/pycore_dict_state.h index bb6fe2625975596..68fd98570857a8e 100644 --- a/Include/internal/pycore_dict_state.h +++ b/Include/internal/pycore_dict_state.h @@ -16,6 +16,7 @@ struct _Py_dict_state { PyMutex watcher_mutex; // Protects the watchers array (free-threaded builds) _PyOnceFlag watcher_setup_once; // One-time optimizer watcher setup PyDict_WatchCallback watchers[DICT_MAX_WATCHERS]; + PyObject *empty_frozendict; }; #define _dict_state_INIT \ diff --git a/Include/internal/pycore_pylifecycle.h b/Include/internal/pycore_pylifecycle.h index 12e1e78526db35a..9b4a2eb5fc20164 100644 --- a/Include/internal/pycore_pylifecycle.h +++ b/Include/internal/pycore_pylifecycle.h @@ -39,6 +39,7 @@ extern PyStatus _Py_HashRandomization_Init(const PyConfig *); extern PyStatus _PyGC_Init(PyInterpreterState *interp); extern PyStatus _PyAtExit_Init(PyInterpreterState *interp); extern PyStatus _PyDateTime_InitTypes(PyInterpreterState *interp); +extern PyStatus _PyDict_Init(PyInterpreterState *interp); /* Various internal finalizers */ @@ -55,6 +56,7 @@ extern void _PyAST_Fini(PyInterpreterState *interp); extern void _PyAtExit_Fini(PyInterpreterState *interp); extern void _PyThread_FiniType(PyInterpreterState *interp); extern void _PyArg_Fini(void); +extern void _PyDict_Fini(PyInterpreterState *interp); extern void _Py_FinalizeAllocatedBlocks(_PyRuntimeState *); extern PyStatus _PyGILState_Init(PyInterpreterState *interp); diff --git a/Lib/test/test_capi/test_dict.py b/Lib/test/test_capi/test_dict.py index 4cf404e9f563272..4434818fe3810b0 100644 --- a/Lib/test/test_capi/test_dict.py +++ b/Lib/test/test_capi/test_dict.py @@ -669,10 +669,14 @@ def test_frozendict_new(self): self.assertIsNot(fd2, fd) self.assertEqual(fd2, fd) - # PyFrozenDict_New(NULL) creates an empty dictionary - dct = frozendict_new(NULL) - self.assertEqual(dct, frozendict()) - self.assertIs(type(dct), frozendict) + # PyFrozenDict_New(NULL) gets the empty frozendict singleton + singleton = frozendict() + self.assertIs(frozendict_new(NULL), singleton) + + # PyFrozenDict_New(iterable) gets the empty frozendict singleton + # if iterable is empty + for iterable in ((), [], {}, frozendict(), [x for x in ()]): + self.assertIs(frozendict_new(iterable), singleton) if __name__ == "__main__": diff --git a/Lib/test/test_dict.py b/Lib/test/test_dict.py index 6d61c71e162f8be..de068c1bbfb7835 100644 --- a/Lib/test/test_dict.py +++ b/Lib/test/test_dict.py @@ -1864,15 +1864,51 @@ def test_merge(self): self.assertIs(frozendict() | fd, fd) fd = FrozenDict(x=1, y=2) - self.assertEqual(fd | frozendict(), fd) - self.assertEqual(fd | {}, fd) - self.assertEqual(frozendict() | fd, fd) + merge = fd | frozendict() + self.assertEqual(type(merge), frozendict) + self.assertEqual(merge, fd) + merged = fd | {} + self.assertEqual(type(merge), frozendict) + self.assertEqual(merge, fd) + merge = frozendict() | fd + self.assertEqual(type(merge), frozendict) + self.assertEqual(merge, fd) # gh-149676: Test hash(frozendict | frozendict) a = frozendict({"a": 1}) b = frozendict({"b": 2}) self.assertEqual(hash(a | b), hash(frozendict({"a": 1, "b": 2}))) + @support.cpython_only + def test_empty_singleton(self): + singleton = frozendict() + self.assertFalse(gc.is_tracked(singleton)) + self.assertTrue(sys._is_immortal(singleton)) + + # test constructor + self.assertIs(frozendict(), singleton) + self.assertIs(frozendict(singleton), singleton) + self.assertIs(frozendict({}), singleton) + self.assertIs(frozendict([]), singleton) + self.assertIs(frozendict((x for x in ())), singleton) + + # test merge + for dict_type in (dict, frozendict, FrozenDict): + empty = dict_type() + self.assertIs(singleton | empty, singleton) + self.assertIs(FrozenDict() | empty, singleton) + + # test .copy() + self.assertIs(singleton.copy(), singleton) + + # test .fromkeys() + self.assertIs(frozendict.fromkeys([]), singleton) + + # test pickle (protocol 0 and 1 don't support frozendict) + for proto in range(2, pickle.HIGHEST_PROTOCOL + 1): + fd = pickle.loads(pickle.dumps(singleton, proto)) + self.assertIs(fd, singleton) + def test_update(self): # test "a |= b" operator d = frozendict(x=1) @@ -1935,6 +1971,8 @@ def test_hash_cpython(self): def test_fromkeys(self): self.assertEqual(frozendict.fromkeys('abc'), frozendict(a=None, b=None, c=None)) + self.assertEqual(frozendict.fromkeys([]), + frozendict()) # Subclass which overrides the constructor created = frozendict(x=1) @@ -1970,12 +2008,13 @@ def __new__(cls, *args, **kwargs): self.assertEqual(created, frozendict(x=1)) # Subclass which doesn't override the constructor - class FrozenDictSubclass2(frozendict): - pass - - fd = FrozenDictSubclass2.fromkeys("abc") + fd = FrozenDict.fromkeys("abc") self.assertEqual(fd, frozendict(a=None, b=None, c=None)) - self.assertEqual(type(fd), FrozenDictSubclass2) + self.assertEqual(type(fd), FrozenDict) + + fd = FrozenDict.fromkeys("") + self.assertEqual(fd, frozendict()) + self.assertEqual(type(fd), FrozenDict) def test_pickle(self): for proto in range(pickle.HIGHEST_PROTOCOL + 1): diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-07-05-05-39-16.gh-issue-153060.p1G8nO.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-07-05-05-39-16.gh-issue-153060.p1G8nO.rst new file mode 100644 index 000000000000000..413324c34e087a5 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-07-05-05-39-16.gh-issue-153060.p1G8nO.rst @@ -0,0 +1 @@ +Add an empty :class:`frozendict` singleton. Patch by Victor Stinner. diff --git a/Objects/dictobject.c b/Objects/dictobject.c index 6029205eac8b20f..845d4a7d1aa47e7 100644 --- a/Objects/dictobject.c +++ b/Objects/dictobject.c @@ -124,6 +124,7 @@ As a consequence of this, split keys have a maximum size of 16. #include "pycore_dict.h" // export _PyDict_SizeOf() #include "pycore_freelist.h" // _PyFreeListState_GET() #include "pycore_gc.h" // _PyObject_GC_IS_TRACKED() +#include "pycore_initconfig.h" // _PyStatus_OK() #include "pycore_object.h" // _PyObject_GC_TRACK(), _PyDebugAllocatorStats() #include "pycore_pyatomic_ft_wrappers.h" // FT_ATOMIC_LOAD_SSIZE_RELAXED #include "pycore_pyerrors.h" // _PyErr_GetRaisedException() @@ -301,7 +302,7 @@ load_keys_nentries(PyDictObject *mp) #ifndef NDEBUG // Check if it's possible to modify a dictionary. // Usage: assert(can_modify_dict(mp)). -static inline int +static int can_modify_dict(PyDictObject *mp) { if (PyFrozenDict_Check(mp)) { @@ -312,6 +313,9 @@ can_modify_dict(PyDictObject *mp) // No locking required to modify a newly created frozendict // since it's only accessible from the current thread. assert(PyUnstable_Object_IsUniquelyReferenced(_PyObject_CAST(mp))); + + // The empty frozendict singleton must not be modified! + assert((PyObject*)mp != _PyInterpreterState_GET()->dict_state.empty_frozendict); } else { // Locking is only required if the dictionary is not @@ -320,7 +324,7 @@ can_modify_dict(PyDictObject *mp) } return 1; } -#endif +#endif // !NDEBUG #define _PyAnyDict_CAST(op) \ (assert(PyAnyDict_Check(op)), _Py_CAST(PyDictObject*, op)) @@ -452,6 +456,30 @@ unicode_get_hash(PyObject *o) return PyUnstable_Unicode_GET_CACHED_HASH(o); } + +static PyObject* +frozendict_get_empty(void) +{ + PyInterpreterState *interp = _PyInterpreterState_GET(); + PyObject *empty = interp->dict_state.empty_frozendict; + // The assertion fails if an empty frozendict was created + // before _PyDict_Init() or after _PyDict_Fini(). + assert(empty != NULL); + assert(_Py_IsImmortal(empty)); + assert(!_PyObject_GC_IS_TRACKED(empty)); + return empty; // borrowed ref since it's immortal +} + + +// Check if a fresh frozendict is empty and so can be replaced with the empty +// frozendict singleton. +static inline int +replace_with_empty_frozendict(PyObject *d) +{ + return (PyFrozenDict_CheckExact(d) && GET_USED((PyDictObject *)d) == 0); +} + + /* Print summary info about the state of the optimized allocator */ void _PyDict_DebugMallocStats(FILE *out) @@ -973,6 +1001,9 @@ static PyObject* new_frozendict_untracked(PyDictKeysObject *keys, PyDictValues *values, Py_ssize_t used, int free_values_on_failure) { + // If used == 0, it means that frozendict_get_empty() could be used + assert(used >= 1); + PyDictObject *mp = PyObject_GC_New(PyDictObject, &PyFrozenDict_Type); return new_dict_impl(mp, keys, values, used, free_values_on_failure, 1, 0); } @@ -3436,12 +3467,16 @@ _PyDict_FromKeys(PyObject *cls, PyObject *iterable, PyObject *value) // gh-151722: If cls constructor returns a frozendict which is tracked by // the GC, create a frozendict copy which is not tracked by the GC. + // A copy is also needed if the constructor returned the empty frozendict + // singleton (which is no tracked by the GC). // // At the function exit, return cls(fd) where fd is a frozendict. // // Untracking the frozendict requires tracking again the frozendict on // error which is more complicated. It's easier to work on a copy. - if (PyFrozenDict_Check(d) && _PyObject_GC_IS_TRACKED(d)) { + if (PyFrozenDict_Check(d) + && (_PyObject_GC_IS_TRACKED(d) || (d == frozendict_get_empty()))) + { need_copy = 1; PyObject *copy = frozendict_new_untracked(&PyFrozenDict_Type); @@ -3594,6 +3629,11 @@ _PyDict_FromKeys(PyObject *cls, PyObject *iterable, PyObject *value) PyObject *copy = _PyObject_CallOneArg(cls, d); Py_SETREF(d, copy); } + else if (replace_with_empty_frozendict(d)) { + // iterable was empty + Py_DECREF(d); + d = frozendict_get_empty(); + } else if (!_PyObject_GC_IS_TRACKED(d)) { _PyObject_GC_TRACK(d); } @@ -4510,10 +4550,10 @@ copy_lock_held_untracked(PyObject *o, int as_frozendict) } PyDictObject *new; if (as_frozendict) { - new = (PyDictObject *)new_frozendict_untracked(keys, NULL, 0, 0); + new = (PyDictObject *)new_frozendict_untracked(keys, NULL, mp->ma_used, 0); } else { - new = (PyDictObject *)new_dict_untracked(keys, NULL, 0, 0); + new = (PyDictObject *)new_dict_untracked(keys, NULL, mp->ma_used, 0); } if (new == NULL) { /* In case of an error, new_dict()/new_frozendict() takes care of @@ -4521,7 +4561,7 @@ copy_lock_held_untracked(PyObject *o, int as_frozendict) return NULL; } - new->ma_used = mp->ma_used; + assert(new->ma_used == mp->ma_used); ASSERT_CONSISTENT(new); assert(!_PyObject_GC_IS_TRACKED(new)); return (PyObject *)new; @@ -5168,14 +5208,14 @@ static PyObject * frozendict_or(PyObject *self, PyObject *other) { if (PyFrozenDict_CheckExact(self)) { - // frozendict() | frozendict(...) => frozendict(...) + // empty frozendict | frozendict(...) => frozendict(...) if (GET_USED((PyDictObject *)self) == 0 && PyFrozenDict_CheckExact(other)) { return Py_NewRef(other); } - // frozendict(...) | frozendict() => frozendict(...) + // frozendict(...) | empty frozendict => frozendict(...) if (PyAnyDict_CheckExact(other) && GET_USED((PyDictObject *)other) == 0) { @@ -5183,7 +5223,18 @@ frozendict_or(PyObject *self, PyObject *other) } } - return _PyDict_Or(self, other); + if (PyFrozenDict_Check(self) && GET_USED((PyDictObject *)self) == 0 + && PyAnyDict_Check(other) && GET_USED((PyDictObject *)other) == 0) + { + // empty frozendict | empty dict => empty frozendict singleton + return frozendict_get_empty(); + } + + PyObject *new = _PyDict_Or(self, other); + if (new != NULL) { + assert(!replace_with_empty_frozendict(new)); + } + return new; } @@ -5398,13 +5449,20 @@ frozendict_vectorcall(PyObject *type, PyObject * const*args, if (!_PyArg_CheckPositional("frozendict", nargs, 0, 1)) { return NULL; } + int no_kwnames = (kwnames == NULL || PyTuple_GET_SIZE(kwnames) == 0); - if (nargs == 1 && kwnames == NULL - && PyFrozenDict_CheckExact(args[0]) - && Py_Is((PyTypeObject*)type, &PyFrozenDict_Type)) - { - // frozendict(frozendict) returns the same object unmodified - return Py_NewRef(args[0]); + if (Py_Is((PyTypeObject*)type, &PyFrozenDict_Type)) { + if (nargs == 0 && no_kwnames) { + // frozendict() => empty frozendict singleton + return frozendict_get_empty(); + } + + if (nargs == 1 && no_kwnames + && PyFrozenDict_CheckExact(args[0])) + { + // frozendict(frozendict) returns the same object unmodified + return Py_NewRef(args[0]); + } } /* gh-151722: Keep the frozendict untracked until it is fully built, @@ -5430,6 +5488,12 @@ frozendict_vectorcall(PyObject *type, PyObject * const*args, } } + if (replace_with_empty_frozendict(self)) { + // the positional argument was an empty dictionary or an empty iterator + Py_DECREF(self); + return frozendict_get_empty(); + } + _PyObject_GC_TRACK(self); return self; } @@ -8443,19 +8507,26 @@ frozendict_new_untracked(PyTypeObject *type) static PyObject * frozendict_new(PyTypeObject *type, PyObject *args, PyObject *kwds) { + if ((args == NULL || PyTuple_GET_SIZE(args) == 0) + && (kwds == NULL || PyDict_GET_SIZE(kwds) == 0)) + { + return frozendict_get_empty(); + } + PyObject *d = frozendict_new_untracked(type); if (d == NULL) { return NULL; } - if (args != NULL) { - if (dict_update_common(d, args, kwds, "frozendict") < 0) { - Py_DECREF(d); - return NULL; - } + if (dict_update_common(d, args, kwds, "frozendict") < 0) { + Py_DECREF(d); + return NULL; } - else { - assert(kwds == NULL); + + if (replace_with_empty_frozendict(d)) { + // the positional argument was an empty dictionary or an empty iterator + Py_DECREF(d); + return frozendict_get_empty(); } _PyObject_GC_TRACK(d); @@ -8481,8 +8552,7 @@ PyFrozenDict_New(PyObject *iterable) return frozendict; } else { - PyObject *args = Py_GetConstantBorrowed(Py_CONSTANT_EMPTY_TUPLE); - return frozendict_new(&PyFrozenDict_Type, args, NULL); + return frozendict_new(&PyFrozenDict_Type, NULL, NULL); } } @@ -8504,6 +8574,7 @@ frozendict_copy_impl(PyFrozenDictObject *self) PyObject *copy = anydict_copy_untracked((PyObject*)self); if (copy != NULL) { + assert(!replace_with_empty_frozendict(copy)); _PyObject_GC_TRACK(copy); } return copy; @@ -8536,3 +8607,32 @@ PyTypeObject PyFrozenDict_Type = { .tp_vectorcall = frozendict_vectorcall, .tp_version_tag = _Py_TYPE_VERSION_FROZENDICT, }; + + +PyStatus +_PyDict_Init(PyInterpreterState *interp) +{ + PyObject *empty = anydict_new_untracked(&PyFrozenDict_Type); + if (empty == NULL) { + return _PyStatus_NO_MEMORY(); + } + _PyFrozenDictObject_CAST(empty)->ma_hash = -1; + // Do not track the empty frozendict singleton in the GC + assert(!_PyObject_GC_IS_TRACKED(empty)); + + _Py_SetImmortal(empty); + + interp->dict_state.empty_frozendict = empty; + return _PyStatus_OK(); +} + + +void +_PyDict_Fini(PyInterpreterState *interp) +{ + PyObject *empty = interp->dict_state.empty_frozendict; + interp->dict_state.empty_frozendict = NULL; + if (empty != NULL) { + _Py_ClearImmortal(empty); + } +} diff --git a/Python/pylifecycle.c b/Python/pylifecycle.c index 01ca459b2eb2b8f..2e4b897597f836f 100644 --- a/Python/pylifecycle.c +++ b/Python/pylifecycle.c @@ -978,6 +978,11 @@ pycore_interp_init(PyThreadState *tstate) return status; } + status = _PyDict_Init(interp); + if (_PyStatus_EXCEPTION(status)) { + return status; + } + status = pycore_init_types(interp); if (_PyStatus_EXCEPTION(status)) { goto done; @@ -2138,6 +2143,8 @@ finalize_interp_clear(PyThreadState *tstate) finalize_interp_types(tstate->interp); + _PyDict_Fini(tstate->interp); + /* Finalize dtoa at last so that finalizers calling repr of float doesn't crash */ _PyDtoa_Fini(tstate->interp);