如何在Python中创建不可变对象?
虽然我从来没有需要这样做,但我突然想到,在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]
访问 a
和 b
这两个变量,这就有点烦人了。
在纯Python中,这样做可能吗?如果不行的话,我该如何通过C扩展来实现呢?
(只要在Python 3中有效的答案都是可以接受的)。
更新:
从Python 3.7开始,推荐使用 @dataclass
装饰器,具体可以参考新接受的答案。
27 个回答
最简单的方法是使用 __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__()
是个问题。如果有人这么做,那是她自己的风险。
我刚想到的又一个解决方案:要实现和你原始代码一样的效果,最简单的方法是
Immutable = collections.namedtuple("Immutable", ["a", "b"])
这个方法虽然不能解决通过 [0]
等方式访问属性的问题,但至少它简洁很多,而且还有一个额外的好处,就是和 pickle
以及 copy
兼容。
namedtuple
创建了一种类型,类似于我在 这个回答 中描述的,也就是从 tuple
派生出来的,并使用了 __slots__
。这个功能在 Python 2.6 及以上版本中可用。
使用冻结的数据类
在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
如你所见,即使obj1
和obj2
的类型不同,甚至它们的字段名称也不同,obj1 == obj2
仍然会返回True
。这是因为使用的__eq__
方法是元组的那个,它只比较字段的值,而不管它们的位置。这可能会导致很多错误,特别是当你在这些类上进行子类化时。