继承:是否可以使用传统属性覆盖属性?

24

假设我们想创建一个类族,这些类是一个总体概念的不同实现或特化。假设对于一些派生属性,存在一个合理的默认实现。我们希望将这个默认实现放入基类中。

class Math_Set_Base:
    @property
    def size(self):
        return len(self.elements)

所以,在这个有些愚蠢的例子中,子类将自动能够计算其元素的数量。
class Concrete_Math_Set(Math_Set_Base):
    def __init__(self,*elements):
        self.elements = elements

Concrete_Math_Set(1,2,3).size
# 3

但是,如果子类不想使用默认值呢?以下方法行不通:

import math

class Square_Integers_Below(Math_Set_Base):
    def __init__(self,cap):
        self.size = int(math.sqrt(cap))

Square_Integers_Below(7)
# Traceback (most recent call last):
#   File "<stdin>", line 1, in <module>
#   File "<stdin>", line 3, in __init__
# AttributeError: can't set attribute

我知道可以通过另一个属性来覆盖某个属性,但我想避免这样做。因为基类的目的是让其用户的生活尽可能轻松,而不是通过强制实现(从子类的狭义角度来看)复杂且多余的访问方法来增加膨胀。
能否做到这一点?如果不能,那么下一个最好的解决方案是什么?

3
这很可能表明类层次结构本身存在问题。那么最好的解决方案是重构该层次结构。你提到:“对于一些派生属性,有一个合理的默认实现”,并且举了数学集合和 len(self.elements) 的实现例子。这个非常具体的实现为该类的所有实例强加了一个约定,即它们都提供 elements 属性,它是一个 可计数容器。然而,你的 Square_Integers_Below 类似乎不会像这样行为(也许它会动态生成其成员),因此它必须定义自己的行为方式。 - a_guest
1
覆盖只是必要的,因为它首先继承了(错误的)行为。len(self.elements)不适合数学集合的默认实现,因为存在“许多”甚至没有有限基数的集合。一般来说,如果子类不想使用其基类的行为,则需要在类级别上覆盖它。实例属性,顾名思义,是在实例级别上工作的,因此受类行为的控制。 - a_guest
8个回答

14

这将是一个冗长的答案,可能只会起到补充说明的作用...但你的问题让我深入研究了一下,所以我想分享我的发现(和痛苦)。

最终你可能会发现这个答案对你的实际问题没有帮助。事实上,我的结论是 - 我不会这样做。话虽如此,这个结论的背景可能会让你感到有趣,因为你正在寻找更多细节。


纠正一些误解

第一个答案,在大多数情况下是正确的,但并非总是如此。例如,考虑这个类:

class Foo:
    def __init__(self):
        self.name = 'Foo!'
        @property
        def inst_prop():
            return f'Retrieving {self.name}'
        self.inst_prop = inst_prop

inst_prop是一个属性,但不可撤销地成为实例属性:

>>> Foo.inst_prop
Traceback (most recent call last):
  File "<pyshell#60>", line 1, in <module>
    Foo.inst_prop
AttributeError: type object 'Foo' has no attribute 'inst_prop'
>>> Foo().inst_prop
<property object at 0x032B93F0>
>>> Foo().inst_prop.fget()
'Retrieving Foo!'

这完全取决于您的property首先在哪里定义。如果您的@property在类“scope”(或真正的namespace)内定义,则它变成了类属性。在我的示例中,类本身在实例化之前不知道任何inst_prop。当然,在这里作为属性它并不是非常有用。

首先,让我们谈谈您关于继承解析的评论...

那么继承与这个问题有什么关系呢?以下文章稍微深入地探讨了这个主题,方法解析顺序与此有些相关,尽管它主要讨论的是继承的广度而不是深度。

结合我们的发现,考虑到以下设置:

@property
def some_prop(self):
    return "Family property"

class Grandparent:
    culture = some_prop
    world_view = some_prop

class Parent(Grandparent):
    world_view = "Parent's new world_view"

class Child(Parent):
    def __init__(self):
        try:
            self.world_view = "Child's new world_view"
            self.culture = "Child's new culture"
        except AttributeError as exc:
            print(exc)
            self.__dict__['culture'] = "Child's desired new culture"

想象一下执行这些代码时会发生什么:

print("Instantiating Child class...")
c = Child()
print(f'c.__dict__ is: {c.__dict__}')
print(f'Child.__dict__ is: {Child.__dict__}')
print(f'c.world_view is: {c.world_view}')
print(f'Child.world_view is: {Child.world_view}')
print(f'c.culture is: {c.culture}')
print(f'Child.culture is: {Child.culture}')

结果如下:
Instantiating Child class...
can't set attribute
c.__dict__ is: {'world_view': "Child's new world_view", 'culture': "Child's desired new culture"}
Child.__dict__ is: {'__module__': '__main__', '__init__': <function Child.__init__ at 0x0068ECD8>, '__doc__': None}
c.world_view is: Child's new world_view
Child.world_view is: Parent's new world_view
c.culture is: Family property
Child.culture is: <property object at 0x00694C00>
注意以下几点:
  1. 可以应用self.world_view,但无法应用self.culture
  2. culture不存在于Child.__dict__中(即类的mappingproxy,不要与实例__dict__混淆)
  3. 尽管culture存在于c.__dict__中,但没有被引用。

你可能已经猜到原因了——world_viewParent类作为非属性覆盖,因此Child也能覆盖它。而culture是继承的,所以它只存在于Grandparentmappingproxy

Grandparent.__dict__ is: {
    '__module__': '__main__', 
    'culture': <property object at 0x00694C00>, 
    'world_view': <property object at 0x00694C00>, 
    ...
}

实际上,如果您尝试删除Parent.culture
>>> del Parent.culture
Traceback (most recent call last):
  File "<pyshell#67>", line 1, in <module>
    del Parent.culture
AttributeError: culture

你会注意到,即使是对于{{Parent}},它也不存在。因为该对象直接引用了{{Grandparent.culture}}。


那么,分辨率顺序怎么样?

我们有兴趣观察实际的分辨率顺序,让我们尝试移除Parent.world_view

del Parent.world_view
print(f'c.world_view is: {c.world_view}')
print(f'Child.world_view is: {Child.world_view}')

想知道结果是什么吗?

c.world_view is: Family property
Child.world_view is: <property object at 0x00694C00>
Great! Please let me know what text you would like me to translate and into which language.
Child.world_view = "Child's independent world_view"
print(f'c.world_view is: {c.world_view}')
print(f'Child.world_view is: {Child.world_view}')

del c.world_view
print(f'c.world_view is: {c.world_view}')
print(f'Child.world_view is: {Child.world_view}')

Child.world_view = property(lambda self: "Child's own property")
print(f'c.world_view is: {c.world_view}')
print(f'Child.world_view is: {Child.world_view}')

结果是:

# Creating Child's own world view
c.world_view is: Child's new world_view
Child.world_view is: Child's independent world_view

# Deleting Child instance's world view
c.world_view is: Child's independent world_view
Child.world_view is: Child's independent world_view

# Changing Child's world view to the property
c.world_view is: Child's own property
Child.world_view is: <property object at 0x020071B0>
这很有趣,因为c.world_view被恢复为其实例属性,而Child.world_view是我们分配的属性。删除实例属性后,它将恢复为类属性。重新分配Child.world_view到属性后,我们立即失去对实例属性的访问。
因此,我们可以推断出以下解析顺序:
1.如果存在一个类属性,并且它是property,通过getterfget检索其值(稍后会详细介绍)。从当前类到基类。 2.否则,如果存在实例属性,则检索实例属性值。 3.否则,检索非property类属性。从当前类到基类。
在这种情况下,让我们删除根property
del Grandparent.culture
print(f'c.culture is: {c.culture}')
print(f'Child.culture is: {Child.culture}')

这将会产生:

c.culture is: Child's desired new culture
Traceback (most recent call last):
  File "<pyshell#74>", line 1, in <module>
    print(f'Child.culture is: {Child.culture}')
AttributeError: type object 'Child' has no attribute 'culture'

Ta-dah!现在Child拥有了自己的culture,这是通过强制插入到c.__dict__中实现的。Child.culture当然不存在,因为它从未在ParentChild类属性中定义,并且Grandparent的已被删除。


这是我的问题的根本原因吗?

实际上,不是。你遇到的错误与我们在分配self.culture时观察到的错误完全不同。但继承顺序为答案设置了背景 - 即property本身。

除了先前提到的getter方法外,property还有一些巧妙的技巧。在这种情况下最相关的是setterfset方法,它由self.culture = ...行触发。由于您的property没有实现任何setterfget函数,Python不知道该怎么做,并抛出一个AttributeError (即can't set attribute)。

如果您实现了setter方法:

@property
def some_prop(self):
    return "Family property"

@some_prop.setter
def some_prop(self, val):
    print(f"property setter is called!")
    # do something else...

实例化Child类时,您将得到:

Instantiating Child class...
property setter is called!

现在你不会再收到AttributeError错误,而是实际调用了some_prop.setter方法。这使你对对象有更多的控制...根据我们之前的发现,我们知道在属性到达之前需要重写类属性。这可以在基类中作为触发器实现。以下是一个新的示例:

class Grandparent:
    @property
    def culture(self):
        return "Family property"
    
    # add a setter method
    @culture.setter
    def culture(self, val):
        print('Fine, have your own culture')
        # overwrite the child class attribute
        type(self).culture = None
        self.culture = val

class Parent(Grandparent):
    pass

class Child(Parent):
    def __init__(self):
        self.culture = "I'm a millennial!"

c = Child()
print(c.culture)

这导致:

Fine, have your own culture
I'm a millennial!

TA-DAH!现在您可以覆盖继承属性的自己实例属性了!


那么,问题解决了吗?

...并没有。这种方法的问题是,现在你无法拥有一个适当的setter方法。有些情况下,你确实想要在property上设置值。但现在,每当你设置self.culture = ...时,它将始终覆盖你在getter中定义的任何函数(在这种情况下,真正的部分只是被@property包装的部分)。你可以添加更细致的措施,但不管怎样,它都将涉及到更多的东西,而不仅仅是self.culture = ...。例如:

class Grandparent:
    # ...
    @culture.setter
    def culture(self, val):
        if isinstance(val, tuple):
            if val[1]:
                print('Fine, have your own culture')
                type(self).culture = None
                self.culture = val[0]
        else:
            raise AttributeError("Oh no you don't")

# ...

class Child(Parent):
    def __init__(self):
        try:
            # Usual setter
            self.culture = "I'm a Gen X!"
        except AttributeError:
            # Trigger the overwrite condition
            self.culture = "I'm a Boomer!", True

这比其他答案所述的类级别的size = None要复杂得多。
你也可以考虑编写自己的描述器,以处理__get____set__或其他方法。 但是归根结底,当引用self.culture时,__get__将始终首先触发;而当引用self.culture = ...时,__set__将始终首先触发。 我尝试过了,无法避免这一点。

问题的核心,在我看来

我在这里看到的问题是 - 你不能两全其美。 property 的作用就像是一个带有方便访问方法(如getattrsetattr)的descriptor。如果你还想让这些方法实现其他目的,那么你只会自找麻烦。我可能会重新考虑一下方法:

  1. 我真的需要这个 property 吗?
  2. 一个方法能为我提供不同的服务吗?
  3. 如果我需要一个 property,是否有任何理由需要覆盖它?
  4. 如果这些 property 不适用于子类,那么子类是否真的属于同一族群?
  5. 如果我确实需要覆盖任何/所有 property,是否有一个单独的方法比简单地重新分配更好,因为重新分配可能会意外地使 property 失效?

对于第5点,我的方法是在基类中添加一个 overwrite_prop() 方法,覆盖当前类属性,以便 property 将不再被触发:

class Grandparent:
    # ...
    def overwrite_props(self):
        # reassign class attributes
        type(self).size = None
        type(self).len = None
        # other properties, if necessary

# ...

# Usage
class Child(Parent):
    def __init__(self):
        self.overwrite_props()
        self.size = 5
        self.len = 10

正如你所看到的,虽然还有点勉强,但至少比神秘的size = None更明确。 话虽如此,最终我不会覆盖该属性,并会重新考虑我的设计。

如果你已经走到这一步了 - 感谢你与我一起走过这段旅程。 这是一个有趣的小练习。


2
哇,非常感谢!我需要一点时间来消化这个,但我想我自己也是要求的。 - Paul Panzer

13
一个属性是一个数据描述符,它优先于具有相同名称的实例属性。您可以使用唯一的 __get__() 方法定义非数据描述符:实例属性优先于具有相同名称的非数据描述符,请参见文档。问题在于下面定义的 non_data_property 仅用于计算目的(您不能定义 setter 或 deleter),但这似乎是您的示例中的情况。
import math

class non_data_property:
    def __init__(self, fget):
        self.__doc__ = fget.__doc__
        self.fget = fget

    def __get__(self, obj, cls):
        if obj is None:
            return self
        return self.fget(obj)

class Math_Set_Base:
    @non_data_property
    def size(self, *elements):
        return len(self.elements)

class Concrete_Math_Set(Math_Set_Base):
    def __init__(self, *elements):
        self.elements = elements


class Square_Integers_Below(Math_Set_Base):
    def __init__(self, cap):
        self.size = int(math.sqrt(cap))

print(Concrete_Math_Set(1, 2, 3).size) # 3
print(Square_Integers_Below(1).size) # 1
print(Square_Integers_Below(4).size) # 2
print(Square_Integers_Below(9).size) # 3

但是这假设您可以访问基类以进行更改。


这非常接近完美。这意味着即使您只定义getter,属性也会隐式添加一个setter,除了阻止赋值之外什么也不做?有趣。我可能会接受这个答案。 - Paul Panzer
是的,根据文档,属性始终定义了所有三个描述符方法(__get __()__set __()__delete __()),如果您没有为它们提供任何函数,则会引发AttributeError。请参见Python等效的属性实现 - Arkelis
1
只要您不关心属性的setter__set__,这将起作用。但是我要警告您,这也意味着您可以轻松地覆盖属性,不仅在类级别(self.size = ...),甚至在实例级别(例如,Concrete_Math_Set(1, 2, 3).size = 10同样有效)。值得思考 :) - r.ook
Square_Integers_Below(9).size = 1 也是有效的,因为它是一个简单的属性。在这个特定的 "size" 情况下,这可能看起来很奇怪(应该使用属性),但在一般情况下 "轻松地在子类中覆盖计算属性",在某些情况下可能是有好处的。你也可以用 __setattr__() 控制属性访问,但这可能会让人不知所措。 - Arkelis
2
我并不否认这些可能有有效的用例,我只是想提到一个警告,因为它可能会在未来复杂化调试工作。只要你和 OP 都意识到了这些影响,那么一切都没问题。 - r.ook

9
一个@property在类级别上被定义。 文档详细介绍了它的工作原理,但可以简单地说,设置或获取属性会转换为调用特定的方法。 然而,管理此过程的property对象是使用类的自身定义来定义的。也就是说,它被定义为一个类变量,但行为像一个实例变量。
这样做的一个后果是,你可以在类级别上随意重新分配它。
print(Math_Set_Base.size)
# <property object at 0x10776d6d0>

Math_Set_Base.size = 4
print(Math_Set_Base.size)
# 4

就像其他类级别的名称(例如方法)一样,您可以通过显式地以不同的方式定义它来在子类中覆盖它:

class Square_Integers_Below(Math_Set_Base):
    # explicitly define size at the class level to be literally anything other than a @property
    size = None

    def __init__(self,cap):
        self.size = int(math.sqrt(cap))

print(Square_Integers_Below(4).size)  # 2
print(Square_Integers_Below.size)     # None

当我们创建实际实例时,实例变量只是遮盖了同名的类变量。 property 对象通常使用一些花招来操作这个过程(例如应用 getter 和 setter),但如果类级别名称未定义为属性,则不会发生任何特殊的情况,因此它就像任何其他变量一样运作。


谢谢,这很简单易懂。这是否意味着即使我的类及其祖先中从未有过任何属性,它仍然会首先搜索整个继承树以查找类级别名称,然后才会查看__dict__?此外,有没有自动化的方法?是的,只需要一行代码,但如果您不熟悉属性等详细信息,这种东西很难理解。 - Paul Panzer
@PaulPanzer 我还没有研究过这背后的程序,所以我自己无法给你一个令人满意的答案。如果你想的话,可以从cpython源代码中尝试解决这个问题。至于自动化这个过程,我认为除了要么一开始就不将其作为属性,要么只是添加一个注释/文档字符串来让那些阅读你的代码的人知道你在做什么之外,没有更好的方法了。例如 # 声明size不是@property - Green Cloak Guy

7
你根本不需要赋值(给 size)。size 是基类中的一个属性,因此你可以在子类中覆盖该属性:
class Math_Set_Base:
    @property
    def size(self):
        return len(self.elements)

    # size = property(lambda self: self.elements)


class Square_Integers_Below(Math_Set_Base):

    def __init__(self, cap):
        self._cap = cap

    @property
    def size(self):
        return int(math.sqrt(self._cap))

    # size = property(lambda self: int(math.sqrt(self._cap)))

你可以通过预计算平方根来进行(微)优化:
class Square_Integers_Below(Math_Set_Base):

    def __init__(self, cap):
        self._size = int(math.sqrt(self._cap))

    @property
    def size(self):
        return self._size

这是一个很好的观点,只需使用子属性覆盖父属性即可!+1 - r.ook
1
这在某种程度上是直截了当的事情,但我对允许非属性覆盖的方式感兴趣,并且特别询问了这方面的方法,以避免不必要地编写记忆化属性而导致不便。 - Paul Panzer
我不确定为什么您想要像那样让代码变得复杂。只要认识到“size”是一个属性而不是实例属性,您就不必做任何花哨的事情。 - chepner
1
我的使用情况是有很多子类和很多属性;在这些方面不必写五行代码而只需一行,我认为这样的繁琐程度是合理的。 - Paul Panzer

2

看起来你想在你的类中定义size:

class Square_Integers_Below(Math_Set_Base):
    size = None

    def __init__(self, cap):
        self.size = int(math.sqrt(cap))

另一个选择是将 cap 存储在您的类中,并使用定义为属性的 size 计算它(覆盖基类的属性 size)。

2
我建议添加像这样的setter:
class Math_Set_Base:
    @property
    def size(self):
        try:
            return self._size
        except:
            return len(self.elements)

    @size.setter
    def size(self, value):
        self._size = value

这样,您就可以像这样覆盖默认的.size属性:
class Concrete_Math_Set(Math_Set_Base):
    def __init__(self,*elements):
        self.elements = elements

class Square_Integers_Below(Math_Set_Base):
    def __init__(self,cap):
        self.size = int(math.sqrt(cap))

print(Concrete_Math_Set(1,2,3).size) # 3
print(Square_Integers_Below(7).size) # 2

1
同时您可以做下一步的事情。
class Math_Set_Base:
    _size = None

    def _size_call(self):
       return len(self.elements)

    @property
    def size(self):
        return  self._size if self._size is not None else self._size_call()

class Concrete_Math_Set(Math_Set_Base):
    def __init__(self, *elements):
        self.elements = elements


class Square_Integers_Below(Math_Set_Base):
    def __init__(self, cap):
        self._size = int(math.sqrt(cap))

这很不错,但你在那里并不真正需要 _size_size_call。你可以将函数调用嵌入到 size 中作为条件,并使用 try... except... 来测试 _size,而不是采取一个额外的类引用,这样就不会被使用。无论如何,我觉得这比最初简单的 size = None 覆盖更加难以理解。 - r.ook

1
我认为在派生类中可以将基类的属性设置为另一个属性,然后使用新值使用基类的属性。在派生类内部进行操作。
在初始代码中,基类的名称“size”与派生类的属性“self.size”之间存在一种冲突。如果我们用“self.length”替换派生类中的名称“self.size”,这种冲突就会变得明显。输出结果如下:
3
<__main__.Square_Integers_Below object at 0x000001BCD56B6080>

那么,如果我们在程序中所有出现的方法名 size 替换为 length,这将导致相同的异常:

Traceback (most recent call last):
  File "C:/Users/Maria/Downloads/so_1.2.py", line 24, in <module>
    Square_Integers_Below(7)
  File "C:/Users/Maria/Downloads/so_1.2.py", line 21, in __init__
    self.length = int(math.sqrt(cap))
AttributeError: can't set attribute

固定的代码,或者说,某种方式可行的版本,是保持代码完全相同,除了类Square_Integers_Below,它将从基类设置方法size为另一个值。
class Square_Integers_Below(Math_Set_Base):

    def __init__(self,cap):
        #Math_Set_Base.__init__(self)
        self.length = int(math.sqrt(cap))
        Math_Set_Base.size = self.length

    def __repr__(self):
        return str(self.size)

然后,当我们运行整个程序时,输出结果为:

3
2

我希望这在某种程度上对您有所帮助。


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