类和对象这几个字,我在不同的学习笔记里抄过不下五遍,真正开窍是在第三遍。标着 xinixn 的那本册子上,关于类的第一页密密麻麻写着 class、__init__、self,旁边画了一堆箭头,现在回头看,那些箭头至少有一半是错的。这篇东西只聊一件事:类和对象——它到底是什么、能解决什么麻烦、学的时候该按什么顺序下手,以及一个新手到中级之间最容易反复栽跟头的地方在哪。适合刚学完函数和循环、正准备跨进面向对象这道门槛的人,也适合那些"语法都会写、一到自己设计就不知道从哪下手"的人。我不会只给你贴一段教科书式的定义,而是把类当成一件工具来拆:它为什么被造出来、什么场景下值得用、什么场景下用了反而更糟。读完你至少能做到两件事:一是看到一个需求,能判断该不该用类;二是写出来的类,别人接手时不用猜每个属性是干什么的。
1. 类和对象解决的到底是什么问题
1.1 从一段到处复制的代码说起
假设要处理一批商品数据,每个商品有名称、单价、库存,需要算总价、判断是否缺货、按折扣改价。不用类,最直接的写法是三个平行的列表,或者一堆字典。刚开始只有两三个商品的时候,这种写法很舒服,names = []、prices = []、stocks = [],谁都能看懂。问题出在第四个字段加进来的时候:你得同时改五六个函数的参数列表,任何一个地方漏改,数据就对不上了。再往后,函数开始变多,calc_total(name, price, stock)、apply_discount(name, price, stock, rate)、check_stock(name, price, stock),每个函数都拖着一串参数,看着就累。
这种写法的本质问题是:数据和操作它的逻辑被拆散了。数据在一处,逻辑在另一处,两者之间靠参数列表这条"线"连着。线越多,越容易断。类的第一个价值就在这儿——把一组总是同时出现的数据,和总是作用于这组数据的函数,捆成一个整体。捆完之后,calc_total不再需要三个参数,它只需要知道自己属于哪个商品。
判断要不要用类,我自己的经验标准是:如果一组数据在超过三个函数里反复成组出现,那就该考虑封装了。
这里有个反面的坑要说清楚:不是所有东西都该做成类。一个只做数学计算、不持有状态的工具函数,塞进类里变成MathUtil.add(),纯属给自己找麻烦。类适合"有状态、且状态会变"的东西。一个纯函数式的转换逻辑,写成模块级函数反而更清晰、更好测试。我见过不少新手刚学完类,恨不得把所有的代码都包进 class 里,结果写出来一堆只有一个方法的类,那不如直接写函数。
1.2 图纸与房子:类、对象、实例化三者的关系
类(class)是图纸,对象(object,也叫实例 instance)是按图纸盖出来的房子。这个比喻老掉牙,但它精准地解释了一个新手最容易混淆的点:类本身不占"业务数据"的位置,对象才占。
一张户型图纸可以盖一百套房,每套房里的家具摆放各不相同。对应到代码里:
- 类定义了"有哪些房间"——也就是有哪些属性;
- 每个对象各自持有自己的房间内容——也就是属性值;
- 类上定义的方法,是所有对象共用的行为,但执行时作用在各自的数据上。
实例化就是"照着图纸盖房"这个动作,语法上就是Product("键盘", 199.0, 20)这一下。这一下发生了三件事:解释器分配了一块内存、把__init__里的初始化逻辑跑了一遍、返回一个指向这块内存的引用。很多教程只讲第一句"创建对象",剩下两句含糊带过,结果学到后面self、引用传递、可变对象共享的时候全乱套。
我用一张表把三个概念对齐一下,这比背定义有用得多:
| 概念 | 对应现实 | 代码里的表现 | 常见误解 |
|---|---|---|---|
| 类 | 图纸、模具 | class Product: | 以为类是"一堆函数的集合" |
| 对象/实例 | 盖好的房子 | p = Product(...) | 以为变量 p 装的是对象本身 |
| 实例化 | 盖房这个动作 | 调用类名加括号 | 以为只是"调了个函数" |
| 属性 | 房子里的房间 | self.name | 以为类里写的变量都归对象 |
| 方法 | 房子里能做的事 | def total(self): | 以为方法跟普通函数没差别 |
第三列最后一行那句"以为变量 p 装的是对象本身",值得单独说。在 Python 里,p只是一个名字,一个指向对象的引用。q = p并不会复制出第二套房,只是给同一套房挂了第二块门牌。改q.name,p.name跟着变。这个认知不建立起来,后面调试 bug 会非常痛苦。
1.3 真正卡住人的不是语法,是建模
语法层面的类和对象,说实话两个小时就能背完:class关键字、__init__、self、点号访问。真正让人卡住一两个月的是另一件事——面对一个需求,怎么决定要拆出哪几个类、每个类该有哪些属性和方法。这个能力叫建模,它没有标准答案,但有判断标准。
我总结的判断标准有三条,按优先级排:
- 单一职责:一个类只因为一个原因被修改。如果需求变更时你发现"改价格逻辑要动这个类,改库存逻辑也要动这个类",说明它承担了两个职责。
- 高内聚:类内部的属性和方法应该互相关联。如果某个属性只有一两个方法用得上,其他方法碰都不碰,它的归属可能有问题。
- 低耦合:类与类之间的依赖越少越好。A 类里直接
new出 B 类并且深度调用 B 的内部细节,一旦 B 改了,A 就崩。
举个具体的例子。要做个订单系统,新手常见的第一版是这样:一个巨大的Order类,里面塞了商品列表、用户信息、优惠计算、库存扣减、日志打印、数据库保存,七八百行。这版的典型症状是:任何一个小改动都可能引发意料之外的连锁反应,测试也没法写,因为想测一个折扣计算,得先构造出完整的订单和数据库连接。
第二版通常按职责拆开:Product管商品本身的属性和校验,OrderItem管"某商品买几个",Order管条目集合和总价,Discount单独管折扣策略,持久化交给另一个Repository。拆完之后每个类都能独立测试,改折扣算法不用碰订单结构。这个拆分过程不是一次到位的,我自己的习惯是先按最直觉的方式写出来,写到觉得别扭的地方,再回头拆。别扭感就是建模信号。
2. 最小可用闭环:把一个类从无写到能跑
2.1 属性和方法的边界怎么划
拿到一个类,第一件事是分清楚哪些东西该存成属性、哪些该做成方法。判断逻辑很简单:属性是"它有什么",方法是"它能做什么"。但这个简单逻辑背后有个容易忽略的取舍——什么东西值得存下来。
拿商品举例。"单价"和"数量"必须存,因为它们各自独立、随时可能变。"总价"要不要存?两种做法都有人用:
- 存成属性,下单时算一次,之后直接读;
- 做成方法或属性方法,每次读的时候现算。
选哪种,取决于总价的计算成本,以及单价数量变化的频率。如果单价和数量随时会改,存下来的总价就是个定时炸弹,改完单价忘了同步总价,账就错了。如果计算很贵且数据基本不变,缓存下来才划算。
class OrderItem: def __init__(self, name, price, qty): self.name = name self.price = price self.qty = qty @property def total(self): return round(self.price * self.qty, 2)这里用@property是个很实用的做法:对外看起来像属性item.total,调用方不用写括号,内部却是现算的,永远不会出现数据不同步。代价是每次访问都要跑一遍乘法,数据量小的时候完全无所谓。
我的默认选择是:派生数据一律现算,除非profile证明它是瓶颈。过早缓存带来的同步bug,远比一点乘法的开销昂贵。
2.2__init__不是构造函数,它只负责初始化
这是个名词误用引发的大面积误解。在很多语言里确实有"构造函数"这个概念,但在 Python 里,真正创建对象的是__new__,__init__干的事情是在对象已经被创建出来之后,往里面填初始数据。两者的区别在 99% 的场景下感觉不到,但在两个地方会咬人:一是在__init__里 return 非 None 值会直接报错,二是继承不可变类型(比如继承str、tuple)时,必须在__new__里改数据,__init__里改不了。
新手阶段记住一句就够:__init__负责"出生时该有的东西都要有"。这句话的实际意义是——一个对象只要构造成功,就应该处于可用状态,不需要再调一个setup()或者init_data()才能用。我见过不少代码是这样的:
p = Product() p.set_name("键盘") p.set_price(199) # 忘了调 set_stock,后面一用就炸这种"半成品对象"是 bug 温床。正确的做法是把必需参数全放进__init__,缺一个就构造不出来,错误在第一时间暴露,而不是等到二十行之后才炸。
2.3self到底是什么,为什么必须写
self大概是新手第一个真正的认知障碍。它看起来像关键字,其实不是,它只是个约定俗成的参数名,位置在方法定义的第一个,代表"调用这个方法时,作用在哪个对象上"。
class Counter: def __init__(self): self.count = 0 def add(self, n=1): self.count += nc = Counter()之后调c.add(3),Python 内部实际做的是Counter.add(c, 3)。也就是说,c被自动塞进了第一个参数。self这个名字存在的唯一目的,就是让你在方法体里能引用到这个对象。
为什么 Python 要把self显式写出来,而不像某些语言那样藏起来?因为它让"这个方法是操作自身数据还是操作外部数据"这件事变得一目了然。看到self.count += n,你就知道它改了对象状态;看到纯参数的运算,就知道它没副作用。这种显式性在排查并发和状态问题时非常值钱。
有个小技巧:类方法里如果整个方法体都没有出现
self,那这个方法八成不该是实例方法,改成静态方法或直接挪出去当普通函数。
2.4 类属性和实例属性:一个能让程序悄悄出错的坑
类体里直接写的变量是类属性,所有实例共享一份;在__init__里用self.xxx =赋值的是实例属性,每个对象一份。这个区别平时不显眼,一碰上可变对象就出事。
class Cart: items = [] # 类属性,所有购物车共享! def add(self, item): self.items.append(item)这段代码的本意显然是每辆购物车有自己的商品列表,但items写在类体里,成了一个共享列表。c1.add("键盘")之后,c2.items里也会神奇地出现"键盘"。更阴险的是,如果改成self.items = self.items + [item],它反而又正常了——因为这是重新绑定,会创建实例属性。同一个字段,.append出 bug,+不出 bug,这种不一致能让人调半天。
正确的写法是把可变类型的初始值一律放在__init__里:
class Cart: tax_rate = 0.08 # 类属性:所有购物车共用的常量,合理 def __init__(self): self.items = [] # 实例属性:每辆车独立判断标准很简单:不可变的值(数字、字符串、元组)可以放类属性当常量;可变的(list、dict、set)必须放__init__。
3. 封装、继承、多态:看它们各自挡掉什么麻烦
3.1 封装的核心是收窄变更面
封装经常被简化成"属性前面加两个下划线",这是对它的矮化。封装的真正目的是:让外部代码只依赖少数几个稳定接口,内部怎么改都不影响外面。
举个现实中的例子。一个BankAccount类,余额如果直接暴露成self.balance,外部代码可以随意写account.balance = 999999,任何校验都形同虚设。加一层方法之后:
class BankAccount: def __init__(self, balance=0): self._balance = balance @property def balance(self): return self._balance def withdraw(self, amount): if amount <= 0: raise ValueError("取款金额必须为正") if amount > self._balance: raise ValueError("余额不足") self._balance -= amount外面只能读account.balance,想改必须走withdraw,所有校验都在这一个入口里。哪天要加"单日取款上限"或者"手续费",只改这个方法就够了。
Python 里的下划线是约定而非强制:_balance表示"别从外面碰",__balance(两个下划线)会触发名称改写,稍微硬一点,但也不是真正的私有。这套机制依赖团队共识,但正因为它是共识,代码可读性反而更好——看到_开头,老手一眼就知道这是内部实现。
实战经验:属性不要一上来就全做成 property。先用最直接的公有属性,等到真的需要校验或者需要改计算方式时,再升格成 property。因为从公有属性改成 property 是向后兼容的,反方向才痛苦。
3.2 继承的适用边界与组合的替代方案
继承解决的是"是一种"的关系。SavingsAccount是一种BankAccount,Dog是一种Animal,这种关系用继承没问题。但实际项目里,大量继承其实是在表达"有一个"或者"用得上",这就用错了。
判断要不要继承,我一般问三个问题:
- 子类真的是父类的一种特例吗?还是只是复用了父类的几个方法?
- 子类能不能完全替代父类出现的位置,而不破坏调用方的预期?
- 如果我以后再加三个子类,继承树会不会变得难以理解?
第二个问题对应的是里氏替换原则。如果Square继承Rectangle,但set_width会同时改高度,那把这个正方形塞进任何按矩形逻辑写的代码里都会出问题。这种继承表面省事,实际是给未来埋雷。
继承用得不合适的时候,组合通常是更好的替代:
# 继承写法:Logger 硬编码在类里 class OrderService(BaseService): def place(self): self.log("下单") # 组合写法:把能力当依赖传进来 class OrderService: def __init__(self, logger): self.logger = logger def place(self): self.logger.info("下单")组合的好处是:依赖可替换、可打桩、可测试,而且不会形成深层继承链。继承链一深,改父类一个方法,能影响到五层以下的子类,这种间接影响极其难排查。我的经验法则是:继承层级不要超过两层,超过两层就退回去想想能不能用组合。
3.3 多态:同一句调用,不同行为
多态听着玄,其实就是:调用方写同一句代码,传进去的对象不同,实际跑的逻辑不同。
class FixedDiscount: def apply(self, amount): return max(0, amount - 20) class PercentDiscount: def apply(self, amount): return round(amount * 0.9, 2) def checkout(amount, policy): return policy.apply(amount)checkout(100, FixedDiscount())和checkout(100, PercentDiscount())走的是同一行调用,结果不同。这个能力带来的最大好处是——加新策略不用改调用方。以后要加满减、加阶梯折扣,只需要新增一个类,checkout一个字都不用动。
attention 一下:多态在 Python 里靠的是"鸭子类型",不要求这些类有共同的父类,只要有同名方法就行。这带来了极大的灵活性,代价是错误发现得晚——如果你把policy.apply写成policy.calc,只有真正运行到那一行才会报错。缓解办法有两个:一是给这组类定义共同的抽象基类,二是写单元测试覆盖每种实现。我一般两个都做。
3.4 魔术方法:让自定义对象融入语言生态
魔术方法(双下划线包围的方法)不是炫技用的,它解决的是"我写的对象怎么像内置类型一样自然地用"这个问题。最值得先掌握的是四个:
| 方法 | 触发时机 | 实际用处 |
|---|---|---|
__repr__ | 打印、调试、REPL 里查看 | 让日志里看到的是有用信息,而不是一串地址 |
__eq__ | 用==比较 | 按业务含义判断相等,而不是比内存地址 |
__len__ | len(obj) | 集合类对象的自然长度语义 |
__iter__ | for x in obj | 让对象可被遍历,调用方代码更简洁 |
__repr__的优先级最高,几乎每个自定义类都该写。默认的<Product object at 0x7f8a>在排查问题时毫无帮助,而Product(name='键盘', price=199.0)一眼就能看出内容。注意__repr__是给开发者看的,理想情况下它返回的字符串能直接复制回代码里重建对象。
__eq__有个配套的坑:一旦重写了__eq__,对象的哈希值默认就不可用了,放进 set 或当 dict 的键会报错。这时候需要同时实现__hash__,而且两者要遵守同一个约定——相等的对象必须哈希相同。
如果只是想要一个数据载体类,别手写这一堆方法,直接上
dataclass。@dataclass装饰器会自动生成__init__、__repr__、__eq__,加个frozen=True连不可变和哈希都一并解决。
4. 完整实操:写一个经得起用的类
4.1 需求拆解与建模决策
光看片段学不会,得完整写一遍。需求定成:做一个购物车模块,支持加商品、删商品、算总价、应用折扣、清空。约束是同一个商品重复加入要合并数量,折扣策略可扩展,任何非法输入都要在第一时间报错。
建模上有几个决策点值得说明:
决策一:商品和条目分开。商品描述的是"这个东西是什么",条目描述的是"这个东西买了几个"。合并数量这件事发生在条目上,如果混在一起,商品本身的单价会被条目数量污染,同一个商品在不同订单里就没法共用了。
决策二:折扣独立成策略。不写死在购物车里,也不写成if-else分支。理由是折扣规则变化最频繁,独立出去之后新增规则不用碰购物车逻辑。
决策三:购物车只管容器职责。不负责持久化、不负责打印、不负责发通知。这些交给外部协调,购物车保持纯粹,测试才容易写。
4.2 代码落地
先写值对象部分:
from dataclasses import dataclass, field @dataclass(frozen=True) class Product: sku: str name: str price: float def __post_init__(self): if self.price < 0: raise ValueError(f"商品 {self.sku} 价格不能为负") if not self.sku: raise ValueError("商品编号不能为空") @dataclass class CartItem: product: Product qty: int = 1 def __post_init__(self): if self.qty <= 0: raise ValueError("数量必须为正整数") @property def subtotal(self): return round(self.product.price * self.qty, 2) def merge(self, other): if self.product.sku != other.product.sku: raise ValueError("不同商品不能合并") self.qty += other.qtyfrozen=True让 Product 不可变,这带来两个隐性收益:一是可以安全地作为 dict 的键(需要配合__hash__,dataclass 在 frozen 时自动生成),二是杜绝了"某个地方偷偷改了单价导致全盘算错"的可能。价格调整的正确做法是造一个新 Product,而不是改老对象。
__post_init__里做校验,是 dataclass 场景下校验的入口。它会在自动生成的__init__末尾被调用,正好用来挡住非法值。这里我把校验放在构造期,是为了让错误尽可能早地暴露——一个价格为负的商品对象,根本不应该存在。
再写购物车本体:
class Cart: def __init__(self): self._items = {} self._discount = NoDiscount() def add(self, product, qty=1): new_item = CartItem(product, qty) old = self._items.get(product.sku) if old: old.merge(new_item) else: self._items[product.sku] = new_item def remove(self, sku): if sku not in self._items: raise KeyError(f"购物车中没有商品 {sku}") del self._items[sku] def set_discount(self, policy): if not hasattr(policy, "apply"): raise TypeError("折扣策略必须实现 apply 方法") self._discount = policy @property def subtotal(self): return round(sum(i.subtotal for i in self._items.values()), 2) @property def total(self): return self._discount.apply(self.subtotal) def clear(self): self._items.clear() def __len__(self): return sum(i.qty for i in self._items.values()) def __iter__(self): return iter(self._items.values()) def __repr__(self): return f"Cart(items={len(self._items)}, total={self.total})"几个设计点展开说说。内部用 dict 以 sku 为键存条目,而不是用 list。理由是查重和删除都是 O(1),重复加同一商品时直接命中已有条目合并,不用遍历。代价是保持了插入顺序(Python 3.7+ 的 dict 保证顺序),所以遍历展示时顺序稳定,这个副作用正好有用。
set_discount里用了hasattr做运行期检查,这是 Python 里模拟接口约束的常用手段。检查放在设置时而不是计算时,能保证错误发生的位置离引发它的代码足够近。如果等total算到一半才报AttributeError,排查成本会高很多。
4.3 折扣策略与边界处理
折扣单独一组,都用同一个协议:
class NoDiscount: def apply(self, amount): return round(amount, 2) class FullReduction: def __init__(self, threshold, reduce): if threshold <= 0 or reduce <= 0: raise ValueError("门槛和优惠额必须为正") if reduce >= threshold: raise ValueError("优惠额不能大于等于门槛") self.threshold = threshold self.reduce = reduce def apply(self, amount): if amount >= self.threshold: return round(amount - self.reduce, 2) return round(amount, 2) class PercentOff: def __init__(self, rate): if not 0 < rate < 1: raise ValueError("折扣率必须在 (0,1) 之间") self.rate = rate def apply(self, amount): return round(amount * self.rate, 2)FullReduction里那句"优惠额不能大于等于门槛"的校验,看着像多此一举,实际很有必要。如果允许reduce >= threshold,满 100 减 120 就会算出负数总价。这种配置错误在真实场景里非常常见——运营活动改个数字就上线了。把校验挡在构造期,总价永远不会为负。
PercentOff的0 < rate < 1这个区间同理。写成rate <= 1会允许"零折",写成rate > 0会允许"加价折扣"。这类边界不是理论洁癖,是上线后真会遇到的。
4.4 用测试把行为钉住
类写完了不算完,得有测试证明它按预期工作。测试重点放在行为而非实现上:
def test_merge_same_sku(): c = Cart() p = Product("K001", "键盘", 199.0) c.add(p, 1) c.add(p, 2) assert len(c) == 3 assert c.subtotal == 597.0 def test_negative_price_rejected(): with pytest.raises(ValueError): Product("K002", "鼠标", -1.0) def test_full_reduction_below_threshold(): c = Cart() c.add(Product("K003", "耳机", 80.0), 1) c.set_discount(FullReduction(100, 20)) assert c.total == 80.0 def test_unknown_sku_remove(): with pytest.raises(KeyError): Cart().remove("NOPE")测试里最有价值的是第二条和第四条:它们验证的是错误路径。很多人的测试只覆盖正常流程,结果线上出问题全在异常分支。判断一个类的鲁棒性,看它的测试里有几条是raises断言就知道了。
5. 踩坑记录与排查手册
5.1 可变默认参数与共享状态
这个坑的经典程度值得单列。函数或方法定义里写def __init__(self, items=[]),那个空列表只创建一次,之后所有调用共享同一个。
# 错误示范 class Box: def __init__(self, items=[]): self.items = items a = Box(); b = Box() a.items.append(1) print(b.items) # [1] 见鬼了改成def __init__(self, items=None): self.items = items if items is not None else []就对了。逻辑是:None作为哨兵值,每次调用时才新建列表。
这个坑之所以难查,是因为在单实例场景下完全测不出来,只有当同一个类被实例化两次以上才会暴露。我踩过的最惨一次是在一个长期运行的服务里,某个容器的默认列表被所有请求共享,症状是数据随机串台,复现概率极低。
记忆口诀:函数默认值只在定义时求值一次。所以默认值必须是不可变对象。
5.2 对象引用、浅拷贝与深拷贝
a = b不复制对象。想要副本,得用copy.copy()或者copy.deepcopy()。这两者的区别是:浅拷贝只复制最外层容器,里面的元素还是共享的;深拷贝递归复制到底。
import copy original = Cart() original.add(Product("K001", "键盘", 199.0), 2) shallow = copy.copy(original) deep = copy.deepcopy(original) original.remove("K001") print(len(shallow)) # 依然正常,因为 dict 是新的 print(len(deep)) # 也正常这个例子还不够刺眼,因为 Cart 的内部元素被删了但对象本身还在。更麻烦的场景是:浅拷贝后的两个对象共享内部元素,改其中一个元素的属性,另一个跟着变。判断要不要深拷贝的标准是:拷贝之后,两个对象是否有任何共享的可变状态。如果有,且你不希望它们互相影响,就得深拷贝。
不过深拷贝不是免费的,对象图大的时候性能开销可观,而且碰到循环引用需要额外处理。更好的做法通常是——让对象尽量不可变,压根不需要拷贝。这也是前面 Product 用frozen=True的理由之一。
5.3 继承链上的方法解析与 super 的坑
Python 支持多继承,方法查找走的是 MRO(方法解析顺序),不是简单的深度优先。super()也不是"调用父类",而是"沿着 MRO 找下一个"。这两点在单继承下看不出区别,一碰多继承就出问题。
class A: def hello(self): print("A") class B(A): def hello(self): print("B") super().hello() class C(A): def hello(self): print("C") super().hello() class D(B, C): pass D().hello() # B -> C -> A输出是 B、C、A,而不是 B、A。因为super()沿着 MRO 走,D 的 MRO 是 D、B、C、A。如果误以为super()就是"调父类",这里必然理解不了。
__init__里的super().__init__()更要注意参数传递。经典错误是:父类__init__只接受两个参数,子类传了三个,或者子类忘了调父类初始化导致父类的属性不存在。后者的报错信息通常是AttributeError: 'X' object has no attribute 'y',而这个y明明在父类里写过——第一次遇到时极其茫然。
排查方法:在子类的__init__第一行加个断点,确认有没有走到super().__init__();再看两边参数列表是否匹配。
5.4 常见问题速查表
| 现象 | 大概率原因 | 处理方向 |
|---|---|---|
| 两个对象的属性互相影响 | 属性定义在类体里,且是可变类型 | 挪进__init__ |
self.xxx报不存在 | 分支里漏初始化,或super().__init__没调 | 检查所有分支路径 |
| 改了一个对象另一个也变 | 赋值只是引用,没做拷贝 | 明确拷贝意图 |
重写__eq__后 set 报 unhashable | 没实现__hash__ | 一并实现或frozen=True |
super()行为诡异 | 多继承下 MRO 不是直觉顺序 | 打印Cls.mro()看实际顺序 |
| 对象打印出来是一串地址 | 没写__repr__ | 补上__repr__ |
| 子类改了父类方法调用报错 | 父类接口被改,子类未同步 | 检查覆写方法的签名一致性 |
| 实例化时报参数过多 | __init__签名与调用不匹配 | 逐项对齐参数 |
这张表可以贴在显示器边上,前四个基本覆盖了新手期 80% 的求助。遇到问题先对表,对不上再去打断点。
6. 练习路线:从会写到会设计
6.1 分阶段练习题设计
光看不会,必须动手。我按难度排一个路线,每一关都卡在一个明确的能力点上。
第一关:只练实例化与属性。写一个Rectangle,有宽高,能算面积和周长。这一关的目标是把__init__、self、方法调用的手感练熟,十分钟搞定。做完之后可以加个is_square属性方法,体会一下@property的用法。
第二关:练封装与校验。写一个Temperature,内部用摄氏度存,对外提供摄氏和华氏两个属性。要求设置非法值时抛异常。这一关练的是:内部表示和外部接口可以不同,以及校验该放在哪。
第三关:练容器类。写一个TaskList,支持添加、删除、按状态筛选、统计完成率,并且可迭代。这一关要练__len__、__iter__、__repr__,以及内部选什么数据结构。
第四关:练策略解耦。给上面的TaskList加一个排序策略,支持按标题、按优先级、按截止时间排序。策略要能独立新增,不改TaskList本身。这一关过不了,说明多态还没真正理解。
第五关:练组合与依赖注入。写一个仓库类TaskRepo负责存取,TaskList通过构造函数接收一个 repo。然后用一个内存版 repo 写测试,完全不用碰数据库。这一关练的是可测试性设计。
五关走完,基本能应付日常业务代码里 90% 的类设计。再往上就是设计模式和架构层面的事了,那是后话。
6.2 读源码时该盯什么
自己写是一回事,看别人怎么组织类是另一回事。读开源代码时,别从头顺着读,效率太低。有个更快的路径:
先找__init__,看这个类出生时需要什么,这就圈定了它的依赖范围。然后扫一遍所有方法名,看它的职责边界在哪,方法名里出现"save"和"calc"往往意味着职责混杂。接着看有没有 property,有的话说明作者在意内部状态的一致性。最后看它被谁实例化、被传给谁,这一圈下来,类与类的关系图就出来了。
我自己的习惯是拿一张纸,边读边画箭头:A 持有 B、C 继承 A、D 依赖 A 的接口。画完之后再看一遍,如果某个类被十几个地方依赖,那它的稳定性要求就很高,改动必须慎重。
6.3 代码评审视角的自查清单
写完一个类,提交之前过一遍下面这几条,能挡掉大部分返工:
- 这个类的每个属性,都有明确的存在理由吗?有没有哪个属性只有一两个方法在碰?
- 构造出来的对象,是不是一定能直接用?有没有必须额外调用才能工作的"半成品"状态?
- 所有可能出错的输入,都在入口拦住了吗?
- 外部代码必须依赖我内部细节的地方,能不能减到更少?
- 如果明天要加一种新的类型或策略,我需要改几个文件?
- 有没有哪个方法是"看着不像这个类该干的事"的?比如一个订单类里出现了发邮件的逻辑。
最后一条尤其值得警惕。职责漂移是渐进发生的,今天加一行日志,明天加一个通知,半年后这个类就成了什么都会的怪物。定期回头看一眼,比一次大重构便宜得多。
说点个人的体会。类和对象这部分内容,我反复看过好几轮,每一轮的收获都不一样。第一轮记住的是语法,第二轮想明白的是职责划分,第三轮才开始理解为什么有些设计宁愿多写二十行代码也不肯多一层继承。真正推动我跨过门槛的,不是背下三大特性,而是有次维护一个八百行的巨型类,改一个折扣逻辑要跑一遍全量测试还总提心吊胆,最后花了两天拆成六个小类,之后每次改动都很轻松。那种"拆开之后世界清净了"的感受,比任何教程都好使。所以你如果现在正卡在"语法都会但不知道怎么用"的阶段,别继续看教程了,去找一段自己以前写的、超过两百行的函数,试着把它拆成三个类,拆的过程比看十篇文章都管用。