本节目标:理解
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) | 操作实例数据 |
@classmethod | cls(类) | 是,且随子类变化 | 备用构造器、类级工厂 |
@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 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。