Python类属性与实例属性的区别及访问规则
详细讲解Python中类属性与实例属性在定义方式、内存存储上的核心区别,梳理解析属性读取的查找顺序、赋值时的遮蔽现象以及可变对象作为类属性的常见陷阱与选型原则。
在Python面向对象编程中,类属性属于类本身,保存在类的命名空间中,由所有实例共享;实例属性属于具体的单个实例,保存在实例各自的命名空间中,彼此相互独立。读取属性时,Python会按照“先查实例自身,再查所属类,最后沿继承链向上查找”的顺序检索;而对属性赋值时,操作目标直接决定了是修改共享数据,还是在当前实例中新建同名属性并遮蔽类属性。
一、定义方式与底层存储位置的区别
区分这两种属性最直接的依据是它们的定义位置与绑定对象。类属性直接写在class代码块内部、所有方法之外,或者在类定义完成后通过类名.属性名 = 值绑定。实例属性则在类的初始化方法__init__或其他实例方法中,通过self.属性名 = 值绑定,也可以在实例创建后通过实例名.属性名 = 值动态添加。
在底层实现中,Python把类和实例都视为独立的对象,并用各自的__dict__字典保存属性(未使用__slots__的情况下)。当Python解释器执行完class语句块时,就会在内存中创建一个类对象,类属性直接存放在这个类对象的__dict__里。每次调用类创建新实例时,Python会为该实例分配一块新的内存空间和独立的__dict__字典,其中只存放属于这个实例自身的属性。
以下代码在Python 3.8及以上环境中均可直接运行。你可以将其保存为attr_basic.py并在终端执行python3 attr_basic.py,观察类与实例各自的命名空间内容:
class Course:
# 类属性:直接定义在类体中,所有课程实例共享
platform = "Python在线学堂"
discount = 0.9
def __init__(self, title, price):
# 实例属性:通过 self 绑定到具体实例,每个实例各自独立
self.title = title
self.price = price
c1 = Course("数据结构基础", 199)
c2 = Course("网络编程实战", 299)
print("c1.__dict__:", c1.__dict__)
print("c2.__dict__:", c2.__dict__)
print("Course.platform:", Course.platform)
print("platform in Course.__dict__:", "platform" in Course.__dict__)
从输出结果可以看到,c1.__dict__和c2.__dict__中仅包含title和price两个键,完全没有platform或discount。类属性只在Course.__dict__中存有一份,不会在每个实例中重复拷贝。
二、属性的读取与查找规则
在读取属性时,类只能访问类属性,不能访问实例属性;而实例既可以访问实例属性,也可以访问类属性。之所以存在这一单向兼容性,是因为实例内部维护了指向所属类的指针(即实例.__class__),而类并不绑定某一个特定的实例。
当你对普通属性(不包含自定义@property等数据描述符)执行实例.属性名读取操作时,Python解释器会严格按照以下步骤查找:
- 第一步:检查实例自身的
__dict__。如果属性名存在于实例.__dict__中,直接返回对应的值,查找立即结束。 - 第二步:检查所属类的
__dict__。如果实例自身没有该属性,Python会顺着实例.__class__找到所属类,检查类.__dict__中是否存在该属性;若找到则返回。 - 第三步:沿父类继承链(MRO)向上查找。如果当前类中也没有,Python会按照方法解析顺序(
类.__mro__)依次检查各个父类的__dict__。 - 第四步:抛出异常。如果直到顶层基类
object仍未找到,也没有定义__getattr__兜底方法,则抛出AttributeError异常。
通过下面的代码示例,可以验证实例与类在读取属性时的权限差异:
# 实例既能读取实例属性,也能读取类属性
print(c1.title) # 命中 c1.__dict__['title']
print(c1.platform) # c1.__dict__ 未命中,回退命中 Course.__dict__['platform']
# 类可以直接读取类属性,但无法读取实例属性
print(Course.platform)
try:
print(Course.title)
except AttributeError as e:
print("报错信息:", e)
三、属性的修改与“遮蔽”现象(Shadowing)
读取属性时Python会自动向上回退查找,但赋值操作(=)不会向上查找,而是直接写入当前操作对象的__dict__中。这就是初学者最容易混淆的地方:用实例.类属性名 = 新值赋值,并不会修改类属性,而是在该实例自己的__dict__里新建了一个同名实例属性。
一旦实例拥有了与类属性同名的实例属性,后续再通过该实例读取此属性时,按照“先查实例自身”的规则,会直接返回实例__dict__中的值,从而把类属性挡在后面。这种现象称为属性遮蔽(Shadowing)。
# 1. 通过实例给类属性同名名称赋值:只会在 c1.__dict__ 中新建属性
c1.discount = 0.75
print("c1.discount:", c1.discount) # 读取的是 c1 自身的实例属性
print("c2.discount:", c2.discount) # c2 没有同名实例属性,仍读取类属性
print("Course.discount:", Course.discount) # 类属性本身并未改变
print("c1.__dict__:", c1.__dict__)
# 2. 通过类名修改类属性:所有未发生遮蔽的实例都会同步看到新值
Course.discount = 0.85
print("修改类属性后 c2.discount:", c2.discount)
print("修改类属性后 c1.discount:", c1.discount) # c1 仍被自身实例属性遮蔽
# 3. 删除 c1 的同名实例属性后,遮蔽解除,恢复读取类属性
del c1.discount
print("删除实例属性后 c1.discount:", c1.discount)
0.9调整为0.85,必须使用Course.discount = 0.85(或在类方法中使用cls.discount = 0.85)。如果在某个实例方法中误写成self.discount = 0.85,只会改变当前单个实例的表现,其他实例不会生效。
四、可变对象作为类属性的常见陷阱
上一节提到通过实例对属性重新赋值(=)不会影响其他实例,但如果类属性是一个可变对象(如列表list、字典dict、集合set),并且通过实例执行了原地修改(如append()、extend()、d[k] = v),结果就会完全不同。
原因在于:执行c1.tags.append("热门")时,Python分两步完成。第一步是读取c1.tags,由于c1.__dict__中没有tags,Python查找到并返回了Course.__dict__['tags']指向的列表对象;第二步是对这个列表对象调用append()方法。整个过程没有发生=赋值,因此不会在c1.__dict__中创建新属性,而是直接修改了所有实例共享的同一个列表。
下面的代码对比了错误写法与正确写法。如果每个实例需要维护自己独立的列表或字典,应当将其放入__init__方法中初始化:
class BadCourse:
# 错误示范:将可变列表作为类属性,期望每个实例有独立标签
tags = []
def __init__(self, title):
self.title = title
class GoodCourse:
# 正确示范:在 __init__ 中为每个实例创建独立的列表对象
def __init__(self, title):
self.title = title
self.tags = []
b1 = BadCourse("Python入门")
b2 = BadCourse("数据分析")
b1.tags.append("限时免费")
print("BadCourse b2.tags:", b2.tags) # b2 被意外污染
g1 = GoodCourse("Python入门")
g2 = GoodCourse("数据分析")
g1.tags.append("限时免费")
print("GoodCourse g2.tags:", g2.tags) # g2 保持独立空列表
五、类属性与实例属性的核心对比及选用依据
在实际编写类时,判断一个属性应当定义为类属性还是实例属性,只需看该数据在多个实例之间是否需要保持各自独立的状态。以下表格与总结图梳理了二者的核心差异与日常使用规范:
| 对比维度 | 类属性(Class Attribute) | 实例属性(Instance Attribute) |
|---|---|---|
| 定义位置 | class块内、方法外部;或通过类名.属性 = 值定义 |
__init__等实例方法内通过self.属性 = 值定义 |
| 存储位置 | 保存在类.__dict__中,全类仅存一份 |
保存在实例.__dict__中,每个实例各存一份 |
| 读取权限 | 可通过类名.属性或实例.属性(未被遮蔽时)读取 |
仅可通过实例.属性读取,通过类名读取会报AttributeError |
| 修改影响范围 | 通过类名.属性 = 新值修改会影响所有未遮蔽该属性的实例 |
通过实例.属性 = 新值修改仅影响当前单个实例 |
| 典型适用场景 | 类级别常量、默认配置项、跨实例共享的计数器或缓存池 | 描述单个对象个体特征的数据(如姓名、价格、独立列表等) |
六、特殊限制:定义了__slots__时的行为变化
上述所有基于__dict__的动态添加属性和遮蔽规则,有一个明确的成立前提:类没有定义__slots__,或者__slots__中包含了'__dict__'。如果为了节省内存而在类中声明了__slots__,实例将不再默认生成__dict__字典,此时对同名类属性通过实例赋值会直接报错。
class SlottedCourse:
__slots__ = ("title", "price")
discount = 0.9
def __init__(self, title, price):
self.title = title
self.price = price
s = SlottedCourse("算法进阶", 299)
print("读取类属性 s.discount:", s.discount)
try:
# 尝试通过实例对类属性同名名称赋值,会被 __slots__ 拦截并抛出 AttributeError
s.discount = 0.8
except AttributeError as e:
print("赋值报错:", e)
原因在于,当类定义了__slots__且未把discount列入__slots__时,实例没有__dict__可用于新建同名实例属性,因此Python将该类属性在实例层面视为只读属性。在这种情况下,要修改discount只能通过SlottedCourse.discount = 0.8在类对象上操作。
本站声明
本文内容仅供参考,不代表本站观点。转载或引用请注明出处。如发现内容问题,请通过联系我们页面反馈。













