面向对象
class Box:
def put(self, x: int) -> None:
self.x = x
obj = Box()
obj.put(1)
Box.put(obj, 1) # 同一件事obj.put(1) 不是「对象身上有一份函数副本」。查找顺序是:在 obj 上找 put,没有;到 type(obj) 上找,找到的是函数;函数是非数据描述符,__get__ 把 obj 绑成 self,得到一个 bound method。所以 obj.put 是 Box.put.__get__(obj, Box),调用时 self 已经在。
C++ 的 obj.method() 编译期就确定了 this 怎么传(成员函数指针 + 对象地址),虚函数多一层 vptr。Go 的 obj.Method() 是 syntatic sugar for T.Method(obj),方法集挂在类型上。Python 把这层糖做成运行时描述符协议——同一套机制还驱动了 @property、@classmethod、@staticmethod、__slots__ 上的成员。搞懂 descriptor,OOP 这章剩下的都是它的应用。
print(Box.put) # function
print(obj.put) # bound method
print(obj.put.__func__ is Box.put)
print(obj.put.__self__ is obj)一、class 是 type 的实例,实例是 class 的实例
Python 的类型系统是两层对象:
class Foo:
pass
obj = Foo()
print(type(obj), obj.__class__) # <class '__main__.Foo'>
print(type(Foo), Foo.__class__) # <class 'type'>
print(type(type)) # <class 'type'> type 是自己的类
print(isinstance(Foo, type), isinstance(obj, Foo))- 普通对象的类是你写的
class - 类对象的类是
type(或type的子类,即 metaclass) type的类是type自己
class Foo: 这条语句运行时大致等价于:
Foo = type("Foo", (object,), {"__module__": __name__})type(name, bases, dict) 是类工厂。三参数 type 造类,一参数 type(obj) 查类。自定义 metaclass 就是继承 type 再塞进 class Foo(metaclass=M):,这篇不展开,记住入口即可。
isinstance(obj, Foo) 沿 MRO 看 type(obj) 是不是 Foo 的子类。type(obj) is Foo 不认子类。obj.__class__ 通常等于 type(obj),除非有人动了 __class__(有的 ORM / 代理会)。
class Bar(Foo):
pass
b = Bar()
print(isinstance(b, Foo), type(b) is Foo) # True FalseC++ 的 typeid / dynamic_cast、Go 的 reflect.TypeOf 是查类型。Python 的类是运行时可变的对象:可以事后挂方法、改基类(很痛)、给实例加任意属性。动态不是没有类型,是类型本身也是堆上的对象。
object 是所有新式类的根(Python 3 只有新式类)。object 的基类是空 tuple:
print(object.__bases__) # ()
print(Foo.__bases__) # (<class 'object'>,)
print(Foo.__mro__)二、__new__ vs __init__
1、谁造对象,谁初始化
构造一次实例的流水线:
Box.__call__(来自type.__call__)被Box()触发Box.__new__(Box, *args, **kwargs)分配并返回实例- 若返回值是
Box的实例,再调Box.__init__(obj, *args, **kwargs) - 返回 obj
__new__ 是静态的(第一个参数是类,不是实例),负责创建。__init__ 是实例方法,负责初始化,必须返回 None。
class Trace:
def __new__(cls, n: int):
print("new", cls, n)
obj = super().__new__(cls)
print("allocated", obj)
return obj
def __init__(self, n: int) -> None:
print("init", n)
self.n = n
t = Trace(1)__init__ 不是构造函数。C++ 的构造函数同时分配(自动存储)和初始化;new T() 分开分配和构造。Go 没有构造函数,惯例 NewFoo() *Foo。Python 把「分配」和「初始化」显式拆成两个钩子。
__init__ 里抛异常,对象已经造出来了,只是没绑到名字上,引用计数掉到 0 就回收。__new__ 失败则 __init__ 不会跑。
2、什么时候要写 __new__
单例: 创建路径必须截在分配之前,__init__ 拦不住,因为 Cls() 每次都会走到 __new__。
class Singleton:
_inst: "Singleton | None" = None
def __new__(cls) -> "Singleton":
if cls._inst is None:
cls._inst = super().__new__(cls)
return cls._inst
def __init__(self) -> None:
# 每次 Singleton() 都会再跑,别在这里 append
pass
a = Singleton()
b = Singleton()
print(a is b)__init__ 在单例里会被反复调用——这是实现单例时的经典坑。把初始化放到 __new__ 里「第一次才做」,或设标志位。后端更常见的是模块级单例 service = Service(),比 class 单例少这些戏。
不可变内置的子类: int / str / tuple / frozenset 的实例一旦造出就不能改 __dict__(它们用值本身当对象)。子类要改值,只能在 __new__ 里把值传给 super().__new__:
class Upperstr(str):
def __new__(cls, s: str) -> "Upperstr":
return super().__new__(cls, s.upper())
print(Upperstr("aq")) # AQ
print(isinstance(Upperstr("aq"), str))在 __init__ 里想改 str 的内容,没有 API。tuple 子类同理。
__new__ 返回别的类型: 若返回的不是 cls 的实例,__init__ 被跳过。工厂可以走这条路,但读者会恨你。宁可写 @classmethod def create(...)。
class MaybeInt:
def __new__(cls, s: str):
if s.isdigit():
return int(s) # 返回 int,不调 MaybeInt.__init__
return super().__new__(cls)
def __init__(self, s: str) -> None:
self.raw = s
print(MaybeInt("12"), type(MaybeInt("12")))
print(type(MaybeInt("xx")), getattr(MaybeInt("xx"), "raw", None))不要把 __new__ 当普通工厂滥用。默认情况只写 __init__。__new__ 是对象系统的钩子,不是「更早的 init」。
三、实例属性 vs 类属性
1、查找顺序
obj.x 的简化流程(忽略描述符细节时):
- 若
type(obj)上的x是数据描述符(定义了__set__或__delete__),走描述符 - 否则看
obj.__dict__['x'] - 否则沿 MRO 看类属性;若是非数据描述符(函数),走
__get__ - 再没有,
__getattr__/AttributeError
class A:
kind = "base" # 类属性,一份,所有实例共享
def __init__(self, name: str) -> None:
self.name = name # 实例属性,进 obj.__dict__
a = A("x")
b = A("y")
print(a.kind, b.kind, A.kind)
print(a.__dict__, A.__dict__["kind"])
a.kind = "override" # 往实例 dict 写,遮住类属性
print(a.kind, b.kind, A.kind)
del a.kind
print(a.kind) # 又回到类属性读类属性通过实例可以读到;写通过实例默认写到实例 dict,不会改类属性。改类属性必须 A.kind = ...。
2、可变类属性的坑
class ConnPool:
conns: list[int] = [] # 所有实例共享
def borrow(self, c: int) -> None:
self.conns.append(c)
p1, p2 = ConnPool(), ConnPool()
p1.borrow(1)
print(p2.conns) # [1],p2 以为自己是空池和函数默认参数同一类错误:可变对象只造了一份,挂在类 dict 上。实例方法里 self.conns.append 不换绑,改的就是那一份。
正确:实例属性在 __init__ 里造;类属性只放不可变常量、或刻意共享的注册表。
class ConnPool:
tag = "pool" # 不可变,当常量 OK
def __init__(self) -> None:
self.conns: list[int] = []刻意共享的例子:子类注册
class Plugin:
registry: dict[str, type["Plugin"]] = {}
def __init_subclass__(cls, *, name: str = "", **kw: object) -> None:
super().__init_subclass__(**kw)
if name:
Plugin.registry[name] = cls
class FooPlugin(Plugin, name="foo"):
pass
print(Plugin.registry)__init_subclass__ 是 3.6+ 的钩子,类定义结束时调用,比 metaclass 轻。注册表放类属性是对的,文档里写清楚。
C++ 的 static 成员、Go 没有类属性(包级变量顶替)。Python 类属性既当 static,又当「实例没覆盖时的默认值」,两种用法搅在一起才容易写错。
3、__slots__
class Point:
__slots__ = ("x", "y")
def __init__(self, x: int, y: int) -> None:
self.x = x
self.y = y
p = Point(1, 2)
print(p.x)
# p.z = 3 # AttributeError
# print(p.__dict__) # 没有 __dict____slots__ 让实例不建 __dict__,属性存在固定偏移里,省内存、略快点访问,禁止随意加属性。子类不声明 __slots__ 会重新长出 __dict__。多重继承多个带 slots 的基类要小心布局冲突。
大量小对象(图节点、解析树、消息结构)才值得上。普通业务 class 用 __dict__ 更省事。dataclass 的 slots=True(3.10+)是同一机制。
四、继承、MRO、C3、super()
1、MRO 不是「父类链」
单继承时 MRO 就是自己往上走到 object。多继承用 C3 线性化,保证:
- 子类出现在父类前面
- 父类之间保持声明顺序
- 单调性(子类的 MRO 是对父类 MRO 的合并,不逆序)
class A:
def ping(self) -> str:
return "A"
class B(A):
def ping(self) -> str:
return "B+" + super().ping()
class C(A):
def ping(self) -> str:
return "C+" + super().ping()
class D(B, C):
def ping(self) -> str:
return "D+" + super().ping()
print(D.__mro__)
print(D().ping()) # D+B+C+AD.__mro__ 是 (D, B, C, A, object)。super() 在 D.ping 里拿到的不是「D 的父类 B」,而是 MRO 里 D 的下一个:B。B 里的 super() 下一个是 C,不是 A。所以 super() 的准确含义是:当前类在当前实例 MRO 上的下一个。
这是协作式多继承(cooperative MI)的基础。菱形继承里每个类只该做自己那一层,然后 super() 交下去,而不是写死 A.ping(self)。写死父类名字会让某一层被跳过或跑两次。
class BBad(A):
def ping(self) -> str:
return "B+" + A.ping(self) # 写死 A,C 被跳过
class DBad(BBad, C):
def ping(self) -> str:
return "D+" + super().ping()
print(DBad.__mro__)
print(DBad().ping()) # D+B+A,没有 C2、C3 合并不了就会炸
class X: ...
class Y(X): ...
class Z(X, Y): ... # TypeError: Cannot create a consistent method resolution orderZ 要求 X 在 Y 前,但 Y 已经把 X 放在自己后面。C3 拒绝猜。C++ 菱形靠虚继承;Python 直接不让你定义这个类。看到 TypeError 就改继承图,不要绕。
3、super() 的两种写法
super().ping() # 3.x 零参,编译器填当前类和 self
super(D, self).ping() # 显式,等价
super(B, self).ping() # 从 B 的下一个开始,跳过 B零参 super() 只能在类定义的函数里用(编译期绑定 __class__)。lambda 和嵌套函数里要用显式两个参数。
super(cls, obj) 返回的是一个代理对象,属性查找从 cls 在 obj 的 MRO 里的下一个开始。它本身是描述符查找的一环。
4、Mixin 怎么写才不会把 MRO 撕碎
Mixin 应当:
- 不假设自己是最左基类之外的位置,方法里用
super() - 尽量不带
__init__状态;有的话也super().__init__并把签名写成**kwargs往下传 - 名字以 Mixin 结尾,避免被当成 is-a
class LogMixin:
def run(self) -> str:
print("log enter")
out = super().run() # type: ignore[misc]
print("log leave")
return out
class Job:
def run(self) -> str:
return "done"
class LoggedJob(LogMixin, Job):
pass
print(LoggedJob().run())
print(LoggedJob.__mro__)LoggedJob 必须把 Mixin 放左边,MRO 才是 Mixin → Job。放反了 Mixin 的 super().run 会撞到 object。Mixin 在左、业务基类在右是约定。
C++ 的虚函数靠 vtable,多重继承有虚基类调整 this 指针。Go 没有继承,嵌入(embedding)是组合,方法提升,没有 MRO。Python 多继承能用,但团队默认还是组合;Mixin 只留给横切(日志、序列化、权限)且写协作式 super()。
五、魔术方法:让对象表现得像内置类型
解释器碰到语法糖就去找对应 dunder。自己造的类型要融入语言,靠这些。
1、__repr__ / __str__
class User:
def __init__(self, uid: int, name: str) -> None:
self.uid = uid
self.name = name
def __repr__(self) -> str:
return f"User({self.uid!r}, {self.name!r})"
def __str__(self) -> str:
return self.name
u = User(1, "aq")
print(repr(u)) # 给开发者,理想是 eval 能还原
print(str(u)) # 给用户
print(f"{u}") # 走 __str__,没有则 __repr__
print([u]) # 容器里走 __repr__只实现一个就实现 __repr__。__str__ 缺省退回 __repr__。__repr__ 应无歧义,方便日志和测试断言。
2、__eq__ 和 __hash__ 必须一起改
class Money:
def __init__(self, cents: int) -> None:
self.cents = cents
def __eq__(self, other: object) -> bool:
if not isinstance(other, Money):
return NotImplemented
return self.cents == other.cents
def __hash__(self) -> int:
return hash(self.cents)
def __repr__(self) -> str:
return f"Money({self.cents})"
print(Money(100) == Money(100))
print({Money(100), Money(100)}) # 一个元素规则(语言参考写明):
- 若
a == b则必须hash(a) == hash(b) - 参与 hash 的字段必须不可变;可变对象当 set 元素,改完就找不到
- 只实现
__eq__不实现__hash__,Python 会把__hash__设成None,对象不可哈希(不能进 set/dict key)——这是对的默认,因为可变实例常被拿来比内容 - 返回
NotImplemented而不是False,让右边的__eq__有机会接手
class OnlyEq:
def __eq__(self, other: object) -> bool:
return False
print(OnlyEq().__hash__) # None
try:
{OnlyEq()}
except TypeError as e:
print(e)可变对象要内容相等、不要哈希:只写 __eq__。不可变记录:eq 和 hash 都基于同一组字段。dataclass 的 frozen=True 会自动做对。
C++ 的 operator== 和 std::hash 特化也是配对的,丢一边 unordered_map 就错。Go 的 map key 要求可比较,没有自定义 hash(1.20+ 也没有用户定义)。Python 把两个钩子都给你,同时把绳子给你上吊。
3、__len__ / __getitem__ / __iter__ / __contains__
class Pages:
def __init__(self, items: list[str]) -> None:
self._items = items
def __len__(self) -> int:
return len(self._items)
def __getitem__(self, i: int | slice) -> str | list[str]:
return self._items[i]
def __iter__(self):
return iter(self._items)
def __contains__(self, x: object) -> bool:
return x in self._items
p = Pages(["a", "b", "c"])
print(len(p), p[1], p[0:2], "b" in p)
for x in p:
print(x)__getitem__ 让对象支持 obj[i] 和切片(i 会是 slice)。只实现 __getitem__ 也能迭代(解释器从 0 数到 IndexError),但显式 __iter__ 更高效。__contains__ 缺省会线性扫迭代器;有更好的结构就自己写。
__bool__ 缺省看 __len__ 非零。空容器当假,和内置一致。
4、其它日常会碰到的
| 语法 | 方法 |
|---|---|
+ - * / // % ** | __add__ 等;反向 __radd__;原地 __iadd__ |
< <= > >= | __lt__ 等;functools.total_ordering 可补齐 |
obj() | __call__,实例变函数 |
with obj | __enter__ / __exit__ |
obj.attr 找不到时 | __getattr__(只在正常查找失败后) |
任何 obj.attr | __getattribute__(容易递归,慎写) |
obj.attr = v | __setattr__ |
del obj.attr | __delattr__ |
bytes(obj) | __bytes__ |
格式化 f"{obj:spec}" | __format__ |
__del__ 是析构,不保证及时调用,解释器退出时可能不跑,异常会被吞成警告。资源释放用 with / contextlib.closing,不要靠 __del__。这点和 C++ RAII 截然不同。
六、property、setter、描述符协议
1、property 是描述符
class Thermostat:
def __init__(self, c: float) -> None:
self._c = c
@property
def celsius(self) -> float:
return self._c
@celsius.setter
def celsius(self, v: float) -> None:
if v < -273.15:
raise ValueError("below absolute zero")
self._c = v
@property
def fahrenheit(self) -> float:
return self._c * 9 / 5 + 32
t = Thermostat(25)
print(t.celsius, t.fahrenheit)
t.celsius = 30
print(t.fahrenheit)obj.celsius 看起来像属性,实际是 Thermostat.__dict__['celsius'].__get__(obj, Thermostat)。没有 setter 的 property 只读,赋值 AttributeError。
C++ 没有这个语法,靠 getter/setter 或公开成员。Go 的导出字段是大写,没有 property,惯例 func (t *T) Celsius() float64。Python 可以先公开属性,以后改 property 不改调用方语法——这是它存在的工程理由。不要一上来所有字段都包 property。
2、描述符协议
class NonNull:
def __set_name__(self, owner: type, name: str) -> None:
self.public = name
self.private = "_" + name
def __get__(self, obj: object, owner: type | None = None):
if obj is None:
return self
return getattr(obj, self.private)
def __set__(self, obj: object, value: object) -> None:
if not value:
raise ValueError(f"{self.public} empty")
setattr(obj, self.private, value)
def __delete__(self, obj: object) -> None:
raise AttributeError("cannot delete")
class Account:
name = NonNull()
def __init__(self, name: str) -> None:
self.name = name
a = Account("aq")
print(a.name)
try:
a.name = ""
except ValueError as e:
print(e)__set_name__(3.6+)在类创建时把字段名告诉描述符。__get__ / __set__ / __delete__ 三个里:
- 定义了
__get__和__set__(或__delete__)→ 数据描述符 - 只有
__get__→ 非数据描述符(函数、staticmethod、classmethod 属于这类)
查找优先级:
- 数据描述符(类上)
- 实例
__dict__ - 非数据描述符 / 普通类属性
__getattr__
所以你不能靠 self.put = 1 把方法盖住之后还指望走函数——可以盖住,因为函数是非数据描述符,实例 dict 优先。property 是数据描述符,实例 dict 盖不住它:
class T:
@property
def x(self) -> int:
return 1
t = T()
t.__dict__["x"] = 99
print(t.x) # 仍是 1,property 赢了这就是 obj.method() 能工作、而 obj.__dict__ 里通常没有方法的原因:方法在类上,靠非数据描述符临时绑定。obj.put = something 会在实例 dict 留下一个普通对象,之后 obj.put() 不再绑定 self。
3、自己写 bound method 能看懂就行
class Bound:
def __init__(self, fn, obj):
self.fn = fn
self.obj = obj
def __call__(self, *args, **kwargs):
return self.fn(self.obj, *args, **kwargs)
class Fn:
def __get__(self, obj, owner=None):
if obj is None:
return self
return Bound(self, obj)
def __call__(self, obj, x: int) -> int:
return x + 1
class C:
inc = Fn()
print(C().inc(3))CPython 的 function.__get__ 返回 types.MethodType。上面是教学模型。日常不要自己实现方法绑定。
七、@staticmethod / @classmethod / 普通方法
三个描述符,第一个参数约定不同:
class Client:
default_timeout = 3.0
def __init__(self, host: str) -> None:
self.host = host
def ping(self) -> str: # 实例方法,描述符绑定 self
return f"pong {self.host}"
@classmethod
def from_url(cls, url: str) -> "Client": # 第一个参数是类
host = url.split("://", 1)[-1]
return cls(host)
@staticmethod
def valid_host(host: str) -> bool: # 什么都不绑
return bool(host) and " " not in host
c = Client.from_url("http://api.local")
print(c.ping(), Client.valid_host("a"), c.valid_host("a"))
print(Client.ping, c.ping)
print(Client.from_url, c.from_url)
print(Client.valid_host, c.valid_host)| 通过实例拿 | 通过类拿 | 典型用途 | |
|---|---|---|---|
| 普通方法 | bound method(self) | function,要自己传实例 | 访问实例状态 |
| classmethod | 绑到实例的类 | 绑到类 | 工厂、替代构造、访问类属性 |
| staticmethod | 原函数 | 原函数 | 和类放一起的纯函数,不读类/实例 |
classmethod 的工厂对继承友好:class Sub(Client): ... 时 Sub.from_url(...) 的 cls 是 Sub,造出来是 Sub 实例。写成 @staticmethod 里写死 Client(...) 就破了。
class Sub(Client):
pass
print(type(Sub.from_url("http://x"))) # Substaticmethod 其实把函数放进类的命名空间,避免模块级函数和类的概念拆开。如果发现一串 staticmethod 完全不碰类,把它们挪到模块级函数更 Go。留在类里是因为 API 想 Client.valid_host 这样找。
C++ 的 static 成员函数 ≈ staticmethod;没有 classmethod,工厂是另一个 static 或友元。Go 的 func NewClient() *Client 是包级函数,接收者方法才有 self。Python 把三种都做成描述符,挂在类命名空间里。
普通方法也可以通过类调用:Client.ping(c),这就是开篇那句。少了 self 会 TypeError: ping() missing 1 required positional argument: 'self'。静态检查器能抓,运行时是运行时。
八、dataclass:frozen、slots、default_factory,怎么选
3.7+ 的 dataclasses 用描述符和代码生成,把 __init__ / __repr__ / __eq__ 等按字段写好。
from dataclasses import dataclass, field
@dataclass
class User:
id: int
name: str
tags: list[str] = field(default_factory=list)
region: str = "cn"
u = User(1, "aq")
u.tags.append("admin")
print(u)
print(u == User(1, "aq", ["admin"], "cn"))可变默认值又来了:tags: list[str] = [] 会在 dataclass() 装饰时被拒绝(ValueError: mutable default ...)。default_factory=list 每次实例化调一次,正确。
1、frozen / slots / order
@dataclass(frozen=True, slots=True, order=True)
class Point:
x: int
y: int
p = Point(1, 2)
print(p, hash(p), p < Point(1, 3))
# p.x = 3 # FrozenInstanceErrorfrozen=True:生成 __setattr__ 禁止改字段,自动实现 __hash__(若 eq=True 且字段都可哈希)。当 dict key 用这个。slots=True(3.10+):生成 __slots__,无 __dict__,内存紧。order=True:按字段字典序生成比较,字段顺序就是比较顺序。eq=False:不生成 __eq__,回退到身份。
field(compare=False, hash=False) 可以把缓存、锁这类字段排除。
from dataclasses import dataclass, field
@dataclass
class Session:
user: str
token: str = field(repr=False)
hits: int = field(default=0, compare=False)2、和普通 class / NamedTuple / pydantic 怎么选
| 普通 class | dataclass | NamedTuple | pydantic v2 | |
|---|---|---|---|---|
| 样板代码 | 自己写 __init__ | 生成 | 生成 | 生成 + 校验 |
| 可变 | 默认可变 | 默认可变,frozen 可选 | 不可变 | 默认可变,frozen 可选 |
| 可哈希 | 自己写 | frozen 才自动 | 默认可以 | frozen 才 |
| 校验 | 自己写 | 无 | 无 | 核心能力 |
| 嵌套解析 | 自己写 | 无 | 无 | model_validate |
| 继承行为 | 随意 | 可以,字段合并有规则 | 弱 | 可以 |
| 依赖 | 无 | 标准库 | 标准库 | 第三方 |
| 适用 | 有行为的对象 | 有一点行为的记录 | 纯记录、当 key | 边界 I/O、配置、API |
经验:
- 领域对象有不变量、有方法、生命周期长:普通 class 或 dataclass + 方法
- 内部 DTO、配置结构、测试夹具:dataclass
- 要当 dict key 的轻量记录:
NamedTuple或frozendataclass - 对外 JSON / 表单 / 配置文件:pydantic(类型注解篇展开)
- 不要用 dict 当结构体传四层函数
dataclass 不是 ORM,不会给你数据库。它也不校验类型:User(id="x", name=1) 运行时过得去。要运行时校验走 pydantic 或自己在 __post_init__ 写。
@dataclass
class Port:
n: int
def __post_init__(self) -> None:
if not (0 < self.n < 65536):
raise ValueError(self.n)
print(Port(8080))__post_init__ 在生成的 __init__ 末尾调用,frozen 实例要改字段用 object.__setattr__(self, "n", n)。
继承:子类字段接在父类后面。父类有默认值、子类再插无默认字段会 TypeError,和普通函数默认参数顺序同一条规则。
九、ABC / Protocol 点到为止
要「必须实现这些方法」有两条路。
ABC(名义子类型): 显式继承,实例化未实现抽象方法会 TypeError。
from abc import ABC, abstractmethod
class Handler(ABC):
@abstractmethod
def handle(self, payload: bytes) -> bytes:
...
class Echo(Handler):
def handle(self, payload: bytes) -> bytes:
return payload
print(Echo().handle(b"ok"))
# Handler() # TypeError@abstractmethod 可以叠 @classmethod / @property。注册虚拟子类 Handler.register(SomeClass) 不改继承,只骗 isinstance。
Protocol(结构子类型,PEP 544): 不需要继承,有这些方法就算。type checker 认,运行时默认不认,除非 @runtime_checkable。
from typing import Protocol, runtime_checkable
@runtime_checkable
class Readable(Protocol):
def read(self, n: int = -1) -> bytes: ...
class FileLike:
def read(self, n: int = -1) -> bytes:
return b""
print(isinstance(FileLike(), Readable)) # True,因为 runtime_checkableGo 的 interface 是结构子类型,不用声明实现。C++ 的抽象基类是名义的,Concepts 是结构的。Python 两套都有:ABC 适合框架插件(强制继承、文档化契约),Protocol 适合已有类型(read() 的东西就能用,不管是不是你的子类)。
细节——泛型 Protocol、typing.Self、与 dataclass 组合——留给类型注解篇。现在记住:后端插件用 ABC 或注册表;函数参数「只要有 close」用 Protocol。
十、组合优于继承:Handler 插件
继承树在后端最容易烂的地方:各种 Handler / Middleware / Adapter 一层套一层,基类 __init__ 要 8 个参数,子类只为改一行。用组合:核心循环认一个协议,插件是独立对象。
from __future__ import annotations
from collections.abc import Callable
from typing import Protocol
class Handler(Protocol):
def handle(self, path: str, body: bytes) -> bytes: ...
class Router:
def __init__(self) -> None:
self._routes: dict[str, Handler] = {}
def register(self, path: str, handler: Handler) -> None:
self._routes[path] = handler
def dispatch(self, path: str, body: bytes) -> bytes:
h = self._routes.get(path)
if h is None:
return b"404"
return h.handle(path, body)
class EchoHandler:
def handle(self, path: str, body: bytes) -> bytes:
return body
class TimeHandler:
def handle(self, path: str, body: bytes) -> bytes:
return b"now"
def with_log(inner: Handler) -> Handler:
class Logged:
def handle(self, path: str, body: bytes) -> bytes:
print("req", path, len(body))
out = inner.handle(path, body)
print("resp", len(out))
return out
return Logged()
def with_auth(inner: Handler, token: str) -> Handler:
class Auth:
def handle(self, path: str, body: bytes) -> bytes:
if not body.startswith(token.encode()):
return b"401"
return inner.handle(path, body[len(token):])
return Auth()
router = Router()
router.register("/echo", with_log(with_auth(EchoHandler(), "s3cr3t")))
router.register("/time", TimeHandler())
print(router.dispatch("/echo", b"s3cr3thello"))
print(router.dispatch("/echo", b"bad"))
print(router.dispatch("/time", b""))Router 不继承任何 Handler。日志、鉴权是装饰器式包装,各自一个类,符合开篇的「对象上的方法是类上的函数」。要加限流,再包一层,不用去改 BaseHandler 的继承链。
对比继承版:class LoggedAuthEcho(LogMixin, AuthMixin, EchoHandler),MRO、super() 顺序、Mixin 能不能漏 super() 全是负担。两个 Mixin 都动同一个 self.user 时,组合至少还能把状态留在各自对象里。
C++ 这边同样的建议(GoF:Favor object composition over class inheritance)。Go 没有继承,embedding 已经是组合。Python 留着继承是因为有时 is-a 真的干净(Upperstr(str)、异常层次、ABC 插件基类)。默认组合,is-a 稳定再继承。
插件注册用入口一节的 __init_subclass__ 或显式 router.register。显式注册更好测、更好看依赖。魔法注册在库里可以,业务里少用。
十一、和 C++ / Go 的 OOP 对照
| 维度 | C++ | Go | Python |
|---|---|---|---|
| 类是对象 | 不是运行时对象 | 无 class | 是,type 的实例 |
| 实例化 | 构造函数 | T{} / NewT() | __new__ + __init__ |
| 方法绑定 | this 编译期 | receiver,值或指针 | 描述符运行时绑 self |
| 虚函数 | virtual + vtable | interface 隐式 | 全部动态查找 |
| 可见性 | private 硬拦 | 首字母大小写 | _ 约定,__ mangling |
| 多继承 | 有,菱形虚继承 | 无,embedding | 有,C3 MRO |
super | 写父类名 / 虚继承 | 无 | MRO 下一个,不是「父类」 |
| 属性 | 成员变量 | 结构字段 | __dict__ 或 slots;property 拦截 |
| 静态方法 | static | 包级函数 | @staticmethod |
| 工厂 | static / 自由函数 | NewT | @classmethod |
| 接口 | 抽象类 / Concepts | interface 结构子类型 | ABC 名义 + Protocol 结构 |
| 析构 | 确定时机 RAII | 无析构,defer | __del__ 不保证;用 with |
| 记录类型 | struct / aggregate | struct | dataclass / NamedTuple |
可见性再钉一次:
class A:
def __init__(self) -> None:
self._ok = 1
self.__hid = 2
a = A()
print(a._ok)
print(a._A__hid) # mangling 只防手误覆盖,不防读__hid 变成 _A__hid,子类里写 self.__hid 是另一份 _B__hid,不会碰到父类的。这不是安全模型。C++ private 编译期拦;Go 小写跨包编译失败。Python 全是君子协定。
十二、面试追问
Q:obj.method() 发生了什么?
在类上找到 function(非数据描述符),__get__(obj, type(obj)) 得到 bound method,再调用。Class.method(obj) 绕过绑定,自己传 self。
Q:__new__ 和 __init__?
new 创建,init 初始化。new 返回非本类实例则 init 不跑。单例、不可变子类才写 new。
Q:为什么可变类属性会串?
类 dict 里一份对象,self.xs.append 原地改。和默认参数同一模型。
Q:super() 调用的是父类吗?
不是。是当前类在该实例 MRO 上的下一个。菱形继承靠这个让每一层都跑到。
Q:实现可哈希对象要注意什么?__eq__ 和 __hash__ 一起改,字段不可变,相等 ⇒ 哈希相等。只写 eq 则自动不可哈希。
Q:数据描述符 vs 非数据描述符?
有 __set__/__delete__ 的是数据描述符,优先于实例 dict。函数是非数据描述符,实例属性能盖住方法。property 是数据描述符,盖不住。
Q:classmethod 和 staticmethod?
前者收类,工厂对子类友好;后者什么都不收,纯函数放进类命名空间。
Q:dataclass 和 pydantic?
dataclass 生成样板,不校验。pydantic 做边界校验和解析。内部用 dataclass,对外用 pydantic。
把这些问题连回开篇:全都是「属性查找 + 描述符」的不同外观。方法、property、classmethod、静态方法、slots 里的成员,没有另一套对象模型。
十三、可运行:描述符、MRO、工厂
"""oop_demo.py — 对象模型对照。Python 3.11+"""
from __future__ import annotations
from dataclasses import dataclass, field
class Upperstr(str):
def __new__(cls, s: str) -> Upperstr:
return super().__new__(cls, s.upper())
class Singleton:
_inst: Singleton | None = None
def __new__(cls) -> Singleton:
if cls._inst is None:
cls._inst = super().__new__(cls)
return cls._inst
class A:
def ping(self) -> str:
return "A"
class B(A):
def ping(self) -> str:
return "B+" + super().ping()
class C(A):
def ping(self) -> str:
return "C+" + super().ping()
class D(B, C):
def ping(self) -> str:
return "D+" + super().ping()
class Checked:
def __set_name__(self, owner: type, name: str) -> None:
self.name = name
self.priv = "_" + name
def __get__(self, obj, owner=None):
if obj is None:
return self
return getattr(obj, self.priv)
def __set__(self, obj, value: int) -> None:
if value < 0:
raise ValueError("neg")
setattr(obj, self.priv, value)
class Account:
bal = Checked()
def __init__(self, bal: int) -> None:
self.bal = bal
class Client:
def __init__(self, host: str) -> None:
self.host = host
@classmethod
def from_url(cls, url: str) -> Client:
return cls(url.split("://")[-1])
@staticmethod
def ok(host: str) -> bool:
return bool(host)
@dataclass(frozen=True)
class Point:
x: int
y: int
tags: tuple[str, ...] = field(default_factory=tuple)
class Sub(Client):
pass
def main() -> None:
obj = Account(10)
print("desc", obj.bal)
try:
obj.bal = -1
except ValueError as e:
print("desc reject", e)
print("mro", D.__mro__)
print("ping", D().ping())
print("upper", Upperstr("aq"), isinstance(Upperstr("aq"), str))
print("single", Singleton() is Singleton())
print("factory", type(Sub.from_url("http://h")), Client.ok("h"))
p = Point(1, 2)
print("point", p, hash(p), {p, Point(1, 2)})
print("bound", Account.__init__, obj.__init__) # function vs method
if __name__ == "__main__":
main()跑完应对上:Checked 拦住负数;D().ping() 是 D+B+C+A;Upperstr 在 __new__ 里变大写;单例身份相同;Sub.from_url 返回 Sub;frozen dataclass 能进 set 且两个 Point(1,2) 去重。
obj.method() 是 Class.method(obj),中间那一跳是描述符。类属性那份可变 list、默认参数那份可变 list、闭包那个延迟绑定的 cell,是同一张对象图上的不同挂法。专栏基础四篇走的都是这条图:名字绑对象,对象有类型,类型也是对象。
