虽然我从来都不需要这样做,但我突然意识到用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变量,这很烦人。

这在Pure Python中可行吗?如果不是,我该如何用C扩展来做呢?

(只能在python3中工作的答案是可以接受的)。

更新:

从Python 3.7开始,要使用的方法是使用@dataclass装饰器,参见最新接受的答案。


当前回答

使用冻结的数据类

对于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__方法是元组的方法,它只比较给定位置的字段的值。这可能是一个巨大的错误来源,特别是如果您是子类化这些类。

其他回答

我通过重写__setattr__创建了不可变类,并且如果调用者是__init__,则允许该集合:

import inspect
class Immutable(object):
    def __setattr__(self, name, value):
        if inspect.stack()[2][3] != "__init__":
            raise Exception("Can't mutate an Immutable: self.%s = %r" % (name, value))
        object.__setattr__(self, name, value)

这还不够,因为它允许任何人的___init__来改变对象,但你懂的。

我不认为这是完全可能的,除非使用一个元组或namedtuple。无论如何,如果你重写了__setattr__(),用户总是可以通过直接调用object.__setattr__()来绕过它。任何依赖__setattr__的解决方案都保证不起作用。

以下是不使用某种元组可以得到的最接近的结果:

class Immutable:
    __slots__ = ['a', 'b']
    def __init__(self, a, b):
        object.__setattr__(self, 'a', a)
        object.__setattr__(self, 'b', b)
    def __setattr__(self, *ignored):
        raise NotImplementedError
    __delattr__ = __setattr__

但如果你足够努力,它就会破裂:

>>> t = Immutable(1, 2)
>>> t.a
1
>>> object.__setattr__(t, 'a', 2)
>>> t.a
2

但Sven对namedtuple的使用确实是不可变的。

更新

由于这个问题已经更新为询问如何在C中正确地做这件事,下面是我关于如何在Cython中正确地做这件事的答案:

第一个immutable.pyx:

cdef class Immutable:
    cdef object _a, _b

    def __init__(self, a, b):
        self._a = a
        self._b = b

    property a:
        def __get__(self):
            return self._a

    property b:
        def __get__(self):
            return self._b

    def __repr__(self):
        return "<Immutable {0}, {1}>".format(self.a, self.b)

和一个setup.py来编译它(使用命令setup.py build_ext——inplace:

from distutils.core import setup
from distutils.extension import Extension
from Cython.Distutils import build_ext

ext_modules = [Extension("immutable", ["immutable.pyx"])]

setup(
  name = 'Immutable object',
  cmdclass = {'build_ext': build_ext},
  ext_modules = ext_modules
)

然后试试吧:

>>> from immutable import Immutable
>>> p = Immutable(2, 3)
>>> p
<Immutable 2, 3>
>>> p.a = 1
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: attribute 'a' of 'immutable.Immutable' objects is not writable
>>> object.__setattr__(p, 'a', 1)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: attribute 'a' of 'immutable.Immutable' objects is not writable
>>> p.a, p.b
(2, 3)
>>>      

第三方attr模块提供了此功能。

编辑:python 3.7已经通过@dataclass在stdlib中采用了这个想法。

$ pip install attrs
$ python
>>> @attr.s(frozen=True)
... class C(object):
...     x = attr.ib()
>>> i = C(1)
>>> i.x = 2
Traceback (most recent call last):
   ...
attr.exceptions.FrozenInstanceError: can't set attribute

Attr通过覆盖__setattr__来实现冻结类,根据文档,Attr在每次实例化时都有轻微的性能影响。

如果您习惯使用类作为数据类型,attr可能特别有用,因为它为您处理样板文件(但没有任何魔力)。特别地,它为你编写了9个dunder (__X__)方法(除非你关闭其中任何一个),包括repr, init, hash和所有比较函数。

Attr还为__slots__提供了一个帮助器。

使用冻结的数据类

对于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__方法是元组的方法,它只比较给定位置的字段的值。这可能是一个巨大的错误来源,特别是如果您是子类化这些类。

如果您对具有行为的对象感兴趣,那么namedtuple几乎是您的解决方案。

正如namedtuple文档底部所描述的,您可以从namedtuple派生自己的类;然后,你可以添加你想要的行为。

例如(代码直接取自文档):

class Point(namedtuple('Point', 'x y')):
    __slots__ = ()
    @property
    def hypot(self):
        return (self.x ** 2 + self.y ** 2) ** 0.5
    def __str__(self):
        return 'Point: x=%6.3f  y=%6.3f  hypot=%6.3f' % (self.x, self.y, self.hypot)

for p in Point(3, 4), Point(14, 5/7):
    print(p)

这将导致:

Point: x= 3.000  y= 4.000  hypot= 5.000
Point: x=14.000  y= 0.714  hypot=14.018

这种方法适用于Python 3和Python 2.7(在IronPython上也进行了测试)。 唯一的缺点是继承树有点奇怪;但这不是你经常玩的东西。