ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Python面向对象编程入门:从类与对象到继承多态和异常处理

Python面向对象编程入门:从类与对象到继承多态和异常处理

简介:《Python Object-Oriented Programming(Fourth Edition)》是Packt于2021年出版、由Steven F. Lott与Dusty Phillips合著的英文PDF电子书,面向希望系统掌握Python面向对象编程并构建健壮、可维护应用与库的初中级开发者。内容围绕封装、继承、多态等核心机制展开,结合类与实例、属性与方法等Python实现方式,延伸到模块化设计、异常处理、单元测试、文档编写以及版本控制、依赖管理和持续集成等工程实践。资源包仅含1个PDF文件,大小约6.51MB,便于离线阅读与检索;目前已有43人学习。读者可借此梳理OOP知识框架,理解如何把设计原则落到可维护的Python代码与库维护流程中,适合作为案头参考和进阶阅读材料。

1. 先别被“面向对象”四个字唬住:它其实是给代码分工的一种办法

刚学 Python 的人大多经历过这个阶段:几十行脚本跑得好好的,一旦写到两三百行,改一个变量名就有三处跟着报错,函数参数越加越长,最后连自己都记不清哪个参数是干什么的。Python 面向对象编程要解决的就是这件事——把数据和对数据的操作捆成一个个“对象”,每个对象只负责自己那部分,出了问题也容易定位。它不是给熟手准备的玄学,恰恰是写给被一团乱麻的脚本折磨过的新手。这门技术适合的读者很明确:能用 Python 写出循环、函数、列表和字典,但一写大一点的程序就组织不起来的人。从写第一个类开始,把零散的代码变成能维护的小项目,就是这本书要走完的路。

2. 搞懂对象和类:第一个例子从“员工工牌”开始写起

2.1 为什么光会写函数,早晚要碰类

先用函数式写法实现一个小需求:根据名字和工号打印工牌。很多人会写成两个列表加一个函数:

names = ["小明", "小红"] ids = [1001, 1002] def make_badge(names, ids, index): # 通过下标同时取两个列表里同一位置的数据 return f"{ids[index]} - {names[index]}"

这样写当然能跑,但问题会随着需求增长慢慢浮现。第一,两个列表必须严格同步:删掉一个人时忘了删另一个列表里的工号,所有人都会错位。第二,一旦要加第三个字段,比如部门,就得改函数的参数列表,所有调用处都要跟着改。第三,这段代码没有告诉别人“名字和工号是一组数据”,读代码的人只能靠猜。

我第一次写这种代码时,在一个员工管理脚本里堆了五个平行列表,跑了一周才发现某次删除操作只删了其中三个列表,数据对不上的也不报错。这就是典型的“数据和操作分家”带来的问题。类的作用,就是把一组相关的数据和对它们的操作放进同一个容器里,用一个变量就能带走全部。

2.2 第一个类:十几行代码就能跑起来

下面这个类只做一件事:保存一个人的名字和工号,并能打印一张工牌。

class Employee: """员工工牌:只保存名字和工号,并提供打印格式。""" def __init__(self, name: str, emp_id: int) -> None: # self 指向“正在被创建的这个对象”本身 self.name = name # 每个实例各自保存一份名字 self.emp_id = emp_id # 每个实例各自保存一份工号 def badge(self) -> str: # 把对象自己的两条数据拼成一行文字返回 return f"{self.emp_id} - {self.name}"

逐行拆开看。class Employee:是定义一个类,类名习惯上用大驼峰。__init__是初始化方法,创建对象时自动执行,用来把初始数据装进去;它的第一个参数self是 Python 自动传入的,代表当前这个对象,新手不用手动传。self.name = name的意思是“把外面传进来的 name 存到这个对象自己身上”。badge是一个方法,本质上就是绑定在类里的函数,它读的是self.name和self.emp_id,所以每个对象打印出来的都是自己的信息。

接着创建两个对象试试:

a = Employee("小明", 1001) # 创建一个对象,__init__ 自动被调用 b = Employee("小红", 1002) # 再创建一个,两者互不影响 print(a.badge()) # 输出:1001 - 小明 print(b.badge()) # 输出:1002 - 小红 a.name = "小明同学" # 改 a 的名字 print(b.name) # 仍然是“小红”,b 不受影响

参数说明:name是字符串,emp_id是整数,badge()不接受额外参数,返回一个字符串。逻辑上,Employee是“模板”,a和b是按模板造出来的两个独立实体,这种实体在术语里叫“实例”。模板只有一份,实例可以有很多份,各自的数据互不干扰——这正是平行列表做不到的事。

要注意一个细节:类里定义的方法,第一个参数必须写self,调用时却不用传,a.badge()和Employee.badge(a)是等价的。新手最常见的报错TypeError: badge() takes 0 positional arguments but 1 was given,基本就是忘了写self。

2.3 类型提示与文档字符串:给三个月后的自己留张字条

上面的代码里,name: str、emp_id: int、-> str这些写法叫类型提示。它们不会在运行时强制检查,写错了也不会当场报错,但好处很实在:编辑器会提前标红明显的错误,同事和你自己半年后回来看,一眼就知道每个参数该传什么。这本书从第二章就开始大量使用类型提示,原因也在这里——面向对象代码里参数多、层次深,没有标注会非常难读。

类型提示可以和默认值配合,给参数留一个“可以不传”的余地:

from typing import Optional class Employee: def __init__(self, name: str, emp_id: int, dept: Optional[str] = None) -> None: self.name = name self.emp_id = emp_id self.dept = dept # 不传部门时就是 None def badge(self) -> str: if self.dept is None: return f"{self.emp_id} - {self.name}" return f"{self.emp_id} - {self.name} [{self.dept}]"

Optional[str]表示“要么是字符串,要么是 None”,= None是默认值。这里有一个需要特别注意的问题:默认值不要用列表、字典这类可变对象,否则所有实例会共享同一个对象,前面有人往里加数据,后面创建的对象就会莫名其妙多出内容。需要可变默认值时,写dept: Optional[list] = None,再在方法里判断并新建。

除了类型提示,"""员工工牌:……"""这种三引号字符串叫文档字符串,写在类或函数的第一行。用help(Employee)就能把它打印出来。它不影响运行,但能让别人不读源码也知道这个类干什么。入门阶段建议每个类都写一句,哪怕只有十几个字。

2.4 模块和包:别把所有类都塞进一个文件

当类多起来之后,全部塞进一个.py文件会变得很难找。Python 的解决办法很朴素:一个.py文件就是一个模块,一个带__init__.py的文件夹就是一个包。按职责拆开,找起来就快。

project/ ├── models/ │ ├── __init__.py │ └── employee.py └── main.py
# models/employee.py class Employee: def __init__(self, name: str, emp_id: int) -> None: self.name = name self.emp_id = emp_id
# models/__init__.py # 把这个包对外的入口集中在这里,外面只需 from models import Employee from .employee import Employee
# main.py from models import Employee # 绝对导入:从项目根目录开始写全路径 e = Employee("小明", 1001) print(e.badge() if hasattr(e, "badge") else e.name)

参数与逻辑说明:models是包名,employee是模块名,from .employee import Employee里的点表示“当前这个包内部”,这叫相对导入,适合包内部互相引用;from models import Employee是绝对导入,从项目顶层开始写,可读性更好。入门阶段建议优先用绝对导入,因为相对导入在脚本直接被运行时容易报ImportError: attempted relative import with no known parent package,这是一个很多人第一次都会掉进去的坑。

拆模块的判断标准不需要太复杂:一个文件超过三四百行、或者明显能分成“数据定义”和“流程控制”两类内容时,就该拆了。拆的时候注意避免循环导入——A 模块导入 B,B 又导入 A,Python 会直接报错。真遇到了,通常说明两个类的职责纠缠在一起,需要重新划分,而不是靠改导入语句硬绕。

3. 继承与多态:为什么“复制一份改改”迟早会翻车

3.1 继承回答的是“是不是一种”的问题

写代码写久了,总会遇到两个类有大量重复字段的情况。比如员工和经理,都有名字和工号,经理还多一个“带队人数”。最直接的做法是把Employee整个复制一份,改个名字加个字段。短期能跑,长期是灾难:以后改工牌格式,两处都得改,漏一处就出现两种不一致的工牌。

继承的思路是:把公共部分放在父类,子类声明“我是一种父类”,自动获得父类的字段和方法,只写自己独有的部分。

class Employee: def __init__(self, name: str, emp_id: int) -> None: self.name = name self.emp_id = emp_id def badge(self) -> str: return f"{self.emp_id} - {self.name}" class Manager(Employee): # 括号里写父类名,表示继承 def __init__(self, name: str, emp_id: int, team_size: int) -> None: super().__init__(name, emp_id) # 先让父类把公共字段装好 self.team_size = team_size # 再补子类独有的字段 def badge(self) -> str: base = super().badge() # 复用父类的拼接逻辑 return f"{base} (带队 {self.team_size} 人)"

参数说明:Manager的__init__多了team_size,前两个参数原样交给super().__init__。super()的意思是“找到父类,调用它的同名方法”,这样父类以后改了字段初始化逻辑,子类会自动跟上。badge里的super().badge()同理,先拿父类结果再加工,避免把格式字符串抄两遍。

判断该不该用继承,有个很实用的自问句:“子类是不是父类的一种?”经理是员工的一种,成立;汽车有四个轮子,轮胎不是汽车的一种,那就该用组合——把轮胎作为汽车的一个属性,而不是让汽车继承轮胎。这个判断标准简单粗暴,但能挡掉大部分误用。

3.2 多态:一个函数吃下所有子类

继承带来的最大好处叫多态。看下面这个函数:

def print_badges(people: list[Employee]) -> None: for person in people: print(person.badge()) # 不关心具体是员工还是经理 print_badges([ Employee("小明", 1001), Manager("小红", 1002, 5), # 经理也能直接传进来 ])

输出会分别是普通格式和带“带队 5 人”的格式。关键在于print_badges里只写了person.badge(),它不需要知道对方到底是哪个类,只要对象有这个方法就调用。以后再加一个“外包人员”类,只要也继承Employee并实现badge,这个函数一行都不用改。

这就是多态的实际价值:新增类型时,改动集中在新类本身,而不是散落在所有调用处。反过来,如果一开始用if 类型 == "经理"这种判断来区分,每加一种类型就要去翻遍所有 if,漏掉一处就是线上问题。

3.3 多重继承和菱形问题:知道有这回事就行

Python 允许一个类同时继承多个父类,叫多重继承。它能用,但新手阶段建议先别碰。原因是一个经典难题:如果 A 是基类,B 和 C 都继承 A,D 又同时继承 B 和 C,那么 D 调用某个方法时,到底走 B 那条线还是 C 那条线?这个结构画出来像一个菱形,所以叫菱形问题。

Python 用一套叫 MRO(方法解析顺序)的规则来决定,大致是按声明顺序从左到右、并且保证子类先于父类。规则本身是确定的,但人脑跟踪起来很费劲,尤其是继承层次深的时候。

给新手的建议很直接:能用组合解决就别用多重继承。组合的意思是“把另一个对象当作自己的属性”,比如Manager里放一个list存下属,而不是让Manager去继承list。这样职责清晰,也不会踩到 MRO 那些需要反复推理的情况。真需要多继承时,往往是在用“混入”类这种模式,那是后面阶段的事。

4. 异常与数据校验:让程序出错时别整个崩掉

4.1 为什么脚本总是死在“最后一行格式不对”

处理文件的脚本有个共同命运:前面九千行都跑得好好的,最后一行多了个空行或者写了个“无”,整个程序直接抛异常退出,前面算好的结果全丢。这不是运气问题,而是没有为“数据不合法”这种情况做准备。

看一个典型写法:

def parse_scores(path: str) -> list[int]: scores = [] with open(path, encoding="utf-8") as f: for line in f: scores.append(int(line.strip())) # 遇到空行或文字就炸 return scores

int("")会抛ValueError,int("无")也会。程序在这里中断,后面的行一行都没处理。面向对象写法里,异常不是“出事了”,而是一种正常的沟通方式:函数发现自己处理不了,就明确抛出来,让调用方决定怎么办。

4.2 try / except 的最小可用写法

改一下上面的函数,让它遇到坏数据时跳过并记录,而不是整体崩掉:

def parse_scores(path: str) -> list[int]: scores = [] with open(path, encoding="utf-8") as f: for line_no, line in enumerate(f, start=1): # 记下行号方便定位 text = line.strip() if not text: # 空行直接跳过,不算错误 continue try: scores.append(int(text)) except ValueError as e: # 只捕获“转数字失败”这一种 print(f"第 {line_no} 行不是数字,已跳过:{text}({e})") return scores

参数说明:path是文件路径,返回一个整数列表;enumerate(f, start=1)让行号从 1 开始,方便报错时定位。逻辑上,try里只放可能出问题的那一句,except里只处理这一种异常。

有两条需要特别注意的地方。第一,不要写裸的except:,那会把键盘中断、内存不足这些也一起吞掉,程序看起来在跑,其实已经不正常了。第二,except里至少要留点痕迹,打印一行或者记日志,否则数据悄悄少了你都不知道,排查起来毫无线索,像面对一个黑匣子。

4.3 自定义异常:给错误起一个能看懂的名字

内置异常类型有限,业务里的问题往往需要更明确的表达。比如分数必须在 0 到 100 之间,超范围时希望调用方一眼看懂是什么错:

class InvalidScoreError(ValueError): """分数超出合理范围时抛出,继承 ValueError 便于统一捕获。""" def __init__(self, value: int, low: int = 0, high: int = 100) -> None: super().__init__(f"分数 {value} 不在 {low}-{high} 范围内") self.value = value # 把出问题的原始值带上,方便上层处理 def check_score(value: int) -> int: if not 0 <= value <= 100: raise InvalidScoreError(value) # 主动抛出,而不是返回一个特殊值 return value

参数说明:value是待检查的分数,low和high是允许范围,默认 0 到 100。继承ValueError的好处是,调用方既可以精确捕获InvalidScoreError,也可以用except ValueError一把兜住,灵活度高。

使用时:

try: check_score(120) except InvalidScoreError as e: print(f"数据有问题:{e},原始值是 {e.value}")

自定义异常的名字本身就是文档。InvalidScoreError比一个-1返回值清楚得多,也比打印一句“出错了”更容易在上层做分类处理。入门阶段不用建很多,按业务里最常出问题的那两三种情况各建一个就够。

5. 新手学面向对象最容易踩的 5 个坑

5.1 概念上的三个坑

坑一:把所有东西都塞进一个类。

现象:写了一个System类,里面有读文件、算数据、打印报表、发邮件十几个方法,文件超过八百行,改任何一处都怕碰坏别处。

原因:把“一个程序”当成了“一个对象”。类的划分依据是职责,不是“都属于这个项目”。

解决:按“谁负责什么”拆。读文件归一个类,计算归一个类,输出归一个类。判断标准很简单:描述这个类时如果要用“并且”连接两件不相关的事,就该拆了。

坑二:一上来就用多重继承。

现象:刚学会继承,觉得一个类继承三四个父类很高级,结果方法调用走向完全猜不准,改父类时子类行为莫名其妙变了。

原因:多重继承的解析顺序需要专门推理,层次一深就超出人脑跟踪范围。

解决:先用单继承加组合。真要复用多个来源的能力,优先把功能拆成独立对象,用属性持有,而不是用继承纳入。

坑三:属性随便改,没有任何约束。

现象:对象的某个字段被赋了明显不合理的值,比如年龄变成负数、状态变成不认识的字符串,程序跑到很后面才出错,排查时完全不知道是谁改的。

原因:Python 的属性默认完全开放,赋值不做检查。

解决:在赋值入口做校验。简单场景可以在方法里判断后抛异常;需要拦截obj.attr = x这种写法时,用@property加 setter,把校验逻辑集中在属性定义处。这样错误在发生的那一刻就暴露,而不是等到几十行之后。

5.2 写法上的两个坑

坑四:可变对象当默认参数。

现象:def __init__(self, tags: list = []),结果第一个实例往tags里加东西,第二个实例创建出来就已经带着那条数据了。

原因:默认参数在函数定义时只求值一次,所有实例共享同一个列表对象。

解决:默认值写None,在方法里判断if tags is None: tags = [],每次创建都得到一个新列表。这个坑和面向对象无关,但在类里出现频率极高。

坑五:在__init__里做太多事。

现象:一创建对象就去读文件、连数据库、发网络请求,测试的时候没法单独构造一个对象,跑一次要等好几秒,还经常因为环境问题失败。

原因:把“初始化数据”和“执行副作用”混在了一起。

解决:__init__只负责把参数存到属性上,重量级操作放到单独的方法里,由调用方在合适的时机触发。这样对象可以随时轻量创建,测试时也能只测需要的那部分。

6. 下一步学什么:从“会写类”到“能写小项目”的路线

学完前面这些,你已经能定义类、写方法、用继承和多态,也知道异常该怎么抛怎么接。接下来的问题不是“还学什么概念”,而是“按什么顺序练,才不至于学一堆零散知识却拼不出东西”。

我给零基础读者的建议路线是这样,按阶段推进,每个阶段都配一个能跑通的小练习:

阶段目标建议练习
第一周把类写顺用类重写你以前写过的任意一个脚本,比如记账、待办清单
第二周用好内置数据结构用dataclass定义数据类,用字典和列表管理一组对象
第三周会拆模块把练习拆成两三个.py文件,用绝对导入串起来
第四周会处理坏数据给练习加上异常处理和输入校验,故意喂错误数据看反应
第五周会写测试用pytest给核心方法写三五个用例,跑通再改代码
第六周认识常见模式读一读迭代器、策略、观察者这几种模式,各写一个十行小例子

几个具体提醒。第一,dataclass值得早点用,它能把一堆self.x = x自动生成,代码立刻短一截。第二,测试不要等到“项目做大了再补”,从第五周开始,每加一个方法就配一个用例,习惯养成了后面省很多事。第三,设计模式不要背,看懂一个就在自己的练习里套一次,套不上就说明场景不对,换一个再试。

关于阅读顺序,我的建议是:先把类、继承、异常这三块吃透,再去看数据结构和迭代器,最后碰测试和并发。并发那块对新手来说门槛明显更高,线程共享内存、多进程开销、异步的事件循环,任何一个都能让人卡住好几天,等前面基础扎实了再回头啃,效率会高很多。

我自己的血泪经验是:曾经为了赶进度,跳过测试直接写一个大类,结果改一个字段名要手动点开十几个地方确认,最后花的时间比从头写还多。从那以后我每次新建一个类,都强制先把最小的那个方法写出来、跑通、再往上加,绝不一口气写完两百行再运行。慢就是快,这句话在面向对象里特别成立。

这份资源把从类的基础到测试、并发的完整路径都铺好了,适合当作长期翻的手边书,而不是从头到尾读一遍就放下。挑一章对着敲一遍,比读三章更有用。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表