v0.2.0 - The object model
Released earlier in development (pre-release).
When you write d = {} in Python, the runtime has to know what
a dict is before it can make one. That sounds trivial and is
not. The dict needs a type object. The type object needs an
MRO. The MRO needs object to be at the root. The keys need to
be hashable, which means they need a __hash__ slot, which is
filled in differently for ints, tuples, and strings. The values
need to be reference-counted, which means refcount fields and
deallocation slots have to be wired before any object exists.
Pull on one thread and the whole sweater comes with it.
v0.2.0 unspools that sweater. It ports the gating subset of
cpython/Objects/ that is needed to do three concrete things:
build a dict, hash a tuple, iterate a list. Everything that
falls under those three operations ships in this release.
Everything that does not (strings, bytes, sets, exceptions,
subtype creation, descriptors) is deferred to a later cut.
The interpreter still does not run Python code. There is no
parser, no compiler, no VM. The only thing that drives the
object model is objtest/, a Go test package that pokes the
objects directly. That is enough to validate the data model
end to end before the VM is layered on top.
Three subsystems land:
objects/. The coreObjectinterface, the refcount machinery, the type-slot surface, and the v0.2 concrete types.typeobj/. C3 linearization and MRO walking.abstract/. The subset ofObjects/abstract.cneeded by the gate (length, getitem, setitem, the arithmetic trio, iteration).
Plus the smoke-test harness, objtest/, that exercises all
three.
Highlights
Three pieces of work define this release.
A real Object interface
Every Python value, ultimately, is a PyObject*. The struct
carries a refcount and a type pointer, and everything else is
either inlined (small ints, single-character strings) or
addressed through the type pointer's slot table.
In Go we cannot just declare a struct and let everyone embed
it; the type system wants more guidance. So objects.Object is
an interface, and every concrete type implements it explicitly.
// objects/object.go
type Object interface {
Header() *Header // gives access to refcount + type
}
type Header struct {
refcount atomic.Int64
typ *Type
}
type VarHeader struct {
Header
size int64 // ob_size for var-sized objects
}
Every concrete type embeds Header (for fixed-size objects
like int, float, bool, None) or VarHeader (for
variable-size objects like tuple, list, dict). The
embedded struct gives them Header() for free.
The refcount is an atomic.Int64 from day one, not a plain
int64. Even in the GIL build, refcount writes are atomic
because the cycle collector (v0.10) and the future
free-threading build (v0.14) both need atomicity. Doing this
later would mean a sweeping rewrite; doing it now is one
keyword.
// objects/refcount.go
func Incref(o Object)
func Decref(o Object)
Incref is a single atomic add. Decref is an atomic sub
plus a check: if the refcount went to zero, look up
tp_dealloc and call it. If the slot is nil, the GC will take
care of the object eventually. The CPython convention is that
every type sets tp_dealloc; we match that for every type that
holds resources outside Go's GC reach (file handles, lock
objects, weakref controllers), and leave it nil for pure data
types where Go's GC suffices.
A type-slot surface that mirrors CPython's PyTypeObject
CPython's PyTypeObject has roughly seventy fields. Many are
function pointers (the slot table), some are arrays
(tp_methods, tp_members, tp_getset), some are bookkeeping
(tp_name, tp_basicsize, tp_itemsize). The v0.2 gate only
needs a fraction of them. We ported that fraction.
// objects/type.go
type Type struct {
Name string
BaseSize uintptr
ItemSize uintptr
// Slot subset present in v0.2:
Repr func(Object) Object
Str func(Object) Object
Hash func(Object) (uint64, error)
RichCmp func(a, b Object, op int) (Object, error)
Iter func(Object) (Object, error)
IterNext func(Object) (Object, error)
Call func(self Object, args, kwargs Object) (Object, error)
Dealloc func(Object)
NumberMethods *NumberMethods // tp_as_number
SequenceMethods *SequenceMethods // tp_as_sequence
MappingMethods *MappingMethods // tp_as_mapping
// MRO is filled by typeobj.SetUpInheritance:
MRO []*Type
}
The three pointer-to-substruct fields (NumberMethods,
SequenceMethods, MappingMethods) mirror the upstream
tp_as_* pattern. They are nil when the type does not
implement that protocol. Doing it this way (rather than
inlining the slots directly into Type) keeps the structure
small for types that only implement one protocol.
// objects/numbermethods.go
type NumberMethods struct {
Add func(a, b Object) (Object, error)
Subtract func(a, b Object) (Object, error)
Multiply func(a, b Object) (Object, error)
// ... 30+ more slots in later releases
}
The v0.2 subset is intentionally small. Each later release fills in the slots it needs and tags the file with the upstream citation. The pattern is the same throughout.
The first concrete types
Nine concrete types land in v0.2:
| Type | What it is | Slot set |
|---|---|---|
int | Arbitrary-precision integers, ported from Objects/longobject.c | Repr, Str, Hash, RichCmp, Number(Add/Subtract/Multiply) |
float | IEEE 754 doubles, ported from Objects/floatobject.c | Repr, Str, Hash, RichCmp, Number |
bool | Subtype of int, two singletons True and False | Inherits int's slots |
None | The singleton, ported from Objects/object.c Py_None | Repr, Hash |
tuple | Immutable sequence, ported from Objects/tupleobject.c | Repr, Hash, RichCmp, Iter, Sequence(Length/GetItem) |
list | Mutable sequence, ported from Objects/listobject.c | Repr, RichCmp, Iter, Sequence(Length/GetItem/SetItem) |
dict | Insertion-ordered hash table, ported from Objects/dictobject.c | Repr, Iter, Mapping(Length/GetItem/SetItem) |
slice | start:stop:step triple, ported from Objects/sliceobject.c | Repr |
range | Lazy integer sequence, ported from Objects/rangeobject.c | Repr, Iter, Sequence(Length/GetItem) |
The dict is the centerpiece. It is a 1:1 port of CPython's
compact-and-ordered dict: the keys and values live in a dense
array indexed by a sparse hash table, insertion order is
preserved, the small-dict optimization (kicks in at less than
8 entries) is in place. We did not simplify it. The compact
layout is what makes Python dicts as fast as they are; pulling
that out and replacing it with a Go map[string]Object would
have been quicker to write and slower at runtime.
// objects/dict.go
type Dict struct {
VarHeader
indices []int32 // hash table; -1 means empty, -2 means deleted
entries []entry // dense, in insertion order
size int
fill int
log2size int // 2^log2size = len(indices)
}
type entry struct {
hash uint64
key Object
value Object
}
The probing sequence, the resize policy, the small-dict threshold, the dummy-slot reuse rules are all upstream's.
// objects/tuple.go
type Tuple struct {
VarHeader
items []Object
}
The tuple hash is the upstream xxHash-flavored mix
(Objects/tupleobject.c tuplehash), with the all-zero
SipHash key for now. The real key plumbing lands in v0.4 when
pyhash.c ports.
What's new
The full feature breakdown, grouped by package.
objects/
The core protocol shared by every concrete type.
Objectinterface. Every concrete type implementsHeader() *Header. The interface is intentionally minimal; everything else is reached through the type slot table.Header. Refcount (atomic.Int64) plus type pointer.VarHeader.Headerplus a 64-bit size field for variable-size objects. The size field is what tuple, list, and dict use forlen().Incref(o). Atomic add to the refcount.Decref(o). Atomic sub plus the dealloc-on-zero check. Callstp_dealloconly if the slot is set; otherwise the Go GC handles cleanup.Is(a, b). Identity comparison. Faster thanRichCmpfor the common case where you just want pointer equality.Hash(o). Looks uptp_hashand calls it. Returns an error if the type is unhashable.Repr(o). Looks uptp_repr. Falls back to a default<type at addr>style string.Str(o). Looks uptp_str. Falls back toReprper upstream.Type. The slot table struct. The slot subset present in v0.2 is:Repr,Str,Hash,RichCmp,Iter,IterNext,Call,Dealloc, plus the three protocol-pointer substructs (NumberMethods,SequenceMethods,MappingMethods).NumberMethods. v0.2 subset:Add,Subtract,Multiply. Each takes twoObjects and returns(Object, error).SequenceMethods. v0.2 subset:Length,GetItem,SetItem. The slice slot (__getitem__with a slice key) is part ofGetItem; the receiver checks for*Slice.MappingMethods. v0.2 subset:Length,GetItem,SetItem. Dicts use this rather than the sequence slots.
typeobj/
The type-construction half. Walks the bases tuple, builds the MRO, makes sure inherited slots are wired.
- C3 linearization. Port of
Objects/typeobject.c mro_implementation. The function is half boilerplate and half clever; we kept the clever half verbatim because it is small and well-tested upstream. Lookup(t, name). Walks the MRO looking forname, returning the first match. The slot lookup the runtime does on every method call goes through this function.- Slot inheritance. When a type inherits from another and does not set a slot, the inherited slot's pointer is copied. This is the inverse of CPython's slot-update machinery; we are doing the easy direction (newly created types pulling slots from their bases). The hard direction (mutating a class and propagating slot updates to subclasses) lands with the descriptor protocol in v0.4.
The built-in types (int, float, tuple, etc.) register
their slots through this package at init time. By the time
the gate runs, the MRO of every built-in type points at
object at the root, and lookups walk the chain.
abstract/
The subset of Objects/abstract.c the gate needs. The
functions exposed here are what user code (and the future
VM) call when it does not want to dispatch through a slot
manually.
PyObject_Length(o). Looks up the sequence or mapping length slot and calls it.PyObject_GetItem(o, key). Dispatches to the mappingGetItemif the type has one; otherwise dispatches to sequenceGetItemwith an integer key check.PyObject_SetItem(o, key, value). Same dispatch asGetItembut the setter side.PyNumber_Add(a, b). Callsa.tp_as_number.Add(a, b)first; if that returnsNotImplemented, callsb.tp_as_number.Add(a, b). This is the reflected-operand fallback CPython uses; the rule is "left operand first, right operand second, error if neither implements it".PyNumber_Subtract(a, b). Same pattern as Add.PyNumber_Multiply(a, b). Same pattern as Add.PyIter_Next(it). Callsit.tp_iternext(). Returnsnil(and no error) on exhaustion. The caller distinguishes exhaustion from error by checking the return values; we do not use a sentinel exception here because exceptions land in v0.3.
objtest/
The first gate. Three Go test functions exercise the v0.2 subsystems end to end. The gate runs in CI on every commit to main.
TestGateBuildDict. Builds a dict, sets a few keys, reads them back, deletes one, confirms the size and the ordering. Exercisesobjects/dict,abstract/getitem,abstract/setitem,objects/hash.TestGateHashTuple. Builds a tuple of small ints, hashes it underPYTHONHASHSEED=0, asserts the result matches the byte sequence CPython produces under the same seed. Exercisesobjects/tuple,objects/int,hash.Secret,typeobj.Lookup.TestGateIterList. Builds a list, gets its iterator, walks the iterator to exhaustion, sums the values. Exercisesobjects/list,abstract.PyIter_Next,objects/int.Add.
If any of the three breaks, the rest of the v0.2 surface is suspect. They are intentionally small and load-bearing; a green gate means the data model is internally consistent.
Why we built it this way
A few choices in this release set patterns that propagate.
Why an interface instead of a struct
In Go, the natural way to express "every Python object has a refcount and a type" is to declare a struct with two fields and embed it everywhere. We considered that path. The problem is that some Python objects are very small (the small-int cache holds 257 ints, each one byte of payload plus the header) and some are very large (a fully populated dict's header is dozens of bytes before the actual entries). A uniform struct would either bloat the small types or constrain the large ones.
Using Object as an interface and Header as an embeddable
struct gives us both. Small types embed Header and add one
payload field. Large types embed VarHeader and add many
payload fields. The interface call cost is one indirection
per Header() call; the type-slot lookup is a separate
indirection that already happens for every method call.
Neither shows up in profiles. Profile-driven flattening can
happen later if we ever need it.
Why we ported dictobject.c faithfully
The CPython dict is the single most performance-critical
data structure in the runtime. Every attribute lookup goes
through one. Every module global is a dict entry. Every
function's keyword arguments are a dict in the calling path.
The compact-and-ordered layout (originally Raymond
Hettinger's design, polished over PEP 468) is what keeps
attribute access fast and memory use low. Replacing it with
a Go map would have lost the insertion ordering (Go's map
iteration is intentionally randomized), lost the compact
layout (Go maps are not compact), and lost the constant-time
key-by-index access we will need for iteration in v0.6.
Faithful port, no shortcuts.
Why we deferred exceptions
CPython's exception machinery is a multi-file subsystem.
Objects/exceptions.c is the exception type hierarchy.
Python/errors.c is the raise / catch dispatch.
Python/traceback.c is the traceback object. The exception
state is part of the per-thread state, which depends on the
thread-state machinery that lands in v0.9. The traceback
references frame objects, which depend on the VM in v0.6.
The set of dependencies forms a graph that points forward
roughly five releases.
We could have stubbed exceptions in v0.2 (a single
error interface plus a couple of types), but stubbed
exceptions would shape the API of every function in
abstract/ and force a rewrite when the real machinery
arrives. So abstract.PyIter_Next returns nil on
exhaustion rather than raising StopIteration; v0.3 ports
the exception types and the iterator surface gets its
proper error semantics then.
Why the all-zero hash key for now
tuple.__hash__ needs to combine the per-element hashes
into a tuple-level hash. CPython does this with a mixing
function whose constants are derived from xxHash. The
function takes a SipHash key as input. We do not have a real
SipHash key yet (the hash secret from v0.1.0 is randomized
but the SipHash function that consumes it lands in v0.4).
The all-zero-key result is deterministic and matches what
CPython produces under PYTHONHASHSEED=0, which is the
mode the gate test uses. Once the real key lands, the same
test will run under random keys and confirm byte identity
against CPython.
The contract is that hash output is identical to CPython for the same seed. We will pay that contract; we just pay it in two installments.
Where it lives
The three packages plus the test harness:
objects/. One file per concrete type plus the protocol files.objects/object.go. TheObjectinterface and theHeader/VarHeaderstructs.objects/refcount.go.Incref/Decref.objects/type.go. TheTypeslot table.objects/int.go. Theinttype, ported fromObjects/longobject.c.objects/float.go. Thefloattype, ported fromObjects/floatobject.c.objects/bool.go. Thebooltype, ported fromObjects/boolobject.c.objects/none.go. TheNonesingleton.objects/tuple.go. Thetupletype, ported fromObjects/tupleobject.c.objects/list.go. Thelisttype, ported fromObjects/listobject.c.objects/dict.go. Thedicttype, ported fromObjects/dictobject.c.objects/slice.go. Theslicetype, ported fromObjects/sliceobject.c.objects/range.go. Therangetype, ported fromObjects/rangeobject.c.
typeobj/. C3 linearization plus MRO walking.typeobj/mro.go. Port ofObjects/typeobject.c mro_implementation.typeobj/lookup.go.Lookup(t, name).
abstract/. The subset ofObjects/abstract.cthe gate uses.abstract/sequence.go.PyObject_Length,PyObject_GetItem,PyObject_SetItem.abstract/number.go.PyNumber_Add,PyNumber_Subtract,PyNumber_Multiply.abstract/iter.go.PyIter_Next.
objtest/. Smoke tests for the gate.objtest/gate_test.go. The three gate tests.
Compatibility
Nothing user-visible runs at this stage. But three behavioral choices set precedent.
- Hash output is bit-identical to CPython under the same
seed, for every value that has a hash slot in v0.2. We
pin this in
objtest/gate_test.go TestGateHashTupleand the test runs on every CI pass. v0.4's real hash key rollout will preserve this property. - Dict iteration preserves insertion order. This is the
Python 3.7+ guarantee. We honor it now even though the
language surface that exposes it (the
for k in dloop) is not in the VM yet. - Refcount is atomic everywhere. We pay the atomic cost in v0.2 rather than retrofit it later when the cycle collector and the free-threaded build need it.
Out of scope
The following are explicitly deferred. None is forgotten; each has a release attached to it.
- Subtype creation.
type(name, bases, dict)and the user-defined-class machinery land in v0.7 with__build_class__. - Full descriptor protocol.
__get__,__set__,__delete__and the slot updaters that walk subclasses land in v0.4 alongside the strings the descriptors return. - Strings and bytes.
str,bytes,bytearray,memoryviewship in v0.4 with the real SipHash port. - Set and frozenset. Port of
Objects/setobject.c, also v0.4. - Exceptions. v0.3. Both the type hierarchy
(
Objects/exceptions.c) and the raise / catch dispatch (Python/errors.c). - Cycle collector. Port of
Modules/gcmodule.c. v0.10. - Free-threading reader paths. The per-object lock machinery the free-threaded build wires onto the type slots. v0.14.
What's next
v0.3.0 adds exceptions. The exception type hierarchy lands
(BaseException, Exception, ValueError, TypeError,
KeyError, the OS error family, the arithmetic error
family), the raise / catch dispatch lands, and the iterator
surface gets its proper StopIteration semantics. The
abstract/ functions that currently signal failure through
return values switch to raising real exceptions.
Then v0.4 adds the rest of the protocol-foundation types: strings, bytes, sets, the full hash machinery, and the descriptor protocol. After that, v0.5 ports the parser and AST so that we can actually feed source code into the system, v0.6 adds the compiler and VM, and the first runnable Python lands.
Acknowledgments
Objects/dictobject.c is one of the most carefully designed
data structures in any language runtime. Reading it is its
own kind of education. The compact-ordered layout, the
probing sequence, the resize policy, the dummy-slot
reuse rules are all worth the time it takes to study them.
We owe Raymond Hettinger, Tim Peters, Mark Shannon, and the
rest of the CPython core for getting that design right at
the source.