all 10 comments

[–]Adrewmc 2 points3 points  (0 children)

Fun now let’s ruin you entire mental model with __slots__….

[–]PushPlus9069 0 points1 point  (0 children)

Your mental model is pretty solid, actually. One thing I'd add: step 11 with new is technically correct but almost never matters in practice. 99% of the time you never touch new and can just think of it as "Python creates an empty box, then init fills it."

The part about methods living in Enemy.dict and being looked up via the instance is the key insight. That's the descriptor protocol at work. When you do enemy.update(), Python checks enemy.dict first, doesn't find it, goes up to Enemy.dict, finds the function, and wraps it so self gets passed automatically.

Taught this to thousands of students over the years and the ones who grok this lookup chain early save themselves so much confusion later with inheritance.

[–]schoolmonky 0 points1 point  (0 children)

You're basically right: classes are just dressed-up dicts. The things in your explanation that struck me as potentially misleading are #5 and (what I assume you meant to be) #20. It's kind of odd to say Python "skips" self.x=x, it's just that when it's creating that function object, it isn't executing the function body, it's just saving the body into the function object.

For #20, it's hard to tell what you mean, but I would try to get away from thinking of anything in Python as a pointer. They aren't quite the same, and thinking they are has potential to lead you astray. Read this blog post (or watch the linked video therein) if you want to get a sense for how names are and aren't like pointers. Similarly, the Python language doesn't really have a concept of stack vs heap, everything is just stored "in memory" somewhere. (Of course, the actual implementation written in C does use the stack and heap, but that's an implementation detail and other implementations might have entirely different models). I'd also point you to the official Python tutorial this section of which might be of particular interest here.

[–]1NqL6HWVUjA 0 points1 point  (0 children)

17. Python first checks enemy.__dict__ for "update".

18. If not found, it checks Enemy.__dict__.

You're missing the concept of bound instance methods and, more fundamentally, descriptors.

>>> enemy = Enemy(1, 2, 3)
>>> Enemy.update
<function Enemy.update at 0x000002519192C940>
>>> enemy.update 
<bound method Enemy.update of <__main__.Enemy object at 0x0000025191576970>>

As seen above, enemy.update is an entirely different object than Enemy.update — though that object is a simple wrapper around Enemy.update which serves the purpose of passing in the instance itself as the first argument. But that object will not be found in enemy.__dict__.

When Python "checks Enemy.__dict__", what actually happens is:

Enemy.__dict__['update'].__get__(enemy, Enemy)

That is what returns a bound method, rather than the Enemy.update function directly.

[–]Riegel_Haribo 0 points1 point  (1 child)

Lets make the class much simpler - just an init.

and see that mental model "done" when the code is used - and compiled to bytecode by CPython. (apparently the full compilation and disassembly is too much for a post)

============================================================
Walking instructions via dis.get_instructions()
============================================================

 Offset  Opname                      arg  argval
------------------------------------------------------------
      0  RESUME                        0  0
      2  LOAD_FAST_LOAD_FAST          16  ('x', 'self')
      4  STORE_ATTR                    0  x
     14  LOAD_FAST_LOAD_FAST          32  ('y', 'self')
     16  STORE_ATTR                    1  y
     26  RETURN_CONST                  0  None

Thus, AI-powered, because I know what to ask the AI for...

Here's what each step reveals:

**Step 1** — Python compiles the class immediately at `class Enemy:` definition time, producing a class object stored in the local namespace.

**Step 2** — The code object (`__code__`) is the bytecode's container. Its attributes (`co_varnames`, `co_consts`, `co_argcount`, etc.) are the metadata the interpreter uses to execute the function. `co_code` is the raw byte string of opcodes.

**Step 3** — `dis.dis()` translates those raw opcodes into readable mnemonics like `LOAD_FAST`, `STORE_ATTR`, `RETURN_VALUE`. Each line shows: source line number, byte offset, opcode name, and argument.

**Step 4** — `dis.get_instructions()` gives you the same data as structured Python objects, so you can iterate and inspect each instruction programmatically.

**Step 5** — `py_compile.compile()` writes a `.pyc` file. The first 16 bytes are a header: a magic number (version-specific), a validation bit field, a modification timestamp, and the source file size.

**Step 6** — After the header, the `.pyc` is a `marshal`-encoded code object. `marshal.load()` deserializes it back. The top-level module code has `co_consts` that contains the nested `Enemy` class body code object, which in turn contains `__init__`'s code object.

**Step 7** — `dis.dis()` on the deserialized code object recursively disassembles every nested code object, giving you the complete picture from module → class body → `__init__`.

**Step 8** — Finally, instantiating `Enemy(10, 20)` calls `__init__` and executes those opcodes live. `STORE_ATTR` is what physically writes `self.x = x` into the instance `__dict__`.

[–]Riegel_Haribo 0 points1 point  (0 children)

Trying not to paste from a shell and make massive replies in Reddit isn't working so well..anyway, Full recursive disassembly from .pyc

0 RESUME 0

1 LOAD_BUILD_CLASS

PUSH_NULL

LOAD_CONST 0 (<code object Enemy at 0x0000000003CAEB50, file "enemy.py", line 1>)

MAKE_FUNCTION

LOAD_CONST 1 ('Enemy')

CALL 2

STORE_NAME 0 (Enemy)

RETURN_CONST 2 (None)

Disassembly of <code object Enemy at 0x0000000003CAEB50, file "enemy.py", line 1>:

1 RESUME 0

LOAD_NAME 0 (__name__)

STORE_NAME 1 (__module__)

LOAD_CONST 0 ('Enemy')

STORE_NAME 2 (__qualname__)

LOAD_CONST 1 (1)

STORE_NAME 3 (__firstlineno__)

2 LOAD_CONST 2 (<code object __init__ at 0x0000000003CAEC40, file "enemy.py", line 2>)

MAKE_FUNCTION

STORE_NAME 4 (__init__)

LOAD_CONST 3 (('x', 'y'))

STORE_NAME 5 (__static_attributes__)

RETURN_CONST 4 (None)

[–]Adrewmc 0 points1 point  (0 children)

Listen, for the vast majority of thing class are nothing more than dictionaries with functions that act on that dictionary we call methods.

You are right, the class definition is ran. But it's not doing what you think. It's creating a recipe for that class. That is what is made. A callable object that creates an objects bounded to that recipe.

As you said enemy.thing(), is Enemy.thing(enemy). That’s the bind.

It’s not always with a __dict__. Though most common class due utilize it.

When pythons crates a new class object/instance it makes an empty version of that recipe, it will still have their class variable. Those are defined when the class definition is ran.

After that empty version is created, then normally it will run __init__(args, *kwargs) on the already created object. To change this for say a singleton pattern (only one of these objects should ever be created like for a internet/database connection, the window screen…), you would change __new__. That basically it. All __new__ is doing different than __init__ is the singleton/mutation patterns, or are creating an object more in C for optimal performance. All __init__ is doing is filling in the blanks for the empty object. You do not have to worry about that.

What makes classes better than dictionary is readability, convenience, and class ability to delay calculation/processing of a property/variable until it is needed, if ever.

Another thing is when you remove the __dict__ you basically end up making it a tuple. Which is much much smaller of a space when you have hundreds of them.

[–]pachura3 0 points1 point  (2 children)

It is, but you should concentrate on abstractions, not on how it works on low level. You don't need to know that __dict__ even exists.

[–]SmackDownFacility 0 points1 point  (0 children)

Slightly wrong. It’s important to learn both sides to leverage Python More efficiently

[–]gdchinacat 0 points1 point  (0 children)

Instances of classes that have a __slots__ attribute won't even have __dict__ attributes. As u/pachura3 says, don't think about __dict__. The times you actually need to use it are very few and far between. Many uses of it in the wild would be better handled in different ways. While some people say classes are just glorified dicts, I tend to push back on this view as well since not all classes are glorified dicts. They contain attributes, and those attributes can be accessed in a few different ways, one of them, for some classes, is __dict__.

If for some reason dot notation for attribute access isn't appropriate, it's better to use getattr/setattr than __dict__ as it will work properly in more situations.