虽然我从来都不需要这样做,但我突然意识到用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装饰器,参见最新接受的答案。


当前回答

这里有一个优雅的解决方案:

class Immutable(object):
    def __setattr__(self, key, value):
        if not hasattr(self, key):
            super().__setattr__(key, value)
        else:
            raise RuntimeError("Can't modify immutable object's attribute: {}".format(key))

从这个类继承,在构造函数中初始化字段,就完成了所有设置。

其他回答

我找到了一种方法,不用子类化tuple, namedtuple等。你所需要做的就是在初始化后禁用setattr和delattr(如果你想让一个集合成为不可变的,也要禁用setitem和delitem):

def __init__(self, *args, **kwargs):
    # something here

    self.lock()

其中lock可以是这样的:

@classmethod
def lock(cls):
    def raiser(*a):
        raise TypeError('this instance is immutable')

    cls.__setattr__ = raiser
    cls.__delattr__ = raiser
    if hasattr(cls, '__setitem__'):
        cls.__setitem__ = raiser
        cls.__delitem__ = raiser

你可以用这个方法创建类Immutable,并像我展示的那样使用它。

如果你不想在每个init中都写self.lock(),你可以用元类自动实现:

class ImmutableType(type):
    @classmethod
    def change_init(mcs, original_init_method):
        def __new_init__(self, *args, **kwargs):
            if callable(original_init_method):
                original_init_method(self, *args, **kwargs)

            cls = self.__class__

            def raiser(*a):
                raise TypeError('this instance is immutable')

            cls.__setattr__ = raiser
            cls.__delattr__ = raiser
            if hasattr(cls, '__setitem__'):
                cls.__setitem__ = raiser
                cls.__delitem__ = raiser

        return __new_init__

    def __new__(mcs, name, parents, kwargs):
        kwargs['__init__'] = mcs.change_init(kwargs.get('__init__'))
        return type.__new__(mcs, name, parents, kwargs)


class Immutable(metaclass=ImmutableType):
    pass

Test

class SomeImmutableClass(Immutable):
    def __init__(self, some_value: int):
        self.important_attr = some_value

    def some_method(self):
        return 2 * self.important_attr


ins = SomeImmutableClass(3)
print(ins.some_method())  # 6
ins.important_attr += 1  # TypeError
ins.another_attr = 2  # TypeError

就像字典一样

我有一个开源库,在那里我以函数的方式做事情,所以在不可变对象中移动数据是有帮助的。但是,我不希望必须转换我的数据对象以便客户机与它们交互。所以,我想到了这个-它给你一个字典一样的对象,这是不可变的+一些帮助方法。

这要归功于Sven Marnach对限制属性更新和删除的基本执行的回答。

import json 
# ^^ optional - If you don't care if it prints like a dict
# then rip this and __str__ and __repr__ out

class Immutable(object):

    def __init__(self, **kwargs):
        """Sets all values once given
        whatever is passed in kwargs
        """
        for k,v in kwargs.items():
            object.__setattr__(self, k, v)

    def __setattr__(self, *args):
        """Disables setting attributes via
        item.prop = val or item['prop'] = val
        """
        raise TypeError('Immutable objects cannot have properties set after init')

    def __delattr__(self, *args):
        """Disables deleting properties"""
        raise TypeError('Immutable objects cannot have properties deleted')

    def __getitem__(self, item):
        """Allows for dict like access of properties
        val = item['prop']
        """
        return self.__dict__[item]

    def __repr__(self):
        """Print to repl in a dict like fashion"""
        return self.pprint()

    def __str__(self):
        """Convert to a str in a dict like fashion"""
        return self.pprint()

    def __eq__(self, other):
        """Supports equality operator
        immutable({'a': 2}) == immutable({'a': 2})"""
        if other is None:
            return False
        return self.dict() == other.dict()

    def keys(self):
        """Paired with __getitem__ supports **unpacking
        new = { **item, **other }
        """
        return self.__dict__.keys()

    def get(self, *args, **kwargs):
        """Allows for dict like property access
        item.get('prop')
        """
        return self.__dict__.get(*args, **kwargs)

    def pprint(self):
        """Helper method used for printing that
        formats in a dict like way
        """
        return json.dumps(self,
            default=lambda o: o.__dict__,
            sort_keys=True,
            indent=4)

    def dict(self):
        """Helper method for getting the raw dict value
        of the immutable object"""
        return self.__dict__

辅助方法

def update(obj, **kwargs):
    """Returns a new instance of the given object with
    all key/val in kwargs set on it
    """
    return immutable({
        **obj,
        **kwargs
    })

def immutable(obj):
    return Immutable(**obj)

例子

obj = immutable({
    'alpha': 1,
    'beta': 2,
    'dalet': 4
})

obj.alpha # 1
obj['alpha'] # 1
obj.get('beta') # 2

del obj['alpha'] # TypeError
obj.alpha = 2 # TypeError

new_obj = update(obj, alpha=10)

new_obj is not obj # True
new_obj.get('alpha') == 10 # True

另一个想法是完全不允许__setattr__而使用object。构造函数中的__setattr__:

class Point(object):
    def __init__(self, x, y):
        object.__setattr__(self, "x", x)
        object.__setattr__(self, "y", y)
    def __setattr__(self, *args):
        raise TypeError
    def __delattr__(self, *args):
        raise TypeError

当然你可以用object。__setattr__(p, "x", 3)来修改一个Point实例p,但您的原始实现遭受同样的问题(尝试tuple。__setattr__(i, "x", 42)在一个不可变实例)。

您可以在原始实现中应用相同的技巧:去掉__getitem__(),并在属性函数中使用tuple.__getitem__()。

使用冻结的数据类

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

我已经创建了一个小型类装饰器decorator,以使类不可变(除了在__init__内部)。作为https://github.com/google/etils的一部分。

from etils import epy


@epy.frozen
class A:

  def __init__(self):
    self.x = 123  # Inside `__init__`, attribute can be assigned

a = A()
a.x = 456  # AttributeError

这也支持继承。

实现:

_Cls = TypeVar('_Cls')


def frozen(cls: _Cls) -> _Cls:
  """Class decorator which prevent mutating attributes after `__init__`."""
  if not isinstance(cls, type):
    raise TypeError(f'{cls.__name__} is not a class.')

  cls.__init__ = _wrap_init(cls.__init__)
  cls.__setattr__ = _wrap_setattr(cls.__setattr__)
  return cls


def _wrap_init(init_fn):
  """`__init__` wrapper."""

  @functools.wraps(init_fn)
  def new_init(self, *args, **kwargs):
    if hasattr(self, '_epy_is_init_done'):
      # `_epy_is_init_done` already created, so it means we're
      # a `super().__init__` call.
      return init_fn(self, *args, **kwargs)
    object.__setattr__(self, '_epy_is_init_done', False)
    init_fn(self, *args, **kwargs)
    object.__setattr__(self, '_epy_is_init_done', True)

  return new_init

def _wrap_setattr(setattr_fn):
  """`__setattr__` wrapper."""

  @functools.wraps(setattr_fn)
  def new_setattr(self, name, value):
    if not hasattr(self, '_epy_is_init_done'):
      raise ValueError(
          'Child of `@epy.frozen` class should be `@epy.frozen` too. (Error'
          f' raised by {type(self)})'
      )
    if not self._epy_is_init_done:  # pylint: disable=protected-access
      return setattr_fn(self, name, value)
    else:
      raise AttributeError(
          f'Cannot assign {name!r} in `@epy.frozen` class {type(self)}'
      )

  return new_setattr