引言
这门课的前七章带你走完了「用 Python 写清楚一段逻辑」的全部基础:第二章学会用变量与类型描述数据,第三章学会用流程控制驱动逻辑,第四章学会用函数抽象行为,第六章学会用字典组织记录。但你有必要停下来想想:如果程序里有五十个学生、三百张订单,每张订单还有明细行,我们该把这些数据放哪里?答案通常是字典——student = {"name": "Alice", "age": 20}。可问题随之而来:操作数据的函数散落各处,print_student(s)、update_student(s, ...) 靠参数把同一份数据传来传去;有人写 "name",有人写 "years",字典的格式毫无约束;一旦某个字段改名,所有相关函数都要被翻出来改一遍。数据与操作被撕成两半,程序的复杂度开始失控。
本篇是第八章「面向对象入门」的第一课。这一章共十篇,路线是:先花七篇把核心机制逐个讲透(类与实例、__init__ 初始化、三种方法、封装与 property、继承、多态、魔法方法),再用两篇做完整实战(图书管理系统、订单与库存建模),最后以一篇综合测验收尾。本篇解决整个章节最根本的问题:类(class)与实例(instance)到底是什么?它们之间是什么关系?为什么说这是「思维转变」?
阅读前提:熟悉变量与类型(第二章)、函数与参数(第四章)、字典与引用概念(第六章)。作用域与闭包的知识本篇用不到,但第四章「函数是一等对象」的思想很重要——本篇文章末尾你会发现,类在 Python 里同样是一等对象。
概念与原理
从「散装数据」到「模板 + 对象」
先回顾一下用字典组织数据的三个固有缺陷。第一,结构无保证:两处代码创建同一种记录时,没人强迫它们用相同的键;第二,操作无归属:处理数据的函数放在模块顶层,和数据的定义分离,调用时要手动把数据传进传出;第三,无法区分「同型不同源」:两本同名同作者的书,在字典视角下就是两个内容相同的字典,语言层面无法表达「它们是同一本书的两个副本、还是两本不同的书」这种语义。
面向对象编程(Object-Oriented Programming,OOP)对这三个问题的回应是一句话:把数据与操作它的函数打包成一个整体,并让这个整体可重复产出。打包所用的「模具」叫类(class),模具产出的每一个具体对象叫实例(instance)。打一个三合一的比喻:
- 图纸与楼房:图纸永远只有一张,照图纸可以盖出一百栋楼;图纸决定每栋楼都有哪些房间(结构),但每栋楼的门牌号、住户各不相同(状态)。
- 饼干模具与饼干:模具决定了饼干的形状,每个饼干是独立的一块。
- 表格模板与表格:Excel 里的表头是一行(列名固定),每一行数据是一个具体的记录。
这三者的共同点是:模板描述「长什么样」,实例承载「具体值」。类在 Python 里正是干这个的。
class 语法与类属性
用 class 关键字定义类,类体缩进书写,就像 def 定义函数那样。类体顶层的赋值语句创建的是类属性(class attribute),它存在类的命名空间里,被所有实例共享:
class Student:
school = "阳光大学"
s1 = Student()
s2 = Student()
print(s1.school, s2.school)
print(type(s1).__name__)
print(type(s1))
print("两个实例是同一个对象吗:", s1 is s2)
print("id 是否相同:", id(s1) == id(s2))
阳光大学 阳光大学
Student
<class '__main__.Student'>
两个实例是同一个对象吗: False
id 是否相同: False
逐行解读这段输出。Student() 是实例化(instantiation):把类名当成函数来调用,Python 就会创建一个全新的实例并返回它。s1 和 s2 各自独立——is 比较返回 False,id() 不相同。这里给 id() 一个准确的说法:语言规范只保证 id 在对象的生命周期内唯一,CPython(你在第一章装的那个解释器)的实现里它就是对象的内存地址,所以你也可以把它理解为「对象在内存中的门牌号」。两个实例是两个不同的对象,type(s1).__name__ 是 Student,type(s1) 打印出 <class '__main__.Student'>,意思是「s1 的类型是名为 Student 的类」。
关于命名,注意一个细节:类名用帕斯卡命名法(PascalCase),每个单词首字母大写,例如 Student、OrderLine、HttpClient;而实例变量沿用变量命名的 snake_case,例如 s1、alice。这与第二章讲的变量命名规范一脉相承——类名大写是为了让你在代码里一眼认出「这是一个类」。
两个命名空间:实例属性和类属性
现在做一个小实验:不给类加任何属性,而是直接把属性挂到实例上。
class Student:
school = "阳光大学"
s1 = Student()
s2 = Student()
s1.name = "Alice"
s2.name = "Bob"
print("类命名空间:", [k for k in Student.__dict__ if not k.startswith("__")])
print("s1 命名空间:", s1.__dict__)
print("s2 命名空间:", s2.__dict__)
类命名空间: ['school']
s1 命名空间: {'name': 'Alice'}
s2 命名空间: {'name': 'Bob'}
每个对象都有一本内部「属性账本」,Python 里它叫 __dict__(命名空间,namespace)。从输出可以看到:school 记在类的账本上,而 s1.name = "Alice" 这一句把 name 写进了 s1 自己的账本,与 s2 互不相干。这就是上一篇(第六章)讲引用时你见过的思想在类层面的重现:每个实例是独立的对象,各自持有一份数据。
注意 __dict__ 本身也是以双下划线开头的特殊属性,过滤掉它和 __module__、__doc__ 这些「系统账目」之后,类账本上只剩我们定义的 school。这套「类一份账本、实例各一份账本」的布局,是理解后面所有陷阱的基石。
属性查找:实例 → 类 → object
那 s1.school 到底是怎么找到值的?school 不在 s1 的账本里,Python 是怎么知道的?答案是属性查找(attribute lookup),顺序固定为三段:
- 先查实例的
__dict__; - 没找到,再查类的
__dict__; - 还没找到,就沿继承链向上(本篇只有一个类,继承链的顶端是
object),找object提供的属性; - 全部落空,抛出
AttributeError。
class Student:
school = "阳光大学"
s1 = Student()
s1.name = "Alice"
print(s1.name) # 第 1 档:实例命名空间命中
print(s1.school) # 第 2 档:实例没有,去类命名空间
print(type(s1.__str__)) # 第 3 档:一路找到 object 的内建方法
print(hasattr(s1, "age")) # 全链路都没有:视为没有该属性
Alice
阳光大学
<class 'method-wrapper'>
False
__str__ 就是一个典型例子:你自己没定义它,Student 类里也没有,它来自 object——所有类的共同祖先。正因为它存在,任何一个实例都能被 print() 输出成一段默认文本(具体的输出格式本篇不必深究)。hasattr(s1, "age") 返回 False,说明「全链路都找不到」被当作「没有这个属性」。
这里必须澄清一个初学者普遍的误解:属性查找(attribute lookup)与变量名解析(name resolution)是两回事。找不到属性抛 AttributeError;而代码里直接写一个没有定义的名字,抛的是 NameError。Python 找不到变量名时,最后会去 builtins 模块里找(比如 len、print 之所以处处可用),但属性查找不会去 builtins 里兜底——s.len 和 len 是两码事:
class Student:
pass
s = Student()
# s.age # 属性缺失 → AttributeError
# print(age) # 名字缺失 → NameError(假设全局也没有 age)
AttributeError: 'Student' object has no attribute 'age'
NameError: name 'age' is not defined
这两条错误信息分别是本机实测的输出。把两者区分清楚,日后看报错信息会少走很多弯路。
类也是对象
最后一个反直觉的事实:类本身也是对象。class 语句执行完毕后,得到的 Student 是一个「类对象」,它也有类型——它的类型是 type。
class Student:
pass
s = Student()
print(type(s)) # 实例的类型:Student 类
print(type(Student)) # 类的类型:type(元类)
print(type(type(Student)))
print(Student.__name__)
print(isinstance(s, Student))
print(isinstance(s, object))
print(isinstance(Student, type))
<class '__main__.Student'>
<class 'type'>
<class 'type'>
Student
True
True
True
type(Student) 是 <class 'type'>,也就是说类对象由 type 这个**元类(metaclass)**创建——「创建类的类」就叫元类,这个概念本篇点到为止,元编程的细节会到很后面的章节(第 16 章)才展开。对现在的你来说,需要记住两点:其一,isinstance(s, object) 为 True,因为实例的继承链顶端是 object;其二,类既然也是对象,第四章「函数是一等对象」的精神同样适用于类——类可以被赋值给变量、放进列表、传进函数。很多框架(比如你以后会遇到的 Web 框架、ORM)正是利用这一点在运行时摆弄类。
类属性共享与遮蔽的完整规则
把「类一份账本、实例各一份账本」加上「查找先实例后类」,三类行为就全部可以推演出来了:
- 修改类的属性(
Student.school = ...),所有实例立刻看到新值——因为查找总会落到类账本; - 给实例赋值一个与类属性同名的属性(
s1.school = ...),实例账本多出一条,从此 s1 的查找第一步就命中,类账本被「遮」住了,这叫做遮蔽(shadowing); - 删除实例属性(
del s1.school),查找回落到类账本,原来的类属性重新可见。
操作与实现
下面把这套规则完整跑一遍,再做一个综合示例。
共享、遮蔽与回落
class Student:
school = "阳光大学"
s1 = Student()
s2 = Student()
Student.school = "星辰大学"
print(s1.school, s2.school)
s1.school = "自定义学校"
print(s1.school, s2.school)
del s1.school
print(s1.school, s2.school)
星辰大学 星辰大学
自定义学校 星辰大学
星辰大学 星辰大学
三段输出对应三条规则:改类属性,全员同步;实例赋值,仅自己遮蔽;删掉实例属性,恢复共享。注意第二段输出里 s2 一直显示「星辰大学」——s1 的遮蔽完全没有波及它。
综合示例:学习平台的课程卡片
把概念拼成一个贴近真实工程的例子。一个学习平台要展示课程,课程元数据(所属平台)全站共享,每门课的价格、标题各不相同:
class Course:
platform = "知行学堂" # 类属性:全站课程共享
course_a = Course()
course_b = Course()
course_a.title = "Python 基础"
course_a.price = 0
course_b.title = "数据结构与算法"
course_b.price = 66
print(course_a.title, "|", course_a.platform, "|", course_a.price)
print(course_b.title, "|", course_b.platform, "|", course_b.price)
Course.platform = "知行学堂 v2"
print("平台升级后:", course_a.platform, "——", course_b.platform)
Python 基础 | 知行学堂 | 0
数据结构与算法 | 知行学堂 | 66
平台升级后: 知行学堂 v2 —— 知行学堂 v2
平台改名后,两门课显示的平台同时更新——这就是「共享」在真实场景中的价值:同类的公共信息只存一份,改一处处处生效。但你也应该已经隐约感到不安:手写 course_a.title = ... 太繁琐,而且课程信息分散在实例化之后的三个赋值语句里,漏写一个字段也不会报错。这个痛点正是下一篇的主角 __init__ 要解决的——先按下不表。
易错点与陷阱
陷阱一:实例化时忘写括号,属性被写进类账本
Student 和 Student() 天差地别:前者只是把类对象引用赋给变量(第四章讲过,函数不带括号也只是引用),后者才真正创建实例。忘写括号的后果非常隐蔽——给「实例」赋值时,写的其实是类属性:
class Student:
school = "阳光大学"
s1 = Student # 忘了括号:s1 只是类的别名
s1.name = "Alice" # 这一句把属性写到了类上!
s2 = Student()
print(s2.name) # Alice——被污染
Alice
s2 刚出生,什么都不该有,却读到了 name——因为 s1.name = "Alice" 实际上执行的是 Student.name = "Alice",写进了类账本,所有实例共享。这类 bug 不报错、不崩溃,直到数据显示错乱才被发现,排查时又极难定位。判断口诀:调用类名记得带括号。
与之相对的另一种报错形态是:类本身的账本里确实没有这个属性,于是抛 AttributeError:
class Student:
school = "阳光大学"
# print(Student.age) # 类账本里没有 age
AttributeError: type object 'Student' has no attribute 'age'
(错误信息的句式固定为 type object '类名' has no attribute '属性名',属性名会随你访问的名字变化——比如访问 Student.name 时,末尾就是 'name'。)
陷阱二:可变的类属性被全实例共享「传染」
类属性是共享的,这本身不难理解;但它共享的是同一个对象——如果类属性是列表、字典这类可变对象,任何一个实例通过它去修改内容,等于在改所有人的共享数据:
class ShoppingCart:
items = [] # 类属性:所有实例共享同一个列表
cart_a = ShoppingCart()
cart_b = ShoppingCart()
cart_a.items.append("苹果")
print(cart_b.items)
class ShoppingCartV2:
def __init__(self):
self.items = [] # 实例属性:每个购物车独立(下一篇详解 __init__)
ca = ShoppingCartV2()
cb = ShoppingCartV2()
ca.items.append("苹果")
print(cb.items)
['苹果']
[]
cart_a 只是往自己的 items 里添了苹果,cart_b 的购物车却凭空多出一袋苹果——因为 cart_a.items 查找落到类账本,两人手里是同一个列表对象(第六章的引用概念,在这里是同一个对象的不同名字)。修复方式即第二段代码:把 items 变成实例属性,让每辆车各持一份。而「在创建实例的同时自动完成这份赋值」,正是下一篇的主角——__init__。
陷阱三:混淆 AttributeError 与 NameError
这一条在「属性查找」一节已经铺垫:属性查找走「实例 → 类 → object」链路,失败抛 AttributeError;变量名字解析走「局部 → 全局 → builtins」,失败抛 NameError。见到 AttributeError: 'Student' object has no attribute 'xxx' 时,检查的是「有没有给实例/类赋这个属性,或拼写对不对」;见到 NameError: name 'xxx' is not defined 时,检查的是「这个变量名有没有被赋值」。
小结
本篇完成了从「散装数据」到「模板 + 实例」的思维转变:类(class)是模板,用 class 关键字定义,类体顶层的赋值生成被所有实例共享的类属性;实例(instance)是模板产出的具体对象,用 ClassName() 实例化,各自持有独立的实例属性。每个对象(包括类自身)都有一本 __dict__ 账本,属性查找按「实例 → 类 → object」的顺序进行,全线落空则抛 AttributeError。类本身也是对象,由元类 type 创建。最后必须警惕:忘写实例化括号会把属性写进类账本,可变类属性会被所有实例共享。
下一篇我们将解决本篇留下的最大痛点——实例属性靠手动赋值既繁琐又易错,__init__ 让每个实例在出生那一刻就自动完成初始化。
练习与思考题
-
定义类
Employee,类属性company为"云杉科技"。创建两个实例并分别给它们赋值不同的name,验证:修改类属性后两个实例都看到新值;给其中一个实例赋同名属性后只有它看到自定义值。 -
不加任何代码,推断下面程序的输出,再实际运行验证:
class Box: label = "通用" b1 = Box() b2 = Box() b1.label = "b1 专用" Box.label = "改过" del b1.label print(b1.label, b2.label)答案导向:
b1经过「遮蔽 → 删除回落」两步,最后显示「改过」;b2始终显示「改过」。 -
用
__dict__验证「类也是对象」:定义一个空类A并实例化a,分别打印A.__dict__与a.__dict__,观察两者的键集合有何不同,思考哪些键是什么时候写进去的。 -
思考题:控制台报
AttributeError: type object 'Course' has no attribute 'title',与报NameError: name 'course' is not defined,分别可能是哪类代码写错了?两者的排查方向有何不同?