Classes and objects in practice
Open in the interactive app: practice, quiz, mock interview →In one minute
- A class is a type that bundles data with the operations allowed on it; an object is one instance of that type, living somewhere in memory with its own copy of the data. The point of bundling is to keep invariants: rules about the data that every operation preserves.
- A good class makes invalid states impossible to construct and impossible to reach. Validate in the constructor, change state only through methods that re-check the rules, and never hand out references to internal mutable collections.
- Decide early whether a class is a value (two instances with the same fields are the same thing, like an amount of money or a track) or an entity (it has an identity that persists while its fields change, like a playlist or an account). That decision drives equality, hashing and copying.
- Languages disagree on defaults. Python and JavaScript assign references; Go copies structs but shares the slices and maps inside them; Python compares objects by identity unless you define
__eq__; Go's==compares fields. - Agents generate classes with public mutable fields, no validation and
__eq__without__hash__. Review asks: what are the invariants, which constructor or method could break them, and what happens when this object is a dictionary key or gets copied?
How it works
Data plus the rules that guard it
A music app stores playlists as dictionaries: {"name": "Run", "tracks": [...], "limit": 3600}. Three months later there are playlists with empty names, tracks with negative durations from a bad import, and a playlist 40 minutes over its limit because one code path appended to tracks directly.
A class moves the rules next to the data. The constructor refuses to build an invalid playlist, and the only way to change the track list is add, which checks the limit first. Every other function can then assume a playlist is valid. That is the real product of a class: the places a bug can create bad data shrink from "everywhere" to "these six methods".
Constructors establish, methods preserve
An invariant has two halves. The constructor establishes it: trim the name, reject empty ones, reject a limit below one second. Each mutating method preserves it: check first, then mutate, so a failed call leaves the object unchanged. The order matters. A method that appends a track and then raises "over limit" has already broken the playlist; the exception just reports the damage.
Two leaks undo this work. Returning the internal list (return self._tracks) lets callers append behind the method's back, so return a copy or an immutable view. Accepting a list in the constructor and storing it directly lets the caller keep a reference and mutate it later, so copy on the way in as well.
Values and entities
Two Track("Intro", "Nils", 214) objects are interchangeable: same title, artist and duration, same thing. A track is a value, and values should be immutable, compare by their fields and hash by the same fields, so they work as dictionary keys and set members. Changing a value means making a new one.
A playlist is an entity. Rename it, add three songs, and it is still the same playlist, the one with ID 41 that a user shared last week. Two playlists with identical songs are still two playlists. Entities compare by identity, usually an explicit ID rather than memory address, because the same entity may be loaded twice from a database into two objects that must compare equal.
Mixing these up causes real bugs. Value equality on a mutable entity breaks hash tables: put a playlist in a set, add a song, and the set can no longer find it. Identity equality on a value lets duplicates slip through, because a freshly parsed track never matches a stored one.
Equality and hashing per language
- Python:
==falls back to identity unless the class defines__eq__. Defining__eq__sets__hash__toNone, so instances become unhashable until you define__hash__from the same fields.@dataclass(frozen=True)generates all three consistently. - Go:
==on structs compares every field, and a struct whose fields are all comparable can be a map key with no code at all. A struct with a slice or map field cannot be compared and will not compile as a map key. - JavaScript and TypeScript:
===on objects is identity, andMapandSetuse identity for object keys. Value semantics need anequalsmethod and a string or tuple key, such as `${title}|${artist}|${seconds}`.
Copy semantics
In Python and JavaScript, b = a copies a reference: there is still one object. In Go, b := a on a struct copies the fields, which looks like a full copy until a field is a slice or map, whose header is copied but whose backing storage is shared. Shallow copies (copy.copy, {...obj}, slices.Clone of pointers) copy one level. The Prototype lesson covers deep copies of graphs; for everyday classes the rule is simpler. Values are immutable, so sharing them is safe and copying them is pointless. Entities own their mutable parts, and a "copy" of an entity is usually a new entity with a new ID and its own collections, such as "duplicate playlist".
Reviewing agent-written code
An agent asked for a Playlist class will produce public fields, a constructor that stores whatever it is given, and perhaps a plain @dataclass, which compares playlists by content and makes them unhashable. Ask for written invariants with a test per invariant, frozen=True on values, ID-based equality on entities, and a test that mutating a returned collection does not change the object.
The same shape elsewhere
Value versus entity is the duplicates problem shape. Event deduplication, idempotency keys and cache keys all ask when two things are the same thing. A cache keyed on a mutable object and an idempotency key built from fields that change on retry are the same bug.
Down the stack
Layer 1The contract
A class promises that every instance reachable by callers satisfies its invariants, both after construction and after every method call, including failed ones. Value types promise that equality is reflexive, symmetric and transitive, that equal instances have equal hashes, and that both are stable for the object's lifetime. Entities promise that equality follows identity and does not change when fields change. A class documents whether returned collections are copies or views, and whether copying an instance shares anything.
Layer 2The mechanism
CPython represents every object as a PyObject header (a reference count and a pointer to the type) followed by type-specific data. A normal class instance stores attributes in a dictionary; since PEP 412, instances of one class share a single keys table and store only values, and 3.11 and later can keep those values inline in the object until something forces a real dict. __slots__ replaces the dictionary with fixed offsets, implemented as descriptors on the class. Attribute lookup walks the type's method resolution order (MRO); a per-type cache tagged with a version number skips the walk, and the 3.11 specialising interpreter rewrites hot LOAD_ATTR instructions into variants such as LOAD_ATTR_INSTANCE_VALUE that read a fixed slot after one type check.
V8 gives each object a pointer to a hidden class (V8 calls it a map) that records property names and offsets. Objects built by the same constructor, assigning fields in the same order, share a map, and inline caches at each property access remember "this map means offset 16". Adding a property later, or in a different order, creates a new map and can turn a fast monomorphic call site into a slow megamorphic one.
Go has no object header. A struct is its fields, laid out in declaration order with alignment padding. Methods are ordinary functions with the receiver as the first argument, resolved at compile time; dynamic dispatch exists only through interface values, which are two words: an itab pointer (type plus method table) and a data pointer.
Layer 3The machine
Object size is mostly headers and pointers. On 64-bit CPython a plain instance with three attributes costs roughly 100 to 200 bytes including its dictionary or inline values, plus 16 bytes of garbage collector header; with __slots__ it is about 56 to 72 bytes. Small objects come from pymalloc pools of fixed size classes in 8-byte steps up to 512 bytes, so a 57-byte request uses a 64-byte block. A JVM object has a 12-byte header with compressed class pointers, padded to a multiple of 8. A V8 object has a map pointer, a properties pointer and an elements pointer before its in-object fields, each 4 bytes with pointer compression.
Go's layout makes padding visible. struct{ a bool; b int64; c bool } is 24 bytes, because b must start at an 8-byte boundary and the struct size rounds up to 8; reordering to struct{ b int64; a, c bool } gives 16 bytes. Across ten million elements in a slice that is 80 MB saved, and fewer cache lines touched per scan. Allocation cost differs too: Go's escape analysis keeps non-escaping structs on the stack at zero allocation cost, V8 bump-allocates new objects in a young generation in a few nanoseconds, and CPython pays roughly 50 to 100 ns to create an instance through __init__.
Why it's built this way
- Data that any code can modify ends up invalid somewhere, so data is bundled with the only operations allowed to change it.
- Checking rules everywhere is impossible to maintain, so the constructor establishes them once and methods preserve them, which lets the rest of the code assume validity.
- Some things are defined by their contents and others by their history, so languages and libraries distinguish value equality from identity, and hash tables require the two to agree with hashing.
- Dynamic languages cannot know an object's shape at compile time, so runtimes recover it at run time with shared key tables, hidden classes and inline caches.
- Every header, pointer and padding byte is multiplied by the number of instances, so compact layouts (
__slots__, field ordering, value types) matter only when instances run into millions.
Diagram
classDiagram
class Track {
+title: str
+artist: str
+seconds: int
+equals(other) bool
+hash() int
}
class Playlist {
+id: str
-name: str
-max_seconds: int
-tracks: list~Track~
+add(track)
+remove(track) bool
+tracks() list~Track~
+rename(name)
+copy_as(new_id) Playlist
}
Playlist "1" o-- "*" Track : holds valuesWorked example
A podcast platform keeps 12 million episode records in memory in a ranking service, each a Python object with eight attributes. With ordinary instances, tracemalloc showed about 230 bytes per object plus its strings: 2.8 GB of objects before any string data, and the service needed 6 GB hosts. Garbage collection passes took 900 ms because the collector walks every container object.
The team made the episode class a @dataclass(frozen=True, slots=True), since ranking only reads episodes and updates arrive as whole new records. Objects dropped to about 104 bytes, saving 1.5 GB. Because the class was now a hashable value, they also deduplicated identical episodes syndicated under different feeds through a dictionary keyed by the episode itself, which removed another 9% of objects. Frozen instances made the deduplication safe, since nobody could mutate a shared episode.
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Plain dict or map | No ceremony; easy to serialise | No invariants; typos create new keys | Throwaway scripts, data in transit |
| Immutable value class | Safe to share and hash; trivially thread-safe | New object per change | Money, coordinates, tracks, IDs, config |
| Mutable entity with ID equality | Models things that change over time | Must guard invariants in every method | Accounts, orders, playlists, sessions |
Class with __slots__ or packed struct | Less memory, faster attribute access | No dynamic attributes; harder to extend | Millions of instances |
| Record or dataclass generator | Equality, hash and repr generated consistently | Defaults (mutable, eq by value) may be wrong for entities | Most value types |
Pitfalls in production
- A mutable object used as a dictionary key or set member, then mutated, so lookups miss it. Fix: hash only immutable values; give entities ID-based hashing.
__eq__defined without__hash__, which makes instances unhashable in Python, or a JavaequalswithouthashCode, which silently breaksHashMap. Fix: generate both, or define both from the same fields and test them together.- Getters that return internal lists, so callers bypass validation. Fix: return copies or read-only views, and copy collections passed into constructors.
- Methods that mutate before validating, leaving objects half-changed when they raise. Fix: compute and check first, mutate last.
- Go structs copied by value that still share a slice or map, so the "copy" changes the original. Fix: clone collection fields explicitly in a copy method.
- V8 objects with fields added conditionally after construction, giving many hidden classes and slow call sites. Fix: initialise every field in the constructor, in the same order.
Leadership lens
- In design review, ask "is this a value or an entity?" for every new domain type. The answer decides equality, hashing, mutability and the database key, and changing it later touches every caller.
- Require invariants to be written down and tested through public methods. They are the cheapest documentation of business rules a team will ever have.
- Treat memory layout as a scaling decision, not a style one: it matters at millions of instances and is noise below. Ask for a measurement before anyone adds
__slots__everywhere. - Steer agents with a template: invariants in a docstring, validation in the constructor, check-then-mutate methods, defensive copies, and equality tests. Generated classes without these look complete and are not.
Check yourself
Why does a method that appends first and validates second break the class contract even though it raises an error?
Show answer
The object has already been mutated into an invalid state, and the caller may catch the error and keep using it. Checks must happen before any mutation so a failed call leaves the object unchanged.
Why should a playlist compare by ID rather than by its tracks?
Show answer
A playlist is an entity whose contents change over time; comparing by contents would make its hash change on every edit and would treat two different playlists with the same songs as one.
A Go struct holds a
[]stringfield. What happens tob := aand to using the struct as a map key?Show answer
The assignment copies the slice header, so
aandbshare the backing array. A struct with a slice field is not comparable, so it cannot be a map key and will not compile as one.When is
__slots__worth adding?Show answer
When a class has a fixed set of attributes and you hold enough instances (hundreds of thousands or more) that the per-instance dictionary shows up in memory profiles.
Watch
- Raymond Hettinger: Dataclasses, the code generator to end all code generators (PyCon 2018): what
@dataclassgenerates for equality, hashing, ordering and immutability, and when to use it. - JavaScript Engines: The Good Parts (Mathias Bynens and Benedikt Meurer, JSConf EU 2018): how engines represent objects with shapes and inline caches.
- Slots, slots, slots, everybody: an exploration of __slots__ (Douglas Anderson, PyCon Canada): the memory cost of per-instance dictionaries and how slots remove it.
Free resources
- Data model (Python docs):
__eq__,__hash__,__slots__and the rules that tie them together. - dataclasses (Python docs):
frozen,eq,slotsand how the generated hash depends on them. - Shapes and inline caches (Mathias Bynens): how JavaScript engines share object layouts between instances.
- Fast properties in V8 (V8 blog): in-object, fast and dictionary properties and when objects fall back to slow mode.
- The Go Programming Language Specification: the comparison rules that decide which structs can use
==and be map keys. - Value Object (Martin Fowler): the value versus reference object distinction and why value objects should be immutable.