如何在Python中创建不可变对象?

255 投票
27 回答
140121 浏览
提问于 2025-04-16 10:47

虽然我从来没有需要这样做,但我突然想到,在Python中创建一个不可变对象可能会有点棘手。你不能仅仅重写 __setattr__,因为那样的话,你连在 __init__ 方法中设置属性都做不到。通过继承元组来实现是一个可行的办法:

class Immutable(tuple):
    
    def __new__(cls, a, b):
        return tuple.__new__(cls, (a, b))

    @property
    def a(self):
        return self[0]
        
    @property
    def b(self):
        return self[1]

    def __str__(self):
        return "<Immutable {0}, {1}>".format(self.a, self.b)
    
    def __setattr__(self, *ignored):
        raise NotImplementedError

    def __delattr__(self, *ignored):
        raise NotImplementedError

但是这样一来,你就可以通过 self[0]self[1] 访问 ab 这两个变量,这就有点烦人了。

在纯Python中,这样做可能吗?如果不行的话,我该如何通过C扩展来实现呢?

(只要在Python 3中有效的答案都是可以接受的)。

更新:

从Python 3.7开始,推荐使用 @dataclass 装饰器,具体可以参考新接受的答案。

27 个回答

97

最简单的方法是使用 __slots__

class A(object):
    __slots__ = []

现在,A 的实例是不可变的,因为你不能给它们设置任何属性。

如果你想让这个类的实例包含数据,可以把它和 tuple 结合起来:

from operator import itemgetter
class Point(tuple):
    __slots__ = []
    def __new__(cls, x, y):
        return tuple.__new__(cls, (x, y))
    x = property(itemgetter(0))
    y = property(itemgetter(1))

p = Point(2, 3)
p.x
# 2
p.y
# 3

编辑:如果你也想去掉索引功能,可以重写 __getitem__()

class Point(tuple):
    __slots__ = []
    def __new__(cls, x, y):
        return tuple.__new__(cls, (x, y))
    @property
    def x(self):
        return tuple.__getitem__(self, 0)
    @property
    def y(self):
        return tuple.__getitem__(self, 1)
    def __getitem__(self, item):
        raise TypeError

注意,在这种情况下,你不能使用 operator.itemgetter 来获取属性,因为这会依赖于 Point.__getitem__() 而不是 tuple.__getitem__()。此外,这并不会阻止使用 tuple.__getitem__(p, 0),但我几乎无法想象这会造成什么问题。

我认为创建不可变对象的“正确”方式不是写一个 C 扩展。Python 通常依赖于库的开发者和使用者是成年人,而不是强制执行接口,接口应该在文档中清楚地说明。这就是为什么我不认为通过调用 object.__setattr__() 绕过重写的 __setattr__() 是个问题。如果有人这么做,那是她自己的风险。

134

我刚想到的又一个解决方案:要实现和你原始代码一样的效果,最简单的方法是

Immutable = collections.namedtuple("Immutable", ["a", "b"])

这个方法虽然不能解决通过 [0] 等方式访问属性的问题,但至少它简洁很多,而且还有一个额外的好处,就是和 pickle 以及 copy 兼容。

namedtuple 创建了一种类型,类似于我在 这个回答 中描述的,也就是从 tuple 派生出来的,并使用了 __slots__。这个功能在 Python 2.6 及以上版本中可用。

117

使用冻结的数据类

在Python 3.7及以上版本中,你可以使用一个叫做数据类的东西,并且加上frozen=True选项,这是一种非常符合Python风格且易于维护的方法来实现你的需求。

它的样子大概是这样的:

from dataclasses import dataclass

@dataclass(frozen=True)
class Immutable:
    a: Any
    b: Any

因为数据类的字段需要类型提示,所以我使用了来自typing模块的Any

为什么不使用命名元组

在Python 3.7之前,常常会看到命名元组被用作不可变对象。但这在很多方面都可能会很棘手,其中一个问题是命名元组之间的__eq__方法并不考虑对象的类。例如:

from collections import namedtuple

ImmutableTuple = namedtuple("ImmutableTuple", ["a", "b"])
ImmutableTuple2 = namedtuple("ImmutableTuple2", ["a", "c"])

obj1 = ImmutableTuple(a=1, b=2)
obj2 = ImmutableTuple2(a=1, c=2)

obj1 == obj2  # will be True

如你所见,即使obj1obj2的类型不同,甚至它们的字段名称也不同,obj1 == obj2仍然会返回True。这是因为使用的__eq__方法是元组的那个,它只比较字段的值,而不管它们的位置。这可能会导致很多错误,特别是当你在这些类上进行子类化时。

撰写回答