Python只读列表使用@property装饰器

18

简短版

我能否使用Python的属性系统创建只读列表?

详细版

我创建了一个Python类,其中包含一个列表成员。我希望在列表修改时执行某些操作。如果这是C ++,我将创建getter和setter,以便在调用setter时执行我的记账操作,并且我将使getter返回const引用,以便编译器在我试图通过getter修改列表时发出警告。在Python中,我们有属性系统,因此不必为每个数据成员编写普通的getter和setter(幸好)。

然而,请考虑以下脚本:

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)。

一个解决方法是在getter方法中返回列表的深拷贝(即return self._myList[:])。如果列表很大或复制在内部循环中进行,则可能需要进行大量的额外复制。(但是过早的优化是万恶之源。)此外,虽然深拷贝可以防止绕过保管机制,但如果有人调用.myList.append(),则他们的更改将被静默丢弃,这可能会产生一些痛苦的调试。如果引发异常那将很好,这样他们就知道了他们正在违背类的设计。

解决最后一个问题的方法是不使用属性系统,而是制定“普通”的getter和setter方法:

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),这些额外的括号会提示他们可能得到一个副本,而不是对内部列表数据的引用。这种方法的缺点是编写getter和setter时有点不符合Python的风格,如果类有其他列表成员,我要么必须为它们编写getter和setter(呕吐),要么使接口不一致。(我认为稍微不一致的接口可能是所有问题中最小的。) 是否还有其他解决方案?可以使用Python的属性系统创建只读列表吗?

1
最好的解决方法可能是放弃让事物只读的想法。毕竟,在Python中,我们都是自愿成年人 :-) - roippi
为什么不使用frozenset?https://dev59.com/I2Yq5IYBdhLWcg3whQ40 - Ali SAID OMAR
1
或者从你的属性返回一个tuple();那将是一个只读序列。 - Martijn Pieters
@roippi 这是一个很好的观点。Python缺少"const"是一项有利有弊的设计决策,后者显然比前者更重要。然而,属性装饰器似乎是专门为了使属性只读而设计的。浅复制/追加问题似乎削弱了它,而且似乎是一个普遍的问题(如果可以这么说),所以我想知道处理它的聪明方式是什么。我的理解是,属性实际上是为原始类型而设计的,但子类化(或使用元组)是处理这种情况的一种方式。 - ngb
@ngb:我在下面添加了一个答案,建议从属性继承。我真的很想知道您对这个解决方案的看法。 - Matthijs
6个回答

11

您可以使方法返回一个围绕原始列表的包装器--collections.Sequence可能有助于编写它。或者,您可以返回一个tuple--将列表复制到元组中的开销通常是可以忽略的。

然而,最终,如果用户想要更改基础列表,他们可以这样做,而且真的没有什么可以阻止他们的东西。(毕竟,如果他们想要,他们可以直接访问self._myList)。

我认为像这样做的pythonic方式是记录他们不应该更改列表,如果他们这样做了,那么当他们的程序崩溃时,这是他们的问题。


2
或者从getter返回一个元组? - Martijn Pieters
@MartijnPieters -- 是的,我在发布后不久就考虑到了这个问题...使用'tuple'的唯一缺点是它会复制。但大多数情况下,这应该不会有影响... - mgilson
2
只需复制索引;如果列表中的项数<=20,则Python很可能会重用其保留的缓存元组对象之一。 - Martijn Pieters
1
@mgilson 谢谢!我接受了这个答案,因为它是最早的答案之一,并且提到了子类化和返回元组。关于真正试图阻止用户——并不是我想要阻止用户;我只是想通过给他们一些警告来帮助他们,如果他们正在做一些可能会产生副作用的事情。如果他们搞乱了一个以“_”开头的变量,我认为可以假设他们已经意识到他们正在承担更高水平的风险,就让它自然发展吧。 - ngb
1
@Martijn Pieters 我喜欢将其转换为元组的想法。此外,我不知道Python有一些优化,例如将列表转换为元组。这在某种程度上解决了我关于额外复制的担忧。你是否知道任何描述这种优化的地方?我在Google上搜索了一下,但显然我的搜索技巧不够好。 - ngb
我在我的回答中涵盖了这个行为。 - Martijn Pieters

3

尽管我已经将它作为一个属性

即使它是一个属性,也无所谓,你返回的是指向该列表的指针,所以你可以修改它。

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


子类化可能是这种情况下最通用的解决方案,我想。 - ngb
虽然这是一个老问题和老答案,但我觉得我应该指出这个解决方案会破坏LSP,因此应该小心使用。如果我没记错的话,Kotlin在List和MutableList中就是这样做的。 - Yes

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


非常希望听取其他人的意见,因为我还没有看到类似的实现。

如果能够实际捕获静音列表的所有函数,这可能是一个解决方案。您知道是否可以在不花费数小时进行研究的情况下保证这一点吗?无论如何,这是一个相当代码密集的解决方案。在可能的情况下,我认为我更愿意使用元组。命名元组可能适用于字典的替代品。还不确定返回类型为FrozenList的对象是否会破坏某些类型接口? - Erik Verboom
所有可变性选项的好处很不错。还没有找到是否覆盖了所有内容的来源。这确实是一个代码密集型的解决方案,但当在库/框架中实现时可能值得付出额外的开销:代码只需编写一次,改进的装饰器的应用可能会频繁发生。Frozenlist确实是一种不同的类型,但比元组更少。isinstance(result,list)仍适用于FrozenList,但不适用于元组。 - Matthijs

1

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

from typing import Sequence

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

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

无论是 mypy 还是 pyright,在尝试修改使用 Sequence提示的列表时,都会报错:

$ 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,但它只提供对存储列表引用的变量的保护,而不是对列表本身的保护,因此在这里没有用处,但在其他情况下可能会有用。


0

两种主要建议似乎要么使用元组作为只读列表,要么子类化列表。我喜欢这两种方法。

从getter返回一个元组或者从一开始就使用元组可以防止使用 += 运算符,该运算符可能是一个有用的运算符,并且通过调用setter触发簿记机制。但是,返回元组只需要一行代码更改,如果您想进行编程防御但认为在脚本中添加整个类可能过于复杂,则这很好。

以下是说明这两种方法的脚本:

import collections


def main():

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

    try:
        foo.myList.append(4)
    except RuntimeError:
        print('Appending prevented.')

    # Note that this triggers the bookkeeping, as we would like.
    foo.myList += [3.14]
    print('foo.myList:', foo.myList)

    try:
        foo.readOnlySequence.append(8)
    except AttributeError:
        print('Appending prevented.')
    print('foo.readOnlySequence:', foo.readOnlySequence)


class UnappendableList(collections.UserList):
    def __init__(self, *args, **kwargs):
        data = kwargs.pop('data')
        super().__init__(self, *args, **kwargs)
        self.data = data

    def append(self, item):
        raise RuntimeError('No appending allowed.')


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

    @property
    def myList(self):
        return UnappendableList(data=self._myList)

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

    @property
    def readOnlySequence(self):
        # or just use a tuple in the first place
        return tuple(self._readOnlySequence)


if __name__ == '__main__':
    main()

输出:

foo.myList: [1, 2, 3]
Appending prevented.
Insert bookkeeping here
foo.myList: [1, 2, 3, 3.14]
Appending prevented.
foo.readOnlySequence: (5, 6, 7)

此回答由 OP ngb 以 CC BY-SA 3.0 授权发布,并作为对问题 Python read-only lists using the property decorator编辑


0
为什么列表比元组更可取?元组在大多数情况下都是“不可变列表”,因此它们的本质是只读对象,不能直接设置或修改。此时,我们只需要不为该属性编写setter即可。
>>> 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
>>> 

原则上,我喜欢将成员作为列表,这样我就可以使用+=运算符(调用setter),而不必编写和使用某种“addToMyMember()”方法。但是你的观点很好;如果我真的希望某些东西是不可变的,那就让它不可变。我最终可能会这样做,无论+=运算符如何。 - ngb

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