ARTICLE DETAIL

资讯详情

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

Python面向对象实战:从函数到类的演进、核心概念与封装继承多态应用

Python面向对象实战:从函数到类的演进、核心概念与封装继承多态应用 1. 为什么从函数式写法转向类——一个用户管理脚本的演进过程1.1 第一版函数加全局变量代码量少但隐患多先看一段我早期写过的Python脚本。当时想做一个简单的用户管理小工具需求也不复杂能新增用户、删除用户、打印所有用户列表。我用了最直观的写法全部堆在函数里users [] def add_user(name, age): users.append({name: name, age: age}) def delete_user(name): for i, user in enumerate(users): if user[name] name: del users[i] return True return False def print_users(): for user in users: print(f姓名: {user[name]}, 年龄: {user[age]}) add_user(张三, 28) add_user(李四, 31) print_users()这段代码能跑而且在脚本只有几十行的时候非常好用。问题出在什么地方一旦项目规模涨起来比如加一个订单模块、再一个商品模块你就会发现全局变量越来越难管。所有函数都能直接操作users这个列表你根本没法保证某个模块不会意外修改它。另一个隐患是函数之间的参数传递今天add_user需要name和age明天要加一个phone字段所有相关函数全得跟着改参数列表。我当时遇到的实际场景是一个自动化脚本写到大概三百行函数有十几个全局变量有七八个。某次运行中突然发现users列表里混进了一条脏数据排查了半天发现是某个工具函数在循环里误调用了users.append。那一刻我意识到纯函数式写法在当前这个阶段已经到头了我需要把“数据”和“操作数据的方法”绑在一起——这就是Python面向对象最核心的动机也是我当时翻开“python编程语法基础笔记”里面向对象章节时最迫切的需求。1.2 第二版用类把“数据”和“操作数据的方法”绑在一起同样是用户管理用类重写之后长这样class UserManager: def __init__(self): self.users [] def add_user(self, name, age): self.users.append({name: name, age: age}) def delete_user(self, name): for i, user in enumerate(self.users): if user[name] name: del self.users[i] return True return False def print_users(self): for user in self.users: print(f姓名: {user[name]}, 年龄: {user[age]}) manager UserManager() manager.add_user(张三, 28) manager.add_user(李四, 31) manager.print_users()对比一下逻辑几乎一模一样但结构发生了本质变化users不再是全局变量而是通过__init__挂到实例上的self.users所有操作users的函数变成了类的方法天然和这份数据绑定在一起如果想创建两套互不干扰的用户列表直接manager1 UserManager()、manager2 UserManager()就行这个切换给我最直观的感受是代码的“内聚性”提高了。数据的定义、操作数据的入口、数据的边界都在同一个地方找问题和改逻辑的时候不用满文件乱翻。1.3 什么时候该写类、什么时候继续用函数面向对象确实好但很多人容易走向另一个极端什么东西都塞进类里反而把简单的事情搞复杂了。我总结了一个很实用的判断标准情况建议只有一组独立的计算逻辑比如把字符串转成日期、计算两个数之和用函数需要长期保存一组状态并且这些状态会被多个函数反复读取或修改用类同一套逻辑需要生成多个互不影响的独立实体比如多个用户、多个订单用类只是临时把几个值打包传参不如直接用dataclass或字典看复杂度决定Python面向对象的精髓不在于“必须用类”而在于“用类把复杂状态管理起来”。一个脚本里函数主导、遇到复杂实体再用类这种混合写法在实际项目中非常常见也完全合理。2. 类和对象的最小可用框架init、self 和实例化过程2.1init不是构造函数而是“初始化方法”初学者最容易产生的一个误解是__init__是构造函数对象是在它里面创建的。这个理解在Python里并不准确因为Python对象创建的真实流程是两步class Person: def __new__(cls, *args, **kwargs): # 第一步先创建一个裸对象一般不手动覆盖 instance super().__new__(cls) return instance def __init__(self, name): # 第二步给这个裸对象塞属性完成初始化 self.name name实际上当你在代码里写p Person(张三)的时候Python解释器先调用__new__创建一个空壳对象然后把张三传进__init__去填充属性。日常开发中你基本不需要重写__new__但理解这个流程非常有帮助尤其是后面看源码时看到单例模式、元类这些概念你会意识到事情的源头都在__new__那里。我在学习“Python面向对象”时踩过的第一个坑是在__init__外定义可变默认值# 错误示例 class Order: items [] def add_item(self, item): self.items.append(item) order1 Order() order2 Order() order1.add_item(苹果) print(order2.items) # [苹果]被污染了这个 bug 极其隐蔽。类属性items是定义在类这块模板上的所有实例共享同一个列表。正确做法是把可变对象放进__init__里作为实例属性# 正确示例 class Order: def __init__(self): self.items [] def add_item(self, item): self.items.append(item) order1 Order() order2 Order() order1.add_item(苹果) print(order2.items) # []2.2 self 到底是谁它只是“当前实例”的引用self是Python面向对象里一个绕不过去的概念。很多人初学时觉得这个参数很神秘甚至有人误以为它是关键字。实际上它就是一个普通参数名Python在调用manager.add_user(张三, 28)时会自动把manager这个实例本身作为第一个参数传进去等价于UserManager.add_user(manager, 张三, 28)。为了理解self我找了一个生活化的类比假设有一个模板叫“学生信息表”每个学生拿到一张空表开始填写。__init__相当于“你拿到表之后把自己的名字、班级填上去”self就是“你手里正拿着的那张表”。不同学生手里的表互不干扰这就是实例隔离的本质。Python 只是约定了第一个参数叫self换成一个别的名字也能运行class Demo: def __init__(this, value): this.value value d Demo(10) print(d.value) # 10不报错但我强烈建议永远不要这么干。整个Python社区的代码规范、第三方库、框架全都默认使用self改名除了给人添堵没有任何价值。2.3 实例属性与类属性的区别用代码演示一下两者的区别你就全明白了class Employee: company 某某科技 # 类属性所有实例共享 def __init__(self, name): self.name name # 实例属性每个实例独立 e1 Employee(张三) e2 Employee(李四) print(e1.company, e2.company) # 某某科技 某某科技 e1.company 改了个名 # 注意这里是给e1新建了一个实例属性 print(e1.company) # 改了个名 print(e2.company) # 某某科技其他实例不受影响注意最后一步写e1.company 改了个名并没有改写类属性而是给e1这个实例新增了一个名为company的属性从此e1.company读取时优先命中实例属性类属性被遮挡了。这个知识点看起来基础但在实际项目中特别容易引发诡异 bug。我曾经参与过一个数据采集项目多个采集器实例同时运行其中一个实例因为某种原因给一个类属性赋了新值结果所有实例都跟着变了。排查了一下午最后发现是在某段代码里误用了ClassName.attr xxx的方式赋值。血的教训。3. 封装、继承、多态不是考纲术语而是代码组织的三板斧3.1 封装把不该暴露的细节挡住用 property 做可控的属性访问很多教材把封装讲得很玄乎什么“数据隐藏”“信息隐蔽”。用大白话讲封装的目的是外部代码不需要知道内部细节只需要通过固定入口操作数据这样你以后改内部实现的时候不会波及外面的调用方。Python里没有真正意义上的“私有变量”双下划线开头的属性其实是“名字重整”name mangling它只是在类内部把__secret改写成了_ClassName__secret外部依然能用改写后的名字访问。因此Python社区更常用的约定是单下划线开头比如_name表示“这是我们内部的变量外部请自觉别动”。property是封装里一个极其好用的工具。直接看代码class User: def __init__(self, name, age): self.name name self._age age property def age(self): 外部读取age时走这里 return self._age age.setter def age(self, value): if value 0 or value 150: raise ValueError(年龄必须在0到150之间) self._age value u User(张三, 30) u.age 28 print(u.age) # 28 # u.age 999 # 直接抛 ValueError这个写法的价值在于你可以在“读属性”和“写属性”的入口处统一做校验、转换、日志等逻辑但外部调用方依然是用u.age和u.age 28这种最自然的方式读写不用改成u.get_age()、u.set_age(28)。等你哪天想从“直接存年龄”改成“存出生日期再计算年龄”外部代码一行都不用动只要改property内部实现即可。3.2 继承抽取公共逻辑把变的部分留给子类继承是面向对象里复用代码最直接的手段。遇到多个类有大量共同逻辑时可以抽出基类子类只写差异部分class Animal: def __init__(self, name): self.name name def eat(self): print(f{self.name} 正在吃东西) def sound(self): raise NotImplementedError(子类必须实现 sound 方法) class Dog(Animal): def sound(self): print(汪汪汪) class Cat(Animal): def sound(self): print(喵喵喵) dog Dog(旺财) cat Cat(咪咪) dog.eat() # 继承自Animal cat.eat() # 继承自Animal dog.sound() # 汪汪汪 cat.sound() # 喵喵喵这里raise NotImplementedError是个很实用的技巧基类先把“子类必须实现的接口”占好位万一有人新建子类时忘了实现sound调用时就会立刻报错而不是等到后面出了奇怪数据才发觉。继承中还有一个高频操作是调用父类方法用super()class Base: def __init__(self, name): self.name name print(Base.__init__) class Child(Base): def __init__(self, name, age): super().__init__(name) # 先初始化父类属性 self.age age print(Child.__init__)super()的核心作用是按照方法解析顺序MRO找到下一个该调用的类它不只是简单调用“父类”在多继承时它会沿着一个线性序列依次查找。这一点后续看框架源码时特别重要但刚入门阶段你只需要记住子类重写了__init__之后如果还需要父类定义的属性记得调用一次super().__init__(...)这是新手最容易漏掉的。3.3 多态同一个接口不同的实现Python里鸭子类型天然支持多态用一句话概括同样的调用代码传入不同对象表现出不同行为。Java、C#中多态需要靠接口或抽象类来约束而Python因为“鸭子类型”的存在天然支持多态写起来更自由。“鸭子类型”来自一句话如果一只鸟走路像鸭子、游泳像鸭子、叫声像鸭子那它就是鸭子。放到代码里就是一个对象能不能作为文件用不取决于它是不是File类的实例而取决于它有没有read、write方法class FileReader: def __init__(self, path): self.path path def read(self): with open(self.path, r, encodingutf-8) as f: return f.read() class StringBuilder: def __init__(self, content): self.content content def read(self): return self.content.upper() def process(data_source): print(data_source.read())这里process函数根本不在乎传进来的是文件还是字符串构建器只要有read方法就能跑。这个特性让Python代码组织起来非常灵活。虽然Python不需要强制接口但如果你希望约束子类必须实现某些方法可以使用abc模块的abstractmethod团队协作时这个约束挺有用from abc import ABC, abstractmethod class Shape(ABC): abstractmethod def area(self): pass # class Circle(Shape): ... # 不实现area的话实例化就报错4. 魔术方法决定一个类的“高级感”str、repr、eq、lt这类一定要理解4.1str和repr的分工给用户看 vs 给开发者看面向对象学了一段时间之后你会发现代码里满是print(对象)打出来的是一串看不懂的__main__.Order object at 0x7f8a1c022e80。这时候就该实现__str__或__repr__了。两者分工不同__repr__给开发者看目标是“不丢信息”最好能直接重建这个对象__str__给终端用户看目标是“可读友好”由print()和str()调用class Order: def __init__(self, order_id, amount): self.order_id order_id self.amount amount def __repr__(self): return fOrder(order_id{self.order_id!r}, amount{self.amount!r}) def __str__(self): return f订单号:{self.order_id}, 金额:{self.amount}元 order Order(A001, 99.5) print(repr(order)) # Order(order_idA001, amount99.5) print(order) # 订单号:A001, 金额:99.5元最佳实践是优先实现__repr__因为Python的print在找不到__str__时会退而求其次使用__repr__。至于!r这个格式化符号它的意思是调用repr()来格式化这样字符串值会带上引号调试时一眼就能看出类型。4.2eq和hash的关系改了 equals 就得改 hash 的坑默认情况下两个对象比较的是“内存地址”即a b等价于a is b。但业务上我们往往希望“属性相同就算同一个对象”这时候需要重写__eq__class User: def __init__(self, uid, name): self.uid uid self.name name def __eq__(self, other): if not isinstance(other, User): return NotImplemented return self.uid other.uid def __hash__(self): return hash(self.uid) u1 User(1, 张三) u2 User(1, 李四改名了) print(u1 u2) # True因为它俩uid相同最容易被忽略的是__hash__。Python有条规定一个类如果重写了__eq__它默认的__hash__会被置为None对象会变成不可哈希的放进set或作为字典的 key 时会直接报TypeError: unhashable type: User。解决方式就是像上面一样手动实现__hash__且必须保证“相等对象的哈希值一定相等”通常基于__eq__里用到的那几个字段来计算就行。这个坑我踩过一次项目里用自定义对象做去重hash 没实现死活跑不起来查半天才明白是__eq__把__hash__弄丢了。4.3 运算符重载和上下文管理Python 允许类重载运算符让对象支持、、等原生操作。最常用的场景有两个。一是排序。给一个类实现__lt__小于sorted()就能直接对对象列表排序class Score: def __init__(self, name, value): self.name name self.value value def __lt__(self, other): return self.value other.value def __repr__(self): return fScore({self.name}, {self.value}) scores [Score(张三, 88), Score(李四, 95), Score(王五, 72)] print(sorted(scores)) # [Score(王五, 72), Score(张三, 88), Score(李四, 95)]二是上下文管理。实现__enter__和__exit__之后对象就能配合with语句使用这在资源管理里非常实用比try...finally写起来清爽得多class ManagedFile: def __init__(self, path): self.path path def __enter__(self): self.f open(self.path, w, encodingutf-8) return self.f def __exit__(self, exc_type, exc_val, exc_tb): self.f.close() with ManagedFile(hello.txt) as f: f.write(hello)哪怕with块里抛了异常__exit__也会保证文件被关闭。后面查with的实现原理、以及contextlib.contextmanager装饰器时理解这个接口是基础中的基础。5. 一个连数据库的订单类的完整落地过程5.1 需求描述和数据表设计前面讲的概念都比较零散下面我用一个完整的例子把这些知识点串起来实现一个简单的订单管理类数据存储在 SQLite 里支持创建订单、查询订单、统计总金额。为了控制篇幅表结构设计得尽量简洁字段名类型说明idINTEGER PRIMARY KEY订单ID自增user_idTEXT用户IDamountREAL订单金额statusTEXT订单状态如 pending / paid / cancelledcreated_atTEXT创建时间ISO格式5.2 先写数据库操作层面向对象并不排斥过程化代码。数据库连接这种低频操作用普通函数管理反而简洁import sqlite3 from datetime import datetime DB_PATH orders.db def get_connection(): conn sqlite3.connect(DB_PATH) return conn def init_db(): conn get_connection() try: conn.execute( CREATE TABLE IF NOT EXISTS orders ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, amount REAL NOT NULL, status TEXT NOT NULL DEFAULT pending, created_at TEXT NOT NULL ) ) conn.commit() finally: conn.close()这里每次操作都新建连接、用完就关闭对于轻量项目完全够了。有人会纠结要不要用连接池我的建议是SQLite 这种嵌入式数据库根本不需要连接本身开销很低如果是 MySQL、PostgreSQL再用连接池不迟。5.3 Order 类的具体实现核心的订单类我这样设计class Order: def __init__(self, user_id, amount, statuspending, order_idNone, created_atNone): self.order_id order_id self.user_id user_id self.amount amount self.status status self.created_at created_at or datetime.now().isoformat(timespecseconds) classmethod def create(cls, user_id, amount): 业务方法表示用户下了一笔新订单 return cls(user_iduser_id, amountamount, statuspending) def save(self): 把自身写入数据库如果已有ID则更新 conn get_connection() try: if self.order_id is None: cursor conn.execute( INSERT INTO orders (user_id, amount, status, created_at) VALUES (?, ?, ?, ?), (self.user_id, self.amount, self.status, self.created_at) ) conn.commit() self.order_id cursor.lastrowid else: conn.execute( UPDATE orders SET status ? WHERE id ?, (self.status, self.order_id) ) conn.commit() finally: conn.close() return self classmethod def get_by_id(cls, order_id): 类方法从数据库加载一个订单 conn get_connection() try: row conn.execute( SELECT id, user_id, amount, status, created_at FROM orders WHERE id ?, (order_id,) ).fetchone() finally: conn.close() if row is None: return None return cls( order_idrow[0], user_idrow[1], amountrow[2], statusrow[3], created_atrow[4] ) staticmethod def total_amount_by_user(user_id): 静态方法统计某用户所有已支付订单的总金额 conn get_connection() try: row conn.execute( SELECT COALESCE(SUM(amount), 0) FROM orders WHERE user_id ? AND status paid, (user_id,) ).fetchone() finally: conn.close() return row[0] def pay(self): if self.status ! pending: raise ValueError(只有待支付订单才能支付) self.status paid self.save() def __repr__(self): return fOrder(order_id{self.order_id!r}, user_id{self.user_id!r}, amount{self.amount!r}, status{self.status!r})这个类涵盖了很多面向对象知识点我逐个拆一下__init__的order_idNone, created_atNone默认参数设计是有讲究的create新订单时还没有ID从数据库加载时再填入ID同一个__init__兼顾两种场景classmethod的create方法是业务入口它表达的是“用户下了一笔订单”这个语义比直接调用构造器更贴近业务classmethod的get_by_id是“从数据库加载”的入口它负责查询并转成Order对象staticmethod的total_amount_by_user不依赖具体某个订单实例它是对订单这个集合的统计查询pay方法展示了业务逻辑和状态流转数据落库的操作收敛到save里__repr__方便调试打印订单对象时能看到完整信息5.4 完整跑一遍增删改查把上面代码保存为order_demo.py然后写一段调用脚本验证if __name__ __main__: init_db() # 创建订单并保存 order1 Order.create(U001, 199.0) order1.save() print(order1) order2 Order.create(U001, 88.5) order2.save() order2.pay() # 支付订单2 print(order2) # 从数据库加载 loaded Order.get_by_id(order1.order_id) print(loaded) # 统计用户U001已支付总金额 total Order.total_amount_by_user(U001) print(fU001已支付总金额: {total})运行之后你会看到控制台打印出订单的ID、状态变化以及统计结果。这一条链路完整串起了__init__、实例方法、类方法、静态方法、异常抛出、继承自object的魔术方法这几大块内容读一遍代码、亲手跑一遍我对“Python面向对象”的理解立刻从抽象概念变成了可操作的工具。6. 面向对象里最容易翻车的几个地方6.1 可变对象作为默认参数函数定义时就执行一次这是Python最经典的坑。前面提到过Order.items []的类属性问题其实普通函数也有同样的问题def append_item(item, items[]): items.append(item) return items print(append_item(苹果)) # [苹果] print(append_item(香蕉)) # [苹果, 香蕉]注意第二次调用时列表没清空问题根源在于默认参数的值在函数定义时就被计算并保存了以后每次调用都复用同一个列表对象。修正方式是用None作为默认值在函数体内创建新列表def append_item(item, itemsNone): if items is None: items [] items.append(item) return items在类和对象的场景下这个问题同样存在于__init__的参数上比如def __init__(self, tags[])一定要养成可变默认参数用None的习惯。6.2 继承中的init没有被调用子类定义了__init__但没有调用父类的__init__导致父类属性丢失这是新手写继承时最常遇到的错误。看这段典型报错class Base: def __init__(self, name): self.name name class Child(Base): def __init__(self, name, age): self.age age # 忘了调用 super().__init__(name) c Child(张三, 28) print(c.name) # AttributeError: Child object has no attribute name排查思路很简单如果子类报AttributeError: xxx object has no attribute yyy先检查子类__init__里有没有调用super().__init__(...)特别是当父类__init__里有属性赋值语句时八成是这一条漏了。6.3 循环引用与对象销毁写了类之后对象之间经常互相持有引用。比如订单里有用户对象用户对象又引用一个订单列表这种结构很容易形成循环引用。Python的垃圾回收器是能处理循环引用的但如果对象定义了__del__方法循环引用时__del__的行为会变得不可预测。一个比较实用的替代方案是使用weakref模块的弱引用。弱引用不会增加对象的引用计数适合用在“A 知道 B但不想影响 B 的销毁”的场景import weakref class User: def __init__(self, name): self.name name class Order: def __init__(self, user): # 这里不直接持有强引用而是持有弱引用 self.user_ref weakref.ref(user) u User(张三) order Order(u) del u # 用户对象此时可以被销毁当然日常小项目里循环引用其实很少成为瓶颈Python的gc模块默认是开启的能自动回收大部分循环引用。只有当你用__del__做资源清理且对象数量很大时才值得认真思考弱引用方案。我在爬虫项目里对每个请求对象做过类似处理效果还可以但绝大多数情况不用这么早优化。6.4 为面向对象而面向对象的过度设计写了几年代码之后我反而对“少用类”更宽容了。有些代码写类纯粹是图“像Java一样规范”结果把状态满天飞、耦合度不降反升。我自己现在的判断标准是类至少要有两个以上方法且这些方法共享状态否则写个dataclass或者干脆用字典就够了。比如只是打包几个字段最省事的是dataclassesfrom dataclasses import dataclass dataclass class Item: name: str price: float quantity: int 1 item Item(苹果, 5.5, 3) print(item) # Item(name苹果, price5.5, quantity3) print(item.price) # 5.5它自动帮你生成了__init__、__repr__、__eq__比手写一堆魔术方法省事得多。我一直觉得写类不是目的管理复杂度才是目的。最后再分享一个我的个人习惯我现在写类之前会先写两三行“用这个类”的调用代码从调用方的视角倒推需要哪些方法、哪些属性。比如要做一个Order我会先写下order Order.create(U001, 199)、order.pay()、Order.get_by_id(1)这样的代码片段感觉调用起来顺手了再去补类内部实现。这个习惯帮我少写了很多“看起来很面向对象、实际用起来别扭”的代码。如果你正在翻“python编程语法基础笔记”里的面向对象章节我的建议是不要死记硬背概念直接把UserManager的例子自己在本地跑一遍再模仿着写一个订单类、一个商品类。等你自然地写出第一个类文件时面向对象的门就算真正迈进来了。
返回列表