
很多人写 Python 写了一段时间之后会发现一件吊诡的事明明class语法早就看熟了可一到真正需要设计类的时候还是觉得别扭。不是忘了写__init__就是在犹豫这个属性该放类变量还是实例变量继承要不要用接口又该怎么抽。我今天不准备讲一本正经的理论而是把“把 Python 的类写顺手”这条线完整走一遍从最基础的定义开始到继承、组合、抽象基类再到property、dataclass这些工程化常用手段最后用一个订单类的演进案例收尾。无论你是刚装好 Python 环境、正在学 python 类对象的小白还是已经写了几千行业务代码想优化设计的老手这篇都值得花十分钟读完。1. 类的定义从基础语法到语义理解1.1 类到底是什么先搞清楚对象和类的关系很多人一上来就背“类是对象的模板对象是类的实例”背完还是不会用。我更喜欢用一个生活化类比类就像蛋糕模具对象就是模具做出来的蛋糕。模具本身不能吃但它定义了蛋糕的形状、大小、有哪些花纹你做出来的每个蛋糕才是真正能用的对象。换个角度说类把“数据”和“操作数据的方法”绑在一起形成一个内聚的整体。举个例子你想管理一批学生信息。如果不用类你可能写两个列表一个存姓名一个存分数然后写一堆函数去同步维护这两个列表。数据分散、逻辑暴露一旦加字段就得改所有相关函数。用类之后学生本身成为一个概念姓名、分数是它的属性算出等级、打印信息是它的行为。这个封装不是花架子是让代码结构跟着业务走而不是跟着函数走。在 Python 里类本身也是一个对象叫“类对象”用来创建实例。这一点在写元类时会用到但日常开发你只需要记住class语句制造的是一个可调用的工厂对象调用它就能得到实例。1.2 定义类的核心语法init、self、属性、方法先看一个最普通的类定义class Student: def __init__(self, name: str, score: int): self.name name self.score score def level(self) - str: if self.score 90: return A if self.score 60: return B return C这里面的几个点我逐个拆开说。__init__是初始化方法作用是在实例创建之后把传入参数绑定到实例上。很多人把它叫构造函数严格来说不对Python 创建实例时最先调用的是__new__那才是真正分配内存的构造过程__init__只是“入住后打扫房间”。日常业务里你基本不用碰__new__所以把__init__当作“初始化钩子”理解就够了。self是当前实例本身。它不需要你手动传Python 在调用实例方法时自动把实例作为第一个参数塞进来。注意你写多少个方法每个方法都必须给 self 留位否则调用时就会报“缺少参数”的错。这个设计常被吐槽但它让“方法必须知道自己属于哪个对象”这件事变得非常显式。一个类里通常有两种成员属性和方法。属性是数据方法是对数据的操作。比如上面Student里name和score是通过__init__建立的实例属性level是实例方法它通过self.score读取数据并计算。这里有个容易混淆的点__init__内部通过self.name name才把name变成实例属性而不是靠__init__的签名自动完成。刻意强调这一点是因为我见过不少人在__init__里定义了参数却忘了赋值后面用的时候直接 AttributeError。1.3 类变量 vs 实例变量最常见的混乱点这是新手甚至老手都容易翻车的地方。看下面这个例子class Dog: tricks [] # 类变量所有实例共享同一份列表 def __init__(self, name: str): self.name name def add_trick(self, trick: str) - None: self.tricks.append(trick)你创建两只狗分别add_trick然后打印会发现两只狗都拥有了两只狗的技能列表。为什么因为tricks定义在类体里它是类命名空间下的一个变量所有实例访问到的都是同一个列表对象。正确的做法是所有需要“每个实例独立持有”的数据都放在__init__里用self.xxx ...创建class Dog: def __init__(self, name: str): self.name name self.tricks: list[str] [] def add_trick(self, trick: str) - None: self.tricks.append(trick)判断规则很简单如果一份数据是“这个类所有对象都应该共享的”比如一个全局计数器、一个统一配置常量那适合做类变量只要数据跟具体实例相关一律做成实例变量。类变量还有一个用途是避免魔法数比如定义MAX_RETRY 3放在类里面实例可以直接用self.MAX_RETRY访问语义清晰。注意类变量如果是可变对象修改时一定要留个心眼。共享不是问题问题是你没意识到它在共享。写类之前花十秒钟想清楚这个属性属于“类”还是“实例”能省下许多诡异的测试失败。2. 类设计中的关键选择继承、组合与接口2.1 继承要用在“is-a”关系上别为了复用硬凑继承是面向对象里最有名、也最容易被滥用的机制。很多人的第一直觉是A 类里已经有我要的方法让 B 继承 A 就能直接用了。这个想法很危险。继承的正确语义是“is-a”狗是一种动物所以class Dog(Animal)合理圆是一种形状所以class Circle(Shape)合理。但你如果只是因为两个类碰巧都有save方法就让一个订单类去继承文件工具类这个类之间的血缘关系就假了。硬凑继承会带来几个问题一是子类被迫接受父类的全部接口哪怕它对其中一半不感兴趣二是父类一改子类可能莫名其妙崩溃三是多重继承时方法解析顺序会变得扑朔迷离。在实际业务里我判断该不该用继承会问三个问题子类在语义上是否真的是父类的一种子类是否需要替换父类的行为而不只是复用父类的现成代码继承深度是否保持在两层以内顶多三层如果你的答案有两个以上是否那就别用继承。哪怕复制一点代码也好过绑定一段错误的关系。复制带来的成本是暂时的继承带来的耦合是长期的。2.2 组合优先委托、Mixin 与轻量设计既然继承不能乱用那代码复用靠什么最可靠的答案是组合一个类持有另一个类的实例需要某个能力时把请求委托给那个实例。这就像一个公司不一定要让所有部门都汇报给 CEO每个部门各干各的CEO 按需协调。举个实际例子。假设你有一个ReportGenerator类需要从数据库读数据、然后生成 CSV 报告。很多人会写一个ReportGenerator(DatabaseHandler)让报告生成器继承数据库处理器。但报告生成和数据库访问明明是两种职责继承把它们焊死了。更好的方式是这样的class ReportGenerator: def __init__(self, db_handler): self.db_handler db_handler def generate(self) - str: data self.db_handler.fetch() return self._format(data)这样ReportGenerator不关心数据怎么来的只要传入的对象存在fetch方法就可以工作。测试的时候也可以传入假的数据源不用真的连数据库。Python 里还有一种轻量复用方式叫 Mixin本质是一个只提供方法、不独立使用的类。它和真正继承的区别在于Mixin 的语义是“混入一组能力”而不是“是一个”。比如你写一个JSONableMixin给类加to_json方法然后class Order(JSONableMixin, BaseEntity)。这种用法不是不能用但要求你小心处理命名冲突不要有同名方法把彼此覆盖掉。我的建议是Mixin 里最好只提供与业务无强关联、名字足够独特的方法避免一个项目里到处是get_data和被覆盖的惨剧。2.3 抽象基类abc与接口用协议约束实现当多个类必须遵循同一套调用约定时你需要一个“契约”。Python 里最标准的做法是用abc模块定义抽象基类。from abc import ABC, abstractmethod class Payment(ABC): abstractmethod def pay(self, amount: float) - bool: ... class WeChatPay(Payment): def pay(self, amount: float) - bool: print(f微信支付{amount}) return True class AlipayPay(Payment): def pay(self, amount: float) - bool: print(f支付宝支付{amount}) return True抽象基类本身不能被实例化它只负责规定子类必须实现哪些方法。你写一个策略模式、插件系统、或者任何“有多个实现但调用方式统一”的场景抽象基类都很好用。需要注意abstractmethod不能和staticmethod、classmethod、property组合得太随意排列顺序会影响行为建议单独写测试验证。另一种更 Pythonic 的思路是typing.ProtocolPEP 544 引入的结构化子类型。它不做强制继承只检查一个对象是否有指定方法或属性符合“鸭子类型”哲学。from typing import Protocol class CanPay(Protocol): def pay(self, amount: float) - bool: ...这时任何实现了pay方法的类即便不继承CanPay在类型检查器看来也是一个CanPay。抽象基类是运行时强制Protocol 是静态检查时的约定。我的经验是对外部插件、一定要在运行时拦截错误用ABC项目内部团队协作、想让类型提示更友好用Protocol更轻。而“抽象类和普通类的区别”这个问题其实一句话就能说清普通类可以直接实例化抽象类定义了必须由子类补齐的抽象方法本身不完整。它像是建筑图纸和成品房的关系图纸不能住人但规定了房间该有哪些结构。3. 类的最佳实践命名、封装与数据管理3.1 命名与结构类名、方法名、单一职责类命名要用驼峰体名词为主比如OrderManager、UserProfile别用动词。方法命名用蛇形动词或动词短语比如fetch_data、calculate_total。这个规范不只是 PEP 8 的要求更重要的是让读代码的人一眼看出“这是个什么东西能干什么事”。结构上最重要的一条是单一职责一个类应该只有一个改变的理由。判断方法有两个你能否用一句话说清楚这个类负责什么如果说“它负责订单处理和邮件发送”那就拆。它是不是已经开始变得像一个上帝对象什么都能干如果是说明需要拆。拆的时候有个常见误区不是为了拆而拆而是按照依赖方向拆。比如一个类里既有业务计算又有 UI 展示应该把计算逻辑抽成纯业务类把展示逻辑放到视图层。Python 社区对这类代码组织有大量“工程化最佳实践”的讨论落实到类上最核心的就是控制每个类的体积和依赖。一个类超过 300 行、方法超过 10 个的时候我基本会停下来重新划分职责。3.2 属性管理property、slots 与私有属性的边界Python 没有真正的“私有属性”。单下划线开头是约定告诉外部“别动我”双下划线开头会触发名称改写看起来是私有实际上只是换了个名字存储子类和外部仍然能通过特殊手段访问。过度使用双下划线会让代码变得难调试子类继承时还会出现属性改名的意外。我自己的习惯是内部实现细节用单下划线公开接口保持干净不需要用双下划线。设计属性时property是一个非常好的“延迟校验”工具。比如价格字段不能为负数直接在__init__里 if 判断也可以但如果后续通过实例对象赋值校验就不生效了。用property可以把校验集中在一个地方class Product: def __init__(self, price: float): self._price price property def price(self) - float: return self._price price.setter def price(self, value: float) - None: if value 0: raise ValueError(价格不能为负数) self._price value__slots__是另一个锦上添花的手段。它告诉 Python 不需要为每个实例维护一个动态字典省内存、加属性访问速度。代价是不能随意新增属性。适合需要创建海量实例的场合比如数据解析、机器学习中的样本对象。普通业务类没必要用因为动态属性有时候很实用没必要为了微乎其微的性能收益牺牲灵活性。3.3 数据类dataclass写“数据容器”类的时代选择如果你要写的类主要就是装数据没什么复杂逻辑传统写法有大量样板代码__init__手动赋值、__repr__、__eq__。现在有了dataclasses.dataclass这一切都能省掉。from dataclasses import dataclass, field dataclass class User: name: str age: int email: str tags: list[str] field(default_factorylist)这个类自动获得__init__、__repr__、__eq__还能配合类型注解做静态检查。两个容易踩的坑我提前说可变默认值比如空列表不能直接写 []否则所有实例共享同一个列表。必须用field(default_factorylist)。需要让数据不可变时加frozenTrue。但注意frozenTrue下还是能调用对象内部可变值的方法比如user.tags.append(...)不会报错只是不能给user.tags重新赋值。dataclass出现之后我的习惯是纯数据容器一律用dataclass有更多业务行为和约束逻辑的类才用普通类。这样一来代码里“数据怎么组织”和“业务怎么做”分得很清楚读起来不费劲。4. 实操中的类项目案例从零写一个可维护的类4.1 需求拆解与类设计为了把这些原则串起来我们做一个典型案例商品订单类。需求很简单每个订单有订单号、商品列表、优惠折扣。能计算原价总金额、折后金额。能输出一行格式化的订单摘要。拿到这个需求先别急着写代码。做一次职责拆解订单的“数据状态”包括订单号、商品列表、折扣。订单的“行为”包括计算总价、计算折扣价、输出摘要。商品本身是一个更小的数据容器有自己的名称和单价。所以初步方案是一个普通类管理订单订单内部持有一组商品对象。商品类用dataclass做纯数据容器订单类负责计算逻辑。4.2 代码实现与演进第一版最直接能跑不具备太多保护class Order: def __init__(self, order_id: str, items: list, discount: float 1.0): self.order_id order_id self.items items self.discount discount def original_total(self) - float: return sum(item.price * item.quantity for item in self.items) def final_total(self) - float: return self.original_total() * self.discount这个版本的问题在于外部可以随意把discount改成负数可以先有商品再删光再调用虽然不会崩但语义混乱打印订单时默认的repr没法看。于是演进到第二版用dataclass定义商品用property约束折扣from dataclasses import dataclass dataclass class Item: name: str price: float quantity: int class Order: def __init__(self, order_id: str, items: list[Item], discount: float 1.0): self.order_id order_id self.items items self.discount discount property def discount(self) - float: return self._discount discount.setter def discount(self, value: float) - None: if not 0 value 1: raise ValueError(折扣必须在 0 和 1 之间) self._discount value def original_total(self) - float: return sum(item.price * item.quantity for item in self.items) def final_total(self) - float: return self.original_total() * self.discount def __repr__(self) - str: return fOrder(order_id{self.order_id}, items{self.items}, discount{self.discount})到这里订单类已经能防住负折扣商品结构也清楚。但如果后续要支持多种优惠策略比如满减、打折、特价商品把所有计算都写在Order里会很臃肿。于是第三版把折扣策略抽成接口from abc import ABC, abstractmethod class DiscountStrategy(ABC): abstractmethod def apply(self, total: float) - float: ... class PercentDiscount(DiscountStrategy): def __init__(self, rate: float): self.rate rate def apply(self, total: float) - float: return total * self.rate class FreeShippingDiscount(DiscountStrategy): def __init__(self, threshold: float): self.threshold threshold def apply(self, total: float) - float: return total if total self.threshold else total 10Order不再持有折扣数字而是持有一个“策略对象”。将来新增一个“新人立减 20”的规则只需要加一个新类不需要改订单类。这就是“对扩展开放对修改封闭”落到类设计上的样子。4.3 测试与调试类的可测试性设计类设计得好不好测试跑一下就知道。可测试的类通常具备几个特征依赖通过__init__注入而不是在类内部自己 new 一大堆对象。不依赖全局状态和真实时间比如文件路径、网络请求都通过参数传入。对象能比较容易地构造和断言。这时dataclass的自带__eq__就很有用可以直接比较两个对象是否相等。给上面的订单写测试一段 pytest 大概是这样的def test_final_total_with_percent_discount(): order Order( order_idA001, items[Item(name书, price100, quantity2)], discount_strategyPercentDiscount(rate0.8), ) assert order.final_total() 160.0因为折扣策略被独立出去测试订单时也能很方便地传一个“原价返回”的假策略不需要真正构造复杂的优惠逻辑。调试层面自定义__repr__非常关键。Python 默认的__repr__在多参数类上基本没法看你根本分不清两个订单谁是谁。把关键字段放进repr后打印列表或调试问题时一眼就能定位。如果你想让调试信息更强大可以引入rich这类库但至少先做到“打印对象能看到类名和状态”。5. 常见问题与排查技巧实录5.1 可变默认参数经典坑这个坑我已经在前面反复强调过但它是 Python 类最常见的新手问题值得单独再讲一次。问题代码长这样class Task: def __init__(self, tasks[]): self.tasks tasks问题在于默认参数[]只在函数定义时被创建一次之后所有不传tasks的实例拿到的都是同一个列表。你往一个实例里加任务其他实例也跟着加。正确的写法是class Task: def __init__(self, tasksNone): self.tasks [] if tasks is None else tasks或者用dataclass的field(default_factorylist)。出现这个问题的根源是 Python 函数默认参数在定义时求值而不是每次调用时求值。理解这一点后以后看到任何def f(x[])都会本能地警惕。5.2 继承中的 super() 与方法解析顺序单继承时super()很好理解就是调用父类的下一个方法。一旦出现多重继承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): def hello(self): print(D) super().hello()调用D().hello()输出顺序是 D、B、C、A而不是 D、B、A、C。这背后是 C3 线性化算法在起作用。要完全搞懂 MRO 需要看算法但在工程上我的建议很简单尽量避免多重继承如果避免不了就用class_name.__mro__打印出来自己看一眼顺序确认super()的调用链路符合预期。哪怕只是 4 层的菱形继承出错时排查成本也很高。5.3 循环导入与类设计两个模块互相引用对方的类是 Python 项目里的经典报错ImportError: cannot import name ... from partially initialized module。比如a.py里定义了class Ab.py里定义了class BA的某个方法需要BB的某个方法又需要A一导入必然出问题。解决方案分三层最优先的思路是从设计上消除循环依赖。把两边都依赖的公共接口或数据模型抽到第三个模块比如models.py。如果暂时不好重构可以在方法内部延迟导入而不是在模块顶部导入。也可以把from b import B改成import b在方法里使用b.B利用 Python 模块加载完成后再访问属性来绕开部分场景。但延迟导入只是止痛药根治还得靠重新划分职责。类与类之间的依赖应该是单向的A 依赖 B、B 不依赖 A这样代码才能分层演进。5.4 常见问题速查表问题原因解决方法所有实例共享同一个列表/字典可变默认参数在定义时求值一次用None加判断或用dataclass.field(default_factory...)类变量被实例修改后全局污染类变量是可变对象被所有实例共享需要独立数据时放到__init__里用self.xxx创建property递归导致RecursionError内部__init__直接给self.xxx赋值触发 settersetter 再赋值内部用self._xxx存值对外用property定义__slots__后无法动态加属性__slots__移除了实例的__dict__如果确实需要动态属性不要再加__slots__用它的类要注意继承关系dataclass使用可变字段默认值报错直接写 []或 {}非法用field(default_factorylist)多重继承下super()调用顺序诡异方法解析顺序是 C3 线性化结果尽量避免多重继承必要时打印.__mro__确认实例在repr里什么都看不到类没实现__repr__手写__repr__尽量让字符串能重建对象两个模块互相导入报错循环依赖抽公共模块、延迟导入、重新分层这些坑我基本都踩过。每次排查到最后绝大多数原因不是语法不会而是对“类变量和实例变量”“共享与复制”“继承与组合”这几个底层概念理解不够扎实。我之前写过一个订单模块因为默认参数是空列表导致生产环境两个订单共用了商品列表用户 A 下单成功用户 B 的表单也被填上 A 的商品。那次事故之后我才真正明白把这些“小细节”当成大事对待有多重要。我个人的体会是类设计没有一劳永逸的标准答案但有一些判断方法是可以反复用的先想数据边界再想行为归属先倾向组合再考虑继承先保证类能测试再追求性能优化。把这一套想清楚了Python 的类写起来就会自然顺手而不是每次创建新类都在试错。