Skip to content

面向对象

python
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.putBox.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 这章剩下的都是它的应用。

python
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 的类型系统是两层对象:

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: 这条语句运行时大致等价于:

python
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 / 代理会)。

python
class Bar(Foo):
    pass

b = Bar()
print(isinstance(b, Foo), type(b) is Foo)   # True False

C++ 的 typeid / dynamic_cast、Go 的 reflect.TypeOf 是查类型。Python 的类是运行时可变的对象:可以事后挂方法、改基类(很痛)、给实例加任意属性。动态不是没有类型,是类型本身也是堆上的对象。

两层对象:实例 → 类 → type

object 是所有新式类的根(Python 3 只有新式类)。object 的基类是空 tuple:

python
print(object.__bases__)       # ()
print(Foo.__bases__)          # (<class 'object'>,)
print(Foo.__mro__)

二、__new__ vs __init__

1、谁造对象,谁初始化

构造一次实例的流水线:

  1. Box.__call__(来自 type.__call__)被 Box() 触发
  2. Box.__new__(Box, *args, **kwargs) 分配并返回实例
  3. 若返回值是 Box 的实例,再调 Box.__init__(obj, *args, **kwargs)
  4. 返回 obj

__new__静态的(第一个参数是类,不是实例),负责创建。__init__ 是实例方法,负责初始化,必须返回 None

Box():先 new 分配,再 init 初始化

python
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__

python
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__

python
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(...)

python
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 的简化流程(忽略描述符细节时):

  1. type(obj) 上的 x数据描述符(定义了 __set____delete__),走描述符
  2. 否则看 obj.__dict__['x']
  3. 否则沿 MRO 看类属性;若是非数据描述符(函数),走 __get__
  4. 再没有,__getattr__ / AttributeError
python
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、可变类属性的坑

python
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__ 里造;类属性只放不可变常量、或刻意共享的注册表。

python
class ConnPool:
    tag = "pool"              # 不可变,当常量 OK

    def __init__(self) -> None:
        self.conns: list[int] = []

刻意共享的例子:子类注册

python
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__

python
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 的合并,不逆序)
python
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+A

D.__mro__(D, B, C, A, object)super()D.ping 里拿到的不是「D 的父类 B」,而是 MRO 里 D 的下一个:B。B 里的 super() 下一个是 C,不是 A。所以 super() 的准确含义是:当前类在当前实例 MRO 上的下一个

菱形继承:super() 沿 MRO 走,不是沿父类指针

这是协作式多继承(cooperative MI)的基础。菱形继承里每个类只该做自己那一层,然后 super() 交下去,而不是写死 A.ping(self)。写死父类名字会让某一层被跳过或跑两次。

python
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,没有 C

2、C3 合并不了就会炸

python
class X: ...
class Y(X): ...
class Z(X, Y): ...            # TypeError: Cannot create a consistent method resolution order

Z 要求 X 在 Y 前,但 Y 已经把 X 放在自己后面。C3 拒绝猜。C++ 菱形靠虚继承;Python 直接不让你定义这个类。看到 TypeError 就改继承图,不要绕。

3、super() 的两种写法

python
super().ping()                # 3.x 零参,编译器填当前类和 self
super(D, self).ping()         # 显式,等价
super(B, self).ping()         # 从 B 的下一个开始,跳过 B

零参 super() 只能在类定义的函数里用(编译期绑定 __class__)。lambda 和嵌套函数里要用显式两个参数。

super(cls, obj) 返回的是一个代理对象,属性查找从 clsobj 的 MRO 里的下一个开始。它本身是描述符查找的一环。

4、Mixin 怎么写才不会把 MRO 撕碎

Mixin 应当:

  • 不假设自己是最左基类之外的位置,方法里用 super()
  • 尽量不带 __init__ 状态;有的话也 super().__init__ 并把签名写成 **kwargs 往下传
  • 名字以 Mixin 结尾,避免被当成 is-a
python
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 会撞到 objectMixin 在左、业务基类在右是约定。

C++ 的虚函数靠 vtable,多重继承有虚基类调整 this 指针。Go 没有继承,嵌入(embedding)是组合,方法提升,没有 MRO。Python 多继承能用,但团队默认还是组合;Mixin 只留给横切(日志、序列化、权限)且写协作式 super()


五、魔术方法:让对象表现得像内置类型

解释器碰到语法糖就去找对应 dunder。自己造的类型要融入语言,靠这些。

1、__repr__ / __str__

python
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__ 必须一起改

python
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__ 有机会接手
python
class OnlyEq:
    def __eq__(self, other: object) -> bool:
        return False

print(OnlyEq().__hash__)      # None
try:
    {OnlyEq()}
except TypeError as e:
    print(e)

可变对象要内容相等、不要哈希:只写 __eq__。不可变记录:eqhash 都基于同一组字段。dataclass 的 frozen=True 会自动做对。

C++ 的 operator==std::hash 特化也是配对的,丢一边 unordered_map 就错。Go 的 map key 要求可比较,没有自定义 hash(1.20+ 也没有用户定义)。Python 把两个钩子都给你,同时把绳子给你上吊。

3、__len__ / __getitem__ / __iter__ / __contains__

python
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 是描述符

python
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

属性查找:数据描述符优先于实例 dict

C++ 没有这个语法,靠 getter/setter 或公开成员。Go 的导出字段是大写,没有 property,惯例 func (t *T) Celsius() float64。Python 可以先公开属性,以后改 property 不改调用方语法——这是它存在的工程理由。不要一上来所有字段都包 property。

2、描述符协议

python
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 属于这类)

查找优先级:

  1. 数据描述符(类上)
  2. 实例 __dict__
  3. 非数据描述符 / 普通类属性
  4. __getattr__

所以你不能靠 self.put = 1 把方法盖住之后还指望走函数——可以盖住,因为函数是非数据描述符,实例 dict 优先。property 是数据描述符,实例 dict 盖不住它:

python
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 能看懂就行

python
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 / 普通方法

三个描述符,第一个参数约定不同:

python
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(...)clsSub,造出来是 Sub 实例。写成 @staticmethod 里写死 Client(...) 就破了。

python
class Sub(Client):
    pass

print(type(Sub.from_url("http://x")))     # Sub

staticmethod 其实把函数放进类的命名空间,避免模块级函数和类的概念拆开。如果发现一串 staticmethod 完全不碰类,把它们挪到模块级函数更 Go。留在类里是因为 API 想 Client.valid_host 这样找。

C++ 的 static 成员函数 ≈ staticmethod;没有 classmethod,工厂是另一个 static 或友元。Go 的 func NewClient() *Client 是包级函数,接收者方法才有 self。Python 把三种都做成描述符,挂在类命名空间里。

普通方法也可以通过类调用:Client.ping(c),这就是开篇那句。少了 selfTypeError: ping() missing 1 required positional argument: 'self'。静态检查器能抓,运行时是运行时。


八、dataclass:frozen、slots、default_factory,怎么选

3.7+ 的 dataclasses 用描述符和代码生成,把 __init__ / __repr__ / __eq__ 等按字段写好。

python
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

python
@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                      # FrozenInstanceError

frozen=True:生成 __setattr__ 禁止改字段,自动实现 __hash__(若 eq=True 且字段都可哈希)。当 dict key 用这个。
slots=True(3.10+):生成 __slots__,无 __dict__,内存紧。
order=True:按字段字典序生成比较,字段顺序就是比较顺序。
eq=False:不生成 __eq__,回退到身份。

field(compare=False, hash=False) 可以把缓存、锁这类字段排除。

python
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 怎么选

普通 classdataclassNamedTuplepydantic v2
样板代码自己写 __init__生成生成生成 + 校验
可变默认可变默认可变,frozen 可选不可变默认可变,frozen 可选
可哈希自己写frozen 才自动默认可以frozen 才
校验自己写核心能力
嵌套解析自己写model_validate
继承行为随意可以,字段合并有规则可以
依赖标准库标准库第三方
适用有行为的对象有一点行为的记录纯记录、当 key边界 I/O、配置、API

经验:

  • 领域对象有不变量、有方法、生命周期长:普通 class 或 dataclass + 方法
  • 内部 DTO、配置结构、测试夹具:dataclass
  • 要当 dict key 的轻量记录:NamedTuplefrozen dataclass
  • 对外 JSON / 表单 / 配置文件:pydantic(类型注解篇展开)
  • 不要用 dict 当结构体传四层函数

dataclass 不是 ORM,不会给你数据库。它也不校验类型:User(id="x", name=1) 运行时过得去。要运行时校验走 pydantic 或自己在 __post_init__ 写。

python
@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。

python
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

python
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_checkable

Go 的 interface 是结构子类型,不用声明实现。C++ 的抽象基类是名义的,Concepts 是结构的。Python 两套都有:ABC 适合框架插件(强制继承、文档化契约),Protocol 适合已有类型(read() 的东西就能用,不管是不是你的子类)。

细节——泛型 Protocol、typing.Self、与 dataclass 组合——留给类型注解篇。现在记住:后端插件用 ABC 或注册表;函数参数「只要有 close」用 Protocol。


十、组合优于继承:Handler 插件

继承树在后端最容易烂的地方:各种 Handler / Middleware / Adapter 一层套一层,基类 __init__ 要 8 个参数,子类只为改一行。用组合:核心循环认一个协议,插件是独立对象。

python
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++GoPython
类是对象不是运行时对象无 class是,type 的实例
实例化构造函数T{} / NewT()__new__ + __init__
方法绑定this 编译期receiver,值或指针描述符运行时绑 self
虚函数virtual + vtableinterface 隐式全部动态查找
可见性private 硬拦首字母大小写_ 约定,__ mangling
多继承有,菱形虚继承无,embedding有,C3 MRO
super写父类名 / 虚继承MRO 下一个,不是「父类」
属性成员变量结构字段__dict__ 或 slots;property 拦截
静态方法static包级函数@staticmethod
工厂static / 自由函数NewT@classmethod
接口抽象类 / Conceptsinterface 结构子类型ABC 名义 + Protocol 结构
析构确定时机 RAII无析构,defer__del__ 不保证;用 with
记录类型struct / aggregatestructdataclass / NamedTuple

可见性再钉一次:

python
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、工厂

python
"""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+AUpperstr__new__ 里变大写;单例身份相同;Sub.from_url 返回 Sub;frozen dataclass 能进 set 且两个 Point(1,2) 去重。

obj.method()Class.method(obj),中间那一跳是描述符。类属性那份可变 list、默认参数那份可变 list、闭包那个延迟绑定的 cell,是同一张对象图上的不同挂法。专栏基础四篇走的都是这条图:名字绑对象,对象有类型,类型也是对象。