ARTICLE DETAIL

资讯详情

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

Python面向对象编程详解:封装、继承、多态与实战避坑

Python面向对象编程详解:封装、继承、多态与实战避坑 有些人学了几个月Python函数用得很顺列表推导、字典操作信手拈来但一碰到“类”和“对象”这两个词就开始打退堂鼓总觉得这是大项目才需要的东西自己写写脚本、做做分析根本用不上。我见过太多人卡在这个阶段代码量从几百行涨到几千行之后一堆散落的函数互相传参改一个业务逻辑要牵连七八个地方这才回头重新啃面向对象。其实面向对象不是什么悬在云端的高深理论它本质上是一套管理代码复杂度的策略。Python从设计之初就把“一切皆对象”刻进了语言底层整数、字符串、函数、类本身全都是对象所以想在Python里写出真正高质量、可维护的代码面向对象的基本功是绕不开的。这篇文章我会把封装、继承、多态、魔术方法、描述符这些概念全部展开弱化教科书定义用大量可直接运行的代码示例以及我在真实项目里调试到崩溃的实战经验帮你把Python面向对象彻底搞透。1. 面向对象解决的核心问题从一个快速膨胀的订单模块说起1.1 面向过程代码的三个典型症状新手写Python最基本的组织单位是函数。逻辑简单时函数确实很好用但随着需求一个接一个冒出来你一定会撞上下面三种情况。第一种是参数传递爆炸。假设你在写电商系统的订单处理一个订单涉及用户、商品列表、收货地址、优惠券、支付方式、备注……如果每个函数都要操作订单数据很快就变成这样def create_order(user_id, user_name, user_level, items, address, coupon_id, pay_method, note): # 创建订单的逻辑 pass def calc_total_price(user_id, user_name, user_level, items, coupon_id, pay_method): # 计算总价的逻辑 pass调用前要凑齐一长串参数阅读代码时每个函数都得从头捋一遍参数含义新增一个字段就要把所有相关函数都改一遍。第二种是数据与行为割裂。你用字典存用户信息然后在各个函数里手动取键、判断、更新状态。规则散落在不同函数中谁都能直接修改字典的值没有任何约束。等业务复杂到一定程度你压根想不起某个字段在哪些地方被改过。第三种是成片的重复代码。当你遇到几个相似但不完全相同的实体时比如实体商品和虚拟商品有共同字段又有各自逻辑只用函数就得写两套。复制粘贴一时爽后面改一个公共字段要改两个位置稍不留意就漏掉一处线上事故就是这么来的。1.2 封装、继承、多态在真实代码里到底长什么样面向对象为这些问题提供了三条武器分别对应三个概念。封装是把数据和对数据的操作绑在一起并对外隐藏内部细节。对象用属性存数据用方法表达“我能做什么”外部只通过接口操作对象内部怎么存、怎么算调用方不必关心。继承是抽取公共特征。实体商品和虚拟商品都是商品共同字段放进基类各自特殊逻辑在子类补充公共逻辑只改基类一次。多态是同样的方法名不同对象呈现不同行为。调用方只要知道“这个东西有calculate_price方法”不需要关心具体是哪一种商品各算各的价。一个最小可跑的案例class Product: def __init__(self, name, price): self.name name self.price price def calculate_price(self, quantity): return self.price * quantity class PhysicalProduct(Product): def calculate_price(self, quantity): shipping_fee 10 if quantity 1 else 20 return self.price * quantity shipping_fee class VirtualProduct(Product): def calculate_price(self, quantity): subtotal self.price * quantity if quantity 5: subtotal * 0.8 return subtotal调用时完全统一def checkout(product, quantity): total product.calculate_price(quantity) print(f{product.name} x {quantity} 总价{total}) checkout(PhysicalProduct(机械键盘, 399), 1) checkout(VirtualProduct(Python进阶课程, 99), 6)输出结果机械键盘 x 1 总价409 Python进阶课程 x 6 总价475.2checkout函数从头到尾只调用同一个接口传入不同子类实例就能得到不同结果。这段代码里封装保证了每个对象内部自己处理逻辑继承让公共字段只写一遍多态让外层代码完全解耦。1.3 该不该用类我自己的判断信号面向对象虽然好但真不是所有场景都得硬上。我写一次性脚本时习惯先用函数简单直接。判断要不要上类我一般看有没有这些信号同一组数据被多个函数反复操作参数列表已经变得很长你发现自己复制粘贴了大量相似函数只是参数或细节略微不同你希望隐藏部分内部状态防止其他人或未来的自己乱改业务里明显存在分类不同对象对同一行为有不同计算规则。如果一个脚本两百行就跑完了强行封装类只会增加阅读负担。技术选型永远服务于维护成本。2. 类与实例self、__init__和对象创建过程的一次讲清2.1 class语法与实例化的底层过程我们先定义一个最简单的类然后一行行看它发生了什么。class Dog: def __init__(self, name, age): self.name name self.age age def bark(self): print(f{self.name} 汪汪叫)class Dog:这一行创建了一个类对象。注意Python中的类本身也是一个对象它是type类的实例。你不用理解太深但要知道类在内存里是真实存在的可以被传递、赋值、动态修改。执行d Dog(旺财, 3)时解释器做两件事调用Dog.__new__(Dog, 旺财, 3)创建一块新内存区域得到一个空的实例对新实例调用Dog.__init__(d, 旺财, 3)把数据填进去。所以__new__负责“造人”__init__负责“给这个人的户口本填信息”。实际开发中很少重写__new__但理解这一点对后面理解self至关重要。2.2 self不是语法关键字它只是第一个参数Python官方的建议是用self但语法层面它只是约定俗称的名字。你也可以叫this、obj甚至anything运行结果完全一样class Cat: def __init__(this, name): this.name name def meow(anything): print(f{anything.name} 喵喵叫)能工作但请永远遵守社区约定使用self否则协作时没人想看你代码。self的深层含义是当实例调用方法时Python自动将该实例作为第一个参数传进去。所以d.bark()等价于Dog.bark(d)。你看到的方法本质上是一个普通函数存放在类的__dict__里实例调用时自动绑定。这里有一个实际影响如果你定义一个类方法列表例如methods [d.bark]它已经绑定了d后面无论怎么调用都绑在d上。如果你希望不同实例都能调用同一种逻辑用类方法classmethod或静态方法staticmethod。2.3 类属性与实例属性经典修改陷阱类属性定义在类体内、方法外属于类对象本身实例属性通过self.xxx赋值属于具体实例。class Employee: company 极客科技 # 类属性 raise_rate 0.1 # 类属性 def __init__(self, name, salary): self.name name # 实例属性 self.salary salary # 实例属性类属性的特点是所有实例共享。但当你写self.raise_rate 0.2时不是修改类属性而是创建了一个同名实例属性覆盖了类属性。这个覆盖行为非常隐蔽e1 Employee(张三, 10000) e2 Employee(李四, 10000) e1.raise_rate 0.2 # 只是给 e1 新建了实例属性 print(e1.raise_rate) # 0.2 print(e2.raise_rate) # 0.1类属性未被修改 print(Employee.raise_rate) # 0.1修改类属性要通过类本身或typeEmployee.raise_rate 0.2 print(e2.raise_rate) # 现在读取到 0.2有一个特别常见的坑类属性如果是可变对象所有实例会共享同一份数据。比如类属性tasks []某个实例执行self.tasks.append(x)其他实例立刻能看到这个变化因为大家查到的都是同一个列表对象。如果在__init__里用默认参数接收情况更隐蔽第七章会专门讲。2.4 __new__和__init__的分工__new__是类方法即使你定义时不带classmethod装饰器它也是接收的第一个参数是类本身作用是创建并返回实例。__init__接收已经创建好的实例做初始化。大多数场景你只重写__init__。但当你需要实现单例模式、池化对象、或者不可变对象比如子类化tuple时就不得不碰__new__。单例模式的一个简单实现class Config: _instance None def __new__(cls): if cls._instance is None: cls._instance super().__new__(cls) return cls._instance config1 Config() config2 Config() print(config1 is config2) # True这段代码把类实例的创建控制住任意多次实例化都拿到同一个对象。用途是全局共享配置、数据库连接池等场景但注意不是所有单例都优雅后面会提替代方案。3. 魔术方法详解把自定义对象变成Python“原生公民”魔术方法也叫双下划线方法是Python对象模型的灵魂。名字以两个下划线开头和结尾解释器在特定场景自动调用。你不需要显式调用它们只要定义了对象就能原生支持某种语法。3.1str__和__repr展示与调试的正确分工不定义这两个方法时你直接打印对象会得到一串难看的地址class Book: def __init__(self, title, author): self.title title self.author author b Book(Python阴影, 老纪) print(b) # __main__.Book object at 0x7f8a1b3c2550实现__repr__后class Book: def __init__(self, title, author): self.title title self.author author def __repr__(self): return fBook(title{self.title!r}, author{self.author!r}) def __str__(self): return f《{self.title}》by {self.author} b Book(Python阴影, 老纪) print(b) # 《Python阴影》by 老纪 走 __str__ print(repr(b)) # Book(titlePython阴影, author老纪) 走 __repr__推荐做法是__repr__输出一个尽量能重构出对象的字符串__str__输出面向用户的易读文本。如果只实现了__repr__print(obj)和交互式终端都会走__repr__作为兜底。3.2eq__和__hash自定义相等性后集合行为的变化默认情况下两个对象相等靠内存地址判断即使字段完全一致也不相等。如果你希望按业务字段判断就要重写__eq__。class User: def __init__(self, uid, name): self.uid uid self.name name def __eq__(self, other): if isinstance(other, User): return self.uid other.uid return NotImplemented u1 User(1, 张三) u2 User(1, 张三同名) print(u1 u2) # True这里有个极其关键的点重写__eq__后Python会自动把__hash__设为None。一旦对象是unhashable的放进set和dict的键就会直接报错users {u1, u2} # TypeError: unhashable type: User解决方式是同步实现__hash__class User: def __init__(self, uid, name): self.uid uid self.name name def __eq__(self, other): if isinstance(other, User): return self.uid other.uid return NotImplemented def __hash__(self): return hash(self.uid)这样set会自动按uid去重因为哈希相同且相等判断通过。凡是重写__eq__的地方十有八九也要重写__hash__这是我给很多新人代码做Review时最常提醒的一句话。3.3len、getitem、iter容器协议三件套当你希望自定义对象支持len(obj)、obj[i]、for x in obj、x in obj需要实现以下协议class Playlist: def __init__(self, songs): self._songs songs def __len__(self): return len(self._songs) def __getitem__(self, index): return self._songs[index] def __iter__(self): return iter(self._songs)有了这三个方法Playlist实例就像内置列表一样可操作p Playlist([孤勇者, 海阔天空, 平凡之路]) print(len(p)) # 3 print(p[1]) # 海阔天空 print(孤勇者 in p) # True for song in p: print(song)更妙的是Python会在没有__iter__但有__getitem__时自动做旧式迭代不断从0开始尝试取值捕获IndexError后停止。所以最低限度实现__getitem__就能让对象被for循环。3.4enter__和__exit优雅管理资源的上下文管理器我们天天用with open(...) as f它背后的机制就是上下文管理器协议。想让自定义类也支持with只要实现__enter__和__exit__class DatabaseConnection: def __init__(self, dsn): self.dsn dsn def __enter__(self): print(f连接数据库 {self.dsn}) return self def __exit__(self, exc_type, exc_value, traceback): print(关闭数据库连接) return False with DatabaseConnection(mysql://localhost:3306) as db: print(执行SQL)__exit__的三个参数接收的是异常信息。如果返回True表示异常已被吞掉不会向外抛返回False则异常继续传播。这个协议非常适合封装资源释放逻辑防止忘记关闭连接。3.5call让实例像函数一样被调用给类定义__call__后实例就能当函数使class DiscountCalculator: def __init__(self, rate): self.rate rate def __call__(self, price): return price * self.rate nine_discount DiscountCalculator(0.9) print(nine_discount(100)) # 90.0 print(nine_discount(200)) # 180.0这个模式常用来保存状态的函数替代品。比如装饰器中返回一个可调用对象或者策略模式中把不同策略封装成可调用类。它比闭包更直观也能通过继承共享逻辑。4. 继承、super与MRO多层结构的正确打开方式4.1 继承表达的是is-a关系别活成has-a面向对象设计里继承最关键的不是“复用代码”而是表达子类是父类的一种。猫是动物老虎也是猫科这种关系才适合继承。如果你只是想让新类用几个已有方法通常应该用组合把一个对象作为另一个对象的属性而不是继承。举一个反例。有人想实现一个“带日志的列表”直接从list继承并重写appendclass LoggedList(list): def append(self, item): print(f添加: {item}) super().append(item)看起来爽但这类“为了复用而继承”的代码很容易踩到内部方法互相调用的坑。比如list的extend内部不一定会调用append你的日志逻辑可能在某些操作下失效。更稳妥的做法是组合class LoggedList: def __init__(self): self._data [] def append(self, item): print(f添加: {item}) self._data.append(item) def __len__(self): return len(self._data)4.2 super()不是“直接调父类方法”这么简单很多人把super()理解为“获取父类对象”其实不准确。在单继承链里它很像父类但一旦进入多重继承super()实际运作在MRO方法解析顺序上返回的是“当前类后续链路上的下一个类”。看一个经典例子class A: def say(self): print(A) class B(A): def say(self): print(B) super().say() class C(A): def say(self): print(C) super().say() class D(B, C): def say(self): print(D) super().say() d D() d.say()输出不是D B A而是D B C A因为D的MRO是[D, B, C, A, object]super()是沿着MRO往后走不是跳到最近的直接父类。这个机制叫C3线性化Python用它兼顾深度和宽度确保每个类只出现一次、单调且保持直接父类顺序。4.3 钻石继承关系下的MRO计算方法当两个父类共享同一个祖先时形成菱形结构。上面的D(B, C)和B(A)、C(A)就是菱形。很多语言禁止这种结构Python允许但要求你必须理解MRO规则否则顺序搞错初始化都会乱。class Base: def __init__(self, value): self.value value class X(Base): def __init__(self, value, x_flag): super().__init__(value) self.x_flag x_flag class Y(Base): def __init__(self, value, y_flag): super().__init__(value) self.y_flag y_flag class Z(X, Y): def __init__(self, value, x_flag, y_flag): super().__init__(value) self.x_flag x_flag self.y_flag y_flag代码看起来合理但注意Z的MRO是[Z, X, Y, Base, object]。Z.__init__里的super().__init__(value)实际调用的是X.__init__而X.__init__里的super().__init__(value)调用的是Y.__init__此时value被连续传下去随后X和Y各自初始化自己的字段。这种协作式继承要求所有类使用完全一致的__init__签名或者用关键字参数。所以说多重继承不是不能用而是必须遵守规则。我个人的态度是普通业务代码尽量避免复杂多重继承团队协作时心智负担太大。4.4 组合优先于继承的实战理由我接手过一段业务代码有一个Animal基类下面是Mammal、Bird、Fish然后Dog继承Mammal、Puppy继承Dog……技能系统做成了多层继承之后加一个“会飞”的技能恨不得改十来个类。问题在于继继承树是刚性结构而真实业务需求是横切的。一种更可控的做法是组合class Dog: def __init__(self, name): self.name name self.move_strategy GroundMove() self.sound_strategy BarkSound() def move(self): self.move_strategy.move()把变化的行为抽成策略对象通过组合装配。这样增加“会飞的狗”只要传入飞行策略不影响其他类。这是“组合优于继承”最常见的落地场景明确家族关系用继承强调能力装配用组合和依赖注入。5. 多态、鸭子类型与抽象基类Python的接口思维5.1 鸭子类型的代价与收益“如果它走路像鸭子、叫声像鸭子那它就是鸭子。”Python中多态不要求显式接口只要对象有对应方法就能用。例如class Payment: def pay(self, amount): print(f支付 {amount} 元) class WeChatPay: def pay(self, amount): print(f微信支付 {amount} 元) class Alipay: def pay(self, amount): print(f支付宝支付 {amount} 元) def run_payment(payment_obj, amount): payment_obj.pay(amount)run_payment不看类型只看对象有没有pay方法。你给一个完全无关的类只要它定义了pay方法这里就能工作。这就是鸭子类型带来的灵活性。代价也在这里一旦调用方传入的对象缺少方法错误要等运行时才爆出来而不是在类型校验阶段就抓住。小项目里鸭子类型让代码飞快迭代大团队里往往需要在边界处立一些“规矩”。5.2 ABC抽象基类为协作团队立一些规矩abc模块允许你定义抽象基类指明哪些方法子类必须实现from abc import ABC, abstractmethod class Payment(ABC): abstractmethod def pay(self, amount): 子类必须实现支付逻辑 class WeChatPay(Payment): def pay(self, amount): print(f微信支付 {amount} 元) class CashPay(Payment): pass实例化CashPay会直接抛TypeError: Cant instantiate abstract class CashPay with abstract method pay。这条约束把“约定”提前到实例化阶段团队成员忘了实现时立刻报错而不是等运行到调用处才炸。抽象基类还有个好处配合isinstance做类型判断时非常可靠。你可以在抽象基类里声明classmethod __subclasshook__来支持结构性判断让非继承者也算子类但日常业务基本用不到这么深的操作。5.3 协议vs接口什么时候该用哪种Python生态中存在很多协议protocol比如迭代协议、上下文管理器协议它们不要求继承任何类只要求实现对应魔术方法。这种模式非常Pythonic。当你给同事写一个“数据处理器”的接口约束有三档做法第一档纯文档约定文档里写明需要实现process(data)方法。适合小团队和快节奏脚本第二档用ABC做抽象基类强制子类实现。适合明确会有多个实现、需要统一规范的库代码第三档用typing.Protocol做运行时结构检查配合runtime_checkable适合既想要运行时校验又不想强制继承的中间场景。from typing import Protocol, runtime_checkable runtime_checkable class Processable(Protocol): def process(self, data) - str: ... class MyProcessor: def process(self, data) - str: return fprocessed: {data} print(isinstance(MyProcessor(), Processable)) # True实际项目里我第一档用得最多第二档用在接口很稳定的框架层第三档用在团队实现方来自不同模块的中型项目。6. property、描述符与私有约定把“封装”真正做实6.1 前导下划线不是严格私有但请务必遵守Python没有其他语言那样的private关键字。约定是单下划线开头的属性或方法_name表示“内部实现外部不应直接访问”双下划线开头__name会触发名称改写name mangling在类内部变成_ClassName__name但这只是改名字不是真正不可访问。class Secret: def __init__(self): self.__secret 1991 s Secret() # print(s.__secret) # AttributeError print(s._Secret__secret) # 1991 还是能拿到名称改写的价值主要是避免被子类不小心覆盖而不是安全隔离。不要指望靠双下划线做加密安全是靠访问控制层解决的。团队协作中看到单下划线你就当它是私有别去动它。6.2 property给属性访问加一层拦截很多新人在类里写一堆get_xxx、set_xxx方法像Java风格搬到Python。Python的推荐姿势是用property装饰器。class BankAccount: def __init__(self, balance): self._balance balance property def balance(self): return self._balance balance.setter def balance(self, value): if value 0: raise ValueError(余额不能为负数) self._balance value外部代码可以像访问普通属性一样操作但赋值时自动执行校验account BankAccount(1000) account.balance 1500 account.balance -10 # 抛出 ValueError这里的关键是你随时可以在不改外部接口的情况下把普通属性升级成带逻辑的property。这就是封装的实际价值——内部实现可以变外部接口保持稳定。6.3 描述符协议属性控制的底层机制property之所以能工作底层依赖描述符协议一个类只要实现__get__、__set__、__delete__中的任意一个它就能拦截属性访问。理解这一点能让你在需要复用属性逻辑时写出很优雅的代码。假设多个类都需要“非负数值”字段重复写property很烦。用描述符抽出来class NonNegative: def __set_name__(self, owner, name): self.name name def __get__(self, instance, owner): if instance is None: return self return instance.__dict__.get(self.name) def __set__(self, instance, value): if value 0: raise ValueError(f{self.name} 不能是负数) instance.__dict__[self.name] value class Product: price NonNegative() stock NonNegative() def __init__(self, price, stock): self.price price self.stock stockprice NonNegative()在类级别定义后会触发__set_name__记录字段名实例赋值时自动走__set__做校验。这个模式在ORM框架比如SQLAlchemy的Column里大量使用理解了描述符再看那些框架就不迷糊了。但注意描述符属于偏进阶内容如果你还在消化基础概念先把property玩熟就够用千万不要为了炫技硬上描述符。7. 面向对象实战避坑几个让我查了一整天的bug7.1 可变默认参数定义类时最隐蔽的坑任何一个Python老手都遇到过这个问题。在__init__里使用可变对象作为默认参数坑会一直潜伏class Task: def __init__(self, name, tags[]): self.name name self.tags tags t1 Task(写报告, [工作]) t2 Task(买菜) t3 Task(遛狗) t2.tags.append(生活) print(t3.tags) # [生活] tags[]只在函数定义时求值一次后续所有没传tags的实例共享同一个列表。解决办法是默认值设None内部重新创建class Task: def __init__(self, name, tagsNone): self.name name self.tags tags if tags is not None else []这条规则适用于一切可变默认参数列表、字典、集合、自定义对象。我在代码Review中至少拦下二十次这个Bug养成本能反应就不会再犯。7.2 可变对象属性被隐式共享即使默认参数坑规避了还有一个更隐蔽的版本类属性是可变对象且实例没在__init__里覆盖它。class Employee: skills [] def __init__(self, name): self.name name def add_skill(self, skill): self.skills.append(skill) e1 Employee(张三) e2 Employee(李四) e1.add_skill(Python) e2.add_skill(SQL) print(e1.skills) # [Python, SQL] 李四的技能怎么跑到张三这里来了因为两个实例都没给skills赋值读到的都是类属性里同一个列表。修复方式就是在__init__里显式初始化实例数据class Employee: def __init__(self, name): self.name name self.skills []一个简单的判断标准凡是对象自己的状态都必须在__init__里初始化。类属性只放所有实例共享且通常不可变的常量。7.3 继承字段初始化顺序参数名冲突怎么排查多重继承协作式初始化最大的坑是各个类的__init__参数名不同一传就串。class Base: def __init__(self, value): self.value value class X(Base): def __init__(self, x_value, **kwargs): self.x_value x_value super().__init__(**kwargs) class Y(Base): def __init__(self, y_value, **kwargs): self.y_value y_value super().__init__(**kwargs) class Z(X, Y): def __init__(self, x_value, y_value, value): super().__init__(x_valuex_value, y_valuey_value, valuevalue)注意Z的MRO[Z, X, Y, Base]。Z.__init__调用super()进入X.__init__X处理自己的x_value后把剩余关键字参数全部传给super()接下来进入Y.__init__处理y_value最终到Base接收value。这里唯一的正确姿势是让所有类都支持**kwargs并透传否则某个环节吞掉参数后面直接就炸。排查这类Bug时输出MRO非常有用print(Z.__mro__) # (class __main__.Z, class __main__.X, class __main__.Y, class __main__.Base, class object)7.4 一个类设计自查清单写完一个类之后我习惯按下面这个清单快速过一遍能提前拦截掉大部分设计层面的问题这个类是否真的表达了某种明确的业务实体或能力能不能用一个名词描述它所有的实例状态都在__init__里初始化了吗有没有不小心把可变对象放到了类属性对外暴露的接口是不是最小有没有不必要的public方法新增一种“变体”时是通过新建子类或策略对象完成还是得改一堆if/else类里面有没有大量只操作某个局部数据的方法如果有可能拆分出新类更合适重写__eq__的同时是否处理了__hash__把实例塞进set、dict或是JSON序列化时会不会出现意料之外的行为这些问题看起来琐碎但在真实项目里绝大多数维护噩梦都源于类边界的混乱和可变状态的失控。从最开始不理解为什么有人愿意写一堆类到现在看到散落的函数就想抽出来重构这个过程是我认为Python进阶路上最值得投入的一段。面向对象不是一种宗教而是一套工具什么时候用继承、什么时候用组合、什么时候用协议最终衡量标准只有一条改代码时你是更省力了还是更崩溃了。很多时候一个类设计得好的标志不是你加了多高级的语法而是下周你再打开这个文件时三分钟就能想起来它到底在干嘛。
返回列表