好的,我们来详细解析 Python 中访问类变量和实例变量的分步流程。这是一个非常核心且重要的概念,理解了它,你就能彻底明白为什么有时修改一个变量会影响所有实例,而有时又不会。

核心结论

在 Python 中,当你通过一个实例(例如 obj.variable)来访问一个变量时,查找顺序遵循 LEGB 规则的一个特定子集,我们可以称之为 "实例优先" 原则:

  1. 先找实例自己:在实例的专属命名空间(__dict__)中查找。
  2. 再找类:如果实例中没有,就在实例所属的类的命名空间(__dict__)中查找。
  3. 最后找父类:如果类中也没有,就沿着继承链向上,在父类的命名空间中查找,直到找到为止或引发 AttributeError

详细分步流程与示例

我们将通过一个简单的类来演示这个过程。

python

运行

class Dog:
    species = "Canis lupus familiaris"  # 类变量,所有狗共享

    def __init__(self, name, age):
        self.name = name  # 实例变量,每个狗独有
        self.age = age    # 实例变量,每个狗独有

# 创建两个实例
dog1 = Dog("Buddy", 3)
dog2 = Dog("Max", 5)
1. 访问实例变量 (self.name)

当执行 dog1.name 时,流程如下:

Step 1: 检查实例 dog1 的 __dict__

  • Python 首先检查 dog1 对象内部的字典。这个字典存储了所有属于 dog1 自己的属性。
  • 我们可以通过 dog1.__dict__ 来查看:

    python

    运行

    print(dog1.__dict__)  # 输出: {'name': 'Buddy', 'age': 3}
    
  • 因为 'name' 这个键存在于 dog1.__dict__ 中,Python 直接返回其对应的值 'Buddy'
  • 流程结束
2. 访问类变量 (species)

当执行 dog1.species 时,流程如下:

Step 1: 检查实例 dog1 的 __dict__

  • Python 检查 dog1.__dict__{'name': 'Buddy', 'age': 3}
  • 发现里面没有 'species' 这个键。

Step 2: 检查实例所属的类 Dog 的 __dict__

  • 由于在实例中未找到,Python 会向上查找,检查 dog1 所属的类 Dog 的字典。
  • 我们可以通过 Dog.__dict__ 来查看(为了简洁,这里只展示部分内容):

    python

    运行

    print(Dog.__dict__)
    # 输出包含: {'species': 'Canis lupus familiaris', '__init__': <function Dog.__init__ at 0x...>, ...}
    
  • 因为 'species' 这个键存在于 Dog.__dict__ 中,Python 返回其对应的值 'Canis lupus familiaris'
  • 流程结束

同样,当执行 dog2.species 时,它也会因为在 dog2.__dict__ 中找不到而最终在 Dog.__dict__ 中找到同一个值。


关键:修改变量的两种情况

理解访问流程后,最关键的是要区分 “修改” 和 “创建新的实例变量”

情况一:通过实例修改可变类变量(会影响所有实例)

如果类变量是一个可变对象(如列表 list、字典 dict),通过实例对其进行 原地修改 (in-place modification),会影响所有实例。

python

运行

class Dog:
    tricks = []  # 可变类变量

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

dog1 = Dog("Buddy")
dog2 = Dog("Max")

# 1. 访问 tricks
print(dog1.tricks)  # 输出: [] (来自 Dog 类)
print(dog2.tricks)  # 输出: [] (来自 Dog 类)
print(Dog.tricks)   # 输出: []

# 2. 通过 dog1 对 tricks 进行原地修改 (调用 append 方法)
dog1.tricks.append("sit")

# 3. 再次访问
print(dog1.tricks)  # 输出: ['sit']
print(dog2.tricks)  # 输出: ['sit']  <-- 注意!dog2 的 tricks 也变了!
print(Dog.tricks)   # 输出: ['sit']  <-- 实际上是类的 tricks 被修改了

为什么?

  1. dog1.tricks 的查找流程:在 dog1.__dict__ 中没找到,于是找到了 Dog 类的 tricks 列表。
  2. dog1.tricks.append("sit") 这个操作,是对 找到的那个列表对象 进行修改。它并没有创建新变量,只是在原地改变了那个列表的内容。
  3. 因为 dog1 和 dog2 访问的 tricks 都是 Dog 类的那个同一个列表对象,所以一个改了,大家都能看到变化。
情况二:通过实例给类变量名赋值(会创建新的实例变量)

如果通过实例给一个与类变量同名的变量 直接赋值,这不会修改类变量,而是会在该实例中创建一个全新的实例变量。

python

运行

class Dog:
    species = "Canis lupus familiaris"  # 类变量

dog1 = Dog()
dog2 = Dog()

# 1. 初始访问
print(dog1.species)  # 输出: Canis lupus familiaris (来自 Dog 类)
print(dog2.species)  # 输出: Canis lupus familiaris (来自 Dog 类)

# 2. 通过 dog1 给 species 直接赋值
dog1.species = "Wolf"

# 3. 再次访问
print(dog1.species)  # 输出: Wolf (现在访问的是 dog1 的实例变量)
print(dog2.species)  # 输出: Canis lupus familiaris (dog2 仍然访问类变量)
print(Dog.species)   # 输出: Canis lupus familiaris (类变量本身没变)

# 查看 dog1 的 __dict__,可以看到新创建的实例变量
print(dog1.__dict__) # 输出: {'species': 'Wolf'}

为什么?

  1. dog1.species = "Wolf" 这个操作,Python 会将其解释为:“在 dog1 这个实例的命名空间中,创建一个名为 species 的新属性,并赋值为 'Wolf'”。
  2. 从此以后,当再次执行 dog1.species 时,根据我们最开始的查找流程,Python 会在 Step 1 就于 dog1.__dict__ 中找到这个新的实例变量,并返回 'Wolf',它不会再去查找 Dog 类了。这被称为 “实例变量遮蔽(shadowing)了类变量”

总结与最佳实践

操作 通过实例 (obj.var) 通过类 (Class.var)
访问 (读) 先查实例 __dict__,再查类 __dict__,再查父类。 直接查类 __dict__,再查父类。
赋值 (写) 创建或修改实例变量。如果变量名与类变量相同,则遮蔽类变量。 修改类变量。会影响所有未被遮蔽的实例。
修改可变对象 如果 var 是可变对象且来自类,则修改类变量,影响所有实例。 修改类变量,影响所有实例。

最佳实践建议:

  1. 定义默认值:类变量适合用来定义所有实例共享的默认值或常量。

  2. 避免可变陷阱强烈建议不要使用可变对象(如 listdict)作为类变量的默认值,除非你明确希望所有实例共享这个可变对象。如果需要每个实例有一个独立的可变对象(如一个空列表),应该在 __init__ 方法中初始化它。

    python

    运行

    # 错误示范
    class BadDog:
        toys = []  # 所有狗共享一个玩具列表
        def __init__(self, name):
            self.name = name
    
    # 正确示范
    class GoodDog:
        def __init__(self, name):
            self.name = name
            self.toys = []  # 每个狗都有自己的玩具列表
    
  3. 明确意图:如果你想修改类变量,为了代码清晰,最好总是使用 ClassName.var 的形式,而不是 obj.var,这样可以明确你的意图是修改类的属性,而不是创建一个实例属性。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐