ARTICLE DETAIL

资讯详情

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

Python面向对象编程实战:从类与对象到dataclass的完整指南

Python面向对象编程实战:从类与对象到dataclass的完整指南 直接上手写代码久了尤其是写过几个项目以后很多人会对Python面向对象编程OOP产生一种学了但没用上的感觉。函数加字典好像就能解决大部分问题类反而显得啰嗦。但真当项目膨胀到几万行或者需要多人协作维护时有没有用OOP组织代码差距会非常明显。这篇文章我不会跟你念教科书定义而是从为什么需要OOP讲起把类与对象、封装继承多态、魔术方法、dataclass这些核心机制逐个拆开配合能直接跑的代码和实战中踩过的坑帮你建立一套能落地的面向对象设计思路。适合刚学完Python语法、准备进阶的中级开发者也适合一直在用函数式写法、想重构项目的老手。1. 从一段能跑但没法维护的代码说起1.1 面向过程的写法是怎么一步步失控的先假设你要写一个简单的库存管理程序。最初的需求很简单记录商品名称、价格、库存数量卖出一件就减库存。用函数加字典第一版很快就能写完# 面向过程版本 v1 inventory {} def add_product(name, price, quantity): inventory[name] {price: price, quantity: quantity} def sell_product(name, quantity): if name not in inventory: print(商品不存在) return if inventory[name][quantity] quantity: print(库存不足) return inventory[name][quantity] - quantity add_product(苹果, 5.5, 100) sell_product(苹果, 3) print(inventory)这段代码在功能上完全没问题。但接着需求变了每种商品还要记录进货日期、保质期卖出的同时要生成订单记录后台还需要按供应商维度统计数据。这时候你会怎么做大概率是继续往字典里塞键继续加函数参数列表越来越长函数之间的隐式依赖越来越多。大概到第三四个需求叠加进来就会出现这种状态一个函数改了库存结构另外五个函数都得跟着改字典里某个键在某个分支忘记初始化运行时报KeyError调试的时候频繁需要打印整个字典来确认当前状态。最要命的是程序里没有一个地方能清晰地表达商品到底是什么只有一堆散落的字典和操作它们的函数。1.2 OOP真正解决的是数据与行为的归属问题面向对象编程的核心并不神秘它做了一件非常简单的事把数据和对数据的操作绑在一起。商品的价格、库存、保质期是数据加减库存、判断是否过期是操作。在OOP里这些属于同一个商品对象而不是分散在不同函数里。这带来两个很实际的好处。第一修改的影响范围被限制住了。想改库存的扣减逻辑只需要去改商品类内部的方法调用方根本不关心你内部是减一还是减十。第二代码的阅读成本大幅降低。看到product.sell(3)语义一目了然而看到sell_product(inventory, 苹果, 3)你还要去查函数签名确认参数顺序。对此我个人的体会是OOP不是一种语法装饰而是一种思维上的分活机制。你用类把不同的职责切分开每个类只对自己的数据和行为负责协作时只需要暴露方法不需要暴露内部结构。这跟现实中公司分部门是一个道理——财务部不会直接去翻仓库的货架它只跟仓库管理系统对接。当然OOP也有适用边界。写一个几十行的处理脚本、做数据分析的流水线、或者一次性爬虫任务硬套类反而画蛇添足。我见过有人写了两个类去封装一个 requests.post 调用这种为了OOP而OOP的做法同样不可取。合适的判断标准是当你的数据结构和操作它的逻辑开始被多处复用或者需求复杂度让你觉得这个函数我快不知道该传什么参数了那就是考虑用类来收拢的时机。2. 类与对象图纸和实车的关系2.1 定义类的标准姿势里藏着哪些约定类和对象的关系用图纸和实车来类比最形象。类就是图纸它规定了实体应该有什么属性、能做什么操作对象是根据图纸造出来的那台具体车子有自己的实际数值。同一个类可以造出无限多个互不干扰的对象。Python里定义一个最基础的类长这样class Product: def __init__(self, name, price, quantity): self.name name self.price price self.quantity quantity def sell(self, amount): if self.quantity amount: raise ValueError(f{self.name} 库存不足) self.quantity - amount return self.quantity product Product(苹果, 5.5, 100) product.sell(3)这段代码里有几个初学者最容易懵的点。__init__不是构造器准确地说它是初始化方法。在你调用Product(...)的时候Python实际上先调用__new__分配内存创建了一个空对象然后再调用__init__对这个空对象做属性赋值。__init__不需要 return 任何东西写了也不会生效。再说self。它代表正在被操作的那个对象实例。你调用product.sell(3)时Python干了一件隐式的事Product.sell(product, 3)自动把实例本身作为第一个参数传进去。这也是为什么类里所有普通方法的第一个参数都叫self。很多新手问为什么self不用传参答案就是解释器帮你传了。属性赋值在__init__里做而不是在类体里做这个习惯很重要。直接在类体里写quantity 100定义的是类属性它被所有实例共享后面我会细说它的坑。2.2 实例属性与类属性共享和私有的边界类属性是定义在类体中的变量实例属性是在__init__里通过self.xxx赋值的变量。两者的本质区别在于归属和查找顺序。class Product: category 生鲜 # 类属性所有实例共享 def __init__(self, name, price): self.name name # 实例属性 self.price price当访问product.category时Python先在实例的__dict__里找找不到就去类的__dict__里找。实例属性是自己专属的改一个不影响另一个但类属性是共享的。最容易踩的坑是通过实例修改类属性p1 Product(苹果, 5.5) p2 Product(香蕉, 3.0) p1.category 水果 # 这不会修改类属性只是给p1创建了同名实例属性 Product.category 水果 # 这才是修改类属性因为实例赋值操作永远是在创建或修改实例自己的属性不会动类属性。这个机制理解到位后你就能解释很多奇怪的行为了。类属性适合放那些所有实例都一样的默认值、或者需要全局共享的计数器。注意如果类属性的值是列表、字典这类可变对象所有实例共享同一个对象一个实例修改了内容其他实例看到的是修改后的结果。这既是特性也是隐患得用对场景。2.3 实例方法、类方法、静态方法三种工具的取舍普通实例方法接收self这是最常用的。类方法用classmethod装饰第一个参数是cls接收的是类本身静态方法用staticmethod装饰不接收self也不接收cls就是一个放在类命名空间里的普通函数。它们在什么时候派上用场我举个例子class Product: def __init__(self, name, price, quantity): self.name name self.price price self.quantity quantity classmethod def from_dict(cls, data): # 用类方法提供替代构造器 return cls(data[name], data[price], data[quantity]) staticmethod def is_valid_price(price): # 跟实例无关的校验逻辑 return price 0 def total_value(self): return self.price * self.quantity我的使用经验是类方法的典型场景是根据不同的参数形态创建实例。比如从配置文件、从字典、从数据库行分别构造对象你可以定义多个类方法作为不同入口逻辑集中在类内部。静态方法适合放那些与类相关、但又不依赖类任何状态的工具函数比如校验、格式化。值得一提的是静态方法和模块级普通函数本质没有区别不必为了显得面向对象而硬塞。我见过有些人把所有辅助函数都塞进类里加 staticmethod结果类变成了一个盛杂物的筐这反而不利于维护。一个简单的判断标准这个函数如果抽离出来还跟这个类有紧密的语义关联吗如果没有放模块级更清晰。3. 封装、继承、多态三大支柱的设计意图3.1 封装不是藏数据是管好门口很多人一讲封装就背把属性设为私有但实际上Python没有真正的私有机制。所谓_name和__name前者是约定别碰我后者是名字修饰_ClassName__name都是防君子不防小人的。理解封装关键不在于别人不能访问而在于你不需要知道内部怎么实现。像我手头这个商品例子对外暴露的是total_value()、sell()这些操作内部到底是用字典还是用数据库存储过期时间调用方不应该关心。只要方法签名不变内部随便重构外部代码一行不动。这就是封装真正的价值——它把变的部分关在屋里把不变的接口留在门外。3.2 继承的正确姿势先问是不是再问有没有继承能实现代码复用但滥用继承是项目腐化的最大根源。判断该不该继承用的判断标准是is-a关系子类确实是一种父类吗猫是动物所以猫继承动物没问题但猫需要喝水这件事不应该通过让猫继承喝水器来实现那是has-a关系用组合。Python支持多继承语法上没什么门槛但组合起来时的方法解析顺序MRO会成为诡异bug的来源。即使有C3线性化算法兜底我仍然建议绝大多数场景用单继承多层继承或者干脆用组合。一个非常实际的经验是把公共逻辑放到一个基类里子类只做差异化的部分如果发现两个类之间的复用关系是我希望借用它几个方法优先考虑把那些方法抽成独立的类通过依赖注入来协作而不是制造继承链。3.3 多态接口统一行为各异多态在Python里比在Java、C#里自然得多。Java要显式地定义接口或者抽象类Python依靠鸭子类型——如果它走起来像鸭子、叫起来像鸭子那它就是鸭子。只要一个对象有sell方法不管它是Product还是别的什么类调用方都能用它。class DigitalProduct: def sell(self, amount): print(f数字商品卖出 {amount} 份无需减库存) for product in [Product(苹果, 5.5, 100), DigitalProduct(电子书, 9.9, 9999)]: product.sell(1)这段代码里调用循环根本不需要知道列表里是哪种商品统一调用sell就行。这就是多态的好处写通用的代码适配无穷多的具体类型。在Python里与其继承一个基类再去重写方法不如直接约定方法名和签名。这是Python的灵活之处也是它的风险之处——约定一旦被破坏运行期才报错。为此后面我会讲抽象基类它能在一定程度上把这种运行期才发现变成定义期就约束。3.4 MRO与super()多继承下你要知道的事在单继承里super()调用父类方法非常直观。多继承下super()不一定是调直接父类而是按MRO顺序调用下一个类。MRO遵循C3线性化子类优先于父类多个父类按声明顺序从左到右保证每个父类在MRO中只出现一次且顺序一致。class A: def greet(self): print(A) super().greet() class B(A): def greet(self): print(B) super().greet() class C(A): def greet(self): print(C) super().greet() class D(B, C): pass D().greet()执行顺序是 D - B - C - A。很多人会误以为 super() 直接从B跳到A实际上它把C也走了一遍。这种协作式多继承是Python的特色但如果父类的 greet 里都有各自的 super 调用而没有最终兜底类会陷入死循环。所以多继承的基类设计要格外小心最好有一个根类把链收尾。老实说日常业务代码里能用菱形继承的机会微乎其微你只要记住MRO是diamonds顺序、用mro查看就够应付绝大多数问题了。4. 魔术方法让自定义类活成原生类型4.1new想拦在初始化之前先弄清楚它和init的分工绝大多数情况下你只需要__init__。但当你需要控制对象创建本身时比如实现单例模式、缓存复用实例、创建不可变对象就要重写__new__。__new__是真正的构造过程它负责创建并返回实例如果__new__返回的不是当前类的实例__init__不会被调用。一个典型的单例实现class Singleton: _instance None def __new__(cls): if cls._instance is None: cls._instance super().__new__(cls) return cls._instance s1 Singleton() s2 Singleton() print(s1 is s2) # True这里有个容易被忽略的规则__new__是类方法但不需要加classmethod因为Python会特殊处理。它的第一个参数是cls而不是self。每次实例化的时候__new__先跑__init__后跑如果__new__返回的是已有实例那么__init__还会被再次调用Simingleton模式里常见的坑就是对同一个实例重复初始化。4.2str和repr调试体验的分水岭__str__定义的是给人看的信息str(obj)和print(obj)走它__repr__定义的是给解释器/开发者看的信息直接敲对象名或者在列表里查看元素走它。没有实现__str__时Python 会退回去用__repr__。class Product: def __init__(self, name, price): self.name name self.price price def __repr__(self): return fProduct(name{self.name!r}, price{self.price}) def __str__(self): return f{self.name}: ¥{self.price} p Product(苹果, 5.5) print(p) # 走 __str__输出苹果: ¥5.5 print([p]) # 列表打印走 __repr__输出[Product(name苹果, price5.5)]我强烈建议每个类都实现__repr__而且让它尽量输出能重建对象的完整信息。这样你在控制台看到Product(name苹果, price5.5)时一眼就知道这个对象的值如果它只打出Product object at 0x...那你每次调试都要额外打印属性效率低得离谱。4.3eq与hash配对规则决定了你能否把对象放进集合默认情况下两个对象相等基于身份比较is也就是内存地址。要实现值相等重写__eq__class Product: def __init__(self, sku, name): self.sku sku self.name name def __eq__(self, other): if not isinstance(other, Product): return NotImplemented return self.sku other.sku def __hash__(self): return hash(self.sku)Python有一条规则如果重写了__eq__而没有定义__hash__类会变成不可哈希的__hash__被设为 None不能放进集合或作为字典键。原因是两个相等的对象必须有相同的哈希值你自定义了相等逻辑后默认的哈希实现可能违反这条规则。所以要么同时定义__hash__要么明确把__hash__设为 None表示这个对象不可哈希。哈希值只需要基于参与相等比较的字段来计算即可。我自己在写数据模型的时候习惯把sku这类业务唯一标识作为相等和哈希的依据而不是把所有字段都算进去。如果两个对象所有字段都相同才相等那在集合里去重可能不符合预期。4.4call让实例拥有函数的手感实现了__call__后实例可以像函数一样被调用。这在状态化函数、装饰器、以及某些回调场景里非常实用。class Logger: def __init__(self, prefix): self.prefix prefix def __call__(self, message): print(f[{self.prefix}] {message}) log Logger(INFO) log(服务已启动) # 输出 [INFO] 服务已启动跟闭包相比__call__的类方式最大的优势是可读性和可扩展性明确状态存在__init__的属性里行为集中在__call__里要加方法直接加。闭包再复杂一点嵌套函数读起来就很吃力了。4.5enter和exit把资源管理封装进with里with open(...) as f背后的机制就是上下文管理器协议。你写自己的类时如果它有使用前需要准备、使用后需要清理的语义就可以实现这两个方法class DatabaseConnection: def __enter__(self): print(连接数据库) return self def __exit__(self, exc_type, exc_val, exc_tb): print(关闭数据库连接) return False with DatabaseConnection() as conn: print(执行查询)__exit__接收三个参数分别代表异常类型、异常值、回溯信息。如果 with 块里抛了异常这三个参数会被填上返回 True 表示异常被吞掉返回 False 表示继续向上抛。多数情况我推荐返回 False不要静默吞异常不然排障的时候会非常痛苦。5. 进阶工具箱dataclass、property 与抽象基类5.1 dataclass用最少的样板代码定义数据模型Python 3.7 之后引入了dataclass装饰器它是我推荐任何写业务代码的人优先考虑的选择。它自动帮类生成__init__、__repr__、__eq__省下的代码量相当可观from dataclasses import dataclass dataclass class Product: name: str price: float quantity: int 0这一小段代码就能获得与手写__init__、__repr__、__eq__相同的效果默认值也支持了。更重要的是类型注解强制你把字段说清楚IDE补全体验也好了不少。dataclass还支持frozenTrue生成不可变对象这在线程安全场景很受用支持field(default_factorylist)来应对可变默认值问题这个坑下面会重点聊。不过要注意dataclass仍然是个普通类你还能给它加方法、加__post_init__做校验或者派生字段。如果你维护的是旧Python版本3.6及以下也可以用attrs库体验几乎一样。5.2 property把方法调用伪装成属性访问property能把方法伪装成属性好处是调用方代码不用加括号而且可以在赋值或读取时插入校验逻辑。更重要的是它允许你在不改变对外接口的情况下改内部实现class Product: def __init__(self, name, price, quantity): self._name name self._price price self._quantity quantity property def total_value(self): return self._price * self._quantity property def price(self): return self._price price.setter def price(self, value): if value 0: raise ValueError(价格不能为负) self._price value有个容易被诱惑的点property和getter/setter组合会让代码变得冗长。如果你纯粹为了封装而把每个属性都包一层property无异于自找麻烦。Python不像Java没有所有字段必须私有再加getter的惯例。我的实践是先直接用公开属性等真的需要校验或者计算派生字段时再升级为property。这个升级不需要改外部调用所以说property是成本最低的演进手段。5.3 抽象基类与鸭子类型约定 vs 强制鸭子类型灵活但出了问题要等运行期才暴露。抽象基类ABC则能在定义期就约束子类必须实现某些方法把错误提前from abc import ABC, abstractmethod class Product(ABC): abstractmethod def sell(self, amount): pass class Fruit(Product): def sell(self, amount): print(f卖出 {amount} 个水果) class InvalidProduct(Product): pass # 直接实例化会报 TypeError两个使用场景值得考虑用ABC一是框架代码你发布一个类希望使用方必须实现某些接口二是团队协作统一约定接口防止有人偷懒漏实现。但单兵作战或者内部临时脚本强行上ABC反而增加噪音。Python生态更推荐的做法是Protocol——结构子类型它不需要继承任何基类只要类的方法签名匹配就算满足协议。这个思想更贴合鸭子类型比ABC更Pythonic适合那些不打算建立继承关系的场景。6. 从需求到代码一个完整建模案例6.1 原始需求下面用一个简单但真实的小项目来走一遍OOP建模过程。需求是做一个命令行图书库存系统支持添加图书、按ISBN查询图书、借出图书、归还图书、查看所有过期未还的记录。不需要数据库先存内存但要留好持久化接口。6.2 识别领域对象初看需求最容易把图书设计成一个类借阅记录又是一个类图书管理系统再一个类。这个划分基本合理。借出和归还涉及到的核心状态是书的库存量和借阅关系所以Book负责自己的库存增减BorrowRecord负责记录借阅信息Library负责组织整体流程。这种设计背后每个类的职责单了一Book不知道外头谁在借书BorrowRecord不知道书的定价Library把一切串起来。以后想加逾期罚款只需要在BorrowRecord里加字段和方法Library里加流程其他类不用动。6.3 代码落地from dataclasses import dataclass, field from datetime import date, timedelta from typing import Dict, List, Optional dataclass(frozenTrue) class Book: isbn: str title: str author: str dataclass class BorrowRecord: isbn: str borrower: str borrow_date: date due_date: date return_date: Optional[date] None def is_overdue(self, today: Optional[date] None) - bool: today today or date.today() return self.return_date is None and today self.due_date class Library: def __init__(self): self._books: Dict[str, Book] {} self._stocks: Dict[str, int] {} self._records: List[BorrowRecord] [] def add_book(self, book: Book, quantity: int 1) - None: if book.isbn not in self._books: self._books[book.isbn] book self._stocks[book.isbn] quantity else: self._stocks[book.isbn] quantity def find_book(self, isbn: str) - Optional[Book]: return self._books.get(isbn) def borrow(self, isbn: str, borrower: str, days: int 14) - None: stock self._stocks.get(isbn, 0) if stock 0: raise ValueError(f图书 {isbn} 无可借库存) self._stocks[isbn] - 1 self._records.append(BorrowRecord( isbnisbn, borrowerborrower, borrow_datedate.today(), due_datedate.today() timedelta(daysdays), )) def return_book(self, isbn: str, borrower: str) - None: for record in self._records: if (record.isbn isbn and record.borrower borrower and record.return_date is None): record.return_date date.today() self._stocks[isbn] 1 return raise ValueError(f未找到 {borrower} 借阅 {isbn} 的记录) def overdue_records(self) - List[BorrowRecord]: return [r for r in self._records if r.is_overdue()]这里有几个建模细节值得说。Book用frozenTrueISBN、书名、作者是写死的杜绝修改很契合图书元数据的语义。BorrowRecord用普通dataclass因为return_date可变。库存单独用一个字典_stocks映射ISBN到数量而不是把quantity塞进Book里——因为图书信息是不可变的元数据库存是可变的业务状态两者混在一起会在传引用时出bug。使用系统的代码长这样lib Library() lib.add_book(Book(978-7-100-12345-6, Python编程, 张三), 3) lib.borrow(978-7-100-12345-6, 李四) lib.return_book(978-7-100-12345-6, 李四) print(lib.overdue_records())6.4 这个案例的扩展方向这套模型后续演进的空间很清晰。想接数据库只需要把Library类里的字典替换成持久化存储方法签名保持不变调用方无感。想增加逾期罚款给BorrowRecord加fine方法和compute_fine逻辑。想支持多副本独立编号可以再引入一个Copy实体。这种变更局部化正是OOP建模的回报——需求变的时候你大概知道应该改哪个类而不是在半个项目里全局搜索。7. 面向对象实践中的常见陷阱与救火清单7.1 可变默认参数所有人都会踩的洞在类方法的__init__里写def __init__(self, items[])是经典的Python陷阱。默认参数只会在函数定义时被创建一次所有实例共享同一个列表对象一个实例加东西其他实例全都看得到。class ShoppingCart: def __init__(self, items[]): # 危险 self.items items cart1 ShoppingCart() cart1.items.append(苹果) cart2 ShoppingCart() print(cart2.items) # 输出 [苹果]正确的办法是用None做默认值或者在dataclass里用field(default_factorylist)class ShoppingCart: def __init__(self, itemsNone): self.items items if items is not None else []这个坑的底层原因是Python函数的默认值在定义时求值而不是调用时。理解了这一点以后凡是默认值是可变对象list、dict、set你都会本能地想它是不是共享的7.2 继承层级过深维护成本递增的元凶我见过一个项目一个客户订单类有六级继承链最底层的子类想改一个行为得翻上面五个类的代码才能确定影响范围。这是典型的为了抽象而抽象。继承层级每多一层这个类的真实行为到底是什么就越难回答因为它在六个类各拿了一点。我给自己定的规则是继承深度尽量不超过3层。超过3层先反思是否可以用组合替代。比如VIP客户订单到底是客户订单的一种还是订单加了一个VIP客户的关联很多时候后者更符合真实世界。组合的方式是让类持有另一个类的实例需要委托时手动转发方法代码看着多一点但每个类的语义都清楚不少。7.3 滥用getter/setter把Python写成了Java我曾经在代码评审里看到一位同事把类的每个属性都配了property加上setter二十个属性四十个方法没有一个是纯逻辑的。这种防君子不防小人的假装封装增加了大量样板代码还降低阅读速度。Python社区默认的调性是直来直去——需要时再加控制。先写普通属性等校验逻辑真来了再上property调用方不需要改一行。7.4 循环引用类与类相互import的设计信号两个类互相依赖AimportBBimportA运行时报ImportError。这个技术问题背后往往藏着设计问题——两个类的职责边界没划清楚。我遇到这种情况第一步不是用TYPE_CHECKING或者延迟import去糊弄而是思考能不能把相互依赖的那部分逻辑抽到第三个类/模块里循环import是耦合过重的敏感指标疏通它的正确动作是解耦而不是打补丁。7.5 类属性还是实例属性先问自己它跟实例有关吗最后再强调一遍最常被忽略的问题。写类的时候先问自己这个属性是所有实例共用的还是每个实例各自不同题目、价格、库存显然跟实例相关放__init__里一个全局的折扣率、一个统计实例数量的计数器才适合做类属性。放错位置的结果通常不会立刻报错但会在某个深夜调试时给你惊喜。我个人在实际项目里踩过的坑几乎都集中在对象之间共享状态和继承层级蔓延这两类。处理手段其实都是同一个方法论明确每个类的职责边界把共享的东西显式地放在共享的位置把可变的东西限制在最小的作用域里。面向对象编程写久了你对代码的关心点从怎么让函数跑通逐渐变成了怎么让改动落地时不波及其他模块这种设计意识的进步比多背几个语法点有用得多。
返回列表