先聊点真实的。我教Python入门教了这些年,发现一个特别有意思的分水岭:函数、列表、字典这些东西,学员学起来都还行,但一到“类与对象”这章,瞬间就能筛掉一拨人。不是因为他们笨,而是大部分教程上来就扔出一堆术语——封装、继承、多态、实例、方法、属性——还没写一行代码就已经被绕晕了,后面的内容自然全废。所以这篇文章,我就想把“类与对象”这层窗户纸彻底捅破,用最直白的方式,配合大量能直接跑的代码示例,把这块硬骨头啃下来。内容定位在入门级的“A篇”,也就是不涉及继承、多态这些后续概念,先专注搞定:类是什么、对象是什么、为什么需要它们,以及怎么用class关键字把代码写得像样。适合刚学完函数、准备进入面向对象阶段的学习者,也适合学了一遍没学明白、打算重新捋一遍的朋友。
通篇我会用“图纸”和“实物”来类比类和对象——这是我最常用也最不容易出错的理解方式。你不用担心记不住术语,我会把每个概念都落到一个具体的代码场景里,让你看得见、摸得着,然后咱们再回去看那些术语,你会发现原来它们说的都是人话。这篇里没有高深的理论,只有我在实际教学和写代码过程中反复验证过的、最顺滑的学习路径。
1. 用一个生活场景拆解:类就是图纸,对象就是实物
我想先不急着写代码,因为类和对象这个东西,一旦你从代码开始学,很容易陷入“语法会写,但不知道自己在干嘛”的状态。我们先用一个所有人都熟悉的例子——做蛋糕——把概念框架搭起来。
1.1 图纸、模具与成品的类比
假设你想开一个甜品店,每天要烤很多个相同规格的六寸戚风蛋糕。你肯定不会每天重新研究配方、试验火候,而是会把配方、步骤、烘烤温度这些内容固定下来,写成一份标准作业指导书。以后每做一个蛋糕,都照着这份指导书来。这里的“指导书”,就相当于代码里的类;而按照指导书做出来的每一个具体蛋糕——今天上午这个、下午那个、明天那个——每一个都是对象。
用代码的视角重新说一遍:类是一段用来描述“某类事物应该有哪些数据、哪些行为”的代码模板,对象则是根据这个模板在内存里真正创建出来的实体。同一个类可以创建出无数个对象,每个对象都有自己的独立数据,但它们的行为逻辑共用同一套模板。
这个类比可以一直用下去。指导书上写着“这个蛋糕应该是六寸的、重500克”,这叫属性(数据);指导书上写着“把蛋糕脱模”“在表面挤奶油”,这叫方法(行为)。当你照着指导书真的做了一个蛋糕出来,这个蛋糕本身有它的重量、它的烤色,这是实例属性;你能对它做的事情,比如切开、打包,就是实例方法。
1.2 为什么不能只用函数搞定一切
很多人会问:我学了函数,把配方写成一个函数,每次调用函数不也能做蛋糕吗?为什么要搞出类这个东西来?这是个特别好的问题,我每次都要专门解释一下,不然学员心里总是别扭着。
单纯用函数,数据是散落在各个变量里的,你得手动维护一堆“和某个蛋糕相关”的信息:cake_1_weight、cake_1_color、cake_2_weight、cake_2_color……然后把这些变量作为参数传给各个操作函数。一旦蛋糕多了,光是管理这些变量名就能把你逼疯。而类做的事情,是把“数据”和“操作这些数据的函数”打包进同一个容器里。蛋糕的属性和蛋糕的行为天然绑定在一起,你拿到一个蛋糕对象,就知道它能干什么、它自己带着什么数据。
另一个更现实的理由是——类的代码复用效率远高于函数。你写了一个蛋糕类,以后不管做草莓蛋糕、巧克力蛋糕,只需要基于它做小修改(这就是B篇会讲的继承),而不需要把好几层函数调用重新复制一遍。再往深了说,很多成熟的Python库,比如处理数据库的SQLAlchemy、做爬虫的Requests,内部全部是面向对象架构。你不懂类,就永远只能停留在“调用别人函数”的层面,一旦需要看源码、改行为,就彻底抓瞎。
注意:类不是为了显得高级才存在的,它本质上是一种组织代码的结构化手段。当你发现代码里散落着大量相互关联的变量和函数时,就是该用类把他们收拢起来的时候了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 手把手写第一个类:核心语法与成员概念全解析
理论聊完了,我们直接进代码。这一节是整篇的核心,我会从最基础的空类开始,一步步往里面填东西。你最好打开编辑器跟着敲,光看不练,看完就忘。
2.1 类的基本框架与属性定义
先看最简洁的写法:
python复制class Dog:
"""狗狗类,A篇演示用"""
pass
class是关键字,Dog是类名,后面可以不写括号(在Python 3里,不写括号默认继承object基类),冒号后面是类体。就这么几行,一个最简陋的类已经定义好了。pass意思是“这里先不写内容”,防止语法报错。
现在给Dog类加上“属性”。属性就是类或对象身上的数据,分两种:类属性和实例属性,这是新手很容易混淆的第一组概念,我们挨个说。
python复制class Dog:
species = "Canis familiaris" # 类属性:所有狗共享
def __init__(self, name, age):
self.name = name # 实例属性:每条狗独有
self.age = age
类属性写在类体最外层,它是属于“类本身”的变量,所有实例对象共享同一个值,就像“所有狗都属于犬科”这个事实,不会因为某条狗叫旺财就改变。实例属性写在__init__方法里,通过self.变量名来定义,每个对象创建时都会得到一份属于自己的独立数据,旺财是3岁、来福是5岁,互不干扰。
2.2 方法是什么:实例方法、类方法、静态方法的区别
接下来是方法。方法的本质是定义在类内部的函数,但它的第一个参数有特殊含义。最常见的是实例方法,它的第一个参数约定俗成叫self,代表“调用这个方法的那个具体对象”。实例方法的调用方式通常是对象.方法名(),Python会把对象自动传给self参数。
python复制class Dog:
species = "Canis familiaris"
def __init__(self, name, age):
self.name = name
self.age = age
def description(self):
return f"{self.name} is {self.age} years old"
def bark(self):
print(f"{self.name} says Woof!")
这里description和bark都是实例方法。注意bark里用到了self.name,说明它可以读取当前对象的实例属性。这就是方法的好处:它天然知道自己属于谁,不需要你额外传参。
类方法(@classmethod装饰)和静态方法(@staticmethod装饰)在入门A篇不是必须掌握的,但我建议你先知道它们的存在,不然看到别人代码会懵。区别一句话说清:实例方法操作实例数据,类方法操作类属性,静态方法跟类和实例都无关,只是恰好放在类里面的普通函数。
2.3 关键字self的完整理解
很多入门者最大的困惑就是——self到底是啥?能不能不写?我来给你一个彻底的解释。当你在代码里写dog1.bark()时,Python在背后做了一件事:它把dog1作为第一个参数传给了bark方法,所以self这个名字,在dog1调用它的时候,指向的就是dog1这个对象本身。
你可以把self理解成“这个对象自己的一指禅”——方法内部一旦需要访问自己的属性或调用自己的其他方法,就必须通过self来指路。这不是Python独有的设计,Java里叫this,只是Python把显式传递做到了极致。
试想一下:如果没有self参数,方法bark就不知道是该让旺财叫还是让来福叫。有了self,一切就清楚了——谁调用,self就是谁。这个“谁调用,self是谁”的规则,是所有方法机制的地基。你可以在方法里用print(self)看看,会打印出一个内存地址,那就是对象在内存中的门牌号。
实操心得:任何时候你写完一个方法,发现它需要访问对象的属性,那就必须把
self作为第一个参数;如果方法内部什么都不需要访问,纯独立功能,才考虑是否用静态方法。这个判断逻辑,新手照着用就行。
3. 对象的实例化过程:init、self与内存原理一网打尽
定义好类之后,怎么得到对象?这就涉及实例化。这一节我们来拆解对象创建的全过程,同时把几个特别容易翻车的坑提前排掉。
3.1 实例化的完整执行流程
看这行代码:
python复制dog1 = Dog("旺财", 3)
这短短一行,Python做了什么?三步。第一步,调用Dog类本身,分配一块内存空间给即将诞生的对象;第二步,自动调用__init__方法,把“旺财”和“3”这两个参数传进去,把数据填充到这块内存里;第三步,把初始化完毕的对象返回出来,绑定给变量dog1。
所以__init__并不是传统意义上的构造函数——真正分配内存的动作是类型调用时完成的,__init__只负责“初始化”刚出生的对象,给它填上初始数据。它的名字里的双下划线也暗示了它的特殊身份:这是Python内部的钩子方法,你不需要手动调用,实例化时自动触发。
如果你不写__init__方法,也没关系。Python会用一个默认的空初始化逻辑,对象创建时不会自动拥有任何实例属性。但这就意味着你没法在创建时传初始参数,所以大多数情况下你都需要自己定义__init__。
3.2 实例属性与类属性的读取查找顺序
接下来我们把之前提过的类属性和实例属性放一起看,理解它们的读取规则。看这段代码:
python复制dog1 = Dog("旺财", 3)
dog2 = Dog("来福", 5)
print(dog1.species) # Canis familiaris
print(dog2.species) # Canis familiaris
print(dog1.name) # 旺财
print(dog2.name) # 来福
dog1.species和dog2.species一样,因为Python在对象上找不到species时,会自动沿着继承链往上找,最终在Dog类上找到了它。而name是各自对象里独有的属性,所以互不相同。这个查找顺序是:先找实例自身的属性,再找类的属性。
这个顺序会引出一个著名的大坑——给实例赋值同名属性,会“遮蔽”类属性。比如:
python复制dog1.species = "特殊物种"
print(dog1.species) # 特殊物种
print(dog2.species) # Canis familiaris
这其实不是在修改类属性,而是给dog1创建了一个新实例属性,把类属性盖住了。你感觉是改了,但实际上其他对象看到的还是原来的值。这个特性很多人工作一两年都还会踩中。
3.3 可变对象作为默认值:入门的第一个隐蔽Bug
再来一个新手期的高频Bug。如果有人告诉你“给属性设默认值,直接写在__init__参数里”,然后你写成了这样:
python复制class Dog:
def __init__(self, name, toys=[]):
self.name = name
self.toys = toys
dog1 = Dog("旺财")
dog2 = Dog("来福")
dog1.toys.append("球")
print(dog2.toys) # ['球'] ?!
问题来了:dog1只是给玩具列表添加了一个“球”,dog2的玩具列表里居然也跟着出现了。原因是默认参数只会在函数定义的时候求值一次,所有没传入toys的对象,拿到的都是同一个列表对象。这个坑的名字叫“可变默认值陷阱”,Python官方也明确警告过。
正确的做法是:默认值写成None,在方法内部判断并创建新列表。
python复制def __init__(self, name, toys=None):
self.name = name
if toys is None:
toys = []
self.toys = toys
这样每个对象都会得到自己独立的列表。这个写法要养成肌肉记忆,因为不止列表,字典、集合等所有可变对象作为默认值时,一律遵循这个规则。
3.4 为什么print对象那么难看:认识__str__与__repr__
你现在可以试试直接打印对象:
python复制print(dog1)
# 输出:<__main__.Dog object at 0x7f8f1c2b5a90>
如果你在写业务代码,满屏这种内存地址,排查问题能崩溃。解决方法是给类添加__str__方法,它是Python内置的另一个钩子:当对象被print或str()转换时自动调用。
python复制class Dog:
def __init__(self, name, age):
self.name = name
self.age = age
def __str__(self):
return f"Dog(name={self.name}, age={self.age})"
print(dog1)
# 输出:Dog(name=旺财, age=3)
加了__str__之后,打印对象就变成了一行清晰的数据摘要。这个习惯我建议从第一天就养成,每个自定义的类都顺手写一个__str__,调试效率能提升一大截。如果你想追求更好,还可以再写一个__repr__,它面向开发调试场景,是交互式终端里直接输入对象名时显示的内容。入门阶段先掌握__str__就够用了。
注意:
__str__必须返回字符串,否则Python会报错。它的目标读者是“人”,所以要写成可读、能一眼看懂的内容,比如包含类名和关键属性。
4. 对象之间的交互与类的实战设计思路
掌握了语法之后,下一个冲刺点是:怎么把多个对象组合起来,写出像样的业务逻辑。很多入门者写完单个类就不知道下一步了,这一节我用两个实例来带动一下。
4.1 需求驱动的设计:从一个抢红包场景说起
我们来做一个小场景:两个角色互相抢红包。需求很简单——有一个红包类,里面封装了金额和余额;有一个人物类,可以执行抢红包动作。先别急着写代码,先做设计:
- 红包需要哪些属性?总金额、剩余金额、是不是已经被抢完。
- 人物需要哪些属性?名字、抢到的总额。
- 哪个方法放在哪个类里?这是初学者最容易问的问题。
我的经验是:如果一个操作主要修改A对象的数据,并且逻辑上属于A的行为,就把它做成A的方法。抢红包这件事,钱是从红包里扣的、红包余额变了、也要标记状态,所以“扣减金额并返回本次抢到多少”的逻辑放红包类里;而人物类只负责调用“红包的抢取动作”,然后把返回的金额累加到自己头上。
python复制import random
class RedPacket:
def __init__(self, total, count):
self.total = total # 总金额
self.balance = total # 剩余金额
self.count = count # 剩余份数
def grab(self):
if self.count <= 0 or self.balance <= 0:
return 0
if self.count == 1:
amount = round(self.balance, 2)
else:
amount = round(random.uniform(0.01, self.balance / self.count * 2), 2)
self.balance = round(self.balance - amount, 2)
self.count -= 1
return amount
class Person:
def __init__(self, name):
self.name = name
self.total_money = 0.0
def grab_red_packet(self, packet):
money = packet.grab()
if money > 0:
self.total_money = round(self.total_money + money, 2)
print(f"{self.name} 抢到了 {money} 元,累计 {self.total_money} 元")
else:
print(f"{self.name} 来晚了,没抢到")
packet = RedPacket(10, 3)
p1, p2, p3, p4 = Person("张三"), Person("李四"), Person("王五"), Person("赵六")
for p in [p1, p2, p3, p4]:
p.grab_red_packet(packet)
这个例子看起来很简单,但它展现了面向对象的核心思想:状态和行为被封装在一起。红包自己知道还剩多少钱,人物自己知道自己累计抢了多少,外部代码只需要通过方法调用,不需要直接操作内部数据。如果你用纯函数写这个逻辑,你得同时管理红包余额、剩余份数、每个人的累计金额好几组数据,很容易漏掉某个条件导致金额对不上。
4.2 设计类时的四条实战原则
通过上面的例子,我想送你几条我自己设计类时的原则,都是踩过坑换来的:
第一,先写清楚这个类的“一句话职责”。如果一句话说不清楚,或者出现了“又管这个又管那个”,就说明类拆得不够细。比如“Person”管抢红包可以,但你别让它再管“发送系统通知”,那是另一个类的事。
第二,属性尽量在__init__里全部初始化。不要让对象在运行过程中突然多出一个之前没见过的属性。这有助于你理解对象的完整状态,也是很多代码检查工具的规范要求。
第三,对外暴露的方法要少而清晰。红包对外只需要一个grab(),人物的也只暴露grab_red_packet()。内部怎么计算、怎么保留两位小数,都是内政,外部不必关心。这就是“封装”的初步体现。
第四,打印调试信息要集中。我在Person的grab_red_packet里直接print了结果,这在教学代码里没问题,但真实项目里建议统一用日志模块,并且方法尽量只做一件事然后把结果返回,打印交给调用方。
4.3 对象引用与别名:赋值不等于复制
关于对象交互,还有一个绕不开的坑:对象的赋值操作,复制的是“引用”而不是对象内容。看代码:
python复制a = Dog("旺财", 3)
b = a
b.name = "来福"
print(a.name) # 来福
想通过b修改一下,结果a也变了。因为a和b指向的是内存中的同一个对象,b = a只是给同一个对象多贴了一个标签而已。这和列表的浅拷贝问题一样,是Python新手必须刻进DNA的知识点。
如果你真的需要一个与原对象独立的新对象,该怎么做?最简单的办法是调用类构造器,基于原对象的数据创建一个新实例:
python复制b = Dog(a.name, a.age)
如果后面学到B篇的copy模块,用copy.copy、copy.deepcopy也可以。入门阶段,先记住“赋值不复制,只是加标签”,就能避免很多莫名其妙的bug。
实操心得:排查“为什么我只是改了一下,别的对象也变了”类问题,第一反应就应该检查是不是出现了对象别名。这也是我调试教学代码时最高频遇到的问题之一。
5. 一口气吃透:小白必踩的坑与调试排查实用技巧
这一节我们来聊最有价值的部分——经验和坑。很多内容文档里不写,只有真正写过代码、被线上问题折磨过的人才会知道。我整理了入门阶段最经典的几个坑,每个都配了“症状、原因、解法”,直接可以当速查表用。
5.1 高频报错与原因对照表
| 症状(报错信息或现象) | 原因 | 解决办法 |
|---|---|---|
TypeError: bark() takes 0 positional arguments but 1 was given |
定义实例方法时忘了写self参数 |
实例方法的第一个参数必须是self |
AttributeError: 'Dog' object has no attribute 'name' |
对象创建后没有初始化该属性,可能是没写__init__或代码里self.name拼写不一致 |
在__init__里统一初始化,检查属性名拼写 |
| 两个对象明明分别创建,列表属性却互相影响 | 可变默认值陷阱(默认参数用[]) |
默认值改为None,内部判空再创建新对象 |
dog1.name修改后dog2.name也变了 |
赋值导致对象别名,两个变量指向同一个对象 | 确认是否需要独立副本,用类构造器创建新对象 |
打印对象显示<__main__.Dog object at 0x...> |
没有定义__str__方法 |
添加__str__方法,返回可读的字符串摘要 |
方法内部访问name报NameError |
代码只写了name = xxx,忘了写self.name = xxx |
所有实例属性的赋值和访问都必须带self.前缀 |
__init__里写了return然后报错 |
__init__不能有返回值(除None) |
删除return语句,初始化逻辑不需要返回值 |
这张表我建议你收藏。初学阶段的报错,90%都逃不出这几个范围。遇到问题先冷静对照一遍,通常比到处问人要快得多。
5.2 调试类代码的实用技巧
除了看懂报错,我还想分享几个调试类的实用技巧,都是我在教学和项目里验证过的好方法。
第一个技巧,使用vars()函数查看对象内部状态。vars(dog1)会返回一个字典,里面是对象的所有实例属性。调试时打印vars(dog1),一行就能看清当前对象的数据全貌,比逐个print属性省事多了:
python复制print(vars(dog1))
# {'name': '旺财', 'age': 3}
第二个技巧,用isinstance()来检查对象类型。有时候你拿到一个对象,不确定它是不是某个类的实例,直接用isinstance(obj, Dog)判断,返回True/False,比猜要保险得多。
第三个技巧,利用dir()查看对象能用的方法。dir(dog1)会返回对象的所有属性和方法名(包括一大串双下划线开头的内置成员)。当你拿到一个不熟悉的第三方库对象,不知道它能干什么时,dir()是你的第一把钥匙。
第四个技巧,也是最重要的:搞不清逻辑时,把方法调用过程中的self打印出来。比如在bark里print(self),看看到底是哪个对象在调用。这在多对象交互时特别管用,能帮你确认方法的执行主体是不是你预期的那个对象。
5.3 一个练习建议:给现有类加功能
文章最后,按我的习惯,给你留一个动手练习。试着基于上面的Dog类,自己动手加一个compare_age方法,让一条狗可以和另一条狗比较年龄,返回比较结果。比如旺财3岁、来福5岁,调用dog1.compare_age(dog2)就返回“来福比我大2岁”。
这个练习看起来小,但会把这篇提到的核心概念全部串起来:你要在方法里写第二个参数接收另一个Dog对象、要通过self访问自己的年龄、还要访问对方的age属性。能独立写出来,说明A篇你已经扎实掌握了。
踩过几次坑之后你会慢慢发现,类和对象其实一点都不玄乎。它就是一种“把数据和操作打包在一起”的代码组织方式,而你要做的,就是先照着模板写,写多了,设计感自然就出来了。这篇A篇把地基打牢了,后面聊继承和多态的时候就会顺畅很多。等你把这个练习写完,可以自己试着给Dog类增加喂食、玩耍之类的新方法,体会一下扩展一个类到底有多方便——那将是让你真正爱上面向对象编程的时刻。
