“类和对象(2)”这个标题放在目录里,翻译成大白话就是:类,你已经会建了;现在该聊聊怎么建得好。很多朋友第一课结束就能写出class Student: pass,然后student = Student()这种代码,觉得面向对象不过如此。但第二课一打开,看到类方法、静态方法、特殊方法、property、继承重写、多态这一堆名词,整个人就懵在原地。这篇内容我把“类和对象(2)”这部分拆开揉碎,从属性归属讲到继承多态,再到调试实战,目标是让你学完以后,不再只是“会写类”,而是“会设计类”。这堂课没有高深到让人劝退,但它决定了你和“背语法”之间的真正差距。
1. 为什么类和对象要拆成两课讲
1.1 第一课和第二课的分界线在哪里
第一课教的是语法骨架:class关键字长什么样、__init__怎么初始化、self指谁、怎么把一个对象建出来。这些属于“照着抄就能写”的东西。第二课教的是设计骨架:属性该放在类里还是实例里、方法该被谁调用、多个类之间怎么组织协作。很多人第一课学完觉得“太简单了”,就是因为只掌握了语法外壳,没接触真正费脑子的部分。
拿宠物举例子。第一课你可能会写一个Dog类,里面写了eat()、sleep()、bark(),然后很开心地创建了几只狗。第二课你会突然发现,如果你还要写Cat类,里面又要重复一遍eat()和sleep(),这时候就自然产生了一个疑问:能不能把公共的吃和睡抽出来,放到一个父类里,让Dog和Cat只保留自己不同的叫声?这个“抽取公共逻辑”的动作,就是第二课的核心价值。
所以分界线的本质是:从“会创建一个类”到“会设计一类对象的体系”。这中间隔着的不是知识点数量,而是思维方式的转变。
1.2 先看一遍第二课的完整任务清单
“类和对象(2)”通常会覆盖这样一组内容,每个点都对应一类具体问题:
- 属性归属:类属性和实例属性到底有什么区别,哪些数据该共享,哪些数据该独占。
- 方法类型:实例方法、类方法、静态方法分别解决什么问题,什么时候用哪个。
- 特殊方法:怎么让自定义类支持
print()、==、for、with这些语言级操作。 - 继承与重写:如何从父类提取公共逻辑,子类如何做差异化扩展。
- 封装与多态:数据访问如何加防线,代码如何做到“加新类型不改老逻辑”。
把这些点连起来看,你会发现它们其实是一条线:先搞清楚“单个类内部的数据和方法怎么组织”,再搞清楚“多个类之间怎么建立关系”,最后落到设计原则和调试实践上。这一课学完之后,你再看别人写的业务代码,就不会总觉得“这坨代码很怪但说不清哪里怪”了。
2. 属性归属与self的本质:先分清哪些数据是共享的
2.1 类属性和实例属性差在哪
先看一段最普通的代码:
class Student: school = "某中学" # 类属性 count = 0 # 类属性 def __init__(self, name): self.name = name # 实例属性 Student.count += 1这里的school和count是类属性,属于类本身;而name是实例属性,每个学生对象都有自己的那一份。访问的时候,Python 会先到实例自己的属性字典里找,找不到再去类的属性字典里找,再找不到就去父类找。
区别用一张表看更清楚:
| 对比项 | 实例属性 | 类属性 |
|---|---|---|
| 定义位置 | __init__或方法内,用self.xxx赋值 | 类体内直接赋值 |
| 归属 | 每个实例单独一份 | 全类共享一份 |
| 访问方式 | self.xxx | 类名.xxx或self.xxx |
| 典型用途 | 每个对象独有的数据,如姓名、学号 | 公共配置、计数器、默认值 |
共享的意思要理解透:Student.count是全班共享的计数器,任何学生对象创建时给它加一,这个数是所有实例共同维护的。而s1.name = "A同学"不会影响s2.name,因为实例属性天然隔离。
2.2 通过实例赋值为什么改不到类属性
这是新手第二课最常踩的坑,没有之一。
class Student: school = "某中学" s = Student() s.school = "某大学" print(Student.school) # 某中学 print(s.school) # 某大学看起来你“改了”school,但实际上s.school = "某大学"这行代码不是在修改类属性,而是在s自己的属性字典里新建了一个school,把类属性给遮蔽掉了。类属性仍然是“某中学”,只是这个实例再也看不到了。
如果你真想全局修改,必须走类名:
Student.school = "某大学"注意:区分“读共享”和“写遮蔽”。读的时候实例能顺藤摸瓜找到类属性,但一旦用实例做赋值操作,就会在自己身上开一块新空间,把类属性盖住。面试里经常拿这个点出题,答错的人相当多。
2.3 三种方法到底怎么选
类里除了__init__,日常接触最多的是这几种方法:
class Student: count = 0 def __init__(self, name, score): self.name = name self.score = score Student.count += 1 def show_info(self): # 实例方法 return f"{self.name} 的分数是 {self.score}" @classmethod def from_dict(cls, data): # 类方法 return cls(data["name"], data["score"]) @staticmethod def is_valid_score(score): # 静态方法 return 0 <= score <= 100选择逻辑其实很朴素:
- 实例方法:需要读取或修改实例属性,方法体里离不开
self,这是最常见的类型。 - 类方法:方法逻辑和类本身相关,但不需要某个具体实例,装饰器
@classmethod让它接收的第一个参数是cls而不是self。典型用途是“备选构造函数”,比如从字典创建学生对象:Student.from_dict({"name": "小A", "score": 88}),这样调用方不用关心类内部的构造细节。 - 静态方法:方法逻辑和类、实例都没关系,只是恰好放在这个类里而已,用
@staticmethod标记。比如分数是否合法的判断,你完全可以在模块级写个普通函数,但放在类里更便于调用和组织。
很多教程把类方法讲得很玄,其实它在真实项目里最常用的场景就是备选构造器和操作类级状态。抓住“第一个参数是 cls 还是 self”这个判断点,用起来就不纠结了。
3. 特殊方法实战:几行代码让类变得好用
3.1str__和__repr:打印对象时不再看到内存地址
新手第一次打印自定义对象,多半会得到类似<__main__.Student object at 0x7f8c1a2b3c40>的输出,看着就像天书。这就是因为没有实现特殊方法。
class Student: def __init__(self, name, score): self.name = name self.score = score def __str__(self): return f"Student({self.name}, {self.score})" def __repr__(self): return f"Student(name={self.name!r}, score={self.score!r})" s = Student("小A", 92) print(s) # 调用 __str__ print(repr(s)) # 调用 __repr____str__是给用户看的,要求简洁易读;__repr__是给开发者调试用的,理想情况下它的返回值能直接还原出这个对象。如果只实现一个,建议优先实现__repr__,因为当__str__缺失时,Python 会回落到__repr__。
注意:方法名千万别拼错。
__str__写成__str_或者少了魔法方法两侧的双下划线,Python 都会把它当成普通方法,打印结果依旧是内存地址。
3.2 __eq__和比较相关的方法:让对象能用==比较内容
默认情况下,==比较的是两个对象的身份,也就是内存地址。两个学生对象内容一模一样,但它们不是同一个对象,所以==结果是False。这往往不符合业务直觉。
class Student: def __init__(self, student_id, name): self.student_id = student_id self.name = name def __eq__(self, other): if not isinstance(other, Student): return NotImplemented return self.student_id == other.student_id这样两个学号相同的学生对象,用==判断就是相等的。学号是业务上唯一标识学生的东西,用它做相等性判断,比默认的内存地址靠谱得多。
需要注意,Python 3 里一旦实现了__eq__,对象的__hash__会被自动设为None,导致它无法作为字典的 key 或放进 set。如果你希望对象既能比较相等又能正常哈希,需要同时定义__hash__。但在大多数业务场景里,自定义类用==比较内容就够了,哈希的问题按需处理,不必一开始就把所有魔法方法都堆上去。
至于排序,自定义类不一定要实现__lt__。我更推荐直接用:
sorted(students, key=lambda s: s.score)key参数让排序逻辑一目了然,比在每个类里都实现一堆比较运算符更省事。
3.3enter__和__iter:让类融入Python语言生态
特殊方法真正的威力,是让你的类能无缝配合 Python 语法。比如实现__iter__,对象就能直接放进for循环里:
class ScoreSheet: def __init__(self, students): self._students = students def __iter__(self): return iter(self._students) sheet = ScoreSheet([Student("小A", 92), Student("小B", 85)]) for s in sheet: print(s.name, s.score)__iter__返回一个迭代器,iter(self._students)直接复用列表的迭代器即可。这样设计之后,调用方不需要关心ScoreSheet内部是用列表还是元组存数据,只要它能被遍历就行。
同理,实现__enter__和__exit__可以让对象配合with语句做资源管理,比如自动关闭文件、自动释放连接。这背后的逻辑不复杂,但理解了“特殊方法让自定义类融入语言生态”这个思想,以后再看到len(obj)、with obj、for x in obj这种写法,你就知道它们统统能挂到自定义类身上。
4. 继承与重写:把公共逻辑收上来,把差异逻辑放下去
4.1 继承的写法与父子分工
继承的第一直觉是“复用代码”,但更准确地说,它是在表达“is-a”的关系:学生是一个人类,猫是一个动物类。父类放公共逻辑,子类放差异逻辑。
class Person: def __init__(self, name, age): self.name = name self.age = age def introduce(self): return f"我叫{self.name},今年{self.age}岁" class Student(Person): def __init__(self, name, age, student_id): super().__init__(name, age) self.student_id = student_id def introduce(self): return super().introduce() + f",学号是{self.student_id}"这里Student继承了Person的name、age和introduce,同时新增了student_id,还重写了introduce在原有基础上追加学号信息。父类的公共逻辑只用写一遍,子类各自扩展差异点,这就是继承的典型分工。
写继承的时候有一个原则很重要:如果两个类之间没有明确的“is-a”关系,不要为了复用强行继承。比如“订单”和“用户”之间是“属于”的关系,不是“是一种”的关系,这时候更应该把用户作为订单的属性成员,而不是让订单继承用户。组合优于继承,是比语法更优先的设计判断。
4.2 super().init()为何不能省略
很多新手在子类里写了自己的__init__,但不调用super().__init__(),结果创建子类对象后发现父类里定义的属性全都不存在,一脸疑惑。
原因是:父类的__init__并不会自动执行,子类重写了__init__之后,父类的初始化逻辑就完全被覆盖了。如果你希望子类对象拥有父类设置的属性,就必须显式调用super().__init__(参数)。
class Student(Person): def __init__(self, name, age, student_id): # 如果漏掉这一行,self.name 和 self.age 都不存在 super().__init__(name, age) self.student_id = student_id建议在任何子类重写__init__时,第一行就写super().__init__(...)把父类需要参数传进去。这是一种强制性习惯,不是可选项。调试时如果发现子类实例缺属性,优先检查__init__里有没有调用父类初始化。
4.3 重写方法时千万别破坏调用方的预期
重写方法不是随心所欲乱改。父类方法相当于对调用方做了契约承诺,子类重写后,必须保持行为上的兼容。
举个反例:父类Person.introduce()返回“我叫X,今年X岁”,子类重写后只返回“我是学生”,把名字年龄全丢了,调用方本来期望拿到完整介绍的,结果信息缺失。这种做法就破坏了调用方对父类的预期。
用一句大白话概括:凡是能用父类的地方,换成任何子类都应该能正常跑。这叫“里氏替换”,听起来高大上,落到具体写法就是两件事:
- 子类方法的返回值、抛出异常的行为,要和父类大致兼容。
- 如果只是在父类逻辑前后加点东西,优先用
super()调用父类方法,而不是把整个方法逻辑推倒重写。
比如introduce里先super().introduce()拿到原始介绍,再拼接学号,这种“增强式重写”就是安全的重写方式。
5. 封装与多态:面向对象的两根定海神针
5.1 单下划线与双下划线:Python的“私有”到底是怎么回事
很多从其他语言转过来的同学,第一反应是找private关键字,结果发现 Python 没有。它用的是约定和名字重整机制。
_name:单下划线开头,是约定。看到它的人会明白“这是内部成员,外部不要直接碰”,但它并不会阻止你访问。__name:双下划线开头,会触发名字重整。在类定义内部,它会变成_ClassName__name,主要目的是防止子类不小心覆盖父类的内部属性,而不是真的加密。
class Student: def __init__(self, name): self.__name = name s = Student("小A") print(s._Student__name) # 小A,名字被重整了 print(s.__name) # AttributeError,因为类外部访问不到这个原始名字把这段讲清楚,能避免新人在调试里看到_Student__name这种奇怪属性时一头雾水。封装在 Python 里的意义不是“绝对禁止访问”,而是“明确告知边界”,剩下的靠开发者自觉。
5.2 property实战:给属性加一道校验关卡
直接暴露属性虽然方便,但无法控制赋值。比如分数属性,如果外部直接student.score = 150,业务逻辑就崩了。用property可以把赋值动作拦截下来做校验:
class Student: def __init__(self, score): self._score = score @property def score(self): return self._score @score.setter def score(self, value): if not (0 <= value <= 100): raise ValueError("分数必须在0到100之间") self._score = value s = Student(88) s.score = 95 # 正常 s.score = 150 # 抛出 ValueError外部调用的时候写的是student.score = 95,看起来还是在直接操作属性,但背后已经经过了校验。这就是property最妙的地方:接口保持不变,内部实现随意升级。将来你想把score改成“平时成绩加期末成绩加权计算”,调用方一行代码都不用改。
注意:内部存的是
self._score,外部访问的是self.score。命名上故意用单下划线区分“内部存储”和“对外接口”,这是 Python 社区很常见的写法。
5.3 多态的典型写法:只关心方法,不关心类型
多态用大白话说就是:调用方只关心对象会不会某个方法,不关心它到底是什么类。
class Dog: def sound(self): return "汪汪" class Cat: def sound(self): return "喵喵" class Bird: def sound(self): return "啾啾" def make_sound(animal): print(animal.sound()) make_sound(Dog()) make_sound(Cat()) make_sound(Bird())make_sound函数根本不需要检查传入对象的类型,也不要求它们有共同的父类,只要你有sound这个方法,我就能统一调用。这就是“鸭子类型”:走路像鸭子、叫起来像鸭子,那它就是鸭子。
这种设计带来的好处是扩展性极强。以后想加一个Cow,实现sound()返回“哞哞”,make_sound一个字都不用改。在真实项目里,多态配合继承会更强:父类定义了接口,子类各自实现不同行为,外部代码永远只面向父类接口编程。加新子类、改某一种行为,都不会波及调用方。
6. 常见报错与排查技巧实录
6.1 可变类属性:一个实例改了,全班数据都变了
这是我见过最经典的共享陷阱。
class Student: tags = [] def __init__(self, name): self.name = name s1 = Student("小A") s1.tags.append("优等生") s2 = Student("小B") print(Student.tags) # ['优等生'] print(s2.tags) # ['优等生'],小B莫名也被打上了标签问题出在tags = []是类属性,所有实例共享同一个列表。一个实例往里append,所有实例都能看到变化。解决办法很简单:可变属性放到__init__里,用self.tags = []创建实例独立的列表。
排查技巧也很固定:如果一个实例修改的数据“波及”到了其他实例,十有八九就是可变对象被做成了类属性。检查的时候先看类体里有没有直接定义[]、{}、set()。
6.2 __init__的默认参数和返回值:两个隐藏雷区
可变默认参数是函数层面的经典坑,放到类里照样踩:
def __init__(self, name, tags=[]): # 错误示范 self.name = name self.tags = tags默认参数在函数定义时就创建一次,所有没传tags的实例会共享同一个列表。正确写法是把默认值设为None:
def __init__(self, name, tags=None): self.name = name self.tags = tags if tags is not None else []另一个雷区是__init__的返回值。__init__这个方法只能返回None,如果写了return 1,解释器直接抛TypeError: __init__() should return None。很多人把__init__想象成构造函数,觉得可以返回一个对象,但实际上创建对象的过程由__new__负责,__init__只负责初始化,不负责返回东西。
6.3 用vars()和dir()快速定位缺属性和隐藏属性
排查类相关问题,有两个内置工具特别好用。
vars(obj)返回对象的属性字典,也就是obj.__dict__,可以清楚地看到实例上有哪些属性、值是什么。dir(obj)列出对象所有可访问的属性名和方法名,包括从父类继承来的。
比如子类实例缺属性,先执行vars(instance),如果里面没有name、age这些应该存在的键,基本可以断定父类__init__没被调用。再比如调试时看到_Student__name这种奇怪属性,用dir(obj)能看到全貌,就不会觉得代码“见鬼”了。
6.4 常见问题速查表
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
| 一个实例改了列表,其他实例也变了 | 可变对象被定义成类属性 | 检查类体里是否有[]、{}、set() |
| 子类实例缺父类的属性 | 子类__init__忘了调用super().__init__() | 用vars(instance)查看属性字典 |
| 打印对象输出一串内存地址 | 没实现__str__或__repr__ | 补实现,并确认方法名拼写正确 |
两个内容一样的对象用==是 False | 没实现__eq__ | 实现__eq__并定义相等逻辑 |
| 通过实例给类属性赋值后好像没生效 | 实例属性遮蔽了类属性 | 全局修改必须用类名.属性 = 新值 |
__init__里return一个非 None 值 | __init__不允许返回值 | 删掉return语句 |
7. 把类写得更像生产代码的几个习惯
7.1 判断一个类设计好坏的三个标准
第一,一个类能不能用一句话说清它是干什么的。如果答案里出现“还有”“顺便”这些词,说明职责不够单一。比如“这个类管理学生信息,还负责发邮件”,这就是两个职责搅在一起,以后任何一个需求变了都要跑来改这个类。
第二,类之间的关系是组合还是继承。两个类之间如果没有明确的“is-a”关系,优先考虑组合。比如“订单”包含“商品列表”,这就是组合;只有“学生”确实是“人类”的一种,继承才合理。
第三,对外接口是否稳定。类的方法名和参数尽量少变,因为外部调用方为了等你改接口要跟着改代码。宁可内部实现翻天覆地,也要保持对外方法签名稳定,这其实就是封装带来的直接约束。
7.2 先写最小可用类,再考虑“高大上”特性
很多初学者一上来就想写个大而全的类,类方法、静态方法、特殊方法、继承全都要用上,结果写出来的类又绕又难调。我的建议是:先写一个最简单但能跑的类,只包含__init__和一个核心业务方法。让它先产生几个对象,把数据流跑通。然后再根据实际使用的不方便,逐步加__str__、__eq__、property,最后再考虑是不是要抽父类。
类就是这样,一开始只是一个对象工厂,用着用着发现很多地方重复了,再提炼。与其一开始设计一个“完美”的类,不如让它从简单需求里自然生长出来。这种“需求驱动”的做法,比“功能堆砌”更能训练设计能力。
7.3 学习路径建议:怎么把这一课吃透
看完这篇别急着翻到下一节,立刻做一个综合小练习:设计一个Order类,包含订单项列表、总价计算、格式化输出(实现__str__)、按订单号判断相等(实现__eq__)、支持for遍历所有订单项(实现__iter__)。再做一个小项目:一个MemberOrder继承它,多一个会员折扣方法。做完这个,第二课的内容你就基本消化了。
我每次带新人,都会让他们把类和对象的例子在纸上画出“哪些数据属于类,哪些数据属于实例”,这张图画明白,后面继承、多态、property 都顺了。“类和对象(2)”难不是难在语法,难在思维转换:从“写流程”切换到“设计类型”。多写、多改、多看别人优秀的代码,这个弯你一定会转过来的。