AI 技术博客
返回首页
Python 基础 20 分钟阅读

__init__ 与实例属性:初始化与自引用

Python面向对象__init__self初始化

引言

上一篇的结尾留下一个明显的痛点:我们靠 course_a.title = "Python 基础" 这样的手动赋值给实例填属性,每一行都要自己写,漏掉一个字段程序也不报错,五十个实例就要复制五十遍赋值语句。数据越多,手动初始化的成本越高,出错的概率越大。

本篇的主角 __init__ 就是为了解决这个问题而生的:它让「创建实例」和「初始化属性」这两件事在语法上合为一体——Student("Alice", 20) 一个调用,参数传进去,属性自动填好。本篇要讲清楚四件事:__init__ 是什么、它到底什么时候被调用、self 这个神秘的第一个参数是什么,以及初始化时最容易踩的可变默认值陷阱。此外我们还会聊到一个很多教材跳过的问题:在 __init__ 里做校验,是利大于弊还是弊大于利?

阅读前提:class 语法与命名空间(上一篇)、函数参数与默认值(第四章)。本篇不涉及方法体系的完整讨论——实例方法、类方法、静态方法留给下一篇专门展开。

概念与原理

init 是什么

__init__ 是一种双下划线方法(dunder method)——名字以两个下划线开头和结尾。它在 Python 里没有特殊语法地位,只是一个约定好的协议:实例化时,Python 会自动查找对象类型上的 __init__ 方法并调用它。「方法名是协议的一部分」这一点会在第 7 篇讲魔法方法时系统展开,此刻你只需把它当成「实例化时的自动初始化函数」。

它的签名就是实例化的参数签名。定义 def __init__(self, name, age) 之后,Student("Alice", 20) 里的 "Alice" 和 20 就会按普通函数的参数规则(第四章讲过:位置、关键字、默认值都适用)进入 __init__。同时,__init__ 不能返回值——它返回 None 以外的值会直接报 TypeError,这个我们放到易错点详述。

__init__ 的作用可以概括成三步:接收参数 → (可选地)校验参数 → 把数据写进实例的账本(__dict__)。

调用时机:实例化的完整流程

Student("Alice", 20) 这一行代码背后发生了什么?CPython 的实际流程分三步:

  1. 调用 type 上的 __call__ 调起类对象的创建流程;
  2. 先调用 __new__,为实例分配内存,得到一个「空壳对象」;
  3. 再自动调用 __init__,把这个空壳对象和调用参数一起传入,执行初始化;
  4. 把填好的实例返回给调用方。

所以严格来说,__init__ 不是构造函数(constructor)——真正的「构造」(分配内存)发生在 __new__ 里,__init__ 只是初始化器(initializer)。这个区分不只是术语洁癖:它解释了为什么你可以拿到一个「没初始化过」的对象(绕过 __init__ 直接调用 __new__),也解释了为什么 __new__ 是唯一允许返回新对象的环节。日常编程里我们几乎从不重写 __new__,它留给一些特殊场景(单例、不可变对象),那些内容会在元编程章节(第 16 章)详细展开。本篇你只需要记住时序:__new__ 先、__init__ 后,以及 __init__ 不是构造函数这个反直觉的事实。

self 的本质:实例自身引用

def __init__(self, name, age) 里的第一个参数 self,接收的就是「正在被初始化的那个实例」。这是 Python 方法机制的核心约定:实例方法的第一个参数,由解释器自动传成调用它的实例本身。s.introduce() 本质上就是 Student.introduce(s) 的语法糖——self 是一个不折不扣的普通参数,只是被自动填充。

self 这个名字本身毫无魔法,它只是社区约定(PEP 8 建议用 self)。把它改成 this 完全能跑:

class Student:
    def __init__(this, name):   # 不叫 self 也可以
        this.name = name

    def introduce(me):
        return f"我是 {me.name}"

s = Student("Alice")
print(s.introduce())
print(Student.introduce(s))
print(s.introduce() == Student.introduce(s))
我是 Alice
我是 Alice
True

print(s.introduce()) 与 print(Student.introduce(s)) 输出一致,True 证明两者完全等价——「实例调用」只是「类调用 + 显式传实例」的简写。但你应当坚持用 self:改名不影响运行,却影响可读性,还会让 IDE 的自动补全、静态检查工具(第 14 章会讲到)按惯例识别时失效,得不偿失。

self.name = name 这一行值得拆开看:左侧 self.name 是「往实例账本里写一条记录」,右侧 name 是「读取参数的值」。两者同名纯属巧合——参数叫 name,属性也叫 name,靠 self. 前缀区隔开。这也解释了为什么没有 self. 前缀的赋值只是普通局部变量:birth_year = 2000 写在 __init__ 里,只会成为函数体内的临时变量,实例账本上不会留下任何痕迹。

参数默认值与可变默认值陷阱

第四章函数篇讲过一条铁律:默认参数在函数定义时求值一次,而不是每次调用时。__init__ 也是函数,这条铁律原封不动地生效。于是 def __init__(self, items=[]) 里的 [] 在整个程序生命周期里只有一份——所有「使用默认值」的实例拿到的是同一个列表对象,这正是上一篇「可变类属性共享」陷阱的孪生兄弟(那次的共享发生在类账本上,这次的共享藏在默认参数对象里,根因都是「多个引用共享同一个可变对象」)。

class ShoppingCart:
    def __init__(self, items=[]):   # 陷阱:默认列表全局只有一份
        self.items = items

cart_a = ShoppingCart()
cart_b = ShoppingCart()
cart_a.items.append("苹果")
print(cart_b.items)   # 苹果凭空出现

class ShoppingCartFixed:
    def __init__(self, items=None):
        if items is None:
            items = []
        self.items = items

cart_c = ShoppingCartFixed()
cart_d = ShoppingCartFixed()
cart_c.items.append("苹果")
print(cart_d.items)   # 互不影响
['苹果']
[]

修复方案就是教科书级的 None 哨兵写法:默认值用不可变的 None,进入函数后再现场创建列表。每次创建的是新对象,实例之间彻底隔离。

在 init 里做校验:利与弊

既然初始化的必经之路只有 __init__ 一条,很多开发者会顺手把校验塞进去:余额不能为负、数量必须是正整数、日期必须是合法格式……这样做的好处是失败快(fail fast):非法数据在对象诞生那一刻就被拒绝,后续所有使用这个对象的代码都不必再检查一遍「数据是不是合法的」。一个永远不会携带非法状态的对象,在工程上叫「不变量(invariant)得到保证」,这是非常宝贵的设计性质。

但校验也有代价。第一,isinstance 检查是有边界的:它只能区分类型,拦不住语义错误(价格 0.01 元是合法类型但可能是业务错误);第二,校验逻辑写多了,__init__ 会膨胀成几十行的「守门员」,可读性下降;第三,Python 是鸭子类型(duck typing)语言——只要对象的行为像目标类型,它就能用,过于严格的类型检查会把这种灵活性扼杀掉。类型注解(第 14 章)提供了另一套「声明期望、不拦运行」的机制,两者搭配才完整。

实操原则可以概括为两句:只校验不变量,不设防展示性数据(比如课程标题这种「什么都能存」的字段,不必校验);类型错了抛 TypeError,取值不合理抛 ValueError,让调用方从异常类型就能猜到问题性质。

操作与实现

下面把以上概念逐个落到代码上。

最小可用:两个实例互不干扰

class Student:
    def __init__(self, name, age):
        self.name = name
        self.age = age

s1 = Student("Alice", 20)
s2 = Student("Bob", 21)

print(s1.__dict__)
print(s2.__dict__)
print(s1.name, s1.age)
{'name': 'Alice', 'age': 20}
{'name': 'Bob', 'age': 21}
Alice 20

两本账本各记各的——第一篇「实例各持一份数据」的结论,现在由 __init__ 自动保证了。注意 self.name = name 写法:左边写入账本,右边读取参数。

调用时机与参数传递

class Student:
    def __init__(self, name, age):
        print(f"正在初始化:{name}({age} 岁)")
        self.name = name
        self.age = age

s1 = Student("Alice", 20)
s2 = Student(name="Bob", age=21)   # 关键字参数同样可用
print(s1.name, s2.name)
正在初始化:Alice(20 岁)
正在初始化:Bob(21 岁)
Alice Bob

实例化就是一次普通函数调用,位置参数、关键字参数(第四章)都能用。参数个数不匹配时,报错信息直接指向 __init__:

class Student:
    def __init__(self, name, age):
        self.name = name
        self.age = age

s = Student("Alice")
TypeError: Student.__init__() missing 1 required positional argument: 'age'

这条信息非常有用:它告诉你实例化的「签名」就是 __init__ 的签名,缺哪个参数一目了然。

new 与 init 的先后顺序

class Point:
    def __new__(cls, x, y):
        print("__new__ 先执行:分配实例")
        return super().__new__(cls)

    def __init__(self, x, y):
        print("__init__ 后执行:填充数据")
        self.x = x
        self.y = y

p = Point(1, 2)
print(p.x, p.y)
__new__ 先执行:分配实例
__init__ 后执行:填充数据
1 2

super().__new__(cls) 拿到超类(这里是 object)的 __new__ 完成内存分配,__init__ 随后填充数据。日常不用重写 __new__,这个例子只是为了让你亲眼确认时序。

初始化中的校验与异常

class Account:
    def __init__(self, owner, balance=0):
        if not isinstance(balance, (int, float)) or isinstance(balance, bool):
            raise TypeError("balance 必须是数值")
        if balance < 0:
            raise ValueError("balance 不能为负数")
        self.owner = owner
        self.balance = balance

acc = Account("林晓", 1000.5)
print(acc.owner, acc.balance)

try:
    Account("林晓", -5)
except ValueError as e:
    print("ValueError:", e)

try:
    Account("林晓", "很多钱")
except TypeError as e:
    print("TypeError:", e)
林晓 1000.5
ValueError: balance 不能为负数
TypeError: balance 必须是数值

两处细节值得解读。其一,isinstance(balance, (int, float)) 判断里特意排除了 bool——因为 True 本身就是 int 的子类,不排除的话余额「True 元」也会通过类型检查(这是布尔与数值历史渊源带来的经典坑)。其二,非法输入在对象创建瞬间被拒绝,调用方拿到的永远是状态合法的 Account——这就是「失败快」的实际形态。

综合示例:笔记本电脑档案

把本篇所有知识(默认值、校验、实例属性、不变量)拼成一个真实场景:进货系统登记笔记本电脑。

class Laptop:
    def __init__(self, model, cores, ram, price):
        if not isinstance(cores, int) or cores <= 0:
            raise ValueError("cores 必须是正整数")
        if price <= 0:
            raise ValueError("price 必须为正数")
        self.model = model
        self.cores = cores
        self.ram = ram
        self.price = price
        self.in_stock = True

pc1 = Laptop("ThinkBook 14+", 8, 16, 5499)
pc2 = Laptop("MacBook Air", 8, 8, 7999)

print(f"{pc1.model}: {pc1.cores} 核 / {pc1.ram} GB / ¥{pc1.price}")
print(f"{pc2.model}: {pc2.cores} 核 / {pc2.ram} GB / ¥{pc2.price}")
print("两台都在库:", pc1.in_stock and pc2.in_stock)
ThinkBook 14+: 8 核 / 16 GB / ¥5499
MacBook Air: 8 核 / 8 GB / ¥7999
两台都在库: True

in_stock 没有对应的构造参数,却为每个实例都生成了——它是「默认状态」的样板写法:不依赖参数的属性,在 __init__ 里直接赋初值即可。每一台 Laptop 一出生就自带完整的合法状态,之后的代码可以放心使用。

易错点与陷阱

陷阱一:可变默认值共享(本篇已全解)

def __init__(self, items=[]) 的默认列表只有一份,第一次调用时 append 进去的内容会「遗传」给所有后续实例。前面已给出完整演示与 None 哨兵修复,这里补充一条自查经验:看到任何函数的默认参数是 []、{}、set() 这类可变容器,一律当成 bug 处理——第四章的函数、本篇的 __init__,规则都是同一句「默认值在定义时求值一次」。

陷阱二:忘了写 self

定义方法时把 self 漏掉,实例调用时 Python 还是会把实例塞进第一个参数位,于是参数数量对不上:

class Student:
    def __init__(self, name):
        self.name = name

    def introduce(name):        # 忘了 self
        return f"我是 {name}"

s = Student("Alice")
# s.introduce("Bob")
TypeError: Student.introduce() takes 1 positional argument but 2 were given

报错说「函数只接收 1 个参数,却给了 2 个」——因为 s.introduce("Bob") 实际传的是「实例 + Bob」两个参数,而函数只定义了一个 name。看到这类信息,第一反应应该是「某个方法漏了 self」,而不是「调用方传多了参数」。

陷阱三:init 里 return 非 None

__init__ 是初始化器,不是普通工厂函数,返回值必须为 None:

class Student:
    def __init__(self, name):
        self.name = name
        return "完成"   # 非法

# s = Student("Alice")
TypeError: __init__() should return None, not 'str'

想「既能初始化又能返回结果」时,正确的做法是拆出去:初始化交给 __init__,结果由调用方通过实例属性读取(s.name),或者干脆写一个返回新实例的类方法工厂——这是下一篇「类方法」的经典场景,先留个钩子。

陷阱四:把实例属性写成局部变量

忘了 self. 前缀时,赋值只会创建函数局部变量,实例账本上什么都不留下:

class Student:
    def __init__(self, name):
        name = name.title()   # 忘了 self.:只改局部变量

s = Student("alice")
# print(s.name)
AttributeError: 'Student' object has no attribute 'name'

程序不会当场报错(name = name.title() 本身合法),直到你访问 s.name 才发现账本里根本没有这条记录。排查口诀:想写进实例、想给属性赋值,self. 前缀不能省。

小结

__init__ 是实例化的自动初始化器:CPython 先由 __new__ 分配内存,再由 __init__ 填充数据,因此它不是构造函数,这是最常见的概念误读之一。它的签名就是实例化的参数签名,第一个参数 self 由解释器自动传入实例本身,「实例调用」等于「类调用加显式传参」。初始化时牢记三件事:可变默认值陷阱复用第四章的结论(默认参数定义时求值一次);校验只守不变量,类型错误抛 TypeError、取值错误抛 ValueError;写实例属性必须带 self. 前缀。至此,「类 → 模板、实例 → 具体对象、出生即初始化」三部曲已经齐了。下一篇进入方法的完整体系:实例方法、类方法与静态方法。

练习与思考题

  1. 定义类 Book(为第 8 篇的图书管理系统热身):__init__(self, title, author, price=0),price 必须为非负数(违反时抛 ValueError),title 与 author 必须是非空字符串(违反时抛 ValueError)。创建三本书并分别打印它们的 __dict__,验证不变量。

  2. 不用运行,推断下面程序的输出,再实际运行核对(并解释为什么):

    class Counter:
        def __init__(self, history=[]):
            history.append("开")
            self.history = history
    
    a = Counter()
    b = Counter()
    print(a.history)
    print(b.history)
  3. 动手实验:s = object.__new__(Student) 能绕开 __init__ 拿到一个「空壳实例」,此时访问 s.name 会得到什么?根据本篇「new 与 init 分工」的知识解释原因。

  4. 思考题:为什么推荐把校验写在 __init__ 里而不是等数据被使用时才检查?反过来,什么样的字段不值得校验?结合「不变量」与「鸭子类型」两个概念给出你的取舍标准。