Python类深度拆解:__init__、self与实例化核心原理
写 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如果你创建两个实例都不传taskst1 TaskList() t2 TaskList() t1.tasks.append(写代码) print(t2.tasks) # [写代码]两个实例共享了同一个默认列表。原因是def __init__(self, tasks[])里的[]在方法定义时只创建一次之后每次调用都用那同一个列表对象。解决办法是默认值传None在方法体内再新建class TaskList: def __init__(self, tasksNone): 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 fHello, {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 ( fTransaction(amount{self.amount}, fcategory{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是连接两者的桥梁。想通这层关系之后学继承、多态会顺畅很多。