写 Python 写到一定阶段,几乎每个人都会在“类”这里卡一下。函数用得再熟,一见到class关键字、__init__、self这些概念,还是容易懵——我当年也是这样。这篇文章就专门来拆一拆类的基本构造与实例化这件事。我会从设计思路讲起,逐行解析__init__和self,再用一个完整可跑的记账本例子走一遍实例化全流程,最后把新手常见的几类报错和排查思路列出来。适合刚学完函数、正准备跨进面向对象门槛的读者,也适合已经写过一些类、但总觉得有些细节没吃透的朋友。
1. 类怎么设计:写 class 前先想清楚三件事
1.1 不用类的代码为什么会越写越别扭
先看一个我经常用来给新手演示的例子。假设要写一个文本记账工具,记录每笔交易并统计分类总额。如果完全用函数和字典来实现,代码大概长这样:
transactions = [] def add_transaction(transactions, amount, category, date): transactions.append({ "amount": amount, "category": category, "date": date }) def total_by_category(transactions, category): return sum( t["amount"] for t in transactions if t["category"] == category )这段代码能跑,问题也不大。但它有一个隐患:交易数据是普通字典,字段名全靠写代码的人“自觉”。只要有一次手抖,把"amount"写成"Amount",程序不会报错,统计结果却会悄悄出错。这类 bug 最折磨人,因为它不报红、不崩溃,纯粹是数据对不上。
类要解决的,就是把这个“靠自觉维护格式”的问题变成“由结构强制约束”。定义一个Transaction类之后,每次创建交易对象,它天生就带着amount、category、date这些属性;对它的操作也统一由类里的方法负责。数据和行为收拢到同一个模板里,代码结构会清楚很多,也更好维护。
1.2 设计阶段的三板斧:数据、行为、归属
写class关键字之前,我会先在注释里把三个问题列出来,想清楚了再动手:
- 这个对象代表什么东西?它需要保存哪些数据?
- 这个对象能做什么动作?对外提供哪些方法?
- 哪些数据是所有实例共享的,哪些是每个实例独有的?
用前面的记账场景来回答:
- 数据:金额
amount、分类category、日期date - 行为:能新增交易、能按分类统计、能输出所有交易信息
- 共享与独有:分类常量这类信息可以共享;每笔交易的金额、日期则是实例独有的
这三个问题答完,类的基本骨架就已经出来了。很多人的类写得别扭,不是因为语法不熟,而是根本没想清楚这个对象到底要承担什么责任。设计不是玄学,就是这种在最开始多花两分钟的思考。
1.3 类名的约定:细节里藏着可读性
Python 对命名有一套约定俗成的规范,语法不强制,但社区基本都遵守:
- 类名用
CapWords风格,也就是每个单词首字母大写,比如Transaction、BankAccount,方便和snake_case的函数名区分 - 类内的方法和属性用小写加下划线,比如
total_by_category - 不要用
list、dict、str这类内建名称做类名或变量名,否则容易覆盖掉原本的行为
这些习惯看着是小事,等代码量上来、需要别人 review 时,能省掉大量沟通成本。我自己见过把类名写成bankaccount的代码,能跑,但让阅读者很难一眼判断它是一个类还是别的什么。命名不是形式主义,是代码可读性的一部分。
2. 核心语法拆解:init和 self 到底是怎么回事
2.1init:严格说它不是构造函数
很多教程把__init__称为构造函数,这个叫法其实不严谨。Python 里真正负责分配内存、创建空白对象的是__new__;__init__的作用是初始化,也就是把传入的参数填到实例上。所以你可以把__init__理解为“新对象出生后的配置环节”。
最简单的类长这样:
class Transaction: def __init__(self, amount, category, date): self.amount = amount self.category = category self.date = date使用时直接传参,不需要手动调用__init__:
t = Transaction(12.5, "餐饮", "2024-05-01")这里有个新手常犯的误解:以为Transaction(...)就是“调用__init__方法”。实际上,当执行Transaction(12.5, "餐饮", "2024-05-01")时,Python 会自动完成三步——创建一个新对象、调用__init__初始化它、把初始化完成的对象返回出来。你写的Transaction(...)只是在触发这个过程。所以不要在业务代码里显式调用t.__init__(...),这不是它的使用场景。
2.2 self:每个方法都要知道“我是谁”
self是新手最容易卡的点。你肯定会疑惑:为什么定义方法时第一个参数是self,调用时却不用传?
我的理解角度是这样的:当你在实例上调用t.doubled_amount()时,Python 会自动把t本身作为第一个参数传入doubled_amount(self)。也就是说,这里的self指的就是“当前这个实例对象”,方法必须知道“我是谁”,才能访问属于我的属性。
如果方法定义时少写了self,比如:
class Transaction: def __init__(self, amount): self.amount = amount def doubled(): # 忘了写 self return self.amount * 2调用Transaction(10).doubled()会报:
TypeError: doubled() takes 0 positional arguments but 1 was given这个报错信息不说人话,但意思很直白:你调用时悄悄传了一个实例进去,可这个方法定义的时候没留接收位置。这个报错几乎每个人都会遇到一次,遇到别慌,记住“实例方法的第一个参数必须是 self”就行了。
2.3 实例属性与类属性:一个赋值就可能踩坑
这一步藏着 Python 类最经典的坑:实例属性和类属性完全是两回事。
class BankAccount: bank_name = "某某银行" # 类属性,所有实例共享 def __init__(self, balance): self.balance = balance # 实例属性,每个实例独有创建两个账户:
a1 = BankAccount(100) a2 = BankAccount(200) print(a1.bank_name) # 某某银行 print(a2.bank_name) # 某某银行 print(a1.balance) # 100 print(a2.balance) # 200bank_name写在类体里,是类属性,所有实例共享同一份。balance在__init__里通过self.balance赋值,是实例属性,每个实例各存各的。
最经典的坑出现在你试图给某个实例的类属性赋值时:
a1.bank_name = "别的银行" print(a2.bank_name) # 某某银行,没变为什么a2没跟着变?因为给a1.bank_name赋值时,Python 不是修改类上的属性,而是给a1这个实例创建了一个新的实例属性,把原本的类属性遮蔽了。看起来是改了共享值,实际没改到源头。如果真的想改类的共享属性,应该写BankAccount.bank_name = "别的银行"。
2.4 可变默认值:一个让实例互相污染的隐形陷阱
这个坑值得单独拿出来说,因为它太常见了。不要用可变对象做默认参数:
class TaskList: def __init__(self, tasks=[]): # 有问题的写法 self.tasks = tasks如果你创建两个实例,都不传tasks:
t1 = TaskList() t2 = TaskList() t1.tasks.append("写代码") print(t2.tasks) # ['写代码']两个实例共享了同一个默认列表。原因是def __init__(self, tasks=[])里的[]在方法定义时只创建一次,之后每次调用都用那同一个列表对象。解决办法是默认值传None,在方法体内再新建:
class TaskList: def __init__(self, tasks=None): self.tasks = [] if tasks is None else tasks这个坑不仅限于类,普通函数的可变默认参数也一样,只不过类实例的共享会让问题变得更隐蔽。我的习惯是:任何可变对象都不要直接放在默认参数位置。
3. 实例化过程实操:跟着一个记账本例子走一遍
3.1 一个能跑的完整类
把前面的设计思路落地,写一个完整的记账本。这里有两个类:一个负责单笔交易,一个负责整个账簿。
class Transaction: def __init__(self, amount, category, date): self.amount = amount self.category = category self.date = date def info(self): return f"{self.date} [{self.category}] {self.amount:.2f}" class Ledger: def __init__(self): self.transactions = [] def add(self, amount, category, date): self.transactions.append(Transaction(amount, category, date)) def total_by_category(self, category): return sum( t.amount for t in self.transactions if t.category == category ) def all_info(self): return "\n".join(t.info() for t in self.transactions)使用起来:
ledger = Ledger() ledger.add(12.5, "餐饮", "2024-05-01") ledger.add(45, "交通", "2024-05-02") ledger.add(30, "餐饮", "2024-05-03") print(ledger.total_by_category("餐饮")) # 42.5 print(ledger.all_info()) # 2024-05-01 [餐饮] 12.50 # 2024-05-02 [交通] 45.00 # 2024-05-03 [餐饮] 30.00这里Ledger的self.transactions列表里装的每个元素都是Transaction实例。职责划分很清楚:单笔交易的行为归Transaction,账簿的整体逻辑归Ledger,谁也不越界。
3.2 实例化的时候,Python 到底做了什么
当你写下ledger = Ledger(),Python 内部其实做了三件事:
- 分配内存,创建一个空白的
Ledger对象 - 调用
Ledger.__init__(这个空白对象),往它身上挂transactions属性 - 把初始化完成的这个对象返回,绑定到变量名
ledger上
整个流程平时看不见,但理解它特别有用。比如,你就能明白ledger是一个实例对象,而Ledger本身是一个类;为什么isinstance(ledger, Ledger)返回True;为什么type(ledger) is Ledger也为True。类就是模板,实例是模板生产出来的具体产品。
3.3 多实例的独立性:谁的数据就是谁的
继续用记账本做实验:
ledger_a = Ledger() ledger_b = Ledger() ledger_a.add(10, "购物", "2024-05-04") print(len(ledger_b.transactions)) # 0ledger_a记账,ledger_b毫无感觉。因为每次Ledger()都会执行__init__,生成一个全新的self.transactions列表。实例的意义就在这:用同一张模板,生成无数个互不干扰的对象。
但如果把列表放到类属性里,画风突变:
class BadLedger: transactions = [] # 注意:这是类属性 def add(self, item): self.transactions.append(item) a = BadLedger() b = BadLedger() a.add("买了杯咖啡") print(b.transactions) # ['买了杯咖啡']列表变成共享的了。这就是 2.3 节那个类属性和实例属性问题直接导致的后果。所以,凡是要保存“每个对象自己的数据”,一定要在__init__里用self.xxx初始化,不要图省事直接写到类体里。这一点我踩过不止一次,尤其是从写脚本转过来的同学特别容易顺手写错。
3.4 方法调用背后的绑定机制
除了ledger.add(20, "学习", "2024-05-05")这种常见写法,Python 其实还允许绕一下:
ledger = Ledger() Ledger.add(ledger, 20, "学习", "2024-05-05")效果和ledger.add(...)完全等价。区别在于,第一种写法是 Python 帮你做了绑定,把ledger隐式传给self;第二种是显式把ledger作为第一个参数传进去。
这个机制也解释了为什么类内部调用另一个方法时,必须写成self.method(),而不能直接写method()。普通方法在类内部并不会“自动记得自己属于哪个实例”,只有通过实例访问,绑定才会生效。我见过不少人写类方法时直接调另一个方法名,结果收到一个NameError,其实都是对绑定机制不够熟悉。
4. 新手必看:类相关的常见报错与排查思路
4.1 TypeError: method() takes 0 positional arguments but 1 was given
这是我看到的最高频报错,九成和self有关。比如:
class Person: def __init__(self, name): self.name = name def greet(): # 少写 self return f"Hello, {self.name}"调用Person("A同学").greet(),报错就是TypeError: greet() takes 0 positional arguments but 1 was given。
原因前面已经讲过:实例方法调用时,实例本身一定会被当作第一个参数传进去。排查这类报错,先数一数定义时参数个数和调用时传参个数。定义时没写self,调用却报“多了一个参数”,基本就是少self;如果定义写了self,调用却报“参数不够”,多半是没用实例调用,直接用类名调用了。
4.2 类属性神秘共享,实例之间互相污染
这个现象用上面的BadLedger演示过。现实里更隐蔽的版本是:“我明明在__init__里写了self.tasks = tasks,为什么两个实例还是互相影响?”——通常就是 2.4 节那个可变默认参数[]搞的鬼。排查思路很简单:所有需要独立的属性都在__init__里重新赋值,不直接依赖默认参数里的可变对象。另外,如果某个值本身就是只读常量,比如分类枚举、固定配置,用类属性反而更合适,不必矫枉过正。
4.3init里写 return 会直接报错
这个坑比较隐蔽,但遇到一次就记住了。__init__是初始化器,Python 规定它不能显式返回非None的值:
class Demo: def __init__(self): return 42 # 报错运行直接抛TypeError: __init__() should return None。因为__init__不负责创建对象,它没有返回值给调用方。平时大家一般不会主动去 return,但如果你在__init__的分支逻辑里不小心写了return some_value,就会踩到。记住:__init__里要么不写 return,要么就写裸return用来提前结束。
4.4 高频问题速查表
| 报错或现象 | 常见原因 | 处理思路 |
|---|---|---|
TypeError: method() takes 0 positional arguments but 1 was given | 实例方法定义缺self | 给方法补上self参数 |
TypeError: __init__() should return None | __init__里返回了非 None 值 | 去掉 return 或改成裸 return |
| 两个实例的数据互相影响 | 用类属性存可变对象,或可变默认参数 | 在__init__里用self.xxx初始化 |
打印实例得到<__main__.Transaction object at 0x...> | 没有定义__repr__或__str__ | 添加这两个方法,见第 5 章 |
加了@classmethod还是报错 | 第一个参数名写成了self | 类方法第一个参数应写成cls |
访问self.xxx报AttributeError | __init__里没有赋值这个属性 | 检查初始化逻辑,确认赋值语句执行过 |
这张表不是权威清单,是我这几年看报错总结出来的高频项。遇到问题先判断是“定义阶段的问题”还是“调用阶段的问题”,再对症下手,比盲目试错要快得多。
5. 几个让类更好用的扩展小技巧
5.1 加上repr和str,调试不再看内存地址
新手打印实例,看到的往往是<__main__.Transaction object at 0x7f1234567890>,除了一串地址什么信息都没有,调试起来特别痛苦。给类加上一个__repr__就能瞬间改观:
class Transaction: # 前面省略属性初始化逻辑 def __repr__(self): return ( f"Transaction(amount={self.amount}, " f"category='{self.category}', date='{self.date}')" )之后repr(t)、直接打印t都会输出可读的字符串。__repr__是给开发者看的,尽量写成“可以直接复制用来重新创建对象”的格式;__str__是给终端用户看的,可以更友好。如果只定义一个,我建议优先__repr__,因为 Python 在没定义__str__时会自动用它兜底。
5.2 下划线前缀只是约定,不是权限控制
Python 没有 Java 那种private关键字,一切靠约定自觉。单下划线前缀_amount表示“内部实现细节,外部请勿直接访问”,双下划线前缀__amount会触发名称改写机制,但也只是让名称变复杂,并不是真锁死。
我自己的习惯是:内部辅助方法用单下划线开头,比如_calc_total,提醒自己和读者“这是内部细节,接口不稳定”;对外稳定接口才用普通命名。这个约定没什么魔法,但能让类的边界清晰很多。调用方看到下划线前缀,就该明白它不属于公共 API。
5.3slots:实例多到内存吃紧时的优化手段
默认情况下,每个实例都带一个__dict__字典来存属性,字典本身开销不小。如果类要产生海量实例,可以用__slots__显式声明允许的属性名:
class Point: __slots__ = ("x", "y") def __init__(self, x, y): self.x = x self.y = y加了__slots__之后,实例不再需要__dict__,内存占用能明显下降,属性访问也会略快一点。代价是你不能给实例添加__slots__之外的新属性。日常业务代码没必要刻意用,但我确实见过有人处理上百万坐标点时靠它省下不少内存。要用的前提是:你确认这个类不需要动态扩展属性。
最后分享一点个人经验吧。刚学类的头两周,我也被self和共享属性折腾得够呛。后来发现最有效的办法不是死背规则,而是自己写小例子去验证:故意在类里放一个可变类属性,实例化两个对象,看看互相会不会影响;故意少写一个self,亲眼看看报错长什么样。这种“主动制造错误再观察错误”的练习,比看十遍教程都管用。一句话总结我的理解:类是模板,对象是数据,self是连接两者的桥梁。想通这层关系,之后学继承、多态会顺畅很多。