Skip to main content

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 core Object interface, the refcount machinery, the type-slot surface, and the v0.2 concrete types.
  • typeobj/. C3 linearization and MRO walking.
  • abstract/. The subset of Objects/abstract.c needed 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:

TypeWhat it isSlot set
intArbitrary-precision integers, ported from Objects/longobject.cRepr, Str, Hash, RichCmp, Number(Add/Subtract/Multiply)
floatIEEE 754 doubles, ported from Objects/floatobject.cRepr, Str, Hash, RichCmp, Number
boolSubtype of int, two singletons True and FalseInherits int's slots
NoneThe singleton, ported from Objects/object.c Py_NoneRepr, Hash
tupleImmutable sequence, ported from Objects/tupleobject.cRepr, Hash, RichCmp, Iter, Sequence(Length/GetItem)
listMutable sequence, ported from Objects/listobject.cRepr, RichCmp, Iter, Sequence(Length/GetItem/SetItem)
dictInsertion-ordered hash table, ported from Objects/dictobject.cRepr, Iter, Mapping(Length/GetItem/SetItem)
slicestart:stop:step triple, ported from Objects/sliceobject.cRepr
rangeLazy integer sequence, ported from Objects/rangeobject.cRepr, 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.

  • Object interface. Every concrete type implements Header() *Header. The interface is intentionally minimal; everything else is reached through the type slot table.
  • Header. Refcount (atomic.Int64) plus type pointer.
  • VarHeader. Header plus a 64-bit size field for variable-size objects. The size field is what tuple, list, and dict use for len().
  • Incref(o). Atomic add to the refcount.
  • Decref(o). Atomic sub plus the dealloc-on-zero check. Calls tp_dealloc only if the slot is set; otherwise the Go GC handles cleanup.
  • Is(a, b). Identity comparison. Faster than RichCmp for the common case where you just want pointer equality.
  • Hash(o). Looks up tp_hash and calls it. Returns an error if the type is unhashable.
  • Repr(o). Looks up tp_repr. Falls back to a default <type at addr> style string.
  • Str(o). Looks up tp_str. Falls back to Repr per 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 two Objects and returns (Object, error).
  • SequenceMethods. v0.2 subset: Length, GetItem, SetItem. The slice slot (__getitem__ with a slice key) is part of GetItem; 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 for name, 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 mapping GetItem if the type has one; otherwise dispatches to sequence GetItem with an integer key check.
  • PyObject_SetItem(o, key, value). Same dispatch as GetItem but the setter side.
  • PyNumber_Add(a, b). Calls a.tp_as_number.Add(a, b) first; if that returns NotImplemented, calls b.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). Calls it.tp_iternext(). Returns nil (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. Exercises objects/dict, abstract/getitem, abstract/setitem, objects/hash.
  • TestGateHashTuple. Builds a tuple of small ints, hashes it under PYTHONHASHSEED=0, asserts the result matches the byte sequence CPython produces under the same seed. Exercises objects/tuple, objects/int, hash.Secret, typeobj.Lookup.
  • TestGateIterList. Builds a list, gets its iterator, walks the iterator to exhaustion, sums the values. Exercises objects/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. The Object interface and the Header / VarHeader structs.
    • objects/refcount.go. Incref / Decref.
    • objects/type.go. The Type slot table.
    • objects/int.go. The int type, ported from Objects/longobject.c.
    • objects/float.go. The float type, ported from Objects/floatobject.c.
    • objects/bool.go. The bool type, ported from Objects/boolobject.c.
    • objects/none.go. The None singleton.
    • objects/tuple.go. The tuple type, ported from Objects/tupleobject.c.
    • objects/list.go. The list type, ported from Objects/listobject.c.
    • objects/dict.go. The dict type, ported from Objects/dictobject.c.
    • objects/slice.go. The slice type, ported from Objects/sliceobject.c.
    • objects/range.go. The range type, ported from Objects/rangeobject.c.
  • typeobj/. C3 linearization plus MRO walking.
    • typeobj/mro.go. Port of Objects/typeobject.c mro_implementation.
    • typeobj/lookup.go. Lookup(t, name).
  • abstract/. The subset of Objects/abstract.c the 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 TestGateHashTuple and 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 d loop) 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, memoryview ship 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.