Python 面向对象实战:从类到 SOLID 的关键细节

2026-05-26 30 预计阅读时间:1 分钟
来源:realpython.com AI 摘要 原文链接

免责声明:本文为 AI 摘要整理,建议结合原文阅读。摘要可能省略上下文、版本差异或边界条件,不作为官方说明。

预计阅读时间:10 分钟

写 Python 的人大多用过类,但真正把面向对象写到位的并不多——__init____repr__ 有什么区别?super() 在多重继承时到底怎么走?mixin 和普通父类该怎么选?SOLID 原则在动态语言里又该怎么落地?这些问题看似基础,却直接影响代码的可维护性和扩展性。

下面逐个拆解这些核心知识点,并给出可以直接跑的示例。

类与魔术方法:不止是 __init__

Python 的魔术方法(magic methods / dunder methods)是对象与语言基础设施的协议接口。__init__ 负责初始化实例状态,但对象的"构造"其实由 __new__ 完成;__repr__ 决定调试时看到什么,__str__ 决定用户看到什么。

几个容易忽略的细节:

  • __repr__ 应返回能重建对象的字符串(或尽量接近),__str__ 可以更友好。如果只实现一个,选 __repr__——print() 会 fallback 到它。
  • __eq____hash__ 要一起考虑:实现了 __eq__ 的对象默认 __hash__None,放进 set 或当 dict key 会直接报错。
  • __len____getitem__ 让自定义容器无缝对接 len()、索引和迭代。
class Vector:
    """一个简单的二维向量,演示核心魔术方法"""

    __slots__ = ("_x", "_y")  # 固定属性,省内存且防止误加属性

    def __init__(self, x: float, y: float):
        self._x = x
        self._y = y

    @property
    def x(self) -> float:
        return self._x

    @property
    def y(self) -> float:
        return self._y

    def __repr__(self) -> str:
        return f"Vector({self._x}, {self._y})"

    def __abs__(self) -> float:
        return (self._x ** 2 + self._y ** 2) ** 0.5

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, Vector):
            return NotImplemented  # 让 Python 尝试反方向比较
        return self._x == other._x and self._y == other._y

    def __hash__(self) -> int:
        return hash((self._x, self._y))

    def __add__(self, other: "Vector") -> "Vector":
        return Vector(self._x + other._x, self._y + other._y)


# 运行验证
v1 = Vector(1, 2)
v2 = Vector(3, 4)

print(repr(v1))        # Vector(1, 2)
print(abs(v1))         # 2.23606797749979
print(v1 + v2)         # Vector(4, 6)
print({v1, v2})        # {Vector(1, 2), Vector(3, 4)} — 可哈希

__eq__ 返回 NotImplemented 而不是 False,这是一个常见失误。返回 NotImplemented 时,Python 会尝试调用 other.__eq__(self),给对方一个机会;直接返回 False 则短路了这条路径。

继承与 Mixin:组合优于继承的落地方式

Python 的多重继承用 C3 线性化算法决定方法查找顺序(MRO),可以用 ClassName.__mro__ 查看。但多重继承容易变成"菱形问题"——两个父类都继承了同一个祖先,子类调用 super() 时到底走哪条路?

Mixin 是一种更安全的做法:Mixin 类只提供行为,不定义 __init__,不独立使用,也不参与菱形继承的核心路径。

class LogMixin:
    """为任何类添加日志能力——不定义 __init__,不引入状态"""

    def log(self, message: str) -> None:
        # 实际项目中替换为 logging 模块
        print(f"[{self.__class__.__name__}] {message}")


class ValidateMixin:
    """为带 _data 字典的类添加字段验证"""

    REQUIRED_KEYS: set[str] = set()  # 子类覆盖

    def validate(self) -> None:
        missing = self.REQUIRED_KEYS - set(self._data.keys())
        if missing:
            self.log(f"缺少必要字段: {missing}")
            raise ValueError(f"缺少: {missing}")


class UserConfig(LogMixin, ValidateMixin):
    REQUIRED_KEYS = {"name", "email"}

    def __init__(self, data: dict):
        self._data = data
        self.validate()
        self.log("配置已加载")

    def get(self, key: str, default=None):
        return self._data.get(key, default)


# 正常使用
cfg = UserConfig({"name": "Alice", "email": "alice@example.com"})
print(cfg.get("name"))  # Alice

# 缺字段时会报错并日志
try:
    bad = UserConfig({"name": "Bob"})
except ValueError as e:
    print(f"捕获异常: {e}")

Mixin 的关键约束:不要定义 __init__,不要假设子类的具体属性名(除非通过约定接口),只提供方法。这样 Mixin 之间不会争抢初始化顺序,MRO 也保持简单。

属性(Property):用方法伪装属性

@property 把方法调用伪装成属性访问,适合"读时计算"或"写时校验"的场景。但不要滥用——如果一个 getter 描次做大量计算或 I/O,它就不该是 property,因为使用者会以为 obj.x 是廉价操作。

class Temperature:
    """演示 property 的读写校验"""

    def __init__(self, celsius: float):
        self.celsius = celsius  # 注意:这里走的是 setter

    @property
    def celsius(self) -> float:
        return self._celsius

    @celsius.setter
    def celsius(self, value: float) -> None:
        if value < -273.15:
            raise ValueError("温度不能低于绝对零度")
        self._celsius = value

    @property
    def fahrenheit(self) -> float:
        """只读 property——换算结果,不存额外字段"""
        return self._celsius * 9 / 5 + 32

    # 不给 fahrenheit 写 setter → 只读


t = Temperature(25)
print(t.fahrenheit)  # 77.0

t.celsius = 0
print(t.fahrenheit)  # 32.0

try:
    t.celsius = -300
except ValueError as e:
    print(e)  # 温度不能低于绝对零度

__init__ 里写 self.celsius = celsius 会走 setter,校验逻辑在构造阶段就生效——这是 property 的常见最佳实践。

SOLID 在 Python 中的务实解读

SOLID 原则源自静态类型语言,在 Python 里不需要死板照搬,但方向值得参考:

原则 Python 下的要点
S — 单一职责 一个类只变一个原因。如果类既有业务逻辑又管序列化,拆开。
O — 开闭原则 用继承或 Protocol 扩展行为,不改已有代码。
L — 里氏替换 子类能替父类用,不破坏约定。NotImplemented 而非 False 就是例子。
I — 接口隔离 Protocol 定义小接口,别让实现类依赖用不到的方法。
D — 依赖反转 高层模块依赖抽象(Protocol / ABC),不依赖具体实现。

下面用 Protocol 演示 D 和 I 的结合:

from typing import Protocol, runtime_checkable


@runtime_checkable
class Shape(Protocol):
    """最小接口——只要能算面积就行"""
    def area(self) -> float: ...


class Circle:
    def __init__(self, radius: float):
        self.radius = radius

    def area(self) -> float:
        return 3.14159 * self.radius ** 2


class Rectangle:
    def __init__(self, width: float, height: float):
        self.width = width
        self.height = height

    def area(self) -> float:
        return self.width * self.height


def total_area(shapes: list[Shape]) -> float:
    """高层函数只依赖 Shape 协议,不关心具体类"""
    return sum(s.area() for s in shapes)


# Circle 和 Rectangle 没继承任何基类,但满足协议
shapes = [Circle(1), Rectangle(2, 3)]
print(total_area(shapes))  # 9.14159

# runtime_checkable 允许 isinstance 检查
print(isinstance(Circle(1), Shape))  # True

Protocol 是 Python 3.8+ 的结构性子类型(duck typing 的类型检查版)。实现类不需要显式继承,只要方法签名匹配就满足协议——这比 ABC 更轻量,也更贴合 Python 风格。

自检清单

写完一个类之后,可以快速过一遍:

  1. __repr__ 有没有实现?调试时你会感谢自己。
  2. __eq__ / __hash__ 是否配套?需要放进 set/dict 的对象必须两者一致。
  3. Mixin 是否避免了 __init__ 和实例状态?干净 Mixin 不抢初始化。
  4. @property 的 getter 是否是廉价操作?重计算用普通方法。
  5. 子类 能否无缝替换父类使用?如果替换后行为异常,Liskov 被违反。
  6. 依赖 是否指向抽象(Protocol / ABC)而非具体类?高层模块不应因为换了一个实现就改代码。

这些点不需要每次全做,但遇到继承层次变深、类职责模糊时,对照检查能省下大量重构时间。面向对象不是写个 class 就完事——协议、约束和组合方式的选择,才是真正拉开差距的地方。


相关推荐