Python 的类,很多初学者第一次接触的时候都会有这么个感觉:函数还没写利索,怎么又冒出来一个 class,这东西到底是干嘛的,为什么非要用它?我最早学的时候也绕了很久,后来写得多了才发现,类不是 Python 的某个高级炫技功能,而是一种帮你把代码组织得更像现实世界的方式。这篇文章我就从一个实际场景出发,把类的核心概念拆开讲清楚,包括构造方法、实例属性、方法、封装、继承这些绕不开的点,配合完整的代码示例和运行思路,尽量让零基础的人也能跟着写一遍就明白。
这篇文章适合什么阶段的人看?我觉得是你已经能熟练写函数、会用列表和字典存数据、但总觉得代码一复杂就乱成一团的时候。类正好是解决这个问题的一把钥匙。读完你至少能回答这几个问题:类到底是什么,self 为什么每个方法都要写,封装和继承在解决什么痛点,以及我该怎么开始设计自己的第一个类。
1. 类到底解决什么问题:从一份学生成绩单说起
1.1 只用函数和字典时,代码为什么会越来越别扭
先设想一个场景。你帮某老师写一个小程序,管理班里学生的成绩。一开始很简单,每个学生有姓名、学号、三科成绩,你用字典存:
student1 = { "name": "小明", "student_id": "2025001", "math": 88, "chinese": 92, "english": 85 }然后写几个函数来处理这些数据,比如计算总分和平均分:
def total_score(student): return student["math"] + student["chinese"] + student["english"] def avg_score(student): return total_score(student) / 3用起来也还行。但问题在什么地方呢?当你开始有几十个学生、每个学生还要记录考勤、作业、课堂表现,甚至还要支持不同的考试批次时,你就会发现每个函数都得知道自己要操作的字典长什么样,字段名得靠文档去约定,少写一个 key 程序就报 KeyError,多写一个也没人帮你发现。代码看起来是写了不少,但真正写业务逻辑的时间反而少了,大部分时间花在保护数据和检查数据上。
你可能也试过用多个列表分别存储,比如 names 一个列表、math_scores 一个列表,然后用下标去对应。这种方式在小数据量下运行速度确实快,但逻辑上一旦插入或删除一个学生,所有列表的下标全都会错位,排错的时候头大得不行。
1.2 类提供了一种新的组织方式:数据和操作绑在一起
类的出现,就是为了解决“数据和操作它的函数互相分离”这个问题。它做的事情其实很朴素:把一类事物共有的属性,比如学生的姓名、学号、成绩,和这事物能干的事,比如算总分、算平均分、打印成绩单,打包放在一起。这样你就得到一个属于学生的类型,而不是若干个互不相关的字典和函数。
这个概念你可以类比成一张月饼模具。你写一个类,等同于制作了一个模具,模具本身不是月饼,但你拿它一压,就能批量产出结构一模一样的月饼。每产出一个月饼,在程序里就是一个对象,也叫实例。所有月饼都有相同的形状,但每块月饼的花色、重量可以不同,对应到程序里就是每个实例有自己的属性值。
这种思路就是面向对象编程的雏形。它的好处并不是让某一段代码运行得更快,而是让你的代码在使用者角度上更好懂、更好扩展。你把一个学生传给别人时,不再需要交代“这是一个字典,里面有 name、math、chinese、english 这些键”,你只需要说“这是一个 Student 对象”,对方就知道能用它调用 total_score(),这就是类型给你带来的信息量提升。
2. 类的核心组成:构造方法、属性和方法
2.1 定义第一个类:init和 self 是绕不开的两个点
直接用代码来感受。定义一个最简单的学生类:
class Student: def __init__(self, name, student_id, math, chinese, english): self.name = name self.student_id = student_id self.math = math self.chinese = chinese self.english = english def total_score(self): return self.math + self.chinese + self.english def avg_score(self): return self.total_score() / 3这里你至少要注意三个东西。
第一个,__init__,读作“double underscore init”,也常被叫作构造方法。它的作用是规定“创建这个对象时,必须提供哪些初始数据”。每当你写Student("小明", "2025001", 88, 92, 85)的时候,Python 会自动调用这个类的__init__,把你传入的那几个参数和刚创建出来的对象的属性绑定起来。它名字前后带着双下划线,是一种约定,说明这个是 Python 内部的钩子方法,不是给你随便起的普通函数名。
第二个,self。这个是初学者最容易迷糊的地方。你可以这样理解:self代表“当前这个对象自己”。比如你创建了s1和s2两个学生对象,那么当s1.total_score()被调用时,函数里的self就是s1;当s2.total_score()被调用时,self就是s2。Python 在调用实例方法时,会自动把对象本身作为第一个参数传进去,所以你在定义的时候必须留出这个位置,名字一般写成self,虽然不是语法强制,但全社区都这么写,建议不要改成别的。
第三个,self.name = name这种语句是在给实例绑定属性。左边self.name是“当前对象的 name 属性”,右边name是你传入的参数,两者名字碰巧一样,容易混淆。你可以把参数名换成student_name来测试,行为是等价的。
2.2 实例化和属性访问:对象是怎么真正“活”起来的
定义好类之后,创建对象的过程叫实例化。实例化的语法就是调用类名并传入所需参数:
s1 = Student("小明", "2025001", 88, 92, 85) s2 = Student("小红", "2025002", 95, 89, 93)现在每一个s1、s2都是一个独立的对象,有自己独立的属性副本。访问属性或调用方法,用点号.:
print(s1.name) print(s1.total_score()) print(s2.avg_score())运行输出:
小明 265 92.33333333333333这里有个容易忽略但很重要的点:你并没有给类定义什么复杂的数据结构,但对象天然就是一个能够携带数据的容器。你可以在创建之后,随时给一个单独的实例添加新的属性,这在 Python 里是允许的:
s1.class_position = "monitor"不过这种“随手添加属性”的做法我建议少用。它会让你类的结构变得不清晰,其他人在读代码时不知道这个对象到底有哪些属性。最好把所有属性都在__init__里先声明好,哪怕初始值是None,也算给使用者一个明确的预期清单。
另外一个新手常问的问题是:参数这么多,记不住顺序怎么办?我自己的习惯是,当构造方法的参数超过三个,就考虑用关键字参数去实例化,代码可读性会高很多:
s1 = Student(name="小明", student_id="2025001", math=88, chinese=92, english=85)这样写有另一个好处,你传参的顺序可以调换,只要名字对得上就行。对于需要可维护性的工程代码,这几乎是必选项。
3. 方法设计的细节:实例方法、类方法和静态方法怎么选
3.1 三种方法的区别,一句话说清
初学者看到@classmethod和@staticmethod这种装饰器的时候,经常一头雾水。其实它们的区别并不复杂,核心就看这个函数需不需要用到对象数据或类数据。
- 实例方法:第一个参数是
self,要用到具体对象的数据,也就是实例属性。前面写的total_score、avg_score都是这种。这是平时用得最多的方法。 - 类方法:第一个参数是
cls,不依赖某个具体实例,但能访问类级别的数据。用@classmethod装饰。适合写一些“备选的构造方式”。 - 静态方法:没有
self或cls,本质上就是一个普通的函数,只是因为逻辑上和这个类关系密切,所以放在类里面。用@staticmethod装饰。适合写一些工具性辅助函数。
用学生类的例子来扩展。假如每个学生都从同一个班级,班级名称应该所有学生共享,不太应该存在每个实例里,那就可以做成类属性。再比如,有时候你需要从一串字符串“小明,2025001,88,92,85”里创建一个学生对象,这用普通__init__做不到,因为参数数量和格式不匹配,这时候可以做一个类方法from_string:
class Student: class_name = "高三(1)班" def __init__(self, name, student_id, math, chinese, english): self.name = name self.student_id = student_id self.math = math self.chinese = chinese self.english = english @classmethod def from_string(cls, data_str): name, student_id, math, chinese, english = data_str.split(",") return cls(name, student_id, float(math), float(chinese), float(english)) @staticmethod def is_passing(score): return score >= 60调用方式:
s3 = Student.from_string("小刚,2025003,78,65,90") print(s3.name) print(Student.class_name) print(Student.is_passing(s3.math))从实用角度讲,初学者先掌握实例方法就够用八成的场景。类方法和静态方法可以等到你真的需要它们的时候再回顾,不会拖慢你的学习进度。
3.2 为什么说“方法”也是类的核心资产
类里的方法,决定了这个对象能干什么。设计类的过程,本质上就是先想清楚“这个对象有哪些数据”,再想清楚“这些数据要对外提供哪些操作”。数据是名词,方法是动词。学生这个名词,自然要配上一组动词:算总分、算平均分、打印成绩单、比较排名。
我见过很多初学者把方法写得特别长,一个方法里又算分又打印又写文件。这并不是说代码不能跑,而是后续很难测试和复用。更好的做法是让每个方法只做一件小事情。比如打印成绩单这个需求,可以拆成“生成一行格式化的字符串”和“决定把这字符串输出到哪里”两件事,前者用方法实现,后者在外面做。这样当你哪天想从打印到控制台改成写入文件时,只要改调用处,不需要动类内部的逻辑。
这种“高内聚、低耦合”的思想,是你从写小脚本走向写工程代码时最重要的一次思维升级。
4. 封装:把不该让外部直接改的数据藏起来
4.1 属性加一个下划线,是 Python 世界的君子协定
函数式写法里,所有数据都是公开的,谁拿到字典都能改。类给你提供了一层“控制边界”的可能性。封装说的就是:对象内部的数据不直接暴露给外部操作,外部只能通过你定义好的方法来间接访问或修改。
Python 在这点上很有意思,它并不像其他一些语言那样有强制的private关键字,它更多靠约定。属性名以一个下划线开头,比如_score,意思就是“按约定请你把它当成内部数据,别直接动”。这只是君子协定,你硬要在外部访问也拦不住。
两个下划线开头,比如__score,情况稍有不同。Python 解释器会对这种名字做一次改写,把它变成_ClassName__score,使得在类外面直接写s1.__score会报属性不存在的错误。这算是 Python 提供的有限度的“防手贱”机制,但它不是真正意义上的访问控制,更多防的是命名冲突。
我自己在实际项目中很少用双下划线。理由很简单:你没法完全阻止一个执着的人去访问数据,与其让代码看起来密不透风,不如用单下划线加上清晰的 getter/setter 方法,把使用规则表达清楚,合作开发时反而更顺畅。
4.2 用 @property 写出优雅的访问控制
Python 提供了一种把方法伪装成属性的写法,就是@property装饰器。看例子:
class Student: def __init__(self, name, math): self.name = name self._math = math @property def math(self): return self._math @math.setter def math(self, value): if value < 0 or value > 100: raise ValueError("成绩必须在0到100之间") self._math = value这样在外部的使用体验是:
s = Student("小明", 88) print(s.math) s.math = 95看起来就是直接操作属性,但内部其实经过了方法的校验。你不再需要写get_math()和set_math()这类啰嗦的调用。当你把成绩 88 改成 200 时,程序会主动抛错,而不是把脏数据悄悄存进去。
这种写法的价值在于:你可以随时在属性被访问或赋值的时候夹带一点逻辑,而使用者的代码不需要有任何变动。这就是封装带来的自由度。以后你想在成绩变化时打一条日志,或者在赋值时同步更新一个平均值缓存,都只要在这两个方法里加代码。
5. 继承与方法重写:站在已有类的基础上扩展
5.1 从 Student 延伸出 CollegeStudent,少写重复代码
继承解决的是代码复用问题。现实世界里有“学生”和“大学生”,大学生当然也是学生,有姓名学号成绩,但他可能多了专业、导师、选修课这些额外的信息。如果你重新定义一个大学生类,把学生的代码复制一份,那就违背了 DRY 原则,以后学生类要加一个“手机号”字段,大学生类也得同步改,十分痛苦。
继承的写法:
class Student: def __init__(self, name, student_id, math, chinese, english): self.name = name self.student_id = student_id self.math = math self.chinese = chinese self.english = english def total_score(self): return self.math + self.chinese + self.english class CollegeStudent(Student): def __init__(self, name, student_id, math, chinese, english, major): super().__init__(name, student_id, math, chinese, english) self.major = major def introduction(self): return f"我是{self.name},来自{major}专业"这里的关键是super().__init__(...),它调用的是父类Student的初始化逻辑。这句话之后,name、student_id等属性就已经绑定好了,你只需要再补充大学生特有的major。这样父类以后怎么改初始化逻辑,子类都能同步享受,不需要改动子类的代码。
继承关系用大白话描述就是“是一个”的关系。大学生是一个学生,所以适合继承。如果你发现两个类之间更多是“拥有”关系,比如学生拥有一个成绩单,那就不要用继承,而应该把成绩单作为学生的一个属性,这叫组合。初学阶段最常见的错误就是把所有有共性的类硬套进继承关系,最后搞得子类里一堆用不到的方法。判断标准很简单:能不能理直气壮地说“子类是一种父类”?说不出这句话,就不要用继承。
5.2 方法重写和多态让同一个接口适配不同行为
子类可以重新定义父类已有的方法,这叫方法重写。比如大学生和高中生算总分的方式一样,但大学生如果想打印自己成绩单的时候附上专业信息,就可以重写一个方法:
class CollegeStudent(Student): def total_score(self): base = super().total_score() return base + 5 # 虚拟举例:大学生有五分的综合素质加分调用时并不需要关心一个对象到底是 Student 还是 CollegeStudent,写法完全一样:
def print_score(obj): print(obj.total_score())这就是多态:同一个函数,传入不同的对象,会执行各自类里定义的那一版方法。多态没有多高深,它的价值在于让你可以把注意力从“这个具体对象是什么类型”转移到“这个对象能干什么事”上。写出来的函数天然地能兼容父类和所有子类,以后新增一个子类,这个函数一行都不用改。
5.3 组合优先于继承,这句经验之谈别忽略
面向对象设计里流传着一句话:组合优于继承。什么意思呢?还是拿学生举例子。如果你想给一个类加一个“班长职责”,不要试图派生出MonitorStudent,那样类的层次会越扩越复杂。更好的方式是让Student类持有一个Role属性,比如role = "班长",或者持有一个职责对象。这样每个学生都能拥有任何角色,而不需要为角色建一套继承树。
继承本身没有错,但它是强耦合的。父类一变,所有子类都可能受影响;组合只是持有关系,换一个组件不影响主类的结构。我会建议初学者优先使用组合,除非你能明确说出“是一个”的关系,并且确认多个类之间确实共用同一套行为接口。
6. 初学阶段最常踩的五个坑和排查思路
6.1 常见错误速查表
我把带初学者时见得最多的问题整理成了下面这张表,照着排查可以省不少时间。
| 现象 | 大概率原因 | 解决办法 |
|---|---|---|
__init__里写错成_init_(少写两个下划线),实例化时莫名报“takes 1 positional argument” | 构造方法名字拼错,Python 调用不到它 | 检查__init__两边是否各有两个下划线 |
NameError: name 'self' is not defined | 在类外面试图单独调用方法却忘了传对象 | 先创建实例,再用实例.方法()调用 |
方法定义时忘了写self参数,调用时报“takes 1 positional argument but 2 were given” | 实例方法必须留出第一个参数位置给对象自身 | 在方法定义里加上self |
调用s1.print_score()后打印出来的是<function Student.print_score at ...> | 忘了在方法名后面加括号 | 补上() |
| 一个实例修改了类属性,其他实例的值也变了 | 混淆了类属性和实例属性 | 对每个实例单独赋值的属性,都放到__init__里用self定义 |
| 继承父类时子类初始化不了,父类属性全是空的 | 子类__init__里调用了super().__init__() | 检查是否在子类的__init__中正确执行super().__init__(参数...) |
6.2 两个容易让人困惑的小细节
第一个是可变默认参数。如果你在__init__里写了def __init__(self, scores=[]):,那么这个列表会被所有实例共享。第一次往s1.scores里加了数据,s2.scores也能看到,非常迷惑。解决方法是把默认值写成None,然后在方法内部再变成新列表:
def __init__(self, scores=None): if scores is None: scores = [] self.scores = scores第二个是可变对象作为属性时,修改要格外小心。self.scores是一个列表,如果直接对返回的列表调用append,外部代码其实在直接修改对象内部数据。想避免这种情况,可以考虑返回副本,比如return self.scores.copy(),代价自然是多花一点内存,但换来的是更好的封装性。
6.3 调试类的代码,我一般这么做
刚写类的时候报错信息经常看得人一头雾水,尤其是和self、__init__相关的错误。我的排查习惯是先在关键位置打印。
def __init__(self, name): print(f"初始化Student,传入的name={name}") self.name = name打印是笨办法但往往最有效。你再顺着调用栈一层层往上找,通常会很快定位到问题。另外,善用 Python 内置的dir()看一个对象有哪些属性和方法:
print(dir(s1))这个输出会非常长,但里面有你想确认的名字。你也可以用vars(s1)专门查看实例的__dict__,快速确认当前对象上挂载了哪些属性、值是什么。
7. 动手练习:三个从易到难的类设计题目
7.1 练习一:写一个 BankAccount 类
要求包含属性:账号、户主姓名、余额。方法:存款、取款、打印余额。取款时如果余额不足要抛出异常。这个练习主要训练你“把数据校验放进方法里”的感觉。
我建议你做这个练习的时候,刻意把存款和取款的入口做得严格一点。比如存款金额必须大于零、取款金额不能超过余额。你会发现,所有的错误提示都在类内部出现,外部代码根本接触不到balance这个属性,这就是封装最直观的体验。做完之后你可以挑战一下:能不能用@property把balance改成只读属性,让外部永远只能通过deposit和withdraw方法改金额?
7.2 练习二:写一个 Book 类和一个 Library 类
Book 有书名、作者、是否借出状态。Library 维护一个图书列表,提供添加书、借书、还书、查询所有可借书籍的方法。这个练习的训练重点是组合关系。Library 和 Book 之间不是继承关系,Library 只是持有 Book 对象的列表,所以你要学着在一个类的方法里操作另一个类的对象。
借书逻辑特别适合锻炼边界情况处理。比如“这本书已经借出了”和“这本书不存在”是两种不同的错误,能不能给出不同的反馈信息?查询可借书籍时,是用循环过滤一遍列表,还是考虑以后数据量大了怎么办?做完这个题,你对面向对象的感觉会踏实很多。
7.3 练习三:给类加上类方法和静态方法
在你写过的任一练习里,试着添加一个类方法from_dict,让它可以接受字典并创建对象。再添加一个静态方法is_valid_id,用来校验账号或学号格式是否合法。通过这个题目,把三种方法的选用时机过一遍,你以后写类时就能明显减少装饰器乱用的问题。
做完这三道题之后,我建议你把代码分享给别人看,或者过一周再打开自己看一遍。面向对象这门手艺,很大程度上是在“读自己的旧代码”中进步的。你会惊讶地发现,同一段逻辑,换一种类的组织方式,可读性可能差出几倍。
8. 写在最后:把类当作整理代码思维的工具
我在带人入门的时候,经常跟他们说一句话:类不是让你炫技用的,它更像你工具箱里的一个收纳盒。你当然可以把所有工具平铺在桌上,找的时候一眼看得见,但东西一多就容易乱。类做的事情,就是把一套相关的工具和数据放进一个贴着标签的盒子里,让整个工作台清爽起来。
我实际写代码的经验是,类和函数不是二选一的关系。一个正常的中等规模项目,函数依然占绝大多数,类只是在关键的领域模型位置站出来,帮你把复杂状态和业务规则约束住。如果你发现自己在代码里到处写着快要用错的字典结构、大量的重复参数传递,那就是该考虑用类来重构的信号。重构的时候不要一次改太多,先挑一个核心概念,比如 Student、Book、Order,把它做成类,然后把其他代码一点一点迁过去。
Python 的类是一个深不见底的话题,但入门阶段你需要掌握的,就是我上面说的这些:构造方法、实例属性、方法、封装、继承。这些概念背后并没有多么高深的魔法,它们都是程序员为了解决真实世界的混乱而摸索出来的组织技巧。希望你把我这个思路带回去,动手写一写,把报错信息当成练习题而不是拦路虎。等你能顺手写完一个属于自己的类并能舒舒服服地调用它时,面向对象的大门就算真正打开了。