2026/10/16 5:03:31

Python类与对象:从类库到面向对象编程的完整理解

Python类与对象:从类库到面向对象编程的完整理解 我最早接触“类”这个概念是被一套课程视频里的目录卡住的——标题写着“05-1.类库-类 class”。学完函数、列表、字典之后突然冒出一个“class”很多人都迷糊它和函数有什么区别为什么非要用它后来自己写了几年代码又带过不少新人才慢慢发现这一章的编号虽然简单背后承载的却是一整套面向对象的思维方式。这篇内容就是围绕这个“关卡”来写的适合刚学完函数、开始接触面向对象编程的人也适合那些会用类但说不清设计取舍的读者。1. 类和对象为什么代码需要“模具”1.1 从变量到结构体再到类的演进逻辑先看一个非常普通的场景做一个班级通讯录需要记录学生的姓名、学号、电话、邮箱。不学类之前最常见的做法是拆成四个列表names [张三, 李四, 王五] student_ids [20240101, 20240102, 20240103] phones [13800000001, 13800000002, 13800000003] emails [zhangsanexample.com, lisiexample.com, wangwuexample.com]这种写法在上百行代码以内勉强能撑住。一旦要添加一个新学生你得同步往四个列表里各append一次漏掉一个就出问题。要查找某个学生的邮箱得先按姓名找索引再用索引去另一个列表里取一旦中间插入或删除过数据索引对不上bug就来了。后来有人提出“把相关的数据绑在一起”这就是结构体思维。Python里可以用字典模拟student {name: 张三, student_id: 20240101, phone: 13800000001, email: zhangsanexample.com}字典比多个列表好维护但它有个致命问题字典的值没有约束。你写student[name] 123代码不会报错程序里就混进了一个“名字是整数的学生”。而类的作用就是把这些数据和行为封装成一个有模板、有约束的整体就像做月饼的模具——模具长什么样脱模出来的月饼就是什么样。1.2 类和对象的关系是“模具”和“铸件”类class是模板对象object是按照模板实例化的具体结果。一个类可以创建无数个对象每个对象拥有独立的数据但共享同一套行为逻辑。做模具的时候你会关心模具的尺寸、形状。模具本身是“类”用模具压出来的每一个具体月饼是“对象”。月饼和模具长得像但月饼是月饼模具是模具二者不是一回事。很多人学到这里总是混关键就在于没有分清“定义一个模板”和“基于模板造一个实体”这两个动作。在代码里class关键字负责定义模板类名()这种写法负责触发模板造实体实例化class Student: def __init__(self, name, student_id): self.name name self.student_id student_id s1 Student(张三, 20240101) # 造了第一个对象 s2 Student(李四, 20240102) # 造了第二个对象注意s1和s2是两个完全独立的东西。修改s1.name不会影响s2.name。模板里面写的self就是“当前这个具体对象”的代称。你调用s1的某个方法时Python会自动把s1作为第一个参数传给self。2. 从零搭一个类属性、方法与构造过程2.1 定义类的骨架先想数据再想动作很多新手一上来就急着写方法结果类里塞了一堆函数本质上还是过程式思维。正确顺序是先问两个问题这个类要存哪些数据这个类能对外提供哪些行为以学生类为例数据是姓名、学号、联系方式行为是“修改手机号”“查询个人信息”“判断是否属于某年级”。先把数据想清楚再想行为类就自然长出来了。注意行为要跟数据相关不是为了凑方法而写方法。class Student: def __init__(self, name, student_id, phone, email): self.name name self.student_id student_id self.phone phone self.email email def update_phone(self, new_phone): self.phone new_phone def get_summary(self): return f{self.name}{self.student_id}电话{self.phone}邮箱{self.email} def is_in_grade(self, grade_prefix): return self.student_id.startswith(grade_prefix)__init__是这个类第一次被创建时自动执行的方法专门负责初始化数据。它不等于构造函数更准确的说法是“初始化方法”。真正创建对象的是__new__但普通场景不要碰它__init__就够了。2.2 构造过程到底发生了什么当你写下s Student(张三, 20240101, 13800000001, zhangsanexample.com)Python在后台做了两件事首先调用Student.__new__(Student)分配一块内存准备装这个对象然后调用Student.__init__(s, 张三, 20240101, 13800000001, zhangsanexample.com)把参数交给你写的初始化逻辑。self不是关键词是你把s传进去之后第一个参数的名字。只不过Python社区约定俗成叫self你换成this也能跑但千万别换否则读代码的人会疯掉。初始化方法里参数名和属性名撞车的情况很常见。self.name name的意思是“把参数name的值存到当前对象的name属性上”。左右两边的name不是一回事——左边是对象的属性右边是传进来的局部参数。理解这一点后面看继承的super().__init__(...)会更从容。2.3 类里方法之间的相互调用写方法时经常会遇到“这个方法调用另一个方法”。既然所有普通实例方法的第一个参数都是self在方法内部调用同类的方法直接用self.方法名()即可。class Student: def __init__(self, name, student_id, phone, email): self.name name self.student_id student_id self.phone phone self.email email def update_phone(self, new_phone): self.phone new_phone def update_email(self, new_email): self.email new_email def update_contact(self, new_phone, new_email): self.update_phone(new_phone) self.update_email(new_email)这里的update_contact就是方法间的组合。把小的、单一职责的方法拼成更复杂的行为是面向对象编程中最基础的组织方式。不要在一个方法里写几百行先拆小方法再组装大方法。3. 封装的边界公开、私有与属性拦截3.1 为什么不能让外部随便改数据封装简单说就是“控制访问”。比如学生的手机号业务规则要求必须是11位数字。如果外部代码直接student.phone 123规则就绕过了。更好的做法是把数据藏起来只提供“经过校验的修改通道”。Python没有真正意义上的私有变量靠的是名称改写规则在属性名前加双下划线比如self.__phone外部用student.__phone去访问会得到一个AttributeError因为Python在内部把它改成了_Student__phone。这是约定性质的私有不是强制的但对于协作项目来说足够表达“这块数据别乱碰”的意图。class Student: def __init__(self, name, student_id, phone): self.name name self.__phone phone def update_phone(self, new_phone): if len(str(new_phone)) ! 11 or not str(new_phone).isdigit(): raise ValueError(手机号必须为11位数字) self.__phone new_phone3.2 用property做属性拦截有时候不想写方法还是想让调用方像访问普通属性一样直观但又想偷偷加点校验逻辑。这时就用property装饰器。它可以把一个方法包装成“伪装的属性”读的时候像属性写的时候走setter校验。class Student: def __init__(self, phone): self._phone phone property def phone(self): return self._phone phone.setter def phone(self, new_phone): if len(str(new_phone)) ! 11 or not str(new_phone).isdigit(): raise ValueError(手机号必须为11位数字) self._phone new_phone有了这个设计外部代码是student.phone 13800000001看起来就像直接赋值实际上赋值动作被setter拦截并校验了。以后业务规则变了比如要支持手机号末尾带分机号只需要改setter内部逻辑所有调用方的代码都不用动。这就是封装带来的“内部自由外部稳定”。3.3 封装是“信任边界”不是“保险柜”封装的价值不是防黑客而是防止团队协作中不小心绕过规则。别人看到你公开的接口方法、属性就知道能怎么用看到带下划线的变量就默认“你别碰”。这套约定帮你省掉大量沟通成本。提示Python里单下划线_name是“约定私有”双下划线__name是“名改写私有”。对外提供稳定的公开接口内部实现随便改——这条原则比各种花哨的私有机制重要得多。4. 继承与组合代码复用的两种策略4.1 继承解决“是一个”的关系学生类已经有了现在又需要一个研究生类。研究生有导师、研究方向、课题列表但也拥有学生的所有字段和行为。这时可以用继承class GraduateStudent(Student): def __init__(self, name, student_id, phone, advisor): super().__init__(name, student_id, phone) self.advisor advisor self.research_topic None self.courses [] def set_topic(self, topic): self.research_topic topic def add_course(self, course_name): self.courses.append(course_name)super().__init__(...)就是调用父类的初始化方法把父类需要的数据姓名、学号、电话交给它去初始化再扩展自己特有的内容。研究生“是一个”学生这句话成立继承就用得合理。写继承时最容易踩的坑子类的__init__忘了调用super().__init__()结果子类对象创建后父类字段如name全是空的。更隐蔽的坑是父类的__init__签名有变化子类忘记同步导致创建子类时参数不匹配。版本迭代多了父类一改子类集体报错所以继承层数建议控制在两层以内别搞“继承链深不见底”。4.2 组合解决“有一个”的关系研究生“有一个”导师但导师本身也是一个类。与其让研究生继承一个“导师字段”不如直接组合class Advisor: def __init__(self, name, title): self.name name self.title title class GraduateStudent(Student): def __init__(self, name, student_id, phone, advisor_name, advisor_title): super().__init__(name, student_id, phone) self.advisor Advisor(advisor_name, advisor_title)组合的优点是灵活。导师类需要调整不会影响学生类。而继承关系一旦建立父类的改动像暴风雨一样扫过所有子类。所以圈子里的经验之谈是“组合优先于继承”除非你有清晰且稳定的“是一个”逻辑否则优先考虑组合。4.3 多态同一个接口不同的表现继承带来多态。比如不同学历层次的学生计算“是否满足毕业条件”的逻辑不一样class Student: def can_graduate(self): raise NotImplementedError(子类必须实现此方法) class Undergraduate(Student): def can_graduate(self): return self.credits 160 class GraduateStudent(Student): def can_graduate(self): return self.credits 32 and self.paper_published外部的打印成绩单函数只要调用student.can_graduate()不需要关心具体是本科还是研究生。对象自己知道答案。这就是多态父类定义统一接口子类各自实现细节调用方无需分支判断。5. 类库视角把类组织成可维护的“书库”5.1 类库的最小目录结构回到标题里的“类库”两个字。单个类写起来容易把几十个类组织成一个库就是另一回事了。一个可维护的类库至少要遵循“一个模块放一类职责”的原则。一个最小可运行的类库目录通常是my_project/ ├── students/ │ ├── __init__.py │ ├── student.py │ └── graduate_student.py ├── advisors/ │ ├── __init__.py │ └── advisor.py └── utils/ ├── __init__.py ├── validators.py └── printers.py__init__.py里面通常做两件事把该目录对外暴露的类统一导出或者做一些初始化公共变量。这样外部使用时只需要from students.student import Student from advisors.advisor import Advisor如果类库比较大__init__.py还可以进一步提供“门面式”导入把多个模块的类统一收口到一个入口调用方不需要知道内部文件是怎么拆的。这是一种很好的体验设计——库的内部可以随便重构只要入口不变就行。5.2 接口稳定 vs 实现自由设计类库最重要的一条原则接口类名公开方法方法签名要稳定实现方法内部逻辑可以自由改。第一次封装学生类时可能get_summary直接返回字符串后来业务要求返回结构化数据如果一开始外部就依赖返回字符串你改成字典就会炸一片。解决思路是“新老接口并行”老方法保留标记为“即将废弃”新方法另起名字。等所有调用方迁移完再把老方法删掉。这是大库升级常用的套路小项目两个人协作也可以沿用——成本极低收益极高。5.3 版本迭代中的兼容性问题类库一定会经历版本升级。每次升级都问三件事有没有删改现有公开方法有没有改现有方法的参数有没有改现有的返回值的含义如果答案有任何一个“是”就需要升级版本号、写变更说明。一旦发布过旧接口就不能消失至少要先标记废弃再等一到两个版本删除。这个问题在“05-1”这个阶段不会暴露但你要知道类库设计的起点是在单个类这一层就把“公开接口”和“内部实现”的心理边界划清楚。6. 实战踩坑可变默认参数、类变量混淆与继承陷阱6.1 可变默认参数问题写__init__时如果有人图省事写class Student: def __init__(self, name, courses[]): # 错误示范 self.courses courses恭喜你已经埋了一个全类共享的炸弹。默认列表[]只在类定义时创建一次所有不传courses的对象共用同一个列表。A同学加了一门课B同学也被“加了”。这是我的真实踩坑经历之一排查了好久最后一步步跟踪引用才发现是默认参数的问题。注意可变默认参数列表、字典、集合是Python新手最容易踩的坑之一。正确写法是coursesNone在方法内部再判断并赋新值。class Student: def __init__(self, name, coursesNone): self.name name self.courses courses if courses is not None else []6.2 类变量与实例变量的混淆类里直接写的变量是类变量__init__里通过self.赋值的是实例变量。类变量全类共享实例变量每个对象独立。很多时候你只想记录对象自己的状态结果谁写了一个类变量导致所有对象相互串数据。class Counter: count 0 # 类变量所有对象共享 def increment(self): Counter.count 1 # 正确的引用方式 class SafeCounter: def __init__(self): self.count 0 # 实例变量每个对象独立 def increment(self): self.count 1判断技巧很简单跟对象具体数据相关的一律放__init__跟所有对象共同状态相关的才用类变量。宁可多写一行self.也别让数据串台。6.3 子类初始化时忘了super这个问题我在第4节提过但值得再单独说一遍因为它太常见了。写了子类忘了super().__init__(...)子类对象的父类字段全是空的。更隐蔽的是父类构造时需要一堆参数子类调super().__init__的时候可能会少传、多传这时Python会抛出TypeError错误信息会明确告诉你缺了哪个参数但排查起来依然心累。排查思路基类初始化方法里打一行日志看看每个子类创建时到底带了什么参数。或者更简单把父类__init__的参数写全并用*args, **kwargs接住多余的参数向前兼容。class Base: def __init__(self, name, *args, **kwargs): self.name name class Child(Base): def __init__(self, name, age, *args, **kwargs): super().__init__(name, *args, **kwargs) self.age age6.4 连等赋值和对象的引用陷阱还有一个容易踩的坑对象之间的赋值。s2 s1只是让s2指向同一个对象不是复制一份。改s2等于改s1。初学阶段很多人会在这里翻车s1 Student(张三, 20240101) s2 s1 s2.name 李四 print(s1.name) # 输出李四需要复制时请用标准库的copy.copy浅拷贝或copy.deepcopy深拷贝。至于什么时候该拷贝、什么时候直接用引用取决于你是否希望修改影响原对象。数据共享是优点也是bug来源设计类时想清楚“这个对象可不可以被别处持有并修改”能省下大量调试时间。6.5 调试面向对象代码的两个实用技巧第一个技巧print对象时看到的是内存地址没有信息量。建议在类里加一个__repr__方法返回可读的字符串。这时候再打印对象就能直接看到内容。第二个技巧用标准库的inspect模块辅助排查调用关系。比如想知道某个对象是哪个类创建的可以用type(obj).__name__想知道继承链条可以用obj.__class__.__mro__。排查属性到底来自哪个类时__mro__非常直观按顺序列出搜索顺序一眼看出属性被谁覆盖了。class Student: def __repr__(self): return fStudent(name{self.name}, id{self.student_id})写了__repr__之后调试体验会好很多这也是我在实际项目里强烈建议保留的习惯——哪怕代码只有几十行。6.6 设计类的时机不要过早抽象初学阶段容易走另一个极端为了用类而用类。两个函数能搞定的事非抽象出三个类结果代码更绕、更难读。我个人的经验是当你发现一个数据结构在多个函数之间传来传去且对它的操作经常重复出现时再考虑把它封装成类如果只是临时脚本函数配字典完全够用。类的设计是一种投资初期花时间建立结构后期在扩展、复用、维护上赚回来。但它不是零成本过度设计比不过度设计还要可怕。衡量标准就一条新增功能时类是让变化更简单了还是让改动更复杂了每一次重构都朝“更简单”方向走类库就会越磨越好。我从第一次看到“05-1.类库-类 class”这个目录到今天中间踩过参数默认值的坑、父类初始化的坑、类变量共享的坑也经历过把一堆类堆成代码沼泽的失败项目。回头来看最值得花时间的不是记住各种语法细节而是建立一套判断力数据归谁管、行为归谁管、接口怎么暴露、改动影响面多大。把这个判断力练出来后面学继承、多态、设计模式都会顺畅得多。