《Python编程入门》6.1 类、实例与属性查找(MRO)

本节从 class 语句做了什么讲起,厘清 __init__ 是初始化器而非构造函数,实例属性与类属性的区别及可变类属性被所有实例共享的陷阱;再讲方法绑定中 self 的来源、property 与 setter、staticmethod 与 classmethod 的取舍,用 __mro__ 说清属性查找顺序与 C3 线性化,最后用 tracemalloc 实测 __slots__ 的内存收益。

本节目标:理解 class 语句在运行时到底做了什么,掌握实例属性与类属性的区别、方法绑定机制、@property 与三种方法装饰器的取舍,并能用 __mro__ 读懂多重继承的属性查找顺序。
适用版本:Python 3.12+(实测 3.14.6)

6.1 类、实例与属性查找(MRO)

前面几章我们把函数、模块、包都过了一遍,但代码仍以「函数 + 数据」的形式散落各处。当一组数据和操作它们的行为需要长期绑在一起、并且要能造出很多份互不干扰的副本时,就该请出类了。本节先把「类到底是什么、属性从哪里取」这两件事讲透,继承留给下一节。

6.1.1 class 语句在运行时做了什么

很多人以为 class 只是一张「编译期蓝图」,其实它是一条会被立即执行的语句。运行到它时,解释器依次做三件事:执行类体、收集其中的名字、调用元类(默认 type)创建类对象,最后把类对象绑定到类名。

print("before class")
class Demo:
    print("inside class body, running now")
    value = 42
print("after class; Demo.value =", Demo.value)
print("type(Demo) =", type(Demo))

输出:

before class
inside class body, running now
after class; Demo.value = 42
type(Demo) = <class 'type'>

关键点:inside class body 在类定义那一刻就打印了,说明类体不是「等实例化才跑」。类体里能写任意语句(if、循环、函数定义都行),它们按顺序执行,产生的名字进入类的命名空间。类本身也是一个对象,它的类型是 type。

6.1.2 init 不是构造函数

__new__ 才是真正「造出实例」的那一步,__init__ 只负责初始化已经造好的实例:

class Tracked:
    def __new__(cls, *a, **k):
        print("__new__ called")
        return super().__new__(cls)
    def __init__(self, name):
        print("__init__ called")
        self.name = name

t = Tracked("x")
print("t.name:", t.name)

输出:

__new__ called
__init__ called
t.name: x

调用 Tracked("x") 时,Python 先调 __new__ 拿到实例,再把它交给 __init__。__init__ 的返回值会被忽略,而且它必须返回 None,否则报 TypeError: __init__() should return None, not 'int'。日常代码几乎不需要重写 __new__,记住「__init__ 是初始化器,不是构造函数」就够避免语义误解。

6.1.3 实例属性 vs 类属性

属性可以存在两个地方:实例的 __dict__ 或类的命名空间。赋值 self.x = ... 写进实例,类体里直接写的名字属于类:

class Counter:
    total = 0                 # 类属性
    def __init__(self, n):
        self.n = n            # 实例属性
        Counter.total += 1

a = Counter(1)
b = Counter(2)
print("a.n, b.n:", a.n, b.n)
print("Counter.total:", Counter.total)
print("a.__dict__:", a.__dict__)

输出:

a.n, b.n: 1 2
Counter.total: 2
a.__dict__: {'n': 1}

注意 a.__dict__ 里只有 n,没有 total——读取 a.total 时会回退到类去找。这就引出最经典的坑:可变的类属性会被所有实例共享。

class Dog:
    tricks = []               # 可变类属性 —— 危险
    def __init__(self, name):
        self.name = name
    def add_trick(self, trick):
        self.tricks.append(trick)   # 没有赋值,改的是类属性本身

d1 = Dog("Fido")
d2 = Dog("Buddy")
d1.add_trick("roll over")
print("d2.tricks:", d2.tricks)
print("Dog.tricks is d1.tricks:", Dog.tricks is d1.tricks)

输出:

d2.tricks: ['roll over']
Dog.tricks is d1.tricks: True

d1 的技能竟然出现在 d2 身上。原因:self.tricks.append(...) 只是就地修改那个共享列表,没有触发赋值,所以没在实例上新建属性。正确做法是在 __init__ 里为每个实例各建一份 self.tricks = []。判断法则:类属性只放不可变常量或有意共享的状态;凡是每个实例该各有一份的可变对象,一律在 __init__ 里创建。

6.1.4 方法绑定:self 从哪来

instance.method 取到的不是普通函数,而是一个绑定方法(bound method),它把实例和函数打包在了一起:

import types
class Greeter:
    def greet(self):
        return "hi"

g = Greeter()
m = g.greet
print("type(m) is MethodType:", type(m) is types.MethodType)
print("m.__self__ is g:", m.__self__ is g)
print("Greeter.greet is function:", type(Greeter.greet) is types.FunctionType)
print("unbound call:", Greeter.greet(g))

输出:

type(m) is MethodType: True
m.__self__ is g: True
Greeter.greet is function: True
unbound call: hi

所以 self 不是什么魔法变量,它只是「绑定方法在调用时自动补上的第一个参数」。Greeter.greet 本身仍是普通函数,手动传 g 也能调用。

6.1.5 @property、setter 与 deleter

有时我们想让外部「像读属性一样」读值,但读的时候要计算、写的时候要校验。@property 把方法伪装成属性:

class Temperature:
    def __init__(self, celsius):
        self._celsius = celsius
    @property
    def celsius(self):
        return self._celsius
    @celsius.setter
    def celsius(self, value):
        if value < -273.15:
            raise ValueError("below absolute zero")
        self._celsius = value

t = Temperature(25)
print("t.celsius:", t.celsius)
t.celsius = 30
print("after set:", t.celsius)

输出:

t.celsius: 25
after set: 30

t.celsius = -300 会抛出 ValueError: below absolute zero,这就是 setter 的价值:把校验塞进赋值点,外部无法绕过。约定用 _celsius(单下划线)表示「内部字段,请勿直接碰」。再配一个 @celsius.deleter 就能让 del t.celsius 有明确语义,用得较少。

6.1.6 staticmethod 与 classmethod

class Pizza:
    def __init__(self, ingredients):
        self.ingredients = ingredients
    @classmethod
    def margherita(cls):
        return cls(["tomato", "mozzarella"])
    @staticmethod
    def circle_area(radius):
        import math
        return math.pi * radius ** 2
    def __repr__(self):
        return f"Pizza({self.ingredients!r})"

print(Pizza.margherita())
print(Pizza.circle_area(10))

输出:

Pizza(['tomato', 'mozzarella'])
314.1592653589793

区别用一个表说清:

装饰器第一个参数能拿到类吗典型用途
普通方法self(实例)通过 type(self)操作实例数据
@classmethodcls(类)是,且随子类变化备用构造器、类级工厂
@staticmethod无否与类逻辑相关但不需要类/实例的工具函数

@classmethod 的关键是 cls 会跟着子类走:Sub.who() 返回 "Sub" 而非 "Base",这正是 margherita() 这类「备用构造器」能自动适配子类的原因。选择建议:需要按子类定制就用 @classmethod;纯粹的工具函数用 @staticmethod,或干脆提到模块级。

6.1.7 属性查找顺序与 MRO

读属性 obj.attr 时,解释器按固定顺序找:实例 __dict__ → 类的 __dict__ → 基类(沿 MRO 依次)。MRO(Method Resolution Order)是一条由 C3 线性化算出的类列表,用 type(obj).__mro__ 能直接打印:

class A:
    def who(self): return "A"
class B(A):
    def who(self): return "B"
class C(A):
    def who(self): return "C"
class D(B, C):
    pass

print([k.__name__ for k in D.__mro__])
print("D().who():", D().who())

输出:

['D', 'B', 'C', 'A', 'object']
D().who(): B

顺序是 D → B → C → A → object:先自己,再按声明顺序从左到右遍历直接基类,同时保证「子类总在父类之前」。C3 要解决的问题就是:多重继承下如何给出一致的查找顺序,既不违反「子类优先」,又不违反「声明顺序」,还不会自相矛盾。当约束无法同时满足时,Python 直接拒绝创建类:

class X: pass
class Y: pass
class A(X, Y): pass
class B(Y, X): pass
class C(A, B): pass
# TypeError: Cannot create a consistent method resolution order (MRO) for bases X, Y

6.1.8 slots 的内存效果

默认每个实例都带一个 __dict__,这很灵活,但对象一多,内存开销就上来了。__slots__ 声明「这个类只允许这些属性」,从而省掉每个实例的字典:

import tracemalloc

class WithDict:
    def __init__(self, x, y):
        self.x = x; self.y = y

class WithSlots:
    __slots__ = ("x", "y")
    def __init__(self, x, y):
        self.x = x; self.y = y

N = 100_000
Z = 0  # 复用缓存小整数,避免把整数分配计入

def measure(cls):
    tracemalloc.start()
    data = [cls(Z, Z) for _ in range(N)]
    cur = tracemalloc.get_traced_memory()[0]
    tracemalloc.stop()
    return cur

md, ms = measure(WithDict), measure(WithSlots)
print(f"WithDict  : {md/1024/1024:.2f} MiB")
print(f"WithSlots : {ms/1024/1024:.2f} MiB")
print(f"ratio     : {md/ms:.2f}x")

输出(本机 3.14.6 实测):

WithDict  : 9.16 MiB
WithSlots : 5.34 MiB
ratio     : 1.71x

10 万个实例下,__slots__ 省了约 42% 内存。代价是:不能再动态添加未声明的属性,且无法多继承非 slots 的类。适用场景:会被创建成千上万份的小型数据对象。

小结

  • class 是运行时语句:执行类体、收集名字、创建类对象再绑定类名;类也是对象,类型是 type。
  • __new__ 才是构造器,__init__ 只是初始化器,必须返回 None。
  • 实例属性存进 __dict__,读不到时才回退到类;可变类属性被所有实例共享,每个实例该独立持有的可变对象要在 __init__ 里建。
  • instance.method 是绑定方法,self 就是自动补上的第一个参数;Greeter.greet 仍是普通函数。
  • @property + setter 把校验放进赋值点;@classmethod 的 cls 跟随子类,@staticmethod 两者都不要。
  • 属性查找顺序是「实例 → 类 → 基类」,多重继承的顺序由 C3 线性化决定,可用 __mro__ 验证,冲突时 Python 直接报错。
  • __slots__ 用固定属性名换来内存下降,适合海量小对象。

本节把「单个类」讲透了,但真实工程里类与类之间还有关系:谁继承谁、什么时候该用组合、怎样用 dataclass 少写样板。这正是下一节 继承、组合与 dataclass 的主题。

阅读导航:上一节:5.3 循环导入、命名空间包与惰性导入 · 下一节:6.2 继承、组合与 dataclass 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时