使用属性装饰器实现Python只读列表

18 投票
6 回答
11729 浏览
提问于 2025-04-18 05:25

简短版本

我可以用Python的属性系统来创建一个只读列表吗?

详细版本

我创建了一个Python类,这个类里面有一个列表。每次我想在这个列表被修改时,内部都需要做一些事情。如果这是C++,我会创建一些获取和设置的方法,这样每当调用设置方法时,我就可以处理我的记录。而获取方法会返回一个const引用,这样如果我试图通过获取方法修改列表,编译器就会警告我。在Python中,我们有属性系统,所以不再需要为每个数据成员写普通的获取和设置方法了,这真是太好了。

但是,考虑以下脚本:

def main():

    foo = Foo()
    print('foo.myList:', foo.myList)

    # Here, I'm modifying the list without doing any bookkeeping.
    foo.myList.append(4)
    print('foo.myList:', foo.myList)

    # Here, I'm modifying my "read-only" list.
    foo.readOnlyList.append(8)
    print('foo.readOnlyList:', foo.readOnlyList)


class Foo:
    def __init__(self):
        self._myList = [1, 2, 3]
        self._readOnlyList = [5, 6, 7]

    @property
    def myList(self):
        return self._myList

    @myList.setter
    def myList(self, rhs):
        print("Insert bookkeeping here")
        self._myList = rhs

    @property
    def readOnlyList(self):
        return self._readOnlyList


if __name__ == '__main__':
    main()

输出:

foo.myList: [1, 2, 3]
# Note there's no "Insert bookkeeping here" message.
foo.myList: [1, 2, 3, 4]
foo.readOnlyList: [5, 6, 7, 8]

这个例子说明了Python没有const这个概念,允许我通过append()方法修改我的列表,尽管我已经把它设为属性。这可能会绕过我的记录机制(_myList),或者被用来修改那些我希望是只读的列表(_readOnlyList)。

一个解决办法是在获取方法中返回列表的深拷贝(也就是return self._myList[:])。如果列表很大,或者在内部循环中进行拷贝,这可能会导致很多额外的复制。(不过,过早优化是万恶之源。)此外,虽然深拷贝可以防止记录机制被绕过,但如果有人调用.myList.append(),他们的修改会被默默丢弃,这可能会导致调试时的痛苦。如果能抛出一个异常,那就好了,这样他们就知道自己在违背类的设计。

解决这个最后问题的一个办法是不使用属性系统,而是创建“正常”的获取和设置方法:

def myList(self):
    # No property decorator.
    return self._myList[:]

def setMyList(self, myList):
    print('Insert bookkeeping here')
    self._myList = myList

如果用户尝试调用append(),看起来会像foo.myList().append(8),这些额外的括号会让他们意识到他们可能得到的是一个拷贝,而不是内部列表数据的引用。这样做的负面影响是,这种写法有点不符合Python的风格,如果这个类还有其他列表成员,我就得为那些写获取和设置方法(真让人头疼),或者让接口不一致。(我觉得稍微不一致的接口可能是最小的恶。)

还有其他我没想到的解决方案吗?可以用Python的属性系统创建一个只读列表吗?

6 个回答

0

为什么列表比元组更好用呢?元组在很多情况下可以看作是“不可变的列表”,也就是说,它们的内容一旦创建就不能被直接修改或改变。这样一来,元组就像是只读的对象,不能随便改动。如果你用元组,就需要记住不要写可以改变这个属性的代码。

>>> class A(object):
...     def __init__(self, list_data, tuple_data):
...             self._list = list(list_data)
...             self._tuple = tuple(tuple_data)
...     @property
...     def list(self):
...             return self._list
...     @list.setter
...     def list(self, new_v):
...             self._list.append(new_v)
...     @property
...     def tuple(self):
...             return self._tuple
... 
>>> Stick = A((1, 2, 3), (4, 5, 6))
>>> Stick.list
[1, 2, 3]
>>> Stick.tuple
(4, 5, 6)
>>> Stick.list = 4 ##this feels like a weird way to 'cover-up' list.append, but w/e
>>> Stick.list = "Hey"
>>> Stick.list
[1, 2, 3, 4, 'Hey']
>>> Stick.tuple = 4
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: can't set attribute
>>> 
1

这可以通过使用 Sequence 类型提示来实现。与 list 不同,Sequence 是不可修改的:

from typing import Sequence

def foo() -> Sequence[int]:
    return []

result = foo()
result.append(10)
result[0] = 10

当尝试修改一个被 Sequence 提示的列表时,mypypyright 都会报错:

$ pyright /tmp/foo.py
  /tmp/foo.py:7:8 - error: Cannot access member "append" for type "Sequence[int]"
    Member "append" is unknown (reportGeneralTypeIssues)
  /tmp/foo.py:8:1 - error: "__setitem__" method not defined on type "Sequence[int]" (reportGeneralTypeIssues)
2 errors, 0 warnings, 0 informations 

不过,Python 本身并不会在意这些提示,所以需要定期运行这些类型检查工具,或者把它们作为构建过程的一部分。

还有一个 Final 类型提示,类似于 C++ 中的 const,它只保护存储列表引用的变量,而不是保护列表本身,所以在这里用处不大,但在其他情况下可能会有用。

2

提到的解决方案,比如返回一个元组或者继承列表,听起来不错,但我在想,直接继承装饰器是不是更简单呢?我不确定这是不是个傻主意:

  • 使用这个 safe_property 可以防止意外发送混合的API信号(内部不可变的属性是“保护”的,不能进行任何操作,而对于可变属性,某些操作在正常的 property 内置中仍然是允许的)
  • 优点:比到处实现自定义返回类型要简单得多——更容易理解和使用
  • 缺点:需要使用不同的名称
class FrozenList(list):

    def _immutable(self, *args, **kws):
        raise TypeError('cannot change object - object is immutable')

    pop = _immutable
    remove = _immutable
    append = _immutable
    clear = _immutable
    extend = _immutable
    insert = _immutable
    reverse = _immutable


class FrozenDict(dict):

    def _immutable(self, *args, **kws):
        raise TypeError('cannot change object - object is immutable')

    __setitem__ = _immutable
    __delitem__ = _immutable
    pop = _immutable
    popitem = _immutable
    clear = _immutable
    update = _immutable
    setdefault = _immutable


class safe_property(property):

    def __get__(self, obj, objtype=None):
        candidate = super().__get__(obj, objtype)
        if isinstance(candidate, dict):
            return FrozenDict(candidate)
        elif isinstance(candidate, list):
            return FrozenList(candidate)
        elif isinstance(candidate, set):
            return frozenset(candidate)
        else:
            return candidate


class Foo:

    def __init__(self):
        self._internal_lst = [1]

    @property
    def internal_lst(self):
        return self._internal_lst

    @safe_property
    def internal_lst_safe(self):
        return self._internal_lst


if __name__ == '__main__':

    foo = Foo()

    foo.internal_lst.append(2)
    # foo._internal_lst is now [1, 2]
    foo.internal_lst_safe.append(3)
    # this throws an exception

我很想听听其他人的看法,因为我还没见过其他地方实现这个。

3

尽管我把它设成了一个属性

其实这并不重要,即使它是个属性,你返回的是一个指向列表的指针,所以你可以对它进行修改。

我建议你创建一个 列表的子类,并重写 append__add__ 方法。

11

你可以让方法返回一个包装器,包裹住你原来的列表——可以用collections.Sequence来帮助你写这个包装器。或者,你也可以返回一个tuple(元组)——把列表复制到元组里的开销通常是很小的。

不过,最终如果用户想要修改底层的列表,他们是可以做到的,你真的没办法阻止他们。(毕竟,他们可以直接访问self._myList,如果他们想的话)。

我觉得在Python中,处理这种情况的好方法是明确说明他们不应该去修改这个列表,如果他们真的去改了,那程序崩溃了就是他们自己的责任了。

撰写回答