做了无数个业务项目、代码量从几百行涨到几万行之后,我深刻理解了一件事:大部分人并不是不会写 class,而是不知道在什么场景下该用 class,以及该用什么级别的 OOP 手段去解决问题。Python 这门语言最大的迷惑性,在于它几乎不逼你使用任何范式。你可以从头到尾写脚本式函数,也可以像 Java 一样把一切都包进类里,两者都能跑,但上线后维护起来的差别,往往能在三个月内拉开一个团队的执行力差距。
针对“Python 面向对象编程(OOP)终极指南”这个命题,我希望呈现的不只是语法速查,而是从需求推导设计、从设计落代码、从代码反推坑点的完整链路。无论你是刚学完 Python 语法、准备跳槽面试的新人,还是已经写了半年业务脚本但始终觉得自己没“入门”的工程师,读完这篇文章后,你应该能亲手设计出结构清晰、易扩展、经得起需求变更的类模型,也能看懂别人代码里那些隐晦的继承体系和魔术方法到底在做什么。
1. 设计层面的思考:先从三个真实需求说起
1.1 为什么 Python 里的 OOP 和 Java/C++ 不一样
很多朋友第一次接触 OOP 是在 Java 课上,于是天然以为面向对象就一定要有 private 关键字、要写 getter/setter、要 class 套 interface 再套实现类。落到 Python 里,这种思维往往会产生一堆比脚本还难维护的“胖类”。
Python 的 OOP 其实是“鸭子类型 + 动态属性 + 协议约定”的组合体。它不强求你隐藏内部状态,也不强制你定义接口,它更希望你遵循约定:用下划线表示“别碰我”,用魔术方法来融入语言本身。所以,Python 里的设计原则不是靠编译器来兜底,而是靠团队里的每个人理解“命名、边界、职责”这三个词。
我见过一个项目,早期赶进度,所有函数都铺在模块里,后来加了支付逻辑,开始出现pay_by_alipay()、pay_by_wechat()、pay_by_card()这种复制粘贴式函数。这时候如果还不引入一个Payment类来统一接口,下一次活动需求来的时候,改 bug 的人一定会崩溃。
1.2 面向对象到底解决了什么问题
面向对象不是银弹,它只是三种核心能力的组合:
- 封装:把数据和对数据的操作绑在一起,外部只看到有限的入口,改内部实现不影响外部调用。
- 继承:把公共逻辑抽出来,子类复用父类,同时允许扩展差异。
- 多态:同一套调用代码,传入不同的对象,产生不同的行为,调用方完全不用关心具体类型。
在 Python 中,这三个能力都可以用很轻量的方式实现。封装用命名约定和property,继承用普通 class 即可,多态甚至不需要继承,只要对象有同名方法就行。这正是 Python OOP 的智慧所在:约束越少,越考验设计者的自律。
1.3 什么代码适合用 OOP,什么不适合
我经常被问到:到底什么时候该定义类,什么时候直接写函数就行?
我的判断标准很简单:
- 如果只有一组独立函数,数据以参数传入、结果以返回值传出,那么函数式或脚本式写法更直接。
- 如果存在需要长期维护的内部状态,或者多个函数都要操作同一份数据,或者未来有明确的“多套实现”切换需求,这时类就很合适。
- 如果你的代码里出现了同一个对象被反复塞给不同函数当第一个参数,请立刻停下来,把这个对象定义成类。
举个例子:爬虫项目里,一个SpiderSession类维护请求头、代理池、限速策略,比在 20 个函数里反复传session_params要优雅得多。
2. 类与实例的核心设计——别再被 class 吓退
2.1 类的诞生:定义、实例化、初始化
Python 使用class关键字定义类,用类名()创建实例。很多初学者容易混淆“初始化”和“构造”。其实__init__并不负责创建对象,它只负责在对象创建完成后填入初始数据。Python 真正创建对象是__new__在做,但绝大多数场景不需要动它。
来看一个实际业务里常见的用户类:
class User: def __init__(self, user_id: int, name: str, email: str): self.user_id = user_id self.name = name self.email = email self._login_count = 0 def login(self): self._login_count += 1 print(f"{self.name} 登录成功,累计登录 {self._login_count} 次") def to_dict(self): return {"user_id": self.user_id, "name": self.name, "email": self.email}这里_login_count以下划线开头,代表“内部状态,外部不要直接修改”。它不是绝对私有,只是约定。Python 的态度是:大家都是成年人,靠自觉。
2.2 self 到底是什么
self在所有实例方法中都是第一个参数,它指向调用这个方法的实例本身。很多人问:为什么 Python 不能像 Java 一样隐式帮你传this?原因很简单:显式比起隐式更直白(Python 之禅说的)。你把self写成任何名字都能运行,但强烈不建议,因为全社区都认self。
理解self对调试非常关键。当你看到一个实例方法报错说missing 1 required positional argument: 'self',通常不是没传 self,而是你把实例方法当普通函数调用了。比如:
user.login这种写法拿到的只是一个绑定方法对象,并没有真正执行。要执行必须加括号:user.login()。
2.3 类属性、实例属性、查找顺序
类属性是定义在class体内的变量,被所有实例共享。实例属性是在__init__或其他实例方法里通过self.xxx创建的,属于单个实例。Python 查找属性时,先看实例自己的__dict__,找不到再去类里找,这叫“自底向上查找”。
这个机制很容易踩坑。看下面的代码:
class Counter: count = 0 c1 = Counter() c2 = Counter() c1.count += 1 print(c1.count) # 1 print(c2.count) # 0第一次看会以为类属性count是共享计数器,c1.count += 1应该让所有实例都看到变化吧?其实并没有。因为+=会对c1执行赋值操作,这会在c1自己的__dict__里创建一个新的实例属性count,覆盖了类属性的查找结果。想在类层面做共享状态,应该直接操作type(self).count或者用Counter.count += 1。
2.4 动手写一个完整的业务类
综合上面的知识点,我们构建一个更有实际意义的类:订单类。它需要校验金额、计算折扣、输出摘要。
from datetime import datetime class Order: DISCOUNT_RATES = {0: 1.0, 1000: 0.95, 5000: 0.9} def __init__(self, order_id, items, created_at=None): self.order_id = order_id self.items = items # [{name, price, qty}] self.created_at = created_at or datetime.now() self._status = "pending" @property def total_amount(self): return sum(item["price"] * item["qty"] for item in self.items) @property def discount_rate(self): rate = 1.0 for threshold, discount in sorted(self.DISCOUNT_RATES.items()): if self.total_amount >= threshold: rate = discount return rate @property def final_amount(self): return self.total_amount * self.discount_rate def confirm(self): if self._status != "pending": raise ValueError("只有待支付订单可以确认") self._status = "confirmed" def __repr__(self): return f"<Order {self.order_id} total={self.final_amount:.2f}>"这个类用了property把金额计算暴露成属性,调用方写order.final_amount而不是order.final_amount(),语义上更自然。同时,_status被保护起来,只能通过confirm()修改,这就是封装的价值。
3. 方法的三重身份——实例方法、类方法、静态方法
3.1 三种方法,三种使用场景
普通实例方法的第一个参数是self,我们对它很熟了。类方法用@classmethod装饰,第一个参数是cls,它操作的是类本身而不是实例。静态方法用@staticmethod装饰,它与类和实例都没有绑定关系,本质上是一个放在类命名空间里的普通函数。
选型逻辑可以这样记忆:
- 需要访问实例数据或修改实例状态,用实例方法。
- 需要获取类属性或创建实例(比如做替代构造器),用类方法。
- 方法跟当前类有关系,但不需要类和实例的数据,用静态方法。
举一个经典场景:从字典创建用户、从文件列表批量创建用户。用类方法作为“工厂”非常顺手。
class User: def __init__(self, name, age): self.name = name self.age = age @classmethod def from_dict(cls, data): return cls(name=data["name"], age=data["age"]) @classmethod def from_line(cls, line): name, age = line.strip().split(",") return cls(name=name, age=int(age)) @staticmethod def is_adult(age): return age >= 18from_dict和from_line就是所谓的“替代构造器”。如果将来User被继承成VipUser,cls会自动绑定到VipUser,不会出现使用User(...)硬编码导致的子类返回父类问题。
3.2 property 装饰器:从“方法调用”到“属性访问”
@property的价值在于:最初我们写了一个普通属性self._status,后来需求变成“读取状态时要做权限校验”,如果外部全都是order._status这样的访问,改动就灾难了。但如果一开始就提供@property方法,外部访问形式是order.status,内部逻辑想怎么改都行。
property还可以配合 setter 做赋值校验:
class Product: def __init__(self, name, price): self.name = name self._price = price @property def price(self): return self._price @price.setter def price(self, value): if value < 0: raise ValueError("价格不能为负数") self._price = value这样product.price = -100就会直接报错。比在业务逻辑里到处写 if 判断要舒服得多。
3.3 选错方法类型的真实教训
我见过一个同事,把应该定义成@staticmethod的一个纯计算逻辑,写成了实例方法,导致测试时需要实例化整个依赖一堆外部服务的对象才能测。这不仅仅是风格问题,更是测试性与模块耦合度的代价。宁可让静态方法显得“不太面向对象”,也不要为了形式牺牲可用性。
4. 继承、多态与鸭子类型——比想象中更需要克制
4.1 该用继承时用继承,该用组合时用组合
“优先使用组合而非继承”这句话是《设计模式》里的名言,但很多人理解偏了。不是说继承不能用,而是继承建立的是“is-a”关系,组合建立的是“has-a”关系。大部分业务模型里的关系其实都是“has-a”。
举个例子:一个Car类,不能继承自Engine(汽车不是一个发动机),应该持有self.engine = Engine()。反过来,SportsCar继承自Car是合理的,因为跑车确实是一种汽车。
继承最大的问题是层级太深之后,父类的任何改动都可能辐射整个体系。我建议继承深度控制在两层到三层,超过三层的继承链,请停下重构。
4.2 super() 到底在做什么
super()并不是“调用父类”这么简单,它按 MRO(方法解析顺序)返回下一个类。在单继承里它就是父类,在多重继承里它决定顺序的核心。
来看一个正确写法:
class A: def __init__(self, *args, **kwargs): print("A init") super().__init__(*args, **kwargs) class B(A): def __init__(self, *args, **kwargs): print("B init") super().__init__(*args, **kwargs) class C(A): def __init__(self, *args, **kwargs): print("C init") super().__init__(*args, **kwargs) class D(B, C): def __init__(self, *args, **kwargs): print("D init") super().__init__(*args, **kwargs)这个菱形继承的 MRO 是D -> B -> C -> A -> object。如果 B 和 C 都调用super().__init__(),A 的__init__只会执行一次。这是 Python 新式类协作式多重继承的经典表现。但如果中间有人漏了super(),协作链就会断裂,A 可能不会初始化。
4.3 多态:不需要接口也能接口
Python 的多态体现在鸭子类型:只要对象实现了所需的方法,就能传入相应位置。比如定义一组支付处理器:
class Alipay: def pay(self, amount): print(f"支付宝支付 {amount} 元") class WechatPay: def pay(self, amount): print(f"微信支付 {amount} 元") class BankCard: def pay(self, amount): print(f"银行卡支付 {amount} 元") def checkout(pay_method, amount): pay_method.pay(amount)checkout函数不关心你是什么类,只要求你有一个pay方法。这就是面向接口编程,而且接口是隐式的。
当然,隐式接口也有缺点:如果传进来的对象没有pay方法,运行时才报AttributeError。要提前约束,可以用abc模块定义抽象基类,或者用typing.Protocol,这个在后续部分展开。
4.4 多重继承与 MRO 的实操判断
多重继承在 Python 中的确存在且合法。但请记住一句话:多重继承最容易制造认知负担。一段代码里出现三四个父类时,读代码的人很难快速搞明白某个方法的真正来源。
如果你真的需要多重继承,请分配好每个父类的职责,比如一个 mixin 只负责一个横切关注点:
class LogMixin: def log(self, msg): print(f"[LOG] {msg}") class TimeMixin: @property def created_at(self): return self._created_at class Task(LogMixin, TimeMixin): pass这里的LogMixin和TimeMixin都是轻量 mixin,不参与核心业务逻辑的初始化,因此不易冲突。如果两个 mixin 里有同名方法,MRO 靠前优先,也就是写在继承列表前面的类生效。
4.5 鸭子类型与 Protocol:动态与静态的平衡点
从 Python 3.8 开始,typing.Protocol可以描述结构子类型。你不需要强制性继承某个类,只要对象拥有协议里声明的方法和属性,静态类型检查工具就会认为它符合要求。
from typing import Protocol class Payable(Protocol): def pay(self, amount: float) -> None: ... class Alipay: def pay(self, amount: float) -> None: print(f"支付宝支付 {amount} 元") def checkout(pay_method: Payable, amount: float) -> None: pay_method.pay(amount)运行时,checkout还是完全鸭子类型的。但在编辑器和 mypy 里,你能提前发现“这个对象没有 pay 方法”的问题。Protocol 是 Python OOP 生态里被低估的好东西,强烈建议在代码量大的项目里推广。
5. 魔术方法:让你的类融入语言原生世界
5.1__repr__与__str__:调试体验的分水岭
一个没实现__repr__的类,在日志里打出来是<__main__.Order object at 0x7f...>,看半天也看不出内容。而实现好的__repr__能让对象在控制台、日志、调试器里直接“说人话”。
class User: def __init__(self, name, age): self.name = name self.age = age def __repr__(self): return f"User(name={self.name!r}, age={self.age!r})"!r表示用repr()转义字符串,打出来会带引号,排版更标准。__str__是给人看的,__repr__是给开发者看的。如果只实现一个,建议实现__repr__,因为 fallback 时__str__会调用__repr__。
5.2__eq__与__hash__:改了相等就要改哈希
两个自定义对象什么时候算相等?默认情况下,Python 按内存地址比较。如果你想按业务主键(例如 user_id)比较,就需要重写__eq__。但注意:重写__eq__后,__hash__会被自动设为None,这个对象就不可哈希了,不能再放进 set 或作为 dict 的 key。
解决办法是同时重写:
class User: def __init__(self, user_id, name): self.user_id = user_id self.name = name def __eq__(self, other): if not isinstance(other, User): return NotImplemented return self.user_id == other.user_id def __hash__(self): return hash(self.user_id) def __repr__(self): return f"User(user_id={self.user_id!r}, name={self.name!r})"isinstance(other, User)判断避免跟其他类型比较时报错。NotImplemented是让 Python 尝试反向操作的机制,比直接return False更合规。这里有一条原则:对象相等性定义在哪套字段上,哈希也必须基于同一套字段,否则 set 去重会出现“看起来相等但两个都保留”的诡异行为。
5.3__enter__与__exit__:自定义上下文管理器
Python 的with语句依赖__enter__和__exit__。你可以让任何类支持with,从而把资源的获取和释放收拢在同一个块里。
class ManagedFile: def __init__(self, path, mode="r"): self.path = path self.mode = mode def __enter__(self): self.file = open(self.path, self.mode) return self.file def __exit__(self, exc_type, exc_val, exc_tb): self.file.close()这个比 try/finally 干净得多。注意__exit__的返回值:返回True代表异常已被吞掉,不建议这么用;返回False或None代表异常继续向上抛。
5.4 用 dataclass 把样板代码减半
从 Python 3.7 开始,@dataclass装饰器可以根据字段自动生成__init__、__repr__、__eq__等方法。这不是黑魔法,只是在类定义阶段帮你写代码。
from dataclasses import dataclass @dataclass class Product: sku: str name: str price: float = 0.0 stock: int = 0Product(sku="A1", name="苹果", price=5.5)就能直接用了。字段默认值、排序、冻结模式都有参数可配。frozen=True可以让实例像元组一样不可变,对并发和数据安全更友好。dataclass 非常适合项目里的纯数据模型,比手写十几个方法优雅太多。
5.5__slots__:省内存还是自找麻烦
__slots__可以限制实例能绑定的属性名,内部使用压缩后的描述符数组存储,不再用__dict__,每个实例能省相当可观的内存。但代价是:不能动态添加新属性,调试时也少见__dict__信息。我一般只有在创建几百万个对象、明确内存吃紧时才用它,普通业务项目没必要。
6. 面向对象设计原则与工程落地——不是背概念,是为了活下去
6.1 SOLID 用大白话怎么讲
很多面试会问 SOLID,但工程上它不是用来背的,而是用来预判麻烦的:
- 单一职责原则(SRP):一个类只应该有一个改变的理由。说白了,别让一个类既管数据库读写、又管消息推送、又管报表生成。
- 开闭原则(OCP):对扩展开放,对修改关闭。加新功能时优先写新类,而不是去改一堆已经稳定运行的旧代码。
- 里氏替换原则(LSP):子类要能无痛替换父类。如果你发现子类里要抛“这个功能不支持”异常,大概率继承设计错了。
- 接口隔离原则(ISP):不要强迫调用方依赖它不需要的方法。
- 依赖倒置原则(DIP):依赖抽象,不要依赖具体实现。
工程上最直接的一条建议:当你想复制一段旧方法再改两行的时候,想想这是不是 OCP 被打破的信号。新增行为应该通过新类或新参数来完成,而不是把旧函数越改越长。
6.2 工厂模式:把创建对象的逻辑收口
当创建对象需要根据条件做差异初始化时,简单工厂非常靠谱。比如根据支付方式创建不同的支付处理器:
class PaymentFactory: _payments = {} @classmethod def register(cls, key): def decorator(payment_cls): cls._payments[key] = payment_cls return payment_cls return decorator @classmethod def create(cls, key, *args, **kwargs): if key not in cls._payments: raise ValueError(f"不支持的支付方式: {key}") return cls._payments[key](*args, **kwargs) @PaymentFactory.register("alipay") class Alipay: def pay(self, amount): print(f"支付宝支付 {amount} 元") @PaymentFactory.register("wechat") class WechatPay: def pay(self, amount): print(f"微信支付 {amount} 元")这种注册式工厂的好处是:新增支付方式只需要新增一个被装饰的类,不需要改工厂内部逻辑,符合开闭原则。业务代码里PaymentFactory.create("alipay").pay(100)完全不需要关心类名是什么。
6.3 策略模式:在多个算法之间动态切换
策略模式解决的问题是“同一操作,多种实现,运行时切换”。Python 里组件少,甚至可以用字典直接实现,但为了保持组织清晰,我还是推荐定义一个策略基类或用 Protocol。
class DiscountStrategy: def apply(self, total: float) -> float: raise NotImplementedError class NoDiscount(DiscountStrategy): def apply(self, total: float) -> float: return total class FullReduction(DiscountStrategy): def __init__(self, threshold: float, reduction: float): self.threshold = threshold self.reduction = reduction def apply(self, total: float) -> float: return total - self.reduction if total >= self.threshold else total class PercentDiscount(DiscountStrategy): def __init__(self, percent: float): self.percent = percent def apply(self, total: float) -> float: return total * (1 - self.percent)业务逻辑在use_discount里引用策略抽象类型,具体用哪种策略在运行时由外层决定,中间零改动,这就是策略带来“无侵入扩展”的底气。
6.4 依赖注入:不再被全局变量绑架
依赖注入的核心是“把依赖从内部创建改成外部传入”。原因很简单:外部传入的对象可以替换成 mock,测试因此不需要起数据库、调外部接口。
class ReportService: def __init__(self, db_client, notifier): self.db_client = db_client self.notifier = notifier def generate_and_notify(self, report_id): data = self.db_client.fetch(report_id) self.notifier.send(f"报告 {report_id} 已生成,共 {len(data)} 条")测试时可以传内存数据库和 fake notifier,不必碰真实环境。框架层面推荐dependency-injector,但小项目手动在入口组装依赖更轻。关键是不要让业务类内部出现import xx_client; self.client = xx_client()这种硬编码。
7. OOP 中最容易踩的九个坑——含调试思路
7.1 可变默认参数:一个老掉牙但还在伤人的问题
class ShoppingCart: def __init__(self, items=[]): self.items = items这个类有两个问题:一是默认列表只创建一次,所有没传 items 的实例共享同一个列表;二是即使每次实例化时传新列表,如果不拷一份,外部的修改也会影响对象内部。正确做法是:
class ShoppingCart: def __init__(self, items=None): self.items = list(items or [])记住一个原则:默认参数只用不可变对象,涉及容器就复制一遍。
7.2 名称改写并不是私有
__name(双下划线开头)会触发名称改写,在类内部自动变成_ClassName__name。它是为了防止子类不小心覆盖父类的属性,而不是真正的权限控制。外部依然可以通过obj._ClassName__name访问。单下划线才是我们约定俗成的“私有”标记,它不会做任何改写。
设计 API 的时候,想让外部禁止访问,就设计成不对外暴露入口,而不是靠双下划线防君子不防小人。
7.3 继承层级过深导致的行为灾难
一个类继承链上有七层父类,每个父类里又有一堆 mixin,最后这个对象的dir()打出来几百个方法,没人能确定某个方法到底在哪一层被谁覆盖了。此时最简单的重构方案是找出代码里真正复用稳定的部分,用组合代替深继承,让对象持有策略/服务对象而不是反复继承。
7.4 循环导入:类之间的“先有鸡还是先有蛋”
两个模块互相 import 对方时会触发循环导入。解决方案有几个:
- 把公共的基础类型抽到第三个模块。
- 在函数内部延迟 import。
- 把 import 放在模块底部(临时救火,不推荐长期使用)。
设计阶段如果能意识到类之间双向依赖往往说明职责分配有误,优先从设计上消除双向引用。
7.5 调试实战:使用 vars、dir 和 inspect
调试实例时,最实用的三个内置工具:
vars(obj) # 返回实例的 __dict__,只看实例属性 dir(obj) # 列出该对象能访问的所有属性,含继承来的 inspect.getmro(cls) # 查看类的 MRO,理清继承链遇到“这个属性为什么不是我想象的值”时,先打印vars(obj)确认到底是实例属性覆盖了类属性,还是子类覆盖了父类方法。很多诡异的 bug 在看清这两层后就已经找到答案了。
7.6 拷贝陷阱:浅拷贝和深拷贝
自定义类里如果包含可变容器,copy.copy是浅拷贝,内外层对象仍然共享内部列表引用。修改副本的列表数据会影响到原对象。用copy.deepcopy可以解决,但要求对象内部所有属性都是可深拷贝的。更稳妥的方案是在类的__init__里就对入参容器做复制,需求方传什么来都不怕。
8. 一个能直接跑的综合案例:用户与订单的完整服务代码
很多教程拆开讲语法都能看懂,一落到综合项目就懵。我把前面所有内容捏合成一个小型案例。
from dataclasses import dataclass, field from typing import List, Optional from datetime import datetime from abc import ABC, abstractmethod class DiscountStrategy(ABC): @abstractmethod def apply(self, total: float) -> float: pass class NoDiscount(DiscountStrategy): def apply(self, total: float) -> float: return total class VipDiscount(DiscountStrategy): def __init__(self, percent: float): self.percent = percent def apply(self, total: float) -> float: return total * (1 - self.percent) @dataclass class Product: sku: str name: str price: float @dataclass class OrderItem: product: Product qty: int @property def subtotal(self) -> float: return self.product.price * self.qty @dataclass class Order: order_id: str items: List[OrderItem] = field(default_factory=list) discount: Optional[DiscountStrategy] = None created_at: datetime = field(default_factory=datetime.now) _status: str = field(default="pending", init=False, repr=False) def add_item(self, product: Product, qty: int) -> None: self.items.append(OrderItem(product, qty)) @property def total(self) -> float: return sum(item.subtotal for item in self.items) @property def final_total(self) -> float: if self.discount is None: return self.total return self.discount.apply(self.total) def confirm(self) -> None: if self._status != "pending": raise RuntimeError("订单只能被确认一次") if self.total <= 0: raise ValueError("空订单不能确认") self._status = "confirmed" @classmethod def from_product_list(cls, order_id: str, product_qty_pairs: List[tuple]) -> "Order": order = cls(order_id=order_id) for product, qty in product_qty_pairs: order.add_item(product, qty) return order def __repr__(self) -> str: return f"Order(order_id={self.order_id!r}, total={self.final_total:.2f}, status={self._status})"这里面用到了 dataclass、default_factory、property、classmethod、抽象基类、策略模式。你可以在自己的项目里同样组合这些手法,不必逐行背下来,理解每个零件为什么存在才是关键。Order.from_product_list让我省去在外层写一个OrderFactory的繁琐;DiscountStrategy让促销规则可以独立扩展,不会污染订单类本身。
9. 学 OOP 时最容易被忽略的“心智切换”
很多人读了两遍语法书,仍然写不出好设计,问题出在思维模式没有切换。写脚本时我们思考“先做 A 再调 B 函数,判断 C 就输出 D”,这是流程思维;OOP 要求你思考“谁是对象、对象拥有什么、对象能做什么、谁负责创建谁、谁依赖谁”。
我在带人的时候常用一个方法:让新人把需求先写成一堆名词,再把这些名词标上“是另一个名词吗”还是“拥有另一个名词吗”。前者大概率出继承,后者大概率出组合。这个步骤坚持两三个项目后,设计能力会有肉眼可见的提升。
另一个被忽略的点是:OOP 不是以“类多”为荣。过度设计一样是坏味道。如果你的类只有一堆 getter/setter 和一行 pass 的方法,那它还没资格成为类,可能字典加函数都更直白。好的 OOP 设计应该在“抽象层次”和“实现成本”之间找到平衡点,而这个平衡点只能靠持续重构去逼近。
10. 我的实操心得与给新手的行动路线
我做 Python OOP 重构时一直用一套固定流程:先画出模块边界,再定义核心类,接着用 Protocol 或 ABC 把外部接口钉死,最后才写实现。顺序反了,后面全是痛苦。
如果你正在找工作,我建议别只背“什么是多态”这种概念题。亲手写一个“动物叫声”的 demo 没问题,但更要关注:如何用抽象基类约束子类方法、如何用组合规避菱形继承、如何用工厂把创建逻辑收口、如何用__repr__让日志好排查。这些能把“我会 OOP”从口号变成实打实的能力。
对只是想提升编码水平的朋友,我的建议很简单:找出你手头代码里被大量复制粘贴的函数,尝试用策略模式或依赖注入重构一层,然后把两个版本的代码量、改动影响面、可测试性对比一下。做过一次,你就能真正理解今天这篇指南里所有原则在讲什么。
其实 OOP 本身不是目的,它只是帮助你管理复杂度的一种思维工具。使用得当,代码会像一张清晰的地图;使用过度或不当,代码会成为一座迷宫。希望你在写下一个 class 的时候,先停下来想一想:这个类到底为什么存在,它需要维护什么状态,将来最可能怎样变化。带着这些思考去敲代码,你踩过的坑自然会变成经验。