
上一篇我们把 Python 的类和对象过了一遍从class关键字到__init__写了几个像模像样的小例子。但很多人在这一步卡住了语法全认识一进真实项目还是不知道该往哪儿放类、该不该继承、为什么别人的对象用起来像原生类型。这篇是面向对象编程的第二篇不重复讲基础概念专门解决“基础都会代码还是写得很乱”的问题。我会从继承、多态、特殊方法、组合这些真正有杀伤力的点切入配合项目里反复踩过的坑讲清楚什么时候该用类、什么时候别硬套类以及怎样把一段满是if-else的命令式代码慢慢重构成能看懂、能扩展的结构。适合已经看完 Python 基础语法、想真正把 OOP 用到工程里的开发者。阅读之前你只需要知道类、实例、方法这几个概念就够了其余我尽量讲成能直接上手的经验。1. 面向对象的本质是封装变化而不是给代码排排坐很多人刚学 OOP 时都有一个错觉把一堆函数塞进 class 里就是面向对象了。这种写法只能叫“函数搬家”实际收益不大。面向对象真正解决的核心问题是封装变化——把程序中容易变化的部分抽出来让不稳定的需求在局部修改而不是满文件乱窜。1.1 一个订单价格计算的教训假设电商系统里有个订单价格计算刚开始是这样def calc_price(order, user): total sum(item.price for item in order.items) # 普通用户不打折 return total后来加了会员折扣你用if塞进去def calc_price(order, user): total sum(item.price for item in order.items) if user.level vip: total * 0.9 if order.coupon: total - order.coupon.amount # 大促活动又要打八折 if is_promotion(order): total * 0.8 return total三个月后这个函数变成两百行里面全是if-elif。每次新增一个“满减”“秒杀”“黑金会员”你都得翻开这个函数小心翼翼在某个位置插入一段逻辑还要祈祷没碰到别的分支。这个痛苦的根源不是“没用类”而是你让具体的折扣规则直接依赖了调用上下文。正确的做法是定义一个抽象的价格计算入口把不同规则封装成对象通过多态让它们各自处理自己那一份。class DiscountPolicy: def apply(self, total, order): ... class VipDiscount(DiscountPolicy): def __init__(self, rate): self.rate rate def apply(self, total, order): return total * self.rate class CouponDiscount(DiscountPolicy): def __init__(self, amount): self.amount amount def apply(self, total, order): return max(0, total - self.amount) def calc_price(order, policies): total sum(item.price for item in order.items) for policy in policies: total policy.apply(total, order) return total现在新增一个“七夕活动五折”不用碰价格计算函数只需再写一个PromotionDiscount类往policies里塞一下就行。这就是对扩展开放、对修改封闭。1.2 类变量和实例变量的经典翻车现场很多人在这一节栽跟头明明在类里定义了一个列表结果所有实例都互相污染。看这个例子class Task: tags [] # 类变量不是实例变量 t1 Task() t2 Task() t1.tags.append(urgent) print(t2.tags) # [urgent]原因很简单Python 的属性查找是先在实例字典里找找不到就去类字典里找。t1.tags.append往Task.tags这个列表里追加了元素所有实例共享同一个列表自然就串味了。如果你真的想让每个任务有独立的标签应该在__init__里设置class Task: def __init__(self): self.tags []还有一个更隐蔽的坑给类变量赋值反而会让实例“分裂”class Counter: count 0 c1 Counter() c2 Counter() c1.count 1 print(c1.count) # 1 print(c2.count) # 0 c1 创建了实例变量遮蔽了类变量理解这套行为关键要记住一句话Python 中“变量”其实是一个名字绑定到对象不是往盒子里存值。只要记住赋值c1.count 1是在实例命名空间里新增一个count指向整数 1而类里的count仍然指向原来的整数 0就不会再被绕晕。1.3 可变默认值这个千古大坑还有一类问题新手甚至很多工作两三年的同学都会写错class Task: def __init__(self, tags[]): self.tags tagsdef __init__(self, tags[])里的[]只在函数定义时计算一次之后所有实例调用构造器时如果不传tags拿到的都是同一个列表对象。于是两个任务的 tags 互相影响。正确写法是class Task: def __init__(self, tagsNone): self.tags tags if tags is not None else []这里不光是“约定”背后是 Python 默认参数在定义时求值的机制。我在代码评审时看到过太多次因为这类问题导致的诡异 bug某天突然发现 A 任务的标签出现在 B 任务的列表中实际上就是默认参数共享导致的。这类问题排查起来特别费劲因为现象不固定和调用顺序有关。2. 继承没有你想象的那么美——super() 是一条协作链继承是 OOP 教程必讲的核心但也是日常工程里最容易被滥用、最容易翻车的特性。很多人觉得“继承能复用代码所以能继承就继承”于是设计出A extends B extends C的深长继承链最后改一个基类方法所有子类都在叫救命。2.1 super() 其实不是“调用父类方法”在单继承里super().__init__()看起来就是调用父类构造器大家都能理解。但到了多重继承super()的行为就不再是“往上找父类”而是沿着 MRO方法解析顺序继续向后找。换句话说它是一条协作链每个类在链条上选择一个位置把自己的逻辑执行完之后再通过super()把“接力棒”传下去。看一个菱形继承的例子class A: def __init__(self): print(A.init) super().__init__() class B(A): def __init__(self): print(B.init) super().__init__() class C(A): def __init__(self): print(C.init) super().__init__() class D(B, C): def __init__(self): print(D.init) super().__init__() D() # 输出 # D.init # B.init # C.init # A.init如果每个类的__init__里都写了super().__init__()Python 会通过 C3 线性化算法算出 MROD - B - C - A - object并保证每个类只被调用一次。这里 B 和 C 都会执行但 A 不会被执行两次这是很多人不懂super()时最担心的“多次调用”问题。反过来如果 B 的__init__里不调用super().__init__()链条就断了C 和 A 的初始化不会执行。比如设计一个 Mixin 类本意是往其他类里加属性但由于漏写了super()调用导致其他继承者没有正常初始化这种 bug 非常隐蔽。2.2 MRO 能有多绕多重继承不得不看的顺序我自己在写框架插件时经常用 Mixin但每次都要在心里过一遍 MRO。你可以通过类名.__mro__直接查看print(D.__mro__) # (class __main__.D, class __main__.B, class __main__.C, class __main__.A, class object)MRO 规则的核心是子类永远排在父类前面多个父类按声明顺序从左到右同时要保证单调性。比如D(B, C)中 B 在 C 前B 的父类 A 又排到了 C 后面最终形成D - B - C - A。一个实操建议多重继承里的父类尽量做成“行为切片”的 Mixin让它们彼此之间没有共同的非object基类各自只注入一部分方法同时每个 Mixin 里的__init__都要记得super().__init__()并且不要给__init__设置那些其他 Mixin 也需要的同名参数否则 MRO 顺序会让某些参数传不到对应类里。2.3 组合才是多数场景下的正解继承最大的问题是强耦合子类自动获得父类的所有公共方法哪怕你根本不需要以后父类改一个方法签名所有子类都可能跟着崩。而组合has-a 关系可以精确控制需要用到哪些对象对象之间通过接口协作耦合面小得多。举个例子一个“订单服务”需要记录日志。面向对象新手可能写class Logger: def log(self, msg): ... class OrderService(Logger): def create_order(self, ...): self.log(created)这看起来“复用”了 Logger但 OrderService 从此就被绑定在了 Logger 这种实现上以后想换成结构化日志、异步日志得改继承关系。如果用组合就清爽得多class OrderService: def __init__(self, logger): self.logger logger def create_order(self, ...): self.logger.log(created)依赖注入的好处是logger可以从外部传进来测试的时候传一个内存日志对象生产环境传一个写文件的日志对象OrderService 完全不用改。这也是“组合优于继承”这句经典原则背后的真实用意不是不许用继承而是普通业务代码里组合够用的场景就不要用继承把自己焊死。3. 多态Python 不关心你姓什么只关心你会不会这一手多态是面向对象的核心魅力之一但也是中文教程里讲得最玄乎的概念之一。我的理解很简单多态就是同样一个方法调用面对不同类型的对象时表现出各自不同的行为。Python 在这方面天赋异禀因为它是动态语言天然支持鸭子类型。3.1 鸭子类型不检查类型只检查行为“如果它走起来像鸭子、叫起来像鸭子那它就是鸭子。”Python 的函数参数不需要声明类型所以调用时只要对象具备对应的方法/属性就能传进去。比如def read_data(source): data source.read() return data from io import StringIO, BytesIO print(read_data(StringIO(abc))) print(read_data(BytesIO(bxyz)))StringIO和BytesIO没有共同的父类但都有read()方法read_data就都敢接。这比 Java 的接口还要轻量不需要类主动声明“我实现了Readable接口”只要行为对得上就行。鸭子类型在实际项目里的典型价值是替换依赖。比如你开始用一个数据库返回游标对象后来发现测试不方便直接传一个内存里的FakeCursor对象只要有fetchall()方法业务代码就完全不用动。3.2 用 ABC 定义清晰的抽象接口鸭子类型虽然灵活但大项目里全凭自觉也不行。团队协作时你需要给“这个类必须具备哪些方法”立一个明确的规矩。abc模块就是为此准备的from abc import ABC, abstractmethod class PaymentGateway(ABC): abstractmethod def pay(self, amount: int) - str: 发起支付返回交易号 abstractmethod def refund(self, transaction_id: str) - str: 退款子类只要忘记实现pay或refund实例化时就会抛TypeError这比运行时才报AttributeError早多了。ABC 还支持多继承但它标记的是“合约”而不是“实现”所以即使有公共代码也不建议都堆进 ABC 里那只适合放非常稳定的通用逻辑。3.3 typing.Protocol更轻、更 Pythonic 的“鸭子静态化”Protocol是typing模块里的东西它让你在保持鸭子类型灵活性的前提下获得静态检查能力配合 mypy / Pyrightfrom typing import Protocol class Fetchable(Protocol): def fetch(self) - list: ... def run(source: Fetchable): data source.fetch() ...任何对象只要它有fetch(self) - list方法即使它完全没有继承Fetchable也符合这个协议。这是结构子类型structural subtyping它不要求继承只要求形状。项目中如果你只关心“能不能调这个方法”用Protocol比强制继承 ABC 更贴合 Python 的生态。只有你需要在运行时isinstance(obj, SomeABC)做判断时才用 ABC。4. 特殊方法让你的对象活成 Python 原住民平时写业务代码你会遇到两个对象做加法要用、打印对象时想看到可读信息、with语句想自动关闭资源……这些都是通过特殊方法“魔术方法”实现的。掌握了下面几个你的类会直接“长成” Python 内建对象的模样。4.1__repr__和__str__调试友好的第一步自己写的类如果不去实现这两个方法打印出来的是__main__.Task object at 0x10d...毫无信息量。强烈建议每个业务类都加一个__repr__比如class Task: def __init__(self, name, doneFalse): self.name name self.done done def __repr__(self): return fTask(name{self.name!r}, done{self.done!r}) def __str__(self): return f[{x if self.done else }] {self.name}这么做的直接收益是日志、Python 解释器调试、IDE 变量查看都会变得清清楚楚。__repr__要尽量给出足够重建对象的提示__str__则是给人看的展示格式。如果你发现调试一个对象时要反反复复打印内部字段别犹豫先把__repr__写好。4.2__eq__和__hash__放进集合之前先想清楚默认情况下两个对象只要不是同一个实例就是False。如果你希望两个字段一样就算同一个任务需要自己实现class Task: def __init__(self, task_id, name): self.task_id task_id self.name name def __eq__(self, other): if not isinstance(other, Task): return NotImplemented return self.task_id other.task_id def __hash__(self): return hash(self.task_id)但这里有一个 Python 里非常重要的隐性规则重写了__eq__后如果不重写__hash__对象会变得不可哈希__hash__会被设为None。因为两个相等的对象必须拥有相同的哈希值一旦你定义了相等逻辑默认的基于对象地址的哈希就失效了。如果你需要把对象放入 set 或作为 dict 的 key必须同时保证__hash__和__eq__一致。我踩过一个坑给一个数据模型实现了__eq__用来比较字段却忘记重写__hash__结果在单元测试里想把它放进set去重直接报TypeError: unhashable type。后来我把这类对象一律设计成“业务主键不可变”直接按主键做__eq__和__hash__问题就干净了。4.3__enter__和__exit__上下文管理器一次写对人写数据库连接、文件操作、锁获取时with语句是标准姿势。想让自定义类支持with实现这两个方法即可class DBConnection: def __init__(self, dsn): self.dsn dsn def __enter__(self): self.conn connect(self.dsn) return self.conn def __exit__(self, exc_type, exc_val, exc_tb): self.conn.close()如果__exit__返回True会“吞掉”异常一般不要这么做除非你有意做特殊处理。如果嫌手写两个方法麻烦可以直接用contextlib.contextmanager把一个生成器函数变成上下文管理器from contextlib import contextmanager contextmanager def db_connect(dsn): conn connect(dsn) try: yield conn finally: conn.close()这个方案的好处是逻辑连贯yield前面是进入时的准备后面是退出时的清理一眼就能看完。4.4__iter__、__next__和__len__打造可迭代对象只要实现__iter__你的对象就能在for循环里用再实现__len__就能用len()。下面这个例子模拟分页拉取远程 API 数据class PaginatedData: def __init__(self, fetcher, page_size10): self.fetcher fetcher self.page_size page_size self.current_page 0 def __iter__(self): return self def __next__(self): items self.fetcher.get_page(self.current_page) if not items: raise StopIteration self.current_page 1 return items def __len__(self): return len(self.fetcher.count())这样for batch in PaginatedData(fetcher):就能自动一页一页取取完自动停调用方不需要知道分页细节。把这个和__repr__结合起来你的对象就真的像 Python 原住民一样自然了。5. 封装边界与数据类不要一上来就堆 getter/setter封装是面向对象的三大特性之一但有多少人把封装理解成了“把所有属性私有然后写 getter/setter”如果你也这么干说明你还在用 Java 的思维写 Python。5.1property控制属性访问的正确姿势Python 里没有真正的 private 关键字下划线只是约定_name表示“内部保护”__name会触发名称修饰_ClassName__name但也不保证绝对安全。在业务代码里我推荐用单下划线加property来控制访问逻辑而不是提前写一堆get_name()/set_name()。class Order: def __init__(self, amount): self._amount amount property def amount(self): return self._amount amount.setter def amount(self, value): if value 0: raise ValueError(amount cannot be negative) self._amount value直接order.amount -10就会抛异常调用方写起来和普通属性没区别。好处很明显初版可以把属性直接公开后面要加校验时再改成 property外部代码完全不用改这是 Python 封装的优雅所在。5.2dataclasses数据容器的样板代码收割机用dataclass可以省掉大量手动定义__init__、__repr__、__eq__的样板from dataclasses import dataclass, field dataclass class Task: id: int name: str tags: list field(default_factorylist) def display(self): return f{self.id}: {self.name}注意tags不能用[]必须用default_factorylist否则又踩了可变默认值的坑。dataclass默认会生成__init__、__repr__、__eq__省心且有标准行为。但要注意dataclass 适合做“数据容器”也就是字段多、行为少的对象。如果加了一堆方法、验证逻辑、业务规则你其实是在为一个本可以是普通 dict 的结构绑了一个类的外壳反而更绕。有个实操心得当类开始出现三四个字段重复从 dict 里取时我第一反应不是写类而是先写NamedTuple或dataclass。真正的“富对象”需要承担状态流转和业务逻辑那种类才配拥有方法。5.3 到底什么时候该用类这个问题没有标准答案但可以参考一个经验法则你发现一段代码里有“一组相关的数据和行为并且这组行为会因对象状态不同而不同”时用它。如果只是把几个变量打包传参用 dict、NamedTuple 甚至 dataclass 都够了。闭包其实也能做到类似封装def make_task(id, name): done False def mark_done(): nonlocal done done True def info(): return {id: id, name: name, done: done} return info, mark_done这种闭包式的“对象”在某些场合比类更轻量特别是回调函数、状态机里。不要因为学了 OOP 就条件反射地为一切建类面向对象是一种工具不是信仰。6. 实战把一段 if-else 泥潭重构成优雅多态前面讲的都是知识点最后用一个完整的例子带你看一遍在实际项目里如何从乱代码走向面向对象。6.1 初始代码发通知的全家桶假设你的监控系统里有三种通知渠道邮件、短信、Webhook。最初的代码长这样def send_notification(task): if task.channel email: smtp init_smtp() smtp.send(task.recipient, task.content) elif task.channel sms: sms_client.send(task.recipient, task.content) elif task.channel webhook: requests.post(task.webhook_url, json{msg: task.content}) else: raise ValueError(fUnsupported channel: {task.channel})这个函数有三个致命问题第一渠道越多函数越长第二邮件、短信、Webhook 的配置和发送细节全都塞在一起改一个地方容易影响另一个第三没法单独测试某一种渠道。6.2 第一步定义抽象接口先定义一个抽象基类描述“每个渠道都能发送通知”这个核心契约from abc import ABC, abstractmethod class NotificationChannel(ABC): abstractmethod def send(self, task): ...定义接口的价值是让后续新增渠道时有章可循你只要实现send就能接入系统。6.3 第二步各渠道独立实现然后为每种渠道写一个子类把原来的if块搬进去顺便把配置也封装好class EmailChannel(NotificationChannel): def __init__(self, smtp_server, username, password): self.smtp_server smtp_server self.username username self.password password def send(self, task): smtp init_smtp(self.smtp_server, self.username, self.password) smtp.send(task.recipient, task.content) class SmsChannel(NotificationChannel): def __init__(self, client): self.client client def send(self, task): self.client.send(task.recipient, task.content) class WebhookChannel(NotificationChannel): def __init__(self, url): self.url url def send(self, task): requests.post(self.url, json{msg: task.content})注意每个类只负责“怎么用这种渠道发”不负责判断“该不该用这种渠道”。判断职责由调用方独立承担这就是单一职责。6.4 第三步用注册表替换 if-else现在把渠道对象按名称注册到一个字典里调度器拿到渠道名直接取对象class NotificationService: def __init__(self, channels): self.channels channels def send(self, task): channel self.channels.get(task.channel) if channel is None: raise ValueError(fUnsupported channel: {task.channel}) channel.send(task)初始化时组装service NotificationService({ email: EmailChannel(smtp.example.com, user, pass), sms: SmsChannel(sms_client), webhook: WebhookChannel(https://example.com/hook), }) service.send(task)这个重构看起来简单收益却很大新增渠道时只需写一个类注册到字典里每个渠道可以单独写单元测试NotificationService不依赖任何渠道细节测试时也可以传入假的channels。6.5 重构背后的取舍如果把所有通知类型加起来不超过三种而且一年也不会变那最初那个if-else函数其实是可以接受的不一定非要重构。但现实中通知渠道经常加比如飞书、钉钉、企业微信每次加渠道都要改send_notification函数测试也越堆越重这时候面向对象重构的收益就体现出来了。我的经验是不要为了“优雅”而引入类。当你已经闻到 if-else 一股股往外冒、每次需求变更都要动核心函数时才是动手的时机。重构成多态后代码行数未必少但维护成本一定更低——因为你把变化点隔离到了可以独立替换的对象里。我个人在实践里最深的体会是真正让代码变稳的不是用了多少设计模式而是我先问自己一句话——这里最容易变的东西是什么然后用最轻量的方式给这个变化点留口子。类、继承、多态、组合全都是这个问题的不同答案。你在写下一个类之前也不妨先问自己这个问题。