1. 从“第二十三课”说起:为什么“类”是绕不开的分水岭
如果你正在跟着一套系统课程学编程,翻到“第二十三课”这个位置,大概率已经过了变量、循环、函数这几道坎。前面的内容虽然也有难点,但基本属于“把一件事写清楚”的范畴。到了“类”这一课,事情开始变得不一样了——你不再只是写一条条按顺序执行的指令,而是开始描述“一类东西应该长什么样、能做什么”。
这个转变,是很多人学编程过程中第一个真正的分水岭。我见过不少朋友,前面二十几课学得顺风顺水,到“类”这里突然卡住,不是语法看不懂,而是脑子里没有建立起“用代码描述一个概念”的思维方式。他们能写出class Dog:这样的代码,但说不清楚为什么要这么写,更不知道什么时候该用类、什么时候不该用。
所以这篇内容,我想把“什么是类”这件事彻底讲透。不是照本宣科地念语法,而是从“为什么需要类”这个根子上聊起,把类的本质、构成、使用场景、常见误区、实操细节全部拆开揉碎。适合正在学第二十三课的你,也适合学了类但一直用不利索、想回头补课的朋友。核心关键词就一个:类。围绕它,我们会聊到对象、属性、方法、实例化、封装这些配套概念,但主线始终是回答“类到底是什么、怎么用、为什么这么用”。
我个人的习惯是,学任何新概念之前先问三个问题:它解决什么问题?没有它会怎样?有了它之后代码发生了什么变化?带着这三个问题往下看,你会比死记语法的人快得多。
2. 类的本质拆解:它到底在描述什么
2.1 从“一堆变量和函数”到“一个整体”
假设你要写一个程序管理学生信息。不用类的话,你可能会这么干:
student1_name = "A同学" student1_age = 18 student1_score = 92 student2_name = "B同学" student2_age = 19 student2_score = 85两个学生还能忍,二十个学生呢?两百个呢?变量名会变成灾难,而且你没法方便地把“一个学生的所有信息”作为一个整体传递、存储、处理。你可能会想到用字典:
student1 = {"name": "A同学", "age": 18, "score": 92} student2 = {"name": "B同学", "age": 19, "score": 85}这已经好多了,数据被组织成了一个整体。但问题还在:如果我想给每个学生加一个“计算等级”的行为,怎么办?只能再写一个独立的函数,把字典传进去:
def get_level(student): if student["score"] >= 90: return "优秀" elif student["score"] >= 60: return "及格" else: return "不及格"数据和操作数据的逻辑是分离的。字典里只有数据,函数在外面飘着。这种写法在简单场景下没问题,但一旦系统变大,你会发现“学生相关的数据”和“学生相关的操作”散落在各处,改一个地方要翻好几个文件。
类的出现,就是为了把“数据”和“操作数据的行为”绑在一起。用类改写之后:
class Student: def __init__(self, name, age, score): self.name = name self.age = age self.score = score def get_level(self): if self.score >= 90: return "优秀" elif self.score >= 60: return "及格" else: return "不及格"现在,一个Student对象既知道自己叫什么、多大、考了多少分,也知道自己该被评为什么等级。数据和行为成了一个整体。这就是类最核心的价值:把相关的数据和操作封装在一起,形成一个自洽的单元。
2.2 类不是“数据”,而是“数据的模板”
初学者最容易混淆的一点,是把类和对象当成一回事。这里必须掰清楚。
类(class)是模板、是图纸、是概念。对象(object,也叫实例 instance)是根据模板造出来的具体东西。
打个比方:Student这个类,就像学校发的空白学生登记表。表格上规定了要填姓名、年龄、成绩,还规定了“根据成绩算等级”的规则。但这张空白表本身不是任何一个学生。你拿这张表复印一份、填上“A同学、18、92”,这份填好的表才是一个具体的对象。
代码里体现得更清楚:
class Student: def __init__(self, name, age, score): self.name = name self.age = age self.score = score s1 = Student("A同学", 18, 92) s2 = Student("B同学", 19, 85)Student是类,s1和s2是两个独立的对象。它们都遵循Student这个模板的结构,但各自的数据互不影响。改s1.name不会动到s2.name。
提示:判断一个东西是类还是对象,最简单的办法是看它能不能直接“用”。类通常需要经过实例化(调用一次)才能得到一个可用的对象。
Student是类,Student("A同学", 18, 92)的结果才是对象。
2.3__init__到底在干什么
很多人对__init__的理解停留在“初始化方法”这个标签上,但说不清它和普通方法的区别。我用一句话概括:__init__是对象被创建时自动执行的“装配说明书”。
当你写s1 = Student("A同学", 18, 92)时,发生了这么几件事:
- 系统先创建一个空的
Student对象。 - 自动调用
__init__方法,把这个空对象作为self传进去。 - 同时把你写的
"A同学"、18、92分别传给name、age、score。 __init__内部执行self.name = name等赋值,把数据“装”到对象身上。- 装配完成后,把对象返回给
s1。
所以self不是关键字,它只是“当前这个对象”的代号。你完全可以写成this或者别的名字,但 Python 社区约定俗成用self,换了名字别人看着别扭,不建议标新立异。
这里有个实操细节值得注意:__init__不是“创建对象”的方法,对象在调用__init__之前就已经被创建出来了。__init__只负责往这个空壳里填东西。真正创建对象的是__new__,但那是更底层的机制,日常写业务代码基本用不到,第二十三课这个阶段不需要深究。
2.4 属性:对象身上挂着的“数据口袋”
属性就是对象身上存储数据的变量。在类内部,通过self.xxx定义和访问;在类外部,通过对象名.xxx访问。
s1 = Student("A同学", 18, 92) print(s1.name) # A同学 print(s1.score) # 92 s1.score = 95 # 直接修改属性 print(s1.score) # 95属性分两种:实例属性和类属性。实例属性是每个对象各自一份的,比如name、age、score,A同学和B同学的名字不一样。类属性是挂在类上的、所有对象共享的:
class Student: school = "某中学" # 类属性 def __init__(self, name, age, score): self.name = name self.age = age self.score = scoreschool是类属性,所有Student对象都能读到它,而且改一次类属性,所有对象看到的都变。但要注意:如果某个对象写了s1.school = "另一所学校",那只是给s1这个对象单独加了一个实例属性,不会影响其他对象,也不会改到类属性本身。这个坑我在实际项目里见过好几次,排查半天才发现是有人不小心在实例上覆盖了类属性。
2.5 方法:对象会做的“动作”
方法是定义在类里面的函数,第一个参数固定是self。通过对象名.方法名()调用时,Python 会自动把对象本身作为self传进去。
class Student: def __init__(self, name, score): self.name = name self.score = score def get_level(self): if self.score >= 90: return "优秀" elif self.score >= 60: return "及格" return "不及格" s1 = Student("A同学", 92) print(s1.get_level()) # 优秀注意s1.get_level()括号里什么都没写,但方法定义里有一个self。这个self就是s1自己。方法内部通过self.score拿到的是s1的成绩,不是别人的。这就是“方法能操作自己对象的数据”的含义。
方法让对象有了“行为”。没有方法的类,只是一个数据结构;有了方法,对象才真正成为一个能干活的东西。这也是类和字典最本质的区别——字典只能存数据,类能存数据还能定义行为。
3. 为什么非得用类:几个真实场景的对比
3.1 场景一:多个同类数据的批量管理
假设你在做一个成绩统计的小工具,需要处理一个班的学生。用字典列表的写法:
students = [ {"name": "A同学", "score": 92}, {"name": "B同学", "score": 85}, {"name": "C同学", "score": 58}, ] def get_level(score): if score >= 90: return "优秀" elif score >= 60: return "及格" return "不及格" for s in students: print(s["name"], get_level(s["score"]))用类的写法:
class Student: def __init__(self, name, score): self.name = name self.score = score def get_level(self): if self.score >= 90: return "优秀" elif self.score >= 60: return "及格" return "不及格" students = [Student("A同学", 92), Student("B同学", 85), Student("C同学", 58)] for s in students: print(s.name, s.get_level())两种写法都能跑,输出一样。区别在哪?当需求变复杂时,类的优势会迅速放大。比如要给每个学生加“加分”“扣分”“查历史成绩”“导出报告”这些功能,用字典的话,每个功能都是一个独立函数,参数传来传去;用类的话,这些功能都是学生自己的方法,调用起来是s.add_score(5)、s.export_report(),语义清晰,代码组织也更自然。
3.2 场景二:需要保证数据一致性
假设学生成绩必须在 0 到 100 之间。用字典的话,你没法阻止别人写student["score"] = 999,只能在每次用之前手动检查。用类的话,可以在方法里做校验:
class Student: def __init__(self, name, score): self.name = name self.set_score(score) def set_score(self, score): if not 0 <= score <= 100: raise ValueError("成绩必须在0到100之间") self.score = score这样所有修改成绩的入口都收口到set_score方法里,校验逻辑只写一次,不会漏。这就是封装带来的好处:把数据保护起来,只通过受控的接口访问。当然 Python 没有强制私有属性,但通过方法收口是一种约定,团队协作时非常有用。
3.3 场景三:需要多个“变种”共享基础能力
假设你不只管理学生,还要管理老师。老师和学生都有姓名、年龄,但老师有工号、教的科目,学生有成绩、班级。如果各写各的,姓名年龄这些重复逻辑要写两遍。用类继承可以解决:
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, score): super().__init__(name, age) self.score = score class Teacher(Person): def __init__(self, name, age, subject): super().__init__(name, age) self.subject = subjectStudent和Teacher都继承了Person,自动拥有name、age和introduce方法,不用重复写。这就是类在代码复用上的威力。第二十三课可能还没讲到继承,但你现在就应该知道:类不只是“把数据和函数打包”,它还是构建复杂系统的基本积木,继承、多态这些高级特性都建立在类的基础上。
3.4 什么时候不该用类
说了这么多类的好处,也得说说什么情况别硬上类。我见过一些初学者,学了类之后看什么都想封装成类,结果写出一堆只有一个方法、没有任何状态的“类”,纯粹是给代码加了一层没必要的壳。
判断标准很简单:如果一组数据总是成组出现,并且有明确的行为依附于它们,就用类;如果只是临时传几个参数、算一个结果,用函数就够了。比如写一个add(a, b)求两数之和,没必要搞一个Calculator类。但如果是一个计算器,有历史记录、有当前值、有撤销功能,那用类就合适。
提示:不要为了用类而用类。类是组织代码的工具,不是目的。工具用对了地方才有价值。
4. 手把手实操:从零写一个能用的类
4.1 需求定义:先想清楚要描述什么
我们拿一个“购物车”作为实操案例。需求是:一个购物车能添加商品、移除商品、计算总价、显示清单。每个商品有名称和单价。
先别急着写代码,用大白话把需求捋一遍:
- 商品:有名字、有单价。
- 购物车:能装多个商品,能加能减,能算总价,能列出所有商品。
这里其实有两个概念:商品和购物车。商品适合用一个类描述,购物车适合用另一个类描述。购物车里装的是商品对象。这种“一个类里包含另一个类的对象”的结构,在实际开发中非常常见。
4.2 第一步:定义商品类
class Item: def __init__(self, name, price): self.name = name self.price = price def __str__(self): return f"{self.name}({self.price}元)"__str__是一个特殊方法,定义“打印这个对象时显示什么”。不定义的话,打印出来是一串看不懂的内存地址。定义了之后,print(item)会输出人类可读的内容。这个技巧在实际开发中很实用,调试的时候一眼就能看清对象里是什么。
4.3 第二步:定义购物车类
class Cart: def __init__(self): self.items = [] def add(self, item): self.items.append(item) def remove(self, name): for item in self.items: if item.name == name: self.items.remove(item) return True return False def total(self): return sum(item.price for item in self.items) def show(self): if not self.items: print("购物车是空的") return for item in self.items: print(item) print(f"总计:{self.total()}元")几个细节值得说:
self.items = []在__init__里初始化,保证每个购物车对象都有自己独立的商品列表。如果写成类属性items = [],所有购物车会共享同一个列表,这是新手常踩的坑。remove方法按名称查找并删除,找到返回True,没找到返回False,让调用方知道操作结果。total用了生成器表达式,简洁高效。show处理了空购物车的情况,避免打印一片空白让人困惑。
4.4 第三步:跑起来看看
cart = Cart() cart.add(Item("苹果", 5)) cart.add(Item("香蕉", 3)) cart.add(Item("牛奶", 12)) cart.show() # 苹果(5元) # 香蕉(3元) # 牛奶(12元) # 总计:20元 cart.remove("香蕉") cart.show() # 苹果(5元) # 牛奶(12元) # 总计:17元到这里,一个能用的购物车就完成了。你可以继续扩展:加数量、加折扣、加优惠券、持久化到文件。每加一个功能,都是往对应的类里加方法,结构清晰,不会乱。
4.5 参数选择与命名规范
写类的时候,命名有几个约定俗成的规矩,遵守了代码可读性会好很多:
| 元素 | 命名风格 | 示例 |
|---|---|---|
| 类名 | 大驼峰 | ShoppingCart、StudentInfo |
| 方法名 | 小写加下划线 | get_total、add_item |
| 属性名 | 小写加下划线 | user_name、total_price |
| 私有属性 | 前缀单下划线 | _internal_cache |
| 常量 | 全大写 | MAX_SIZE |
这些不是强制的,但整个 Python 社区都这么写,你不这么写别人读起来会别扭。特别是类名用大驼峰这一条,几乎是铁律,看到student小写开头,有经验的人会下意识以为这是个变量或函数,而不是类。
提示:属性名不要和内置函数重名,比如
list、dict、type。虽然语法上允许,但会覆盖内置名字,后面想用内置功能时会出问题。
5. 常见问题与排查技巧实录
5.1 忘记写 self 导致报错
这是最高频的错误,没有之一。症状是调用方法时报TypeError: xxx() takes 0 positional arguments but 1 was given,或者方法内部访问属性时报NameError: name 'xxx' is not defined。
原因通常是定义方法时漏了self参数,或者访问属性时漏了self.前缀。比如:
class Student: def __init__(self, name): name = name # 错误:没有 self. self.name = name # 正确第一行name = name只是把参数赋值给参数自己,对象上根本没有name属性。后面访问self.name就会报错。排查方法:检查每个方法定义的第一个参数是不是self,检查每个属性访问是不是都带了self.。
5.2 可变默认参数的坑
这个问题在函数那一课就该讲,但在类里同样常见:
class Cart: def __init__(self, items=[]): # 危险! self.items = items这样写的话,所有不传items的Cart对象会共享同一个列表。一个购物车加了东西,另一个购物车也会看到。正确写法是用None做默认值:
class Cart: def __init__(self, items=None): self.items = items if items is not None else []这个坑非常隐蔽,因为单独测试一个对象时完全正常,只有创建多个对象时才会暴露。我建议养成习惯:只要默认值是列表、字典、集合这类可变对象,一律用None代替,在函数体内再创建。
5.3 类属性和实例属性搞混
前面提过,类属性是所有对象共享的,实例属性是各自独立的。看这个例子:
class Counter: count = 0 def __init__(self): Counter.count += 1 a = Counter() b = Counter() print(Counter.count) # 2这里count是类属性,每次创建对象都通过Counter.count修改类属性本身,所以能正确计数。但如果写成self.count += 1,情况就变了:self.count += 1等价于self.count = self.count + 1,会先读类属性,然后创建一个同名的实例属性,类属性根本没变。这是很多人写计数器时踩的坑。
5.4 常见问题速查表
| 报错信息 | 可能原因 | 解决方法 |
|---|---|---|
TypeError: xxx() takes 0 positional arguments but 1 was given | 方法定义漏了self | 在方法第一个参数加上self |
AttributeError: 'Xxx' object has no attribute 'yyy' | 属性没在__init__里定义,或拼写错误 | 检查__init__是否赋值了该属性,检查拼写 |
NameError: name 'xxx' is not defined | 方法内访问属性漏了self. | 加上self.前缀 |
| 多个对象数据互相影响 | 用了可变类属性或可变默认参数 | 改为在__init__里创建实例属性 |
| 打印对象显示内存地址 | 没定义__str__或__repr__ | 添加__str__方法返回可读字符串 |
| 子类初始化父类属性失败 | 没调用super().__init__() | 在子类__init__里调用父类初始化 |
5.5 调试类的小技巧
调试类相关问题时,我习惯在__init__末尾加一句print(f"创建了 {self}"),配合定义好的__str__,能清楚看到对象是什么时候被创建的、创建了几个。排查“对象莫名多出来”或“对象没被创建”这类问题时特别有效。
另一个技巧是用vars(obj)查看对象身上所有的实例属性,返回一个字典。有时候你以为是属性没赋值,一看vars才发现是名字写错了。dir(obj)则能看到对象能访问的所有属性和方法,包括从类继承来的,排查“这个方法到底存不存在”时很好用。
提示:
vars()和dir()是内置函数,任何对象都能用,不需要额外导入。调试阶段随手用,比打断点还快。
6. 从类到对象思维:一次认知升级
学完第二十三课,你掌握的不只是一个语法结构,而是一种新的组织代码的方式。以前你写代码是“第一步做什么、第二步做什么”,是线性的、流程式的。现在你开始思考“这个系统里有哪些角色、每个角色有什么数据和行为、角色之间怎么交互”,这是面向对象思维的开端。
我个人的体会是,从“函数思维”转到“对象思维”,最难的不是语法,而是习惯。刚开始你会觉得用类写代码比用函数麻烦,定义一堆东西才能跑起来。但当你写的程序超过几百行、需要多人协作、需要反复修改扩展时,类带来的结构清晰度和可维护性会成倍回报你。
最后分享一个我常用的练习方法:拿你身边任何一个东西,试着用类描述它。比如你的手机,它有什么属性(品牌、颜色、电量),有什么方法(开机、关机、充电)。写出来,实例化几个不同型号的手机,看看它们怎么各自独立工作。这种练习做上十几次,类的思维就长在你脑子里了。
后续你还会学到继承、多态、魔术方法、设计模式,这些全都建立在今天这一课的基础上。把类吃透,后面的路会顺很多。