我当初学Python时,函数、列表、字典这些知识点上手都很快,唯独学到“类”这一章突然就卡住了。class、对象、self、__init__、实例化……一堆名词砸过来,每个字都认识,连在一起就完全不知道在说什么。后来自己写了不少代码,回头再看才发现,类其实没有那么玄乎,它就是把“数据”和“操作这些数据的方法”打包在一起的一种代码组织方式。这篇内容,我会按照自己踩过坑之后捋顺的思路,把Python类的基本概念重新讲一遍,希望能帮你迈过这道坎。
这篇文章适合刚学完函数、准备进入面向对象编程的初学者,也适合那种“教程看了好几遍、一写代码还是不会用类”的读者。我会先讲清楚为什么非要引入类,再一步步写出能跑的代码,最后把几个高频报错和隐藏很深的坑翻出来给你看。读完你不需要马上成为设计大师,但至少能看懂别人的类,也能自己动手写出第一个像样的类。
1. 为什么你要学类:没有类的代码是怎么“乱”起来的
1.1 从函数到类:代码复用的拐点
先想一个问题:函数这么好用,为什么还需要类?
函数解决的是“重复代码”的问题。比如你要计算一堆圆形的面积,写一个circle_area(radius),到处调用就完事了。但现实中的程序,更多是“一堆数据 + 一堆针对这些数据的操作”互相纠缠。比如你在做一个学生管理系统,每个学生有姓名、学号、成绩,你写了add_student、get_score、update_grade这些函数。数据放在哪?大多数人的第一反应是放在字典里:
student = {"name": "张三", "id": "1001", "score": 90}然后函数到处接收这个字典参数。这能跑,但随着逻辑变复杂,你会开始头疼:怎么知道这个字典里有哪些键?哪些函数必须保证“score”这个键存在?如果哪天想给每个学生多记一个“年龄”字段,你得去改多少个地方?
这种“数据散落 + 函数满天飞”的写法,短期看着自由,长期就是灾难。类的出现,就是为了把一组相关的数据和对数据的操作绑在一起,让代码的结构更清晰。
1.2 类到底解决了什么问题——三个现实场景
类解决的问题,总结下来有三个最常见的场景:
第一个场景:相同结构的数据太多。游戏里几十个敌人,每个都有坐标、血量、攻击力,如果你用字典表示,每个敌人就要写一堆重复的初始化代码;写个类,只需要定义一次“敌人长什么样”,然后一个个创建实例。
第二个场景:数据和操作老是成对出现。你开了个银行账户,账号余额和“存款”、“取款”这两个操作天然是一伙的。如果把它们拆成“全局余额变量”和“全局函数”,程序一复杂,你根本搞不清哪个函数改的是哪个账户。放一块儿,矛盾就少很多。
第三个场景:外部接口要统一。你有猫、狗、鸭子,它们都会“叫”。如果用普通函数,你得写cat_sound(cat)、dog_sound(dog)、duck_sound(duck),调用方得记住每个动物类型。如果都继承自一个Animal类,统一调用animal.sound()就行,后面加一个新动物,完全不影响已有的调用代码。
一句话总结:类是“封装 + 复用 + 接口统一”的载体。
1.3 类和对象的关系:别把“图纸”和“房子”搞混
很多初学者挂在嘴边的“对象”是什么?就是类实例化出来的东西。
打个比方。类是建筑图纸,对象是照着图纸建出来的房子。图纸上写了“这个房间 20 平米,朝南”,它只是一种描述;房子才是真实存在的、住进去的东西。同样道理:
class Student:定义的是模板,描述学生应该有哪些数据和哪些能力;s = Student()才是按模板创建了一个具体的“学生对象”,这个对象拥有自己独立的数据。
图纸可以反复用,建出一百栋不同的房子,每栋房子内部装修不一样;类也可以反复实例化,创建一百个学生对象,每个学生名字、成绩各不相同。但“图纸”本身不是房子,你不能住进去;“类”本身也不是对象,你没法直接s.name访问任何具体数据,必须先实例化。
这个区分想通了,后面所有东西都会顺很多。
2. 第一个类:定义、实例化与初识self
2.1 定义一个最简单的类:class语法
Python 里定义一个类,用的就是class关键字。最基础的形式长这样:
class Student: passpass表示“这里暂时什么都不写”,因为 Python 不允许类体完全空着。这个类虽然空无一物,但它已经能实例化了:
s = Student() print(s) # 输出类似 <__main__.Student object at 0x7f8c2a1e4f10>看到那串0x7f...了吗?那是这个对象在内存里的地址。每个对象都有自己独立的地址,这就是“每个房子都有自己的门牌号”。
一个完全空的类没有实际意义。通常你会定义属性(变量)和方法(函数)。
class Student: def set_name(self, name): self.name = name def say_hello(self): print(f"大家好,我是{self.name}")这个类里有了两个方法,每个方法第一个参数都是self。看到这里,大多数人的疑惑开始了:为什么不能没有这个self?
2.2 实例化:类名加括号到底发生了什么
当你在代码里写s = Student()时,Python 在背后做了几件事:
- 调用
Student这个类,相当于触发类的“创建对象流程”; - 分配一块内存,准备存放这个对象的数据;
- 如果类里定义了
__init__方法,自动调用它,把参数传进去; - 把创建好的对象返回给你,赋值给
s。
这个过程就是“实例化”。
有一点很重要:类名后面必须加括号。你写s = Student不会报错,但s只是类的引用,不是对象,后面你调用s.say_hello()就会出问题。这种细节,新手特别容易踩。
实际上即使你不写__init__,Python 也会用默认的方式创建一个空对象。但为了让对象创建时就有数据,几乎每个类都会自己写__init__。
2.3 self是什么:为什么每个方法第一个参数都是它
self大概是初学者第一个看不懂的名词。它到底是不是关键字?不是,self只是一个约定俗成的变量名,你换成this,换成me,甚至换成pig,功能上都能跑。但是没人会这么干,因为团队协作时,所有 Python 程序员一看到self就知道它代表“对象自己”。
我举一个可能不算特别贴切、但特别好懂的类比:self就像是每个对象身上挂的一张名牌。
当你在外部调用s.say_hello()时,Python 实际执行的是Student.say_hello(s),它把你前面的s自动传给了第一个参数self。所以self.name读取的,就是s这个对象自己的name属性。
如果方法不需要访问对象自己的数据,其实可以把self去掉,用静态方法处理。但那是后话,初学者阶段先记住:实例方法的第一个参数必须是self或类似变量,用于接收“对象本身”。
2.4init:构造函数不是“构造”,而是“初始化”
__init__的中文叫法是“构造函数”,听上去像是负责创建对象。但实际上,对象的创建在它之前就完成了。__init__真正的职责是“初始化”,也就是给刚创建出来的对象填充数据。
class Student: def __init__(self, name, score): self.name = name self.score = score s = Student("张三", 90) print(s.name) # 张三 print(s.score) # 90这个写法在创建s时,name和score就被绑定到了s身上。可以给参数加默认值:
def __init__(self, name="匿名", score=0): self.name = name self.score = score这样Student()和Student("李四", 85)都能正常工作。但注意一条规则:带默认值的参数必须放在不带默认值的参数后面,否则解释器直接报语法错误。
3. 属性与方法的正确打开方式
3.1 实例属性:跟着对象走的变量
上一节写的self.name = name,就是定义实例属性。它的特点是“跟着对象走”,不同对象的同名属性互不影响。
s1 = Student("张三", 90) s2 = Student("李四", 88) s1.score = 60 print(s1.score) # 60 print(s2.score) # 88,不受影响你可以随时给一个对象添加新属性,哪怕类里没有定义过:
s1.address = "北京"这在 Python 里是允许的。但我不推荐你滥用,因为这样会让对象的“长相”不可预测,别人读代码时根本不知道这个对象到底有哪些属性。更好的做法是,把所有属性都在__init__里初始化一遍,哪怕暂时没值,先赋一个None或空列表。
3.2 类属性:属于类本身的变量
和实例属性相对的,是类属性。它定义在类内部、方法外部,属于类本身,所有实例共享一份。
class Student: school = "第七中学" # 类属性 def __init__(self, name): self.name = name # 实例属性访问类属性有两种方式:Student.school或s1.school。都能取到同一个值。
危险操作在这里出现了。如果你在实例上对类属性赋值:
s1 = Student("张三") s1.school = "第九中学" # 这不会修改类属性! print(Student.school) # 第七中学 print(s1.school) # 第九中学你没改掉类属性,只是在s1这个对象身上“新建了一个叫school的实例属性”,把原来的类属性给遮蔽了。这就像学院统一规定校服是蓝白色,你给自己单独定制了一套红色校服,学院的规定没变,只有你个人“看起来”变了。
类属性适合放“所有实例共享”的数据,比如学校名、版本号、常量配置。如果数据每个实例都不同,就一定是实例属性,别用类属性。
3.3 方法进阶:实例方法、类方法、静态方法怎么选
初学者一听说“方法”,默认都是实例方法。但你会看到代码里偶尔出现@classmethod和@staticmethod,这三个有什么区别,直接上表格:
| 方法类型 | 装饰器 | 第一个参数 | 能访问实例属性吗 | 能访问类属性吗 | 典型用途 |
|---|---|---|---|---|---|
| 实例方法 | 无 | self | 能 | 能 | 最常见的操作,读写对象数据 |
| 类方法 | @classmethod | cls | 不能 | 能 | 批量创建对象、访问/修改类级配置 |
| 静态方法 | @staticmethod | 无 | 不能 | 不能 | 和类有关、但不依赖类数据的纯函数 |
类方法常见的使用场景是“另类构造函数”。比如你有一个Date类,想支持通过字符串"2025-01-01"创建对象,就可以在类里写一个from_string类方法,内部解析字符串后调用cls(...)返回对象。
静态方法就更简单了,它本质上就是个普通函数,只是放在了类的命名空间里。比如:
class MathHelper: @staticmethod def add(a, b): return a + b print(MathHelper.add(3, 5)) # 8选择标准不复杂:方法需要操作对象的具体数据,用实例方法;方法只涉及类级别的东西,用类方法;方法既不关心对象数据也不关心类数据,就是图个分类存放,用静态方法。
3.4 属性访问的坑:直接赋值和通过方法赋值的区别
Python 的属性默认是“公有”的,外部可以直接s.score = 30修改。这在很多场景下是便利的,但也会有问题。比如你想保证成绩在 0~100 之间,如果外部可以随便s.score = 999,数据就失控了。
通常的解决方案是用方法或属性装饰器。完整讲@property可能要再写一篇文章,但你可以先理解思路:
class Student: def __init__(self, score=0): self._score = score # 约定:下划线开头表示“别直接从外部改” def get_score(self): return self._score def set_score(self, value): if 0 <= value <= 100: self._score = value else: raise ValueError("成绩必须在0到100之间")这里的_score不是真正私有,只是约定俗成地提示“请通过方法访问”。等你看懂了@property装饰器,还可以把访问写得像属性一样自然。初学者先牢记一条:不要把所有逻辑都堆在外部直接改属性上,该加约束的地方,用方法来管。
4. 封装、继承、多态——三大特征一次讲透
4.1 封装:把数据和对数据的操作绑在一起
很多文章把封装解释成“隐藏数据”,结果新手以为封装就是私有变量。实际上,“封装”这个词的字面意思更准确:把东西装进盒子里,封起来。
你的类把学生姓名、成绩和打印自我介绍、修改成绩等方法放在一起,外部调用s.say_hello()就行,不用关心方法内部怎么存数据、怎么格式化字符串,这就是封装最基本的样子。
封装的真正好处是“修改内部实现不影响外部调用”。比如你原来用self.name直接存字符串,后来想改成self.first_name和self.last_name,只要对外方法say_hello的输出保持不变,所有调用方都不用改。反过来,如果外部到处直接操作s.name,你一动内部结构,天就塌了。
4.2 继承:子类复用父类,代码少写一半
继承解决的是“类与类之间的共性”问题。
class Animal: def __init__(self, name): self.name = name def eat(self): print(f"{self.name}正在吃东西") class Dog(Animal): def bark(self): print(f"{self.name}汪汪叫") class Cat(Animal): def meow(self): print(f"{self.name}喵喵叫")Dog和Cat继承了Animal,自动获得了name属性和eat方法。然后各自扩展了自己独有的行为。这就是“代码少写一半”的直接体现。
子类也可以重写父类的方法。如果在子类里定义了一个和父类同名的方法,子类对象调用时会使用子类自己的版本。
如果子类想拥有父类之外的额外初始化逻辑,需要在子类的__init__里调用父类的初始化方法:
class Animal: def __init__(self, name, age): self.name = name self.age = age class Dog(Animal): def __init__(self, name, age, breed): super().__init__(name, age) # 先把父类能处理的处理掉 self.breed = breedsuper()是 Python 提供的“获取父类引用”的方式。新手可以先记住这句模板:子类__init__里第一行写super().__init__(...参数...),把公共属性的初始化交给父类。
4.3 多态:同一个方法名,不同的行为
多态这个词听起来高大上,其实就一句话:同一个方法,在不同对象上调用,会有不同表现。
class Animal: def sound(self): raise NotImplementedError("子类必须实现这个方法") class Dog(Animal): def sound(self): return "汪汪" class Cat(Animal): def sound(self): return "喵喵" def let_them_speak(animal): print(animal.sound()) dog = Dog() cat = Cat() let_them_speak(dog) # 汪汪 let_them_speak(cat) # 喵喵let_them_speak根本不关心传入的到底是Dog还是Cat,只要它有sound方法就行。这给了你很大的扩展自由:以后再加一个鸭子类,只要实现了sound,现有代码完全不用动。
多态的实现基础是 Python 的“鸭子类型”:如果一个对象会走、会叫、会游泳,那它就是鸭子。不强制你是某个类,只要你有某个方法,就能被调用。
4.4 组合与继承怎么选:别一上来就继承
新手学会继承后容易兴奋,看到什么都想继承。但继承不是“银弹”,用错了会制造一堆奇奇怪怪的类层级。
一个简单的判断标准,用“是一个”和“有一个”这两个句式:
- 狗“是”一个动物,适合继承;
- 车“有”一个引擎,适合组合;
- 学生“是”一个人,适合继承;
- 图书馆“有”很多书,适合组合。
组合的思路是:一个类的内部持有另一个类的实例。
class Library: def __init__(self): self.books = [] # 图书馆里有很多书对象 def add_book(self, book): self.books.append(book)在 Python 里,组合往往比深继承更容易维护。因为它不依赖父类的内部实现,耦合度低。所以遇到“不知道该不该继承”的场景,先默认选组合,除非你能明确说出“子类确实是一种父类”,再考虑继承。
5. 实战:写一个图书管理类,把概念串起来
5.1 需求分析与类设计
前面讲了那么多概念,到这里该动手了。我们做一个简单的图书管理场景,要求:
- 每本书有书名、作者、价格;
- 每本书可以被借出,也可以被归还;
- 有一个图书馆类,可以添加书、列出所有书、统计某个作者的藏书量。
这个需求很适合拆成两个类:
Book:表示单本书,关注“书自己”的状态;Library:表示书的集合,关注“书的增删查”。
为什么要拆?因为“借书”这个操作修改的是单本书的状态,而“统计作者藏书量”需要遍历所有书。两种职责混在一起,类会越来越臃肿。这就是一个很基础但很重要的设计直觉。
5.2 核心代码实现与逐行解读
class Book: def __init__(self, title, author, price): self.title = title self.author = author self.price = price self.is_borrowed = False # 默认未借出 def __str__(self): status = "已借出" if self.is_borrowed else "可借" return f"《{self.title}》作者:{self.author},价格:{self.price}元,状态:{status}" def borrow(self): if self.is_borrowed: print(f"《{self.title}》已被借出,不能重复借") return False self.is_borrowed = True print(f"《{self.title}》借出成功") return True def return_book(self): if not self.is_borrowed: print(f"《{self.title}》没有被借出,无法归还") return self.is_borrowed = False print(f"《{self.title}》归还成功")解释几个关键点:
__init__里把属性都初始化好,包括一开始没提到的is_borrowed。这样做的好处是,每个Book对象从出生起就具备完整状态。__str__是 Python 类的“自我介绍”方法,直接print(book)时会调用它。新手不用死记这个方法名,只要知道它是给对象一个“人类可读”的显示形式就行。- 借书逻辑里先判断状态,再改状态,避免“重复借同一本书”这种业务漏洞。这就是把操作封装进方法的价值。
5.3 用法演示与输出结果
再写Library类:
class Library: def __init__(self): self.books = [] def add_book(self, book): self.books.append(book) print(f"添加成功:{book.title}") def list_books(self): if not self.books: print("图书馆里没有书") return for book in self.books: print(book) def count_by_author(self, author): count = 0 for book in self.books: if book.author == author: count += 1 return count使用过程:
library = Library() book1 = Book("Python入门", "张三", 59.0) book2 = Book("算法图解", "李四", 49.0) book3 = Book("Python进阶", "张三", 79.0) library.add_book(book1) library.add_book(book2) library.add_book(book3) book1.borrow() # 《Python入门》借出成功 book1.borrow() # 《Python入门》已被借出,不能重复借 book1.return_book() # 《Python入门》归还成功 library.list_books() print("张三的书数量:", library.count_by_author("张三"))输出结果:
添加成功:Python入门 添加成功:算法图解 添加成功:Python进阶 《Python入门》借出成功 《Python入门》已被借出,不能重复借 《Python入门》归还成功 《Python入门》作者:张三,价格:59.0元,状态:可借 《算法图解》作者:李四,价格:49.0元,状态:可借 《Python进阶》作者:张三,价格:79.0元,状态:可借 张三的书数量: 2这个例子虽然小,但它完整展示了实例属性、实例方法、类与类之间通过对象交互、方法内部做业务判断这些核心概念。你把它跑一遍,基本等于把前面所有内容复习了一遍。
6. 常见错误与排查技巧实录
6.1 忘了写self:TypeError: takes 1 positional argument but 2 were given
这个报错几乎每一个 Python 初学者都见过。看这段代码:
class Dog: def bark(): print("汪汪")运行Dog().bark(),报错:
TypeError: Dog.bark() takes 0 positional arguments but 1 was given意思是:bark定义时不需要参数,但调用时却被传了一个参数。那个参数就是实例本身。解决办法很简单:在方法定义里加上self。
class Dog: def bark(self): print("汪汪")这个报错其实是在提醒你:实例方法必须接收实例自己。所以看到这个错,第一反应永远是去检查方法定义有没有漏self,而不是去检查调用代码。
6.2 可变默认参数的坑:所有实例共享同一个列表
面试题最爱考的一个坑。看下面这个类:
class Student: def __init__(self, name, friends=[]): self.name = name self.friends = friends def add_friend(self, friend_name): self.friends.append(friend_name) s1 = Student("张三") s2 = Student("李四") s1.add_friend("王五") print(s1.friends) # ['王五'] print(s2.friends) # ['王五'] <- 李四一脸懵为什么s2也有王五?因为friends=[]这个默认列表只在函数定义时创建了一次,之后所有没有显式传入friends的实例,拿到的都是同一个列表对象的引用。
解决办法很标准:默认参数用None,在方法内部创建新的空列表。
class Student: def __init__(self, name, friends=None): self.name = name self.friends = [] if friends is None else friends这样每次实例化,friends都是一个新的列表对象,彼此独立。这条规则对所有可变默认参数都适用,包括字典、集合。
6.3 类属性被实例意外修改:以为是“私有”其实是“共享”
前面 3.2 讲过s.school = "新校名"会创建同名实例属性,这还算好。更隐蔽的坑是,如果类属性本身是可变对象(比如列表),通过实例直接修改它,会真的改掉所有实例共享的数据。
class Student: homework_scores = [] # 类属性,共享列表 s1 = Student() s2 = Student() s1.homework_scores.append(90) print(s2.homework_scores) # [90] <- 所有实例都看到 90结合上一节的可变默认参数,你会发现同一个道理:可变对象如果被共享,就要格外小心。如果你想让每个学生的作业成绩独立,就应该在__init__里创建self.homework_scores = [],而不是放在类属性里。
这里也可以顺带记住一个判断口诀:数据跟着对象变,用实例属性;数据对所有对象都一样,用类属性;保存类属性的数据结构如果不希望被实例改,使用不可变类型或者通过类方法统一管理。
6.4 命名约定:_单下划线和__双下划线的真实作用
Python 没有真正的私有属性。但有两种约定,代码里经常会遇到。
单下划线_name:纯粹是约定,表示“这是内部用的,外部不要直接碰”。它不阻止你访问,obj._name照样能取到值,只是大家默认这是内部细节。我在 3.4 里的_score就是这种写法。
双下划线__name:这才是真正有语法的机制。写在类里会触发“名称改写”(name mangling),Python 解释器会把__name自动变成_ClassName__name,防止子类和父类之间意外撞名。
class Student: def __init__(self): self.__secret = "不可描述" s = Student() # print(s.__secret) # AttributeError: object has no attribute '__secret' print(s._Student__secret) # 不可描述看到了吗?用_Student__secret还是能访问到。所以双下划线不是安全防护,只是避免命名冲突。
对于初学者,我建议这样做:写类时,普通属性直接写self.name;想表达“这是内部状态”就用self._name;如果你还没搞明白名称改写机制,暂时不要用双下划线,免得代码里出现一堆奇怪的_ClassName__xxx。
作为总结,我不敢说什么“你已经精通类了”,学编程就是这样,概念理解了是一回事,真正内化还得靠写。你现在可以打开编辑器,把第 5 节的图书管理例子自己敲一遍,然后试着加一个“按书名搜索书”的方法,加一个“归还所有该书”的方法。等你真的把这段代码跑到手熟,再回头看 class、self、__init__这些词,应该就不会发怵了。
我个人在带新手时还有一个屡试不爽的小技巧:每写完一个类,先试着用文字讲一遍“这个类代表什么对象,它有哪些属性,哪些方法,方法改了哪些属性”。能讲清楚,说明你真的理解了。如果讲不清楚,通常不是概念不懂,而是你对数据流还不熟。这个时候别急着学新东西,回去把代码一步步走几遍,比看任何高阶教程都管用。