How CPython Implements Descriptors (property, classmethod, staticmethod, and generic descriptors) in C
Descriptors in CPython 3.15: the PyDescrObject base, tp_descr_get/tp_descr_set slots, data vs non-data descriptors, member_descriptor and getset_descriptor, propertyobject, classmethod and staticmethod, and how attribute resolution invokes them, traced through descrobject.c and funcobject.c.
Descriptors are the mechanism behind property, classmethod,
staticmethod, slots, and most of the attributes you read off classes.
In CPython they are not a separate object hierarchy so much as a pair of
slots on a type object – tp_descr_get and tp_descr_set – plus a
small family of C structs that implement them. This article traces that
implementation through the 3.15 source.
The descriptor protocol: tp_descr_get / tp_descr_set
Every type object carries two optional function pointers:
// Include/cpython/object.h (in PyTypeObject)
descrgetfunc tp_descr_get;
descrsetfunc tp_descr_set;with signatures
PyObject *(*tp_descr_get)(PyObject *self, PyObject *obj, PyObject *type);
int (*tp_descr_set)(PyObject *self, PyObject *obj, PyObject *value);When an attribute lookup finds an object in a class (or its bases) whose
type has tp_descr_get, CPython calls it instead of returning the object
raw. self is the descriptor found, obj is the instance (or NULL
for class-level access), and type is the class. If tp_descr_set is
non-NULL the object is a data descriptor and wins over instance
__dict__ entries; otherwise it is a non-data descriptor and loses to
them. That single distinction is PyDescr_IsData:
// Objects/descrobject.c
int
PyDescr_IsData(PyObject *ob)
{
return Py_TYPE(ob)->tp_descr_set != NULL;
}So the protocol is just: “if the found attribute’s type has these slots, call them.” Everything below is a particular implementation of them.
The common base: PyDescrObject
All the built-in descriptors in descrobject.c share a head:
// Include/cpython/descrobject.h
typedef struct {
PyObject_HEAD
PyTypeObject *d_type; /* type the descriptor belongs to */
PyObject *d_name; /* attribute name */
PyObject *d_qualname; /* qualified name */
} PyDescrObject;d_type is used by descr_check to verify the object an instance
descriptor is applied to is really an instance of that type:
// Objects/descrobject.c
static int
descr_check(PyDescrObject *descr, PyObject *obj)
{
if (!PyObject_TypeCheck(obj, descr->d_type)) {
PyErr_Format(PyExc_TypeError,
"descriptor '%V' for '%.100s' objects "
"doesn't apply to a '%.100s' object", ...);
return -1;
}
return 0;
}d_name/d_qualname drive __qualname__ and the error messages. Each
concrete descriptor embeds this as PyDescr_COMMON and adds its payload.
member_descriptor: PyMemberDescrObject
A member_descriptor is what __slots__ and PyMemberDef produce. It
wraps a PyMemberDef pointing at a C-level field offset:
// Include/cpython/descrobject.h
typedef struct {
PyDescr_COMMON;
PyMemberDef *d_member;
} PyMemberDescrObject;Its get/set are thin wrappers over PyMember_GetOne/PyMember_SetOne,
which read/write the raw C field at the given offset:
// Objects/descrobject.c
static PyObject *
member_get(PyObject *self, PyObject *obj, PyObject *type)
{
PyMemberDescrObject *descr = (PyMemberDescrObject *)self;
if (obj == NULL) {
return Py_NewRef(descr); // class access returns the descriptor
}
if (descr_check((PyDescrObject *)descr, obj) < 0) {
return NULL;
}
...
return PyMember_GetOne((char *)obj, descr->d_member);
}
static int
member_set(PyObject *self, PyObject *obj, PyObject *value)
{
PyMemberDescrObject *descr = (PyMemberDescrObject *)self;
if (descr_setcheck((PyDescrObject *)descr, obj, value) < 0) {
return -1;
}
return PyMember_SetOne((char *)obj, descr->d_member, value);
}Because member_set is non-NULL, every member_descriptor is a data
descriptor, so it shadows instance __dict__.
getset_descriptor: PyGetSetDescrObject
getset_descriptor is the C equivalent of property for types defined in
C: it wraps a PyGetSetDef holding get/set function pointers and a
closure:
// Include/cpython/descrobject.h
typedef struct {
PyDescr_COMMON;
PyGetSetDef *d_getset;
} PyGetSetDescrObject;// Objects/descrobject.c
static PyObject *
getset_get(PyObject *self, PyObject *obj, PyObject *type)
{
PyGetSetDescrObject *descr = (PyGetSetDescrObject *)self;
if (obj == NULL) {
return Py_NewRef(descr);
}
if (descr_check((PyDescrObject *)descr, obj) < 0) {
return NULL;
}
if (descr->d_getset->get != NULL)
return descr_get_trampoline_call(
descr->d_getset->get, obj, descr->d_getset->closure);
PyErr_Format(PyExc_AttributeError, "attribute '%V' ... is not readable", ...);
return NULL;
}
static int
getset_set(PyObject *self, PyObject *obj, PyObject *value)
{
PyGetSetDescrObject *descr = (PyGetSetDescrObject *)self;
if (descr_setcheck((PyDescrObject *)descr, obj, value) < 0) {
return -1;
}
if (descr->d_getset->set != NULL) {
return descr_set_trampoline_call(
descr->d_getset->set, obj, value, descr->d_getset->closure);
}
PyErr_Format(PyExc_AttributeError, "attribute '%V' ... is not writable", ...);
return -1;
}get/set may be NULL, in which case the attribute is read-only or
write-only. Data-descriptor status depends on whether set is provided
at type creation – tp_descr_set is wired to getset_set only when the
PyGetSetDef has a setter.
property: propertyobject
property is the Python-level descriptor, defined in the same file. Its
struct lives in the internal header:
// Include/internal/pycore_descrobject.h
typedef struct {
PyObject_HEAD
PyObject *prop_get;
PyObject *prop_set;
PyObject *prop_del;
PyObject *prop_doc;
PyObject *prop_name;
int getter_doc;
} propertyobject;It implements both slots, so it is a data descriptor:
// Objects/descrobject.c
static PyObject *
property_descr_get(PyObject *self, PyObject *obj, PyObject *type)
{
if (obj == NULL || obj == Py_None) {
return Py_NewRef(self); // class access returns the property itself
}
propertyobject *gs = (propertyobject *)self;
if (gs->prop_get == NULL) {
... raise AttributeError("property ... has no getter") ...
return NULL;
}
return PyObject_CallOneArg(gs->prop_get, obj);
}
static int
property_descr_set(PyObject *self, PyObject *obj, PyObject *value)
{
propertyobject *gs = (propertyobject *)self;
PyObject *func = (value == NULL) ? gs->prop_del : gs->prop_set;
...
if (value == NULL)
res = PyObject_CallOneArg(func, obj);
else {
PyObject *args[] = { obj, value };
res = PyObject_Vectorcall(func, args, 2, NULL);
}
...
}property.__init__ (clinic-generated property_init_impl) stores the
callables, resolves __doc__ from the getter when none is given, and
records getter_doc so the doc assignment can be retried later.
property_copy is what .getter/.setter/.deleter use to build a
new property with one slot replaced – that is how the decorator chaining
works.
Because property_descr_set is always wired up, a property is a data
descriptor even when it has no setter: writes then raise AttributeError
instead of falling through to __dict__.
classmethod: classmethod
Defined in Objects/funcobject.c:
typedef struct {
PyObject_HEAD
PyObject *cm_callable;
PyObject *cm_dict;
} classmethod;Its descriptor binds the wrapped callable to the class, not the instance:
// Objects/funcobject.c
static PyObject *
cm_descr_get(PyObject *self, PyObject *obj, PyObject *type)
{
classmethod *cm = (classmethod *)self;
if (type == NULL)
type = (PyObject *)(Py_TYPE(obj));
return PyMethod_New(cm->cm_callable, type);
}Even instance.method binds to type(instance), and class access binds
to the class passed in. cm_callable is stored under __func__/__wrapped__,
and functools_wraps copies metadata during construction.
staticmethod: staticmethod
typedef struct {
PyObject_HEAD
PyObject *sm_callable;
PyObject *sm_dict;
} staticmethod;static PyObject *
sm_descr_get(PyObject *self, PyObject *obj, PyObject *type)
{
staticmethod *sm = (staticmethod *)self;
return Py_NewRef(sm->sm_callable);
}A staticmethod never binds: it just returns the wrapped callable. It has
no tp_descr_set, so it is a non-data descriptor and, as an
optimization, the attribute machinery in typeobject.c short-circuits it
entirely:
// Objects/typeobject.c (attribute resolution)
if (Py_TYPE(attr_obj) == &PyStaticMethod_Type) {
PyObject *callable = _PyStaticMethod_GetFunc(attr_obj);
if (callable) {
result_ref.ref = PyStackRef_FromPyObjectNew(callable);
goto done;
}
}Generic descriptors and the rest of descrobject.c
The same file also hosts the C-level descriptors used by built-in types:
PyMethodDescrObject(method_descriptor) – wraps aPyMethodDef;method_getbinds it into a C function/method on instance access, or returns the descriptor on class access.PyWrapperDescrObject(slot_wrapper) – wraps awrapperbaseslot such as__init__or__repr__;wrapperdescr_getreturns aPyWrapper_Newbound wrapper.
Both are non-data descriptors (tp_descr_set == 0), so user code can
override them in a subclass __dict__.
Python-level descriptors
A plain Python class can also be a descriptor by defining __get__,
__set__, or __delete__. When such a class is stored in another
class’s __dict__, typeobject.c does not call those methods directly;
instead the machinery in Objects/typeobject.c synthesizes the slot
functions slot_tp_descr_get/slot_tp_descr_set during PyType_Ready,
which look up __get__/__set__/__delete__ on the descriptor’s type
and invoke them. That is how a user-written class MyDescr with a
__get__ method ends up with a non-NULL tp_descr_get even though its
C type started with none. The same mechanism fills in
tp_descr_set when __set__ or __delete__ is present, making the
descriptor a data descriptor.
Py_TPFLAGS_HAVE_GC and friends are unrelated; descriptor-ness is
purely a function of those two slots. PyType_Ready also propagates
tp_descr_get/tp_descr_set from base classes via COPYSLOT, and
inherits Py_TPFLAGS_METHOD_DESCRIPTOR when a type’s tp_descr_get is
the same as its base’s, which lets the interpreter skip some calls for
plain methods.
How attribute resolution drives all of this
The central routine (in Objects/typeobject.c) looks roughly like:
- Look in the metatype; if a data descriptor is found there, call its
tp_descr_getand return. - Otherwise look in the type and its bases.
- If a data descriptor is found, call
tp_descr_get(instance ignored for class-level access). - If a plain value is found, return it, unless an instance
__dict__entry exists and the found descriptor is non-data. - If nothing is found, fall back to
__getattr__/__getattribute__.
That ordering is why property and slots (data descriptors) beat instance
attributes, while functions, classmethod, staticmethod, and method
descriptors (non-data) can be shadowed by instance attributes.
Key takeaways
- Descriptors are just
tp_descr_get/tp_descr_setslots; data vs non-data is determined by whethertp_descr_setis non-NULL. PyDescrObject(d_type,d_name,d_qualname) is the common head formember_descriptor,getset_descriptor, method/slot wrappers.member_descriptorreads/writes a C field viaPyMember_GetOne/SetOne;getset_descriptorcalls aPyGetSetDef’s get/set.propertystoresprop_get/set/del/doc/nameand is always a data descriptor.classmethodbinds to the class (PyMethod_New(cm_callable, type)),staticmethodreturns the callable unchanged, and the type machinery short-circuits the latter.- Attribute resolution calls
tp_descr_getwith(descriptor, instance, class)and orders data descriptors before instance__dict__.
Further reading
Objects/descrobject.c–PyDescrObject,member_get/set,getset_get/set,property_descr_get/set,property_init_impl.Include/cpython/descrobject.h–PyDescrObject,PyMemberDescrObject,PyGetSetDescrObject,PyWrapperDescrObject.Include/internal/pycore_descrobject.h–propertyobject.Objects/funcobject.c–classmethod/staticmethodstructs,cm_descr_get,sm_descr_get.Objects/typeobject.c– attribute resolution and the staticmethod short-circuit.Lib/test/test_descr.py,Lib/test/test_property.py– reference behavior and edge cases.