ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python面向对象编程实战:从类设计到封装继承多态

Python面向对象编程实战:从类设计到封装继承多态 我最初学Python是从判断、循环、函数开始刷的语法都会就是不明白为什么大项目里的人总要提“面向对象编程”。后来被要求用类重构一个爬虫项目写了删、删了写折腾两周才慢慢摸到门道。这篇我想从“为什么需要对象”讲起把封装、继承、多态拆开讲清楚再聊聊Python里那些特别容易踩的类相关的坑以及我后来在真实项目里是怎么判断“这里该不该写类”的。如果你正处于“会写class Student:但根本设计不出有用的类”的阶段这篇应该能给你一点方向感。1. 为什么脚本写得好好的突然需要“对象”这个抽象1.1 从“函数堆叠”到“对象协作”中间差的一次项目重构大多数人在刚开始写Python时习惯是这样的定义几个函数然后从上到下按顺序调用。这个模式在三百行以内非常好使逻辑清楚出错也好找。我自己用脚本处理Excel、写小爬虫、做数据清洗一直都是函数流没觉得缺什么。真正让我意识到函数不够用是在一次需要维护的爬虫项目里。那个项目要抓多个站点的商品信息每个商品有名称、价格、库存、店铺、上架时间等十几个字段。一开始我写了fetch_goods_from_site_a()、parse_goods_a()、save_to_db()这种函数每个函数都靠参数传数据。函数少的时候还好等站点增加到五个每个站点都有自己的解析逻辑我就发现函数签名越来越长def parse_goods_a(raw_html, price_tolerance, discount_threshold, ...): pass更麻烦的是有状态的数据。比如“某个商品刚才的价格是多少现在降了多少”这种跨函数共享的数据用普通函数只能靠全局变量或者到处传参。传参传得多了就会漏传、传错最终改一个需求牵连一堆函数。那时候我才意识到脚本阶段我不需要对象但一旦代码开始描述“有状态的事物”函数流就撑不住了。对象做的事情是把这个问题的组织方式换掉。对象把“数据”和“操作这份数据的方法”捆绑在一起也就是把goods_data和update_price(goods_data)这样的组合变成goods.update_price()。听起来只是写法变了一下但它改变了代码组织的基本单位过去你组织的是操作列表现在你组织的是“数据实体该实体允许的动作”。这个转变就是我理解OOP的起点。1.2 对象到底是什么一张收银台单据的比喻我一直觉得“万物皆对象”这种说法太玄了。对象没那么神秘它更像一张现实世界里的单据。想象你去便利店买东西收银台上有一张单据。单据上写了商品列表、总价、结账状态、支付方式。同时店员在单据上执行动作往里面加一件商品、计算总价、收款、找零、开发票。你不可能绕过店员直接改单据上的金额对吧至少不符合流程。单据上的“状态”和“动作”天然属于同一件事物。不同的单据之间互不干扰A顾客的单据加薯片不会影响B顾客的单据。这其实就是面向对象编程的基本模型把某个事物抽象成一个类类里封装它的属性商品列表、金额、状态再定义它的方法加商品、付款、开发票。实例就是那一张一张具体的单据。再举一个大家更熟悉的例子游戏角色。角色有名字、血量、坐标、装备同时它也能做走路、攻击、加血这些动作。如果你用函数来写就得写walk(role, dx, dy)、attack(role, target)、heal(role, amount)每个函数都要把role这个字典传进去。如果角色有一百个属性传参传得你想哭。用类就变成role.walk(dx, dy)、role.attack(target)、role.heal(amount)role自己知道自己的状态你不需要把整个角色数据搬来搬去。你在搜索热词里能看到很多“python构建邻接矩阵”“python量化交易策略代码”这种需求这些场景说到底也一样矩阵需要一个“行数、列数、数据值”的整体抽象策略回测需要一个“持有仓位、当前资产、买卖指令”的组合。手动用字典模拟的时候很痛苦一旦梳理成对象不仅代码层次清楚出错也好定位。1.3 三个核心机制提前看一眼在真正深入代码之前我先把三个核心机制用一句话点出来后面再逐步展开封装对象内部的数据外部不能随意乱碰只能通过对象提供的方法来操作。继承一个类可以继承另一个类的属性和方法表示“子类是父类的一种特殊版本”。多态同一个动作在不同对象上会产生不同的行为调用方不需要关心到底是谁在执行。这三个概念如果只背定义很快就会忘。它们只有在拧进真实代码之后才会产生感觉。下面我用订单和支付系统的例子来拆解。2. Python中的封装、继承、多态跟我一开始理解的并不一样2.1 封装不是“隐藏数据”而是划定“能碰和怎么碰”的边界很多人学封装的时候记住一句话“把字段设为私有不要让别人直接改”。这句话对了一半但容易走偏。封装的本质不是禁止访问而是把对数据的操作收口到对象自己手里让外部只能通过合理的方法来改变数据。我举一个订单的例子。假设你正在写一个电商系统订单对象有这些数据订单编号、商品列表、状态、总价。如果完全不做封装外部想改什么改什么就会出现这种代码order.status paid order.total_price 0看起来没问题吧但你仔细想想直接改状态等于绕过了整个业务逻辑。正常业务里订单要完成“支付”必须做几件事检查订单是否为待支付状态、更新支付时间、记录日志、可能还要发送通知。如果你允许外部直接order.status paid那这个流程就散落在各处谁都可以改改的时候很容易漏掉别的步骤。所以正确的类设计是提供一个pay()方法把状态迁移判断放在方法内部class Order: def __init__(self, order_id, items): self.order_id order_id self.items items self.status pending self._total None def add_item(self, item): self.items.append(item) self._total None # 总价缓存失效下次计算时重新统计 def total_price(self): if self._total is None: self._total sum(item.price for item in self.items) return self._total def pay(self): if self.status ! pending: raise ValueError(只有待支付订单才能支付) self.status paid # 这里还可以补充支付时间、流水号、日志等逻辑这里有一件有趣的事_total以单下划线开头这是Python约定俗成的“私有”标记。它并不是真的禁止访问你依然可以order._total去读但所有人看到这个名字都会明白这是内部缓存不要动。Python的设计哲学是“大家约定自觉遵守”而不是强行用语法锁死。条件允许的时候我还会用property把一个内部值包成只读属性比如订单状态class Order: def __init__(self, order_id): self.order_id order_id self._status pending property def status(self): return self._status这样外部可以读order.status但没办法直接赋值想改状态只能调用方法。这个模式在你不确定什么时候要加校验逻辑的时候特别好用——先只读将来要加逻辑直接在pay()里做不用改动调用方。2.2 继承不是代码复用的万能药而是“is-a”关系的建模工具初学者最容易对继承产生误解觉得继承就是“抄代码的捷径”A类有的方法B类想用就让B继承A。这种理解在项目里会埋下大雷。继承真正应该表达的关系是“is-a”也就是“子类是一种特殊的父类”。狗是动物所以Dog继承Animal合理。但“为了用Animal里的eat()方法让Robot也继承Animal”就是不合理的因为机器人不是动物。我用支付方式来举例。假设订单系统支持多种支付渠道信用卡、支付宝、现金。这些支付方式的共同点是都能执行“支付”这个动作。于是可以抽象出一个父类class Payment: def pay(self, amount): raise NotImplementedError(子类必须实现 pay 方法) class CreditCardPayment(Payment): def pay(self, amount): print(f信用卡支付 {amount} 元) class AlipayPayment(Payment): def pay(self, amount): print(f支付宝支付 {amount} 元) class CashPayment(Payment): def pay(self, amount): print(f现金支付 {amount} 元)注意这个父类Payment的pay()方法本身并没有做任何业务它直接抛了NotImplementedError。这相当于在说所有支付方式都必须支持pay()但具体怎么支付由每个子类自己决定。这种写法很常见它把“接口约定”和“具体实现”分开了。我在实际项目里发现真正让继承发挥价值的地方在于当你希望一段代码可以同时处理多种子类时。比如订单系统要结算它根本不需要关心用户选的是哪种支付方式只要调用payment.pay(total_price)就行了。具体的支付细节是信用卡还是支付宝被“封装”在了各自的子类里。如果你只是为了复用两三个方法就继承一个类我建议你先停下来想一想这两个类真的存在“是”的关系吗没有的话组合把一个对象作为另一个对象的属性往往是更安全的选择。2.3 多态是“同一个动作不同的实现”也是Python最日常的设计多态这个概念在C或Java里很重但在Python里轻得让很多人没意识到自己天天在用。其实Python的多态靠的是鸭子类型一个对象有没有资格做某件事不看它的类型声明只看它有没有对应的方法。Python官方有一句经典的调侃如果一只鸟走路像鸭子、游泳像鸭子、叫起来像鸭子那它就是鸭子。放到代码里就是如果一个对象有pay()方法那调用方就可以把它当作支付对象来用。这是个很自由的设计。Java里要做多态往往需要接口或者抽象类继承体系而Python不需要强制绑定。比如上面的支付例子哪怕你没有让某个类继承Payment只要它实现了pay()你的结算代码也能正常处理它class WechatPayment: def pay(self, amount): print(f微信支付 {amount} 元) def checkout(payment, amount): payment.pay(amount) checkout(WechatPayment(), 99.5)这段代码不会报错。在Python的世界里checkout函数根本不关心payment到底是什么类的实例它只关心“这个实例有没有pay方法”。这种灵活性让多态在Python里几乎无处不在——最典型的就是内置函数len()。len()为什么既能对列表求长度又能对字符串求长度、对字典求长度因为Python规定一个对象只要实现了__len__方法就能被len()调用。这就是鸭子类型下的多态同一个操作不同对象各自实现。所以在Python里学多态你不需要记住复杂的继承体系你只需要养成设计习惯写函数时只依赖对方必须提供的行为不依赖对方的类型。这样你的代码天然就具备扩展性将来加新类型调用方几乎不用改。3. 让Python的类真正“像Python”的几个核心细节3.1 self不是语法装饰它明确标记了“数据属于谁”很多初学者第一次看到类方法的时候都会被self卡住class Person: def __init__(self, name): self.name name def introduce(self): print(f我是 {self.name})为什么__init__里要有self为什么introduce()里也要有self不加行不行self本质上就是“当前实例自己的引用”。当你在__init__里写self.name name时你是在说“把传入的name存到当前这个新对象的属性里”。如果没有selfPython根本不知道该把name塞到哪个对象上——因为同一个类可以创建无数个实例。Python设计者选择把self显式写出来而不是像Java里的this那样隐藏。好处是类的方法和普通函数在底层保持了统一。你调用person.introduce()Python会把它翻译成Person.introduce(person)也就是说那个“谁调用它谁就被当作第一个参数传进去”的机制是明确可见的。理解这个底层机制之后很多疑惑就解开了。比如你早就见过这种奇怪写法先有实例又直接用类去调方法person Person(张三) print(Person.introduce(person)) # 结果等同于 person.introduce()这完全合法因为在Python眼里这两行的执行过程是等价的person.introduce()只是语法糖。类里还有一种特殊的方法用staticmethod和classmethod装饰。静态方法staticmethod是不需要访问实例数据的工具方法类方法classmethod拿到的是类本身而不是实例。我举个判断值的例子class Cargo: weight_limit 100 classmethod def can_accept(cls, weight): return weight cls.weight_limit staticmethod def format_weight(weight): return f{weight}kgcan_accept用classmethod是因为它需要读取类的上限weight_limitformat_weight用staticmethod是因为它只是纯粹的格式化工具谁的数据都不需要。做设计的时候先问自己“这方法需不需要用到实例属性”需要就加self只需要用到类的共享属性就加cls啥都不需要就加staticmethod。3.2 类变量与实例变量一个最容易踩的初始化大坑Python里有一个坑几乎所有人都会踩一次那就是定义在class内部的变量是“类变量”被所有实例共享定义在self.xxx ...里的才是“实例变量”每个实例各有一份。我见过不少代码把默认值直接写在类体里结果多实例之间相互干扰。看这个错误示范class Student: courses [] # 错误示范这是类变量不是实例变量 def __init__(self, name): self.name name def add_course(self, course): self.courses.append(course) a Student(小明) b Student(小红) a.add_course(数学) print(b.courses) # 输出 [数学]小红被动选了数学为什么会这样因为courses是定义在类上的所有Student实例其实共享同一个列表对象。你往a.courses里追元素实际上改的是类级别的列表b.courses看的也是同一个列表。修正起来很简单把可变对象放到__init__里用self赋值class Student: def __init__(self, name): self.name name self.courses [] # 每个实例都有自己独立的列表 def add_course(self, course): self.courses.append(course)这个坑的根源在于很多人把__init__里的赋值当成“重复劳动”以为在类体里写一遍默认值更整洁。但Python里这两个位置的含义完全不同。类变量适合放常量或者只读配置比如MAX_RETRY 3、DEFAULT_TIMEOUT 30不适合放会在运行时被修改的容器。另一个同源的坑是可变对象作为参数默认值。看这个def add_item(item, cart[]): cart.append(item) return cart第一次调用add_item(苹果)返回[苹果]第二次调用add_item(香蕉)会返回[苹果, 香蕉]而不是一个新的[香蕉]。原因和类变量一样函数定义时创建的那个默认列表被反复使用了。正确做法是默认值设成None函数内部再赋值def add_item(item, cartNone): if cart is None: cart [] cart.append(item) return cart这些坑看着小但在类里出现时特别隐蔽因为它不会报错只是数据慢慢悄悄“串门”。排查到深夜才发现的往往就是这种问题。3.3 魔术方法让你写的对象融入Python的原生表达Python的类里有一种很特殊的方法名字前后各有两个下划线比如__init__、__repr__、__len__、__getitem__。它们被称为魔术方法作用是让自定义对象能够用Python原生的语法去操作。一开始不理解为什么要学这些后来用了几天就被圈粉了。比如你写了一个表示购物车的数据结构class Cart: def __init__(self, itemsNone): self.items items if items is not None else [] def add(self, item): self.items.append(item)如果直接len(cart)会报错。如果你希望这个对象可以像列表一样被len()计算、被下标访问甚至被for循环遍历就得实现魔术方法class Cart: def __init__(self, itemsNone): self.items items if items is not None else [] def add(self, item): self.items.append(item) def __len__(self): return len(self.items) def __getitem__(self, index): return self.items[index] def __repr__(self): return fCart({self.items})有了这些方法之后cart对象就可以这样用cart Cart() cart.add(牛奶) cart.add(面包) print(len(cart)) # 2 print(cart[0]) # 牛奶 for item in cart: print(item) # 可以遍历了__repr__是另一个天天能用到的东西。你在调试的时候打印对象默认看到的是__main__.Cart object at 0x7f...这种看不懂的地址。实现了__repr__打印出来就是Cart([牛奶, 面包])一眼看明白数据长什么样。我自己的习惯是只要写了类顺手就把__repr__写上调试效率立刻高一个档次。还有一个常用的__eq__用来定义两个对象“相等”的逻辑。默认情况下两个实例即使属性完全一样判断也是False因为Python在比较它们的内存地址。如果你希望“订单号相同就算同一笔订单”就自己实现__eq__。魔术方法并不需要背全先记住这几个__init__初始化、__repr__调试输出、__len__支持len、__getitem__支持下标和遍历、__eq__定义相等。其余的需要时再查不丢人。4. 面向对象设计的实际判断标准以及我踩过的坑4.1 什么时候该写类、什么时候别硬造三个反问句帮你做决定学会类的语法之后下一个问题不是“怎么写类”而是“这里到底要不要写类”。很多人陷入另一个极端什么东西都套一个类结果一个几十行的脚本被包装得又臭又长。我在项目总结里给自己定了三个反问句每次犹豫的时候就问一遍是不是有一组数据需要和一组操作长期绑定在一起如果只是临时拿数据算个结果用函数更直接。如果这个数据要在多个步骤里被反复读、反复改那类就值得考虑。我是不是打算创建多个实例只有一个实例的对象意义不大除非它代表某种全局配置或单例角色。调用方是不是希望不关心内部细节如果外部只想说“帮我结账”“帮我发通知”而不想管里面的流程怎么走这就是封装的价值所在。举个反例你在做一个数据清洗脚本只需要读CSV去掉空行按列求和输出结果。这个过程没有跨函数共享的复杂状态没有必要定义CSVProcessor类。直接写两个函数干净利索。硬造类反而让初学者陷入“什么样的类才是好类”的精神内耗。正例是那种状态多、操作多的场景比如文件下载器有URL、文件路径、已下载大小、状态操作有暂停、继续、取消、校验进度。这些状态和操作天然是一体的而且你可能同时下载多个文件多实例需求明确。这种时候写类是顺手且自然的事。4.2 继承过深的代价我Debug了一个三层子类的真实故事前面说过继承容易让人抄代码抄上瘾我这里讲一个自己踩过的真实事故。当时要做一个报表模块我设计了一个BaseReport里面有统计数据、计算平均值、汇总等方法。然后我让RegionalReport继承它加了一个区域筛选参数。再后来又让RegionalSalesReport继承RegionalReport加了销售相关的逻辑。三层继承听起来很合理对不对问题出在第三层子类重写了__init__新增了几个参数但调用父类初始化时漏传了BaseReport里的一个重要字段。结果就是RegionalSalesReport实例化后内部统计状态一直是None但代码不报错只是数据一路错下去。这个坑最折磨人的地方在于因为继承层级多你很难第一时间判断数据是从哪一层失真的。我最后是通过给每层父类的__init__加打印日志一层一层看运行顺序才发现是初始化参数在传递过程中被“吞”了。从那之后我给自己立了两个规矩继承层级不要超过两层。超过两层你就要停下来重新审查设计。如果你发现子类只是为了复用父类几个方法那更可能是组合的问题。子类重写__init__时尽量用super().__init__(...)把父类需要的参数完整传过去不要自作聪明地只传一部分。实际上很多时候“组合优先于继承”能避免这类麻烦。不是“类B是类A的一种”而是“类B拥有一份类A的实例”。比如一台电脑不是“显示器的一种”但它包含显示器。电脑类完全可以有一个self.display Display(...)属性而不是去继承显示器类。4.3 类里的状态管理优先用只读属性和显式方法我再分享一个让我项目质量提升明显的习惯类的属性能做成只读就尽量做只读需要改的地方全部走方法。以前我喜欢直接暴露字段图省事。后来代码量上来了发现一个数据被到处改根本不知道它是被谁改成这样的。把“改”收拢到方法里后调试思路就特别清晰数据不对劲先查有哪些方法会改这个数据挨个打印调用点就行。举个具体例子class DownloadTask: def __init__(self, url): self.url url self._status waiting self._progress 0.0 property def status(self): return self._status property def progress(self): return self._progress def start(self): self._status downloading self._progress 0.0 def update_progress(self, percent): if not 0 percent 100: raise ValueError(进度必须在 0 到 100 之间) self._progress percent if percent 100: self._status finished外部永远看不到_status和_progress的直接赋值只能通过start()和update_progress()来改变状态。这样每次状态变化都经过统一入口新增“打日志”“通知UI”的需求时只需要改这两个方法外面的调用完全不用动。5. 面向对象编程落地时我调整后的设计思路5.1 先写调用逻辑再回头补类比自己硬憋类好用我以前写类的顺序是反的先到处找“什么东西适合做类”然后定义一堆属性和方法最后才去写调用。结果经常是类设计得太“空”方法列表和实际需求脱节写了又删。调整之后我的顺序变成了这样先用普通变量和函数把核心业务逻辑写出来不管好不好看。盯着这段代码找“重复出现的数据组”。如果有一段数据总是被一起传来传去那它就是在提示你这里可以合成一个对象。把调用方式先写出来就像这样order create_order(20250001, [牛奶, 面包]) order.add_item(鸡蛋) order.pay()先把这个“接口草图”定下来然后再去实现类。这样做的好处是类的形状是由需求推出来的不是我拍脑袋拍出来的。接口草图越自然类设计越合理。我后来带过几个新人他们最容易犯的错就是花一整天设计一个“万能类”里面放了十几个方法真正项目里用到的只有两个。接口先行的思路能避免这种浪费。5.2 标准库是最好的OOP教材别只盯着教程里的例子想提升类设计水平有一个被低估的途径读Python标准库的源码。标准库写得很规整又是真实项目比任何教程例子都有参考价值。比如datetime模块里的datetime类它就是一个典型的“把日期数据和时间操作组合在一起”的对象。再比如collections里的Counter、defaultdict你去看它们的实现会明白怎么设计一个“用起来像内置类型”的自定义类。另外一个很实用的练习方式用单元测试来框定类的行为。你写了一个Order类先用pytest写几个测试来断言“新增商品后总价会变”“已支付订单不能再支付”。写测试的过程其实就是你在思考“这个类的对外接口应该长什么样”的过程。测试先写类跟着测试的期望去实现设计出来的接口会特别干净。5.3 入门的一个关键信号开始会说“这个对象应该自己管好自己的状态”最后我想聊一个判断自己是否入门OOP的信号。刚开始学的时候我满脑子都是语法class怎么写、self怎么传、继承怎么用。后来某一天我在写代码时突然冒出这样一个念头“这个订单的状态不应该由外面来改应该让它自己管好自己。”那一刻我才意识到面向对象编程的核心不是语法而是一种责任划分的思维方式。每个类都有自己的职责边界数据归谁管、动作从哪里走边界越清楚代码越稳。封装、继承、多态这些机制其实都是为了让这个边界更清晰而服务的。如果你现在也卡在类设计这一关我的建议是从小的领域模型开始拆把订单、学生、视频、下载任务这种真实凭证做成类别急着套设计模式。跑通一个小例子之后再去尝试改造成多实例、多状态的项目。等你习惯了用“谁负责什么”来组织代码的时候你会发现自己写的东西开始有“设计感”了那才是真的入门了面向对象编程。
返回列表