在__init__之外防止创建新的属性

110

我希望能够创建一个类(用Python编写),一旦使用__init__初始化后,该类不再接受新的属性,但是允许修改现有属性。我可以想到几种hack-ish方式来实现这个目标,例如通过定义__setattr__方法:

def __setattr__(self, attribute, value):
    if not attribute in self.__dict__:
        print "Cannot set %s" % attribute
    else:
        self.__dict__[attribute] = value

我曾尝试在 __init__ 中直接编辑 __dict__,但我想知道是否有更“正式”的方法?


1
katrielalex提出了很好的观点。这并不是什么hacky的东西。你可以避免使用__setattr__,但那可能会变得hacky。 - aaronasterling
我不明白为什么这被称为hacky?这是我能想到的最好的解决方案,比其他一些提出的方案更简洁。 - user1270496
13个回答

103

我不会直接使用__dict__,但是您可以添加一个函数来明确“冻结”实例:

class FrozenClass(object):
    __isfrozen = False
    def __setattr__(self, key, value):
        if self.__isfrozen and not hasattr(self, key):
            raise TypeError( "%r is a frozen class" % self )
        object.__setattr__(self, key, value)

    def _freeze(self):
        self.__isfrozen = True

class Test(FrozenClass):
    def __init__(self):
        self.x = 42#
        self.y = 2**3

        self._freeze() # no new attributes after this point.

a,b = Test(), Test()
a.x = 10
b.z = 10 # fails

非常酷!我想我会拿那段代码并开始使用它。(嗯,我在想它是否可以作为一个装饰器来完成,或者这不是一个好主意...) - weronika
7
晚些时候的评论:我一直成功地使用这个配方,直到将一个属性更改为属性时,其中getter引发了NotImplementedError。花了很长时间才发现这是由于“hasattr”实际上调用“getattr”,在出错的情况下丢弃结果并返回False,参见这篇博客。通过用“key not in dir(self)”替换“not hasattr(self, key)”找到了解决方法。这可能会更慢,但对我来说解决了问题。 - Bas Swinckels
有没有其他方法可以将相同的技术应用于更多的子类?例如,一个新的子类Test2(Test)__init__方法?我能想到的唯一解决方案是在init开始时解除类的冻结状态,并在结束时重新冻结它。但这样做会非常混乱。你有其他聪明的想法吗? - F. Eser

68

使用 slots 是理想选择:

Pythonic 的方式是使用 slots,而非玩弄 __setattr__。虽然这可能解决问题,但并没有带来任何性能改进。对象的属性存储在一个字典 "__dict__" 中;这就是为什么你可以动态地向我们迄今为止创建的类的对象添加属性的原因。使用字典来存储属性非常方便,但对于只有少量实例变量的对象来说,可能会浪费空间。

Slots 是解决空间消耗问题的好方法。它提供了一个静态结构,禁止在实例创建后添加属性,而不是使用动态的字典允许动态添加属性到对象中。

当我们设计一个类时,可以使用 slots 来防止动态创建属性。要定义 slots,必须定义一个名为 __slots__ 的列表。该列表必须包含你想要使用的所有属性名称。下面的示例类演示了这一点,其中 slots 列表仅包含一个属性 "val" 的名称。

class S(object):

    __slots__ = ['val']

    def __init__(self, v):
        self.val = v


x = S(42)
print(x.val)

x.new = "not possible"

=> 创建属性“new”失败:

42 
Traceback (most recent call last):
  File "slots_ex.py", line 12, in <module>
    x.new = "not possible"
AttributeError: 'S' object has no attribute 'new'

注意事项: 1. 自从Python 3.3以来,优化空间消耗的优势已经不再那么令人印象深刻了。使用Python 3.3 Key-Sharing字典来存储对象。实例的属性能够在彼此之间共享部分内部存储,即存储键和它们对应的哈希值的部分。这有助于减少创建许多非内置类型实例的程序的内存消耗。但仍然是避免动态创建属性的方法。 2. 使用slots也有其代价。它会破坏序列化(例如pickle)。它还会破坏多重继承。一个类不能继承自定义有slots或在C代码中定义了实例布局(如list、tuple或int)的超过一个类。

2
这些引用来自哪里?你需要引用出处。如果没有给作者应有的荣誉而使用别人的作品,就接近于抄袭了。详情请参考引用帮助 - wjandrea
我在这里找到了这个引用:https://python-course.eu/oop/slots-avoiding-dynamically-created-attributes.php。还有另一个回答也引用了相同的内容,并提供了一个不包含引用的链接:https://stackoverflow.com/a/54377352/1084416。 - undefined

38

如果有人对使用装饰器来做这个感兴趣,这里有一个可行的解决方案:

from functools import wraps

def froze_it(cls):
    cls.__frozen = False

    def frozensetattr(self, key, value):
        if self.__frozen and not hasattr(self, key):
            print("Class {} is frozen. Cannot set {} = {}"
                  .format(cls.__name__, key, value))
        else:
            object.__setattr__(self, key, value)

    def init_decorator(func):
        @wraps(func)
        def wrapper(self, *args, **kwargs):
            func(self, *args, **kwargs)
            self.__frozen = True
        return wrapper

    cls.__setattr__ = frozensetattr
    cls.__init__ = init_decorator(cls.__init__)

    return cls

非常简单易用:

@froze_it 
class Foo(object):
    def __init__(self):
        self.bar = 10

foo = Foo()
foo.bar = 42
foo.foobar = "no way"

结果:

>>> Class Foo is frozen. Cannot set foobar = no way

+1 对于装饰器版本。对于一个较大的项目,这是我会使用的方式,在一个较大的脚本中,这就有点过头了(也许如果它在标准库中就好了...)。目前只有“IDE风格的警告”。 - Tomasz Gandor
2
这个解决方案如何与继承一起使用?例如,如果我有一个Foo的子类,这个子类默认是一个冻结类吗? - mrgiesel
这个装饰器有没有对应的pypi包? - winni2k
如何增强装饰器以使其适用于继承类? - Ivan Nechipayko

21
实际上,你不需要__setattr__,你需要__slots__。在类主体中添加__slots__ = ('foo', 'bar', 'baz'),Python会确保任何实例上只有foo、bar和baz。但是请阅读文档列出的注意事项!


12
使用__slots__是可行的,但它会破坏序列化(例如pickle),还有其他方面也会受到影响...在我看来,使用slots来控制属性创建,而不是减少内存开销,通常是个坏主意... - Joe Kington
我知道,我也犹豫是否要使用它 - 但是为了禁止新属性而做额外的工作通常也不是一个好主意 ;) - user395760
2
使用__slots__也会破坏多重继承。一个类不能从定义了__slots__的超过一个类或者具有在C代码中定义的实例布局(例如listtupleint)的类中继承。 - Feuermurmel
如果__slots__破坏了你的pickle,那么你正在使用一个古老的pickle协议。对于Python 2中可用的最新协议(即2003年引入的协议2),在pickle方法中传递protocol=-1。Python 3中的默认和最新协议(分别为3和4)都可以处理__slots__ - Nick Matteo
大多数情况下,我最终会后悔使用pickle:https://www.benfrederickson.com/dont-pickle-your-data/ - Erik Aronesty

6

我非常喜欢使用装饰器的解决方案,因为在项目中可以很容易地将其用于许多类,每个类只需进行最少的修改。但是它与继承的兼容性不佳。

以下是我的版本:它只覆盖 __setattr__ 函数 - 如果属性不存在且调用函数不是 __init__,则会打印错误消息。

import inspect                                                                                                                             

def froze_it(cls):                                                                                                                      

    def frozensetattr(self, key, value):                                                                                                   
        if not hasattr(self, key) and inspect.stack()[1][3] != "__init__":                                                                 
            print("Class {} is frozen. Cannot set {} = {}"                                                                                 
                  .format(cls.__name__, key, value))                                                                                       
        else:                                                                                                                              
            self.__dict__[key] = value                                                                                                     

    cls.__setattr__ = frozensetattr                                                                                                        
    return cls                                                                                                                             

@froze_it                                                                                                                                  
class A:                                                                                                                                   
    def __init__(self):                                                                                                                    
        self._a = 0                                                                                                                        

a = A()                                                                                                                                    
a._a = 1                                                                                                                                   
a._b = 2 # error

6
正确的方法是覆盖__setattr__。这就是它存在的原因。

1
__init__ 中正确设置变量的方法是什么?是直接在 __dict__ 中设置吗? - astrofrog
1
我会在__init__中覆盖__setattr__,通过self.__setattr__ = <刚定义的新函数> - Katriel
6
对于新式类来说,这样做行不通,因为__xxx__方法只会在类上被查找,而不会在实例上被查找。 - Ethan Furman

3
这个怎么样:
class A():
    __allowed_attr=('_x', '_y')

    def __init__(self,x=0,y=0):
        self._x=x
        self._y=y

    def __setattr__(self,attribute,value):
        if not attribute in self.__class__.__allowed_attr:
            raise AttributeError
        else:
            super().__setattr__(attribute,value)

2

以下是我想出的一种方法,它不需要在init中使用_frozen属性或freeze()方法来冻结。

init期间,我只需将所有类属性添加到实例中即可。

我喜欢这种方法,因为没有_frozen,freeze()也不会出现在vars(instance)输出中。

class MetaModel(type):
    def __setattr__(self, name, value):
        raise AttributeError("Model classes do not accept arbitrary attributes")

class Model(object):
    __metaclass__ = MetaModel

    # init will take all CLASS attributes, and add them as SELF/INSTANCE attributes
    def __init__(self):
        for k, v in self.__class__.__dict__.iteritems():
            if not k.startswith("_"):
                self.__setattr__(k, v)

    # setattr, won't allow any attributes to be set on the SELF/INSTANCE that don't already exist
    def __setattr__(self, name, value):
        if not hasattr(self, name):
            raise AttributeError("Model instances do not accept arbitrary attributes")
        else:
            object.__setattr__(self, name, value)


# Example using            
class Dog(Model):
    name = ''
    kind = 'canine'

d, e = Dog(), Dog()
print vars(d)
print vars(e)
e.junk = 'stuff' # fails

如果其中一个字段是列表,这似乎无法工作。假设 names=[]。然后 d.names.append['Fido']'Fido' 插入到 d.namese.names 中。我不了解Python的足够多,无法理解为什么会这样。 - Reinier Torenbeek

2

@dataclass(slots=True) Nirvana (Python 3.10)

我对这个@dataclass 的东西很着迷:

main.py

from dataclasses import dataclass

@dataclass(slots=True)
class C:
    n: int
    s: str

c = C(n=1, s='one')
assert c.n == 1
assert c.s == 'one'
c.n == 2
c.s == 'two'
c.asdf = 2

结果:
Traceback (most recent call last):
  File "/home/ciro/main.py", line 15, in <module>
    c.asdf = 2
AttributeError: 'C' object has no attribute 'asdf'

请注意,@dataclass 只需要我们使用类型注释一次来定义属性。
n: int
s: str

然后,没有任何重复,我们免费得到:
  • def __init__(n, s):
        self.n = n
        self.s = s
    
  • __slots__ = ['n', 's']

在这个示例中没有显示的其他免费内容:

在 Python 3.10.7、Ubuntu 22.10 上进行了测试。


1
Jochen Ritzel的FrozenClass很酷,但是每次初始化类时调用_frozen()并不太酷(而且你需要冒忘记它的风险)。我添加了一个__init_slots__函数:
class FrozenClass(object):
    __isfrozen = False
    def _freeze(self):
        self.__isfrozen = True
    def __init_slots__(self, slots):
        for key in slots:
            object.__setattr__(self, key, None)
        self._freeze()
    def __setattr__(self, key, value):
        if self.__isfrozen and not hasattr(self, key):
            raise TypeError( "%r is a frozen class" % self )
        object.__setattr__(self, key, value)
class Test(FrozenClass):
    def __init__(self):
        self.__init_slots__(["x", "y"])
        self.x = 42#
        self.y = 2**3


a,b = Test(), Test()
a.x = 10
b.z = 10 # fails

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接