使用属性装饰器与使用属性类相比,有什么优势?

9
我可以看到在Python中有两种非常相似的属性方式。

(a) 属性类

class Location(object):

    def __init__(self, longitude, latitude):
        self.set_latitude(latitude)
        self.set_longitude(longitude)

    def set_latitude(self, latitude):
        if not (-90 <= latitude <= 90):
            raise ValueError('latitude was {}, but has to be in [-90, 90]'
                             .format(latitude))
        self._latitude = latitude

    def set_longitude(self, longitude):
        if not (-180 <= longitude <= 180):
            raise ValueError('longitude was {}, but has to be in [-180, 180]'
                             .format(longitude))
        self._longitude = longitude

    def get_longitude(self):
        return self._latitude

    def get_latitude(self):
        return self._longitude

    latitude = property(get_latitude, set_latitude)
    longitude = property(get_longitude, set_longitude)

(b)属性装饰器

class Location(object):

    def __init__(self, longitude, latitude):
        self.latitude = latitude
        self.longitude = latitude

    @property
    def latitude(self):
        """I'm the 'x' property."""
        return self._latitude

    @property
    def longitude(self):
        """I'm the 'x' property."""
        return self._longitude

    @latitude.setter
    def latitude(self, latitude):
        if not (-90 <= latitude <= 90):
            raise ValueError('latitude was {}, but has to be in [-90, 90]'
                             .format(latitude))
        self._latitude = latitude

    @longitude.setter
    def longitude(self, longitude):
        if not (-180 <= longitude <= 180):
            raise ValueError('longitude was {}, but has to be in [-180, 180]'
                             .format(longitude))
        self._longitude = longitude

问题

这两段代码是否相同(例如,字节码上)?它们显示相同的行为吗?

有没有官方指南,指导应该使用哪种“风格”?

其中一个优于另一个的真正优势是什么?

我尝试过的

py_compile + uncompyle6

我已经编译了这两个版本:

>>> import py_compile
>>> py_compile.compile('test.py')

我尝试了使用 uncompyle6 对两个文件进行反编译,但返回的结果与原始内容相同(仅格式略有不同)。

import + dis

我还尝试了以下方法:

import test  # (a)
import test2  # (b)
dis.dis(test)
dis.dis(test2)

我对test2的输出非常困惑:

Disassembly of Location:
Disassembly of __init__:
 13           0 LOAD_FAST                2 (latitude)
              2 LOAD_FAST                0 (self)
              4 STORE_ATTR               0 (latitude)

 14           6 LOAD_FAST                2 (latitude)
              8 LOAD_FAST                0 (self)
             10 STORE_ATTR               1 (longitude)
             12 LOAD_CONST               0 (None)
             14 RETURN_VALUE

第一个比较大:

Disassembly of Location:
Disassembly of __init__:
 13           0 LOAD_FAST                0 (self)
              2 LOAD_ATTR                0 (set_latitude)
              4 LOAD_FAST                2 (latitude)
              6 CALL_FUNCTION            1
              8 POP_TOP

 14          10 LOAD_FAST                0 (self)
             12 LOAD_ATTR                1 (set_longitude)
             14 LOAD_FAST                1 (longitude)
             16 CALL_FUNCTION            1
             18 POP_TOP
             20 LOAD_CONST               0 (None)
             22 RETURN_VALUE

Disassembly of set_latitude:
 17           0 LOAD_CONST               3 (-90)
              2 LOAD_FAST                1 (latitude)
              4 DUP_TOP
              6 ROT_THREE
              8 COMPARE_OP               1 (<=)
             10 JUMP_IF_FALSE_OR_POP    18
             12 LOAD_CONST               1 (90)
             14 COMPARE_OP               1 (<=)
             16 JUMP_FORWARD             4 (to 22)
        >>   18 ROT_TWO
             20 POP_TOP
        >>   22 POP_JUMP_IF_TRUE        38

 18          24 LOAD_GLOBAL              0 (ValueError)
             26 LOAD_CONST               2 ('latitude was {}, but has to be in [-90, 90]')
             28 LOAD_ATTR                1 (format)
             30 LOAD_FAST                1 (latitude)
             32 CALL_FUNCTION            1
             34 CALL_FUNCTION            1
             36 RAISE_VARARGS            1

 19     >>   38 LOAD_FAST                1 (latitude)
             40 LOAD_FAST                0 (self)
             42 STORE_ATTR               2 (latitude)
             44 LOAD_CONST               0 (None)
             46 RETURN_VALUE

Disassembly of set_longitude:
 22           0 LOAD_CONST               3 (-180)
              2 LOAD_FAST                1 (longitude)
              4 DUP_TOP
              6 ROT_THREE
              8 COMPARE_OP               1 (<=)
             10 JUMP_IF_FALSE_OR_POP    18
             12 LOAD_CONST               1 (180)
             14 COMPARE_OP               1 (<=)
             16 JUMP_FORWARD             4 (to 22)
        >>   18 ROT_TWO
             20 POP_TOP
        >>   22 POP_JUMP_IF_TRUE        38

 23          24 LOAD_GLOBAL              0 (ValueError)
             26 LOAD_CONST               2 ('longitude was {}, but has to be in [-180, 180]')
             28 LOAD_ATTR                1 (format)
             30 LOAD_FAST                1 (longitude)
             32 CALL_FUNCTION            1
             34 CALL_FUNCTION            1
             36 RAISE_VARARGS            1

 24     >>   38 LOAD_FAST                1 (longitude)
             40 LOAD_FAST                0 (self)
             42 STORE_ATTR               2 (longitude)
             44 LOAD_CONST               0 (None)
             46 RETURN_VALUE

这种差异是从哪里来的?第一个示例的值范围检查在哪里?


1
有一个很大的区别:在第一个例子中,使用代码 Location().latitude = 10 将绕过任何 set_latitude 所做的验证。set_latitude 甚至不会被调用。 - DeepSpace
@DeepSpace 请看一下最后的编辑 - 我在创建问题时犯了一个复制粘贴错误。 - Martin Thoma
那样更有意义。 - user2357112
1
但是您忘记编写getter方法,而且您的“longitude”属性正在使用“latitude”方法。 - user2357112
我建议您在两个版本都能正常工作的情况下,尝试使用timeit模块来查看哪个更快,以及差距有多大。生成的字节码对性能的影响可能并不重要。 - martineau
2个回答

9
你应该总是使用装饰器。其他语法并没有任何优势,只有缺点。
装饰器的目的在于避免使用其他语法。所有你发现的类似name = property(...)这样的例子通常都是早期代码。
装饰器语法是一种“语法糖”,格式为:
@decorator
def functionname(...):
    # ...

被执行的方式很像

def functionname(...):
    # ...

functionname = decorator(functionname)

没有将functionname分配两次(def functionname(...)部分创建一个函数对象并通常分配给functionname,但使用装饰器时,函数对象被直接创建并传递给装饰器对象)。
Python添加了这个特性,因为当您的函数体很长时,您不能轻易地看到该函数已经被装饰器包装。您必须向下滚动以查看,这在您想要了解有关函数的几乎所有其他内容都位于顶部时并不十分有用;参数、名称和文档字符串都在那里。
从原始的PEP 318 – Decorators for Functions and Methods规范中:

应用函数或方法转换的当前方法将实际转换放在函数体之后。对于大型函数,这会将函数行为的关键组件与其余函数的外部接口定义分离。

[...]

对于更长的方法,这变得更加难以阅读。这似乎也不如pythonic为一个概念上的单一声明三次命名函数。

设计目标下:

新语法应该

  • [...]
  • 从函数的末尾移动到前面,这样它就更加明显。
因此使用
@property
def latitude(self):
    # ...

@latitude.setter
def latitude(self, latitude):
    # ...

比起其他方式,这种写法更易读且能够自我说明。
def get_latitude(self):
    # ...

def set_latitude(self, latitude):
    # ...

latitude = property(get_latitude, set_latitude)

不会污染命名空间

接下来,因为@property装饰器用修饰结果(一个property实例)替换你所修饰的函数对象,所以你也避免了命名空间污染。如果没有@property@<name>.setter@<name>.deleter,你必须在类定义中添加3个额外的、独立的名称,但后来却没有人使用:

>>> [n for n in sorted(vars(Location)) if n[:2] != '__']
['get_latitude', 'get_longitude', 'latitude', 'longitude', 'set_latitude', 'set_longitude']

想象一下一个有5个、10个或更多属性定义的类。不熟悉项目的开发人员和自动完成IDE之间的差异肯定会对get_latitude、latitude和set_latitude之间的区别感到困惑,结果你会得到混合风格的代码,并且使得现在难以从类级别公开这些方法。
当然,你可以在latitude = property(...)赋值后立即使用del get_latitude、set_latitude,但这只是为了执行更多的额外代码而没有实际目的。

令人困惑的方法名称

虽然你可以避免不得不在访问器名称前缀添加get_和set_或以其他方式区分名称来创建一个property()对象,但那仍然是几乎所有不使用@property装饰器语法的代码最终用来命名访问器方法的方法。
这可能会导致一些追踪错误的混淆;在其中一个访问器方法中引发的异常会导致追溯中出现get_latitude或set_latitude的名称,而前面的行使用了object.latitude。对于Python属性新手来说,如果他们错过了下面的latitude = property(...)行,则可能不总是清楚两者之间的联系;请参见上文。

访问访问器,如何继承

你可能会指出,你可能需要访问那些函数;例如,在子类中覆盖属性的getter或setter,同时继承其他访问器。
但是,当在类上访问property对象时,已经通过.fget、.fset和.fdel属性为你提供了对访问器的引用:
>>> Location.latitude
<property object at 0x10d1c3d18>
>>> Location.latitude.fget
<function Location.get_latitude at 0x10d1c4488>
>>> Location.latitude.fset
<function Location.set_latitude at 0x10d195ea0>

同时,您可以在子类中重复使用 @<name>.getter / @<name>.setter / @<name>.deleter 语法,无需记住创建新的 property 对象!

在旧语法中,常见的做法是尝试仅覆盖一个访问器:

class SpecialLocation(Location):
    def set_latitude(self, latitude):
        # ...

然后你会想知道为什么继承的property对象没有被捕获。

使用装饰器语法,您可以使用:

class SpecialLocation(Location):
    @Location.latitude.setter
    def latitude(self, latitude):
        # ...

并且SpecialLocation子类被赋予了一个新的property()实例,该实例继承自Location的getter,以及一个新的setter。

简短概括

使用装饰器语法。

  • 它自文档化
  • 它避免了命名空间污染
  • 它使继承访问器属性更加清晰和直观

我认为最后一点是迄今为止最重要的。我不认为第一点有任何情况,也不认为第二点的严重性。 - Martin Thoma
谢谢你的回答 :-) 像往常一样,从你这里学习总是很愉快的 :-) - Martin Thoma
1
哦,谢谢你解释为什么它能工作。我之前非常困惑为什么两个同名的函数不会互相覆盖。这也是我不喜欢装饰器的主要原因;我讨厌代码中的魔法。 - Martin Thoma

3
您的两个版本代码的结果几乎完全相同。您在结尾处拥有的属性描述符在两种情况下的功能是相同的。描述符中唯一的区别在于,如果您真的尝试(通过Location.longitude.fset.__name__)访问函数名称,以及在异常回溯中可能看到的内容,如果出现问题。
唯一的其他区别是在完成后存在get_fooset_foo方法。当您使用@property时,您将不会有那些方法在命名空间中混乱。如果您手动构建property对象,则它们将保留在类命名空间中,因此您可以直接调用它们,而不是通过property对象使用普通属性访问。
不寻常的是,@property语法更好,因为它隐藏了通常不需要的方法。我能想到的唯一原因是,如果您希望将方法作为回调传递给某些其他函数(例如some_function(*args, callback=foo.set_longitude)),则可以公开它们。但是,您只需使用一个lambda作为回调即可(lambda x: setattr(foo, "longitude", x)),因此我认为没有必要为这种边缘情况污染一个不错的API,只是为了多余的getter和setter方法。

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