资讯动态

Python类与对象深度解析:从实例、属性到封装的避坑指南

发布时间:2026/10/18 10:12:13 来源:尧图企业网站定制
1. 为什么“三刷”之后才敢写这篇笔记1.1 第一次刷感觉懂了又好像没懂第一次接触类和对象的时候我干过一件特别典型的事把书上的 Person、Animal、Car 这些示例代码来回抄了五六遍抄完觉得异常踏实。那会儿我的判断标准特别简单——“代码我能看懂语法我都见过”于是我就认为自己已经掌握了。真正的打击来自第一次动手写小项目。我打算做一个简单的图书管理系统结果打开编辑器对着空文件发了半小时呆。我不知道该把哪些东西设计成类更不知道类与类之间怎么协作。那会儿我才反应过来背“类是模板对象是实例”这种口诀和真正写代码之间差了十万八千里。回头看一刷阶段的病根是把类和对象当成了语法知识点去记忆而不是当成“建模工具”去理解。语法可以背建模能力只能靠做。1.2 第二次刷能看懂答案但照样掉坑二刷的时候我开始做练习题尤其是那种“判断下面代码输出结果”的选择题。每次遇到类属性被实例修改、可变对象作为默认参数、对象之间互相引用这类问题我基本是一错一个准。更打击人的是看答案的时候我觉得全明白合上答案自己再写一遍就又犯同样的毛病。这说明我并没有真的理解“运行时发生了什么”只是记住了“某道题应该这么解”。如果题目稍微换个包装我立刻被打回原形。那段时间让我意识到类与对象的难点根本不在语法而在几个底层机制上——名字空间、引用、初始化时机、可变性。这些概念单独拎出来好像都不难但组合在一起之后新手脑子里的模型就崩了。1.3 第三次刷概念开始连成一张网三刷时我没有再从头抄语法也没急着刷更多题而是做了一件很笨但特别有效的事把所有相关概念串成一条线。类的定义、实例的创建、属性查找顺序、内存引用、封装边界、可变对象的共享……当这些词不再是一个个孤立的知识点而是同一个过程的不同侧面时很多问题突然就通了。比如“为什么不能用可变对象做默认参数”“为什么两个变量看似相等但不是同一个对象”“为什么封装不是简单地把属性设为私有”这些疑问终于能互相解释了。这篇笔记就是三刷之后的整理重点记录那些“卡了我很久、后来彻底想通”的环节以及我踩过的具体坑。如果你也正在一刷二刷阶段挣扎或者学完之后发现自己只会抄代码不会设计这篇内容应该能帮你省掉不少时间。2. 类与对象的本质关系类型、实例与命名空间2.1 先看清“类型”和“实例”这两件事很多人会背一句“类就是模板对象就是模板造出来的产品”但真到写代码时还是容易糊涂。我后来发现问题出在“类”和“对象”这两个词在中文语境下太抽象了不如换成“类型”和“实例”来理解。先看一段简单的代码class Student: school 某实验中学 def __init__(self, name, age): self.name name self.age age s1 Student(小明, 12)这里Student是一个类s1是Student的一个实例。Student规定了每个学生对象应该有哪些数据name、age以及能执行哪些行为。s1则是这一条规定下的具体数据名字叫“小明”年龄是 12 岁。我比较喜欢的类比是“原型”和“复制品”类描述的是“全部学生共同拥有的结构”对象描述的是“一个具体学生的实际状态”。写代码的时候Student负责定义s1负责承载真实数据。注意虽然school写在类里面它属于类本身的属性这个下面会细聊。2.2 类属性与实例属性差异在哪里这是二刷时经常让我翻车的地方。同样叫“属性”但定义的位置不同归属完全不同写在__init__里面、以self.name形式出现的是实例属性每个对象各存各的。直接写在类主体里、不带self的属性是类属性它挂在类上所有实例默认共享。Python 的属性查找顺序是先找实例自己的命名空间找不到再去类里找。这个机制非常关键。s1 Student(小明, 12) s2 Student(小红, 11) print(s1.school) # 某实验中学 Student.school 另一所中学 print(s2.school) # 另一所中学因为s1和s2自己都没有school所以查属性时都会退到类属性。只要有人改了Student.school所有实例看到的值都会变。反过来如果给某个实例动态绑定了一个同名属性那只是给这个实例创建了一份私有数据不影响其他实例s1.school 我自己的学校 print(s1.school) # 我自己的学校 print(s2.school) # 另一所中学因为 s2 的命名空间里依然没有 school这个现象我踩过不少次尤其是当类属性是可变对象列表、字典的时候问题会更严重后面专门有一节聊这个。2.3 从“蓝图”到“实体”的瞬间实例化到底做了什么很多人把“实例化”简单理解成“调用一下类的名字”但三刷时我才真正明白它其实是两步操作第一步在内存中为新的对象分配一块空间 第二步调用__init__方法把这块空间里的数据初始化好。把这两步分开看就能解释很多奇怪的问题。比如为什么__init__方法里的self已经存在因为这个对象的内存空间已经分配好了self只是指向这块空间的名称而已。__init__的职责不是“创建”对象而是“把数据填进去”。这是我最想强调的一个认知转变实例化等于分配空间加初始化而不是魔法般地无中生有。理解了这一点你会对“对象”的实体感加深很多后面理解引用、特征、深浅拷贝都会顺畅不少。3. 属性、方法、构造器设计类的顺序有讲究3.1 先把状态想清楚再想行为很多新手写类的时候习惯于上来就写方法理由很直接方法才是“功能”属性只是辅助。这个顺序其实反了。我设计类的顺序通常是先把这个业务对象的“名词”全部列出来再考虑动作。比如做一个订单类订单号下单时间商品清单总金额订单状态这些是状态也就是属性。状态确定之后再做动作添加商品取消订单修改收货地址计算总价为什么先列表格因为属性才是一个类的“骨架”。方法是对属性的加工和流转如果属性想得不够清楚方法设计出来就会很飘会出现“这个动作到底该属于哪个类”的纠结。3.2 构造器不是摆设它是对象的“出生体检”__init__方法也就是构造器我一开始觉得它可有可无因为不给实例属性做初始化Python 也不会报错照样可以给对象动态绑定新属性。这种写法特别容易在项目变得复杂后引发问题。class Order: def __init__(self, order_id): self.order_id order_id self.items [] self.status pending构造器的价值在于保证对象一诞生就具备合法的、完整的状态。如果items没有初始化后面调用任何关于商品的方法都要先判断“这个属性存在吗”代码会变得非常被动。而如果status不设初始值可能过两个星期你就会在调试时发疯这个订单到底走完哪些流程了我自己的体会是构造器更像一道“出生体检”。它不要求你把所有事情做完但至少要把这个对象能不能正常工作的基础条件检查好。比如年龄字段不允许负数那就在__init__里校验而不是指望所有调用方都自觉。3.3 self 不是语法糖它揭示了方法的本质初学者最容易被self绕晕。我一开始以为它是 Python 强加的魔法参数后来才明白它压根就是“一个普通的参数”。在 Python 中实例方法的本质是类定义里存放的一个函数当我们通过实例去调用时这个实例会被自动作为第一个参数传进去。所以我写作方法时习惯把self翻译成“当前这个对象自己”。class Student: def introduce(self): return f我是{self.name}introduce它不知道该输出哪个名字是self告诉它的。假如没有self这个方法就不知道该操作谁的数据。这也是为什么类里面定义的方法如果不加self直接定义成一个普通函数调用时就会报“缺少位置参数”的原因。不要小看这个大前提理解了self之后再看“类方法”“静态方法”“实例方法”的区别思路会清晰很多。4. 从内存与引用角度重新理解对象4.1 变量名不是对象本身它只是一个标签三刷时让我变化最大的一课是意识到“变量名存在内存栈上而对象本体存在堆上”。通俗一点说我们用s1这个名字的时候s1并不是“小明”这个对象而是一个指向小明数据的标签。可以用id()函数来验证s1 Student(小明, 12) s2 s1 s3 Student(小明, 12) print(id(s1)) # 14034... print(id(s2)) # 与 s1 相同 print(id(s3)) # 与 s1 不同这里s2 s1只是把同一个标签贴到了同一个对象上对象还是那一个。而s3虽然名字和数据看起来都和s1一样但它是一个全新的内存空间。所谓“引用”就是“标签和对象之间的纽带”。4.2 可变对象共享陷阱一个改另一个跟着变如果对象是不可变的比如整数、字符串、元组那么引用共享基本没有危害因为任何“修改”都是重新绑定一个新对象。一旦对象是可变容器列表、字典、集合共享引用就会变成隐形的坑。class Cart: def __init__(self, goods): self.goods goods cart1 Cart([]) cart2 Cart([]) cart1.goods.append(面包) print(cart2.goods) # 如果不是同一个列表这里应该还是空的上面这个写法没问题因为两次传的都是不同的空列表。但是如果把构造器改成这样class Cart: def __init__(self, goods[]): # 不要这样写 self.goods goods那么每次创建Cart的时候如果调用方没有传goods用的都是同一个默认列表对象。一个购物车里加了东西另一个购物车也像被“隔空传染”一样跟着变了。这个案例我后面还会专门排查一次这里只需要意识到共享引用不是错误本身它是对象的正常行为问题在于你知不知道这份数据在共享。4.3 和 is 的差别在同一容器里还是在不同房间里理解了“标签与对象”的关系之后和is的差别就非常自然了a [1, 2, 3] b [1, 2, 3] a b # True值相等 a is b # False不是同一个对象比较的是内容is比较的是身份也就是内存地址是否相同。真正常见的误用是把is拿来比较两个整数或字符串有时候能碰巧成立但那是因为 Python 在底层做了缓存而不是因为你理解了引用。实际项目里我基本只用is判断两种场景一个是和None比较另一个是确认两个名字是否确实指向同一个对象。5. 封装不是私有化真正有效的边界设计5.1 “私有”的本质是约定而不是锁很多讲面向对象的教程一上来就强调“封装就是设置私有属性不让外面直接访问”。到了 Python 这里这个说法很容易让人误解因为 Python 没有真正的强制私有单下划线_name只是约定双下划线__name也只是做了名字修饰不能完全禁止外部访问。我踩过的坑就是“为了封装而封装”把所有属性都加上双下划线然后写一大堆 getter/setter。代码变长了可读性变差了但业务上并没有因此安全多少。真正有用的封装是在设计层面明确一件事哪些数据允许外部读哪些数据只允许内部写哪些数据外部根本不需要知道。这不是语法问题而是接口设计问题。5.2 用 property 做受控访问而不是写“无聊的 getter”举个特别典型的例子银行账户余额。余额通常不能被外部直接乱改成负数但外界需要能查询。如果一种方法拿到balance就随便赋值那这个类的边界就形同虚设。Python 里用property可以把这个约束藏在属性访问的背后class BankAccount: def __init__(self, balance): self._balance balance property def balance(self): return self._balance def withdraw(self, amount): if amount 0: raise ValueError(取款金额必须大于0) if amount self._balance: raise ValueError(余额不足) self._balance - amount外部代码看起来是在读account.balance没有额外的方法调用但底层的写入控制一点没丢。关键不是“用不用下划线”而是“余额变化这个动作有没有经过业务规则的检查”。封装的目的就是强迫外部走方法、走规则而不是直接拆开对象的内部结构乱改。5.3 边界清晰的类调用者才敢放心用我在做小项目时有一种强烈体会一个类的边界清不清晰直接决定项目推倒重来多少次。在订单系统里我一开始允许外部直接改订单的status结果业务逻辑完全失控代码里到处出现if order.status paid:这种判断却没有任何地方保证“支付完成后状态一定会从 pending 变成 paid”。后来我把状态改动收敛到pay()、cancel()这些方法里调用者不需要懂内部状态机只需要调用对应动作。所以封装本质上是“把复杂度关进笼子里”。它需要回答的是“外部凭什么不能直接改这里”而不是“我怎样才能让外部改不了”。想清楚这一点很多关于封装的纠结都会消失。6. 重写代码时连续踩过的坑与排查全过程6.1 可变默认参数最像“灵异事件”的 Bug三刷期间我重写一个小型班级选课系统写了一个给课程添加学生的方法class Course: def __init__(self, name, students[]): self.name name self.students students def add_student(self, student_name): self.students.append(student_name)现象特别诡异我分别创建了两个课程往课程 A 里加了一个学生课程 B 的学生列表也跟着出现了这个学生。一开始我以为是自己创建对象时传错了参数反复检查结构都没有发现问题。后来才意识到问题出在students[]这个默认值上。Python 在定义函数的时候只创建一次默认列表之后每次调用构造器且没有传入新列表时大家拿到的都是同一个列表对象。所以“两个课程共享一份学生名单”不是因为并发而是因为默认参数被当作了一个“公共储物箱”。修复方法很简单默认值一律用None进入函数后自己初始化class Course: def __init__(self, name, studentsNone): self.name name self.students [] if students is None else students这个坑最有价值的地方在于如果不知道 Python 是按引用传递默认参数排查起来像撞鬼。6.2 类属性被实例修改后的“幽灵现象”另一个项目里我写了这么一个类class Company: employees [] def __init__(self, name): self.name name def add_employee(self, employee): self.employees.append(employee)我本意是每家公司的员工互不影响但运行后 A 公司加一个员工B 公司的员工清单也跟着多了一个人。原因和上面类似employees是类属性所有Company实例共享这一个列表。我只需要在__init__里给每个实例创建自己的列表class Company: def __init__(self, name): self.name name self.employees []这个案例能帮你理清一个判断思路当你发现“一个实例上的修改莫名其妙影响到另一个实例”时先去看这个属性定义在哪里。如果定义在类主体里而且类型是可变对象那所有权就是它的核心坑。6.3 对象互相引用导致的递归打印调试时直接崩溃还有一次我写了一个模拟图书借阅关系的类Book里面有作者信息Author里面有书籍列表。为了方便调试我在两个类里都实现了__repr__并且在打印书籍时顺便打印作者打印作者时又顺便打印书籍。结果可想而知一调用print(book)代码就在Book - Author - Book - Author之间无限循环最后直接栈溢出。排查过程用排除法先注释掉Author的__repr__打印恢复正常再注释掉Book里的打印内容也能恢复正常。于是确认是互相引用造成的死循环。我后来的处理方式是在repr里只打印对象的核心标识不要顺藤摸瓜打印对方的完整信息。比如书籍打印书名和 ISBN作者打印姓名和作者 ID。这个坑提醒我调试时为了方便打印对象可以理解但对象的字符串输出必须是有限递归的否则就会变成编程界的“鬼打墙”。6.4 排查步骤小结遇到一类对象相关的怪问题我总结了一条固定排查顺序先看属性是实例属性还是类属性尤其是可变类型。再搜构造函数里有没有使用可变默认参数。检查和is的使用场景是否混淆了值相等与对象同一。打印对象之前确认__repr__或__str__是否会导致循环引用。最后检查对象的创建位置是不是多个名字意外共享了同一个引用。按这个顺序走大部分类与对象相关的问题都能在十分钟内定位。7. 用“三刷法”真正学透类和对象一份自检清单与练习思路7.1 写在类旁边的一页“自检问题”第三次学习时我给自己定了一个习惯每定义一个类都要像做体检一样过一遍下面这些问题。这些问题救了我很多次这个类是一个明确的名词吗如果答不上来可能不应该设计成一个类。每个实例属性都保证在__init__里初始化了吗如果某些属性是可选条件那么默认值是什么类属性在我这里到底是“刻意共享”还是“不小心共享”每个实例方法的第一个参数是self吗有没有可能把普通函数误放进了类里有没有可变对象被外部直接拿到了引用并且外部能够绕过方法随意修改这个对象打印出来的信息是否清晰是否安全、不会触发循环如果两个对象看起来内容一样它们需要相等吗需要一样吗这套问题帮我养成了稳定的直觉每次写完类都忍不住在脑子里过一遍。等你熟练以后这些问题会变成潜意识的一部分。7.2 画图法用实体标注理解引用变化如果你还在“看代码能懂、自己写就懵”的阶段我强烈建议你把代码和纸笔配合起来用。我三刷时最有效的一招是把自己当成一台虚拟计算机手动模拟每一步执行后内存里“有哪些对象、哪些标签指向它们”。具体做法是画两层结构上方画“名字”下方画“堆里的对象”。遇到s1 Student(...)就画一个对象框然后把s1标签指向它遇到s2 s1就让两个标签指向同一个框遇到s1.name 新名字就在框里修改数据。不要嫌麻烦练上五六个例子后你的大脑会慢慢建立起对“引用语义”的直觉。这也是我把“类和对象”从一堆语法规则变成真正可推理模型的关键路径。调试时遇到对象一变全变的问题先别急着瞎改代码用笔把这个关系画出来往往一眼就能看出哪里在共享。7.3 我的最终体会类是对项目的“语法化契约”三刷之后我对类与对象有了一个更朴素的总结类不是为了炫技存在的它本质上是开发者和代码之间的契约。你通过类告诉未来的自己和同事这个业务概念有哪些数据、哪些行为、哪些状态不允许随便动。好的类设计会让代码里出现很多“原本可以随意改但你因为封装所以不乱改”的地方。所以再次面对类和对象时我不再问“这门语言怎么定义类”而是问“这个业务世界里什么东西有稳定的状态和明确的行为”。找到答案的那一天类的语法就不再是难点它只是你表达思考的自然工具罢了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑