ARTICLE DETAIL

资讯详情

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

Python面向对象深度实践:从self、__init__到类设计思路全解析

Python面向对象深度实践:从self、__init__到类设计思路全解析 我不是什么老师就是一个天天跟 Python 打交道的开发。这些年经常在论坛、社群里看到同一类问题面向对象学是学了什么封装继承多态都背得滚瓜烂熟可一到自己写项目还是不知道类到底该怎么设计。还有更常见的拿着一个几千行的脚本跑来问能不能帮我改成面向对象我一看里面全是几十个全局变量十几个函数靠隐形的约定互相调用谁改谁知道有多痛苦。这篇文章不打算从零给你讲语法手册。我想换个角度从为什么需要类说起把Python 面向对象那些让人真正卡住的地方——self、__init__、继承里的坑、魔术方法、类方法和静态方法——全部用实际场景讲透。适合正在学Python的同学也适合写了几年Python但始终对面向对象不太有感觉的朋友。看完不求你成为设计大师至少遇到问题的时候能自己判断这个东西该不该用类、类该怎么拆、踩了坑怎么排查。1. 先想明白一件事类不是模板是生产对象的模具1.1 那些年我们见过的函数堆代码先说个真实经历。有一回我接手一个数据清洗脚本600多行没有类全是函数和全局变量。业务逻辑大概是从文件读原始数据做各种规则校验清洗、转换最后输出报表。问题就出在那个各种规则上。第一次写的人用了七八个全局变量存在内存里有的变量在函数A里被修改函数B要读它但函数B又会在某个分支里把它置为空。等你接到手想加一条新规则你根本不敢动那些函数——因为你不知道改了这个函数后面哪个函数会跟着崩。这种代码就是典型的函数堆逻辑确实能跑但数据流是暗流靠的是写代码的人自己记住这个变量现在是什么状态。你一旦把这段代码忘半个月回来再看完全是陌生人写的代码。1.2 面向对象是怎么解决这个问题的面向对象解决这件事的思路特别朴素把数据和操作这些数据的函数打包放进同一个结构里。一个学生的信息名字、年龄、成绩是数据打印信息、算平均分是操作。以前你得写函数avg(score_list)把数据传进函数现在你把数据和操作放在同一个类里对象天生就同时拥有它们。用我刚才说的清洗脚本举例。首先梳理业务里到底有哪些实体——原始记录、清洗规则、报表行、校验结果。然后每个实体对应一个类。数据写进类的属性操作写进类的方法。这样数据就住在它该住的屋子里了函数之间不再靠全局变量偷偷摸摸传数据而是明确地你要处理什么就把那个对象传给你。你自己感受一下这个差别函数式思路data是一份游离的数据一堆函数围着你转你改我改大家改。面向对象思路data是个对象它知道自己是什么、能干什么外面想动它得走它公开的方法。1.3 模具类比类和对象的关系很多人说类就是模板这话对了一半。模板给人的感觉是照着画但类更像是模具——你有一个模具按一下压出来一个零件再按一下又压出来一个每个零件长得一样但它们是独立的个体谁也不会影响谁。class Cookie: def __init__(self, flavor): self.flavor flavor c1 Cookie(巧克力) c2 Cookie(抹茶)Cookie就是那个模具c1、c2是压出来的两个独立饼干。给c1.flavor改名c2完全没感觉。模具本身不会被消耗想压多少压多少。这个类比能帮你理解很多概念类定义了对象有什么属性和方法——相当于模具决定饼干是什么形状。对象是类的实例——饼干是模具压出来的实物。每个对象的属性是独立的——两块饼干虽然是同一个模具出来的但可以有不同的夹心属性值。记住模具这个感觉后面理解self、理解实例变量和类变量的区别都会顺畅很多。2. self和__init__90%的面向对象困惑都在这两个地方2.1 为什么Python非要你手动写self新手最常见的疑问为什么 Java 里写this.name经常可以不写Python 却每个方法第一个参数都要写self不写就报错。原因是 Python 的设计哲学显式优于隐式。Java 的this是编译器在背后帮你传的你感觉不到它的存在Python 则把这个对象自己明明白白地作为一个参数传进来。你调用obj.method()的时候Python 其实做了一件翻译把obj自动传给方法里的self参数。class Student: def introduce(self): print(f我是{self.name}) s Student() s.introduce() # 等价于下一行 Student.introduce(s)你看s.introduce()和Student.introduce(s)本质是一回事Python 只是帮你把第一个参数填好了。这个设计的直接后果就是方法里的 self 就代表未来那个具体的对象。所以别再纠结为什么每个函数都要写 self了它不是装饰品它就是你操作对象的入口。写了 self你才能在方法里访问self.name、self.age才能设置或修改对象自己的状态。2.2 __init__不是构造函数它是初始化器这个误解也特别普遍。很多人把__init__理解成创建对象时被调用的构造函数这么说不算全错但不准确。Python 里真正创建对象分配内存的方法是__new__而__init__是在对象创建完成后被调用用来初始化状态的。你可以这样理解__new__把空壳子造出来。__init__往空壳子里填东西——设置属性。日常开发中 99% 的情况下你都碰不到__new__但你一定要清楚__init__的责任是给新对象设置初始状态。还有个常见误区觉得每个类必须写__init__。不是这样的。如果类不需要初始数据可以不写不写的时候Python 会自动调用父类的__init__通常就是object的。class Simple: def hello(self): print(hello)这个类没有__init__完全可以正常用。需要初始化属性了再写。2.3 把一个类逐步拆开看我们来写一个完整的类逐步拆解class Student: school 第一中学 # 类变量所有学生共享 def __init__(self, name, age): self.name name # 实例变量每个学生自己一份 self.age age self.scores [] # 实例变量用于保存这个学生的成绩 def add_score(self, score): if score 0 or score 100: raise ValueError(成绩必须在0到100之间) self.scores.append(score) def average(self): if not self.scores: return 0 return sum(self.scores) / len(self.scores) def introduce(self): return f{self.name}{self.age}岁来自{self.school}这里有几个点要着重讲school写在__init__外面是类变量所有学生共享这一个值。改它所有学生的school都变。name、age、scores写在__init__里面是实例变量每个对象各存各的。self.scores []这一步把空列表塞给当前对象之后操作self.scores就是在操作这个对象自己的列表。raise ValueError是一种防御性编程在源头挡住非法数据。写类的过程其实是把脑子里对业务实体的认知翻译成代码实体有哪些属性变量、能做什么事方法、状态怎么变化方法里怎么改属性。3. 封装、继承、多态Python里这套东西怎么落地3.1 封装_x和__x的真实区别很多教材讲封装就是私有属性不让外面访问然后举例用双下划线__name。这句话放到 Python 语境里很容易把人带沟里去。Python 并没有真正的私有机制它靠的是约定 名字改编。单下划线开头_name只是约定表示这是内部的东西外部代码别直接动。你从外面访问其实是可以访问的能访问不代表你该访问。双下划线开头__name会发生名字改编name mangling。Python 会把__name改写成_ClassName__name。这主要是为了避免在继承场景下不小心覆盖父类的内部属性而不是真的加密。class Account: def __init__(self, balance): self.__balance balance a Account(100) print(a.__balance) # AttributeError print(a._Account__balance) # 100机制上还是能拿到所以封装真正的意义是什么是隐藏怎么实现暴露怎么用。比如一个银行账户类内部怎么记账、怎么算利息外面不用知道外面只需要调用deposit(100)、withdraw(50)内部保证余额不能为负这类约束就行。好的封装设计会让使用者只关心做什么不关心怎么做也让以后内部实现调整时外部代码不用跟着改。3.2 继承super()的坑和MRO那点事继承的价值是复用和表达一种 is-a 关系。子类自动拥有父类的属性和方法然后按需覆写、扩展。class Animal: def __init__(self, name): self.name name def speak(self): return ...子类覆写方法就完事了最容易被坑的是这句话子类定义了__init__却不调用super().__init__()。那父类里面设置的属性一个都没有一访问就报AttributeError。class Dog(Animal): def __init__(self, name, breed): self.breed breed dog Dog(旺财, 金毛) print(dog.name) # AttributeError: Dog object has no attribute name正确做法是在子类__init__第一行调用父类的初始化class Dog(Animal): def __init__(self, name, breed): super().__init__(name) self.breed breed再深入一点点多继承的时候方法找的顺序是 MROMethod Resolution Order。Python 用 C3 线性化算法计算出一个顺序可以通过ClassName.__mro__查看。我的建议是除非你明确知道自己在干什么否则尽量避开多继承。多个类组合带来的复杂度远比它省下的那几行代码高。3.3 多态鸭子类型比你想的更简单Java 或 C 里的多态通常需要继承同一个接口但 Python 更宽松它靠的是鸭子类型如果它走起来像鸭子、叫起来像鸭子那它就被当成鸭子。也就是说只要一个对象有你要调的方法不管它从哪继承来的你就能用。class Cat: def speak(self): return 喵 class Dog: def speak(self): return 汪 class Car: def speak(self): return 哔哔 def make_sound(obj): print(obj.speak()) make_sound(Cat()) make_sound(Dog()) make_sound(Car())三个类没有继承关系但只要都有speak方法就能被同一个函数调用。用面向对象设计的话说就是调用方不关心具体类型只关心有没有这个能力。很多人觉得多态是个高深概念其实在 Python 里它天天都在发生。比如len()能作用于列表、字符串、字典就是因为它们都有__len__方法。这就是多态不玄乎。3.4 综合案例一个小型工资结算系统光讲概念不过瘾我拿一个小型工资结算系统把这些东西串起来。需求公司有三种员工——普通员工拿月薪经理月薪加绩效奖金实习生按天算钱。月底给所有人发工资。class Employee: def __init__(self, name): self.name name def monthly_pay(self): raise NotImplementedError(子类必须实现monthly_pay) class SalariedEmployee(Employee): def __init__(self, name, salary): super().__init__(name) self.salary salary def monthly_pay(self): return self.salary class Manager(Employee): def __init__(self, name, salary, bonus): super().__init__(name) self.salary salary self.bonus bonus def monthly_pay(self): return self.salary self.bonus class Intern(Employee): def __init__(self, name, daily_wage): super().__init__(name) self.daily_wage daily_wage self.working_days 0 def monthly_pay(self): return self.daily_wage * self.working_days employees [ SalariedEmployee(张三, 8000), Manager(李四, 12000, 3000), Intern(王五, 150, 0), ] for emp in employees: print(f{emp.name}: {emp.monthly_pay()})这个例子清晰展示了封装每个类管好自己的属性、继承都继承Employee复用name、多态循环里统一调用monthly_pay()不同类给出不同结果。新增一种员工类型时只需要加一个新子类调用方代码一行都不用改。这是面向对象最实用的价值之一把变化点隔离在新增的类里而不是打乱原有代码。4. 日常敲代码最容易翻车的四个面向对象大坑4.1 坑一类变量被实例共享了我见过太多人写过这种代码class Dog: tricks [] # 计划每个狗的技能结果翻了车 d1 Dog() d2 Dog() d1.tricks.append(打滚) print(d2.tricks) # [打滚]d2也被影响了为什么因为tricks []是类变量它在内存里只有一份所有实例共享同一个列表。d1.tricks.append(...)是在这个共享列表上做修改所有实例都能看到。解决办法很简单可变对象列表、字典、集合一律放进__init__里作为实例变量。class Dog: def __init__(self): self.tricks [] d1 Dog() d2 Dog() d1.tricks.append(打滚) print(d1.tricks) # [打滚] print(d2.tricks) # []判断口诀凡是每个对象各存一份、互不影响的数据必须写到__init__里绑定到self上。类变量只放那些所有对象共享同一个值的常量比如school 第一中学。4.2 坑二可变对象当作方法默认参数这个坑不只在面向对象里出现但类的方法里同样常犯class Task: def __init__(self, name, tags[]): # 别这么干 self.name name self.tags tags t1 Task(写报告, ) t1.tags.append(重要) t2 Task(买菜) print(t2.tags) # [重要]居然继承了别人的标签问题的根源是def __init__(self, name, tags[])里的[]只在函数定义时创建一次所有没传tags的实例拿到的是同一个列表对象。正确姿势是传None在方法内部再创建新对象class Task: def __init__(self, name, tagsNone): self.name name self.tags tags if tags is not None else []这是个老生常谈的坑但架不住它经典。凡是可变默认参数默认值一律写None在函数体里再赋值。4.3 坑三以为在修改实例属性其实改了类属性这个坑是坑一的反方向。看代码class Student: scores [88, 92] s1 Student() s1.scores.append(95) # 原地修改类变量被改了 print(Student.scores) # [88, 92, 95] s2 Student() s2.scores [60, 70] # 重新赋值创建了实例属性和类变量屏蔽了 print(s2.scores) # [60, 70] print(Student.scores) # [88, 92, 95]类变量没受影响看出来了吗原地修改和重新赋值是两回事。s1.scores.append(...)是在原列表上改改的是那个共享的类变量。s2.scores [...]是另起炉灶给这个实例创建了一个新属性从此它不再看类变量。排查这个坑的时候先分清你是在修改对象指向的内容还是在给对象换一个指向。写类时我还是那句老话可变数据放实例变量别放在类变量上。4.4 坑四子类忘了调用 super().init前面继承那节提了一嘴这里再展开。子类如果不调用super().__init__()父类里__init__设置的属性就全部缺席常见的表现就是AttributeError。还有一种更隐蔽的情况父类的__init__里打开了一个文件、建立了一个连接子类忘了调用导致资源没被初始化后面用的时候不是报错而是静默失败。这种 bug 特别难找。建议子类如果重写了__init__第一行先问自己父类的初始化逻辑要不要保留通常是要的。如果不需要父类任何初始状态也要想清楚别顺手就漏掉了。用 IDE 的跳转检查一下父类__init__写了什么比靠记忆靠谱。5. 让对象活起来魔术方法、property和类方法5.1str__与__repr让对象打印出来能看懂不重写魔术方法的时候你打印一个对象看到的是这种东西__main__.Student object at 0x7f8d5c2b12e0难看而且没用。重写__str__之后就有救了class Student: def __init__(self, name, age): self.name name self.age age def __str__(self): return fStudent(name{self.name}, age{self.age}) def __repr__(self): return fStudent({self.name}, {self.age})__str__和__repr__的区别是__str__给用户看重在可读性__repr__给开发者看最好能只看输出就能重新创建出这个对象。一个常用习惯是让__repr__返回一个类似构造代码的字符串这样在交互环境里调试起来特别方便。5.2 property把方法包装成属性校验逻辑藏进赋值有些属性你在赋值的时候要检查合法性。以前你会写set_balance(value)和get_balance()两个方法但这样调用方每次都要写一堆obj.get_*()和obj.set_*()丑。property可以把方法伪装成普通属性用起来就像直接访问属性一样但内部可以加校验class Account: def __init__(self, balance): self._balance balance property def balance(self): return self._balance balance.setter def balance(self, value): if value 0: raise ValueError(余额不能为负数) self._balance value调用的时候a Account(1000) print(a.balance) # 像访问属性一样读 a.balance 1500 # 像设置属性一样写内部走了setter校验property的价值在于你一开始可以简单用公开属性以后要加校验、加日志、加缓存直接改成property方法调用方代码完全不用变。这是 Python 治理公共属性后患的经典手段。5.3 classmethod和staticmethod到底什么时候用哪个这个问题几乎每个学面向对象的人都问过。我用一张表说明白方法类型第一个参数能访问什么典型用途实例方法self实例变量、类变量操作对象本身的状态类方法cls只能访问类变量不能访问实例变量提供替代构造函数、读取类级状态静态方法无都不依赖纯粹是类命名空间下的函数和类相关但不依赖类/实例的逻辑从实际代码角度讲classmethod最常见的用法是替代构造函数class Date: def __init__(self, year, month, day): self.year year self.month month self.day day classmethod def from_string(cls, text): year, month, day map(int, text.split(-)) return cls(year, month, day) d Date.from_string(2024-12-01)好处是调用方不需要知道构造细节而且从字符串创建对象的逻辑放在类本身上符合数据和处理数据的方法放一起的面向对象思想。staticmethod则适合放那些和这个类有关但不需要任何类状态的工具函数比如日期合法性校验class Date: ... staticmethod def is_valid(year, month, day): if month 1 or month 12: return False days [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] if (year % 400 0) or (year % 4 0 and year % 100 ! 0): days[1] 29 return 1 day days[month - 1]判断逻辑很简单需要访问实例数据用实例方法需要访问类数据或要做工厂方法用classmethod啥都不依赖只是逻辑上归这个类管用staticmethod。6. 写了几年Python类的一些实际经验6.1 一个类只干一件事别造上帝类我见过一个类能连接数据库、能发邮件、能生成报表、还能记录日志。它当然很方便但过几个月没人敢改它——牵一发而动全身。一个类应该只有一个职责。判断方法很简单用一句话说清楚这个类是干嘛的。如果这句话里有和或者同时那多半该拆了。比如这个类负责用户管理可以这个类负责用户管理、订单处理和日志记录就要拆成三个类。拆分不是为了好看是为了让代码能独立演进。改动一个模块时不至于影响其他模块。就像拆房子一样承重墙和非承重墙之间得留缝地震了还能各自晃动。6.2 组合优于继承能用有一个就不要硬凑是一个继承表达的是 is-a 关系组合表达的是 has-a 关系。编程里有个经验优先组合而不是继承。举个例子一个自行车类如果你给每种配件建一个类然后去继承可能就变成BicycleWithBell、BicycleWithBasket、BicycleWithBoth类数量爆炸。而组合的做法是class Bicycle: def __init__(self): self.bell Bell() self.basket Basket()是不是清爽多了面向对象设计里滥用继承是代码膨胀的头号原因。每次想继承的时候先问问自己这个类真的是那个类的一种吗如果只是需要用到它的某个功能那用组合更合适。6.3 不是所有代码都要写成类最后说句可能不太正确但很真实的话不是所有 Python 代码都非得塞进类里。如果你只写一个几十行的小脚本处理一段数据就完事那函数完全够用硬套面向对象反而画蛇添足。类适合的场景是同一份数据状态在多个函数间传递。需要多种实现来应对同一套调用方式多态场景。业务实体有明确的属性和行为。如果只是三五个互相独立的函数那就写函数。面向对象是个工具不是使命。项目里可以混合用底层工具函数用函数式写法业务实体用类策略部分用继承或多态这样反而比为了面向对象而面向对象的代码更健康。最后再分享一点我的切身体会。很多人觉得面向对象难是把它当成了一套必须严格遵守的教条——必须封装必须多态必须继承。其实真正写项目的时候它更像一套组织代码的思路让你的数据和处理数据的方法齐整地待在一起让变化的地方有地方落脚。写类的时候少想点花活多想想下周我来看这段代码还能不能一眼看懂你设计的类多半就差不到哪去。真遇到问题也别慌把 self 和__init__捋清楚再对照本文的几个常见坑排查基本都能解决。
返回列表