Python 的 property():向类添加托管属性

举报
Yuchuan 发表于 2021/10/15 15:50:17 2021/10/15
【摘要】 一个属性是一种特殊类型的类成员,它提供的功能是在常规属性和方法之间的某处。属性允许您在不更改类的公共 API 的情况下修改实例属性的实现。能够保持 API 不变有助于避免破坏用户在旧版本类之上编写的代码。 属性是在类中创建托管属性的Pythonic方式。它们在现实世界的编程中有几个用例,使它们成为您作为 Python 开发人员技能集的一个很好的补充。

目录

使用 Python 的property(),您可以在类中创建托管属性。当您需要修改其内部实现而不更改类的公共API时,您可以使用托管属性,也称为属性。提供稳定的 API 可以帮助您避免在用户依赖您的类和对象时破坏他们的代码。

属性可以说是最流行的以最纯粹的Pythonic风格快速创建托管属性的方式。

在本教程中,您将学习如何:

  • 在您的类中创建托管属性属性
  • 执行惰性属性评估并提供计算属性
  • 避免使用settergetter方法让你的类更 Pythonic
  • 创建read-onlyread-writewrite-only属性
  • 为您的类创建一致且向后兼容的 API

您还将编写一些property()用于验证输入数据、动态计算属性值、记录代码等的实际示例。为了充分利用本教程,您应该了解Python 中面向对象编程和装饰器的基础知识。

管理类中的属性

当您在面向对象的编程语言中定义一个类时,您可能最终会得到一些实例和类属性。换句话说,您最终会得到可通过实例、类或什至两者访问的变量,具体取决于语言。属性代表或保存给定对象的内部状态,您经常需要访问和改变它。

通常,您至少有两种方法来管理属性。您可以直接访问和改变属性,也可以使用methods。方法是附加到给定类的函数。它们提供对象可以使用其内部数据和属性执行的行为和操作。

如果您向用户公开您的属性,那么它们将成为您类的公共API的一部分。您的用户将直接在他们的代码中访问和改变它们。当您需要更改给定属性的内部实现时,就会出现问题。

假设您正在Circle上课。最初的实现有一个名为.radius. 您完成了该类的编码并将其提供给您的最终用户。他们开始Circle在他们的代码中使用来创建许多很棒的项目和应用程序。做得好!

现在假设您有一个重要用户向您提出新要求。他们不想Circle再存储半径。他们需要一个公共.diameter属性。

此时,删除.radius以开始使用.diameter可能会破坏某些最终用户的代码。您需要以除删除.radius.

JavaC++等编程语言鼓励您永远不要公开您的属性以避免此类问题。相反,您应该提供gettersetter方法,也分别称为accessorsmutators。这些方法提供了一种无需更改公共 API 即可更改属性的内部实现的方法。

注意: Getter 和 setter 方法通常被认为是一种反模式和面向对象设计不佳的信号。这个命题背后的主要论点是这些方法打破了封装。它们允许您访问和更改对象的组件。

最后,这些语言需要 getter 和 setter 方法,因为如果给定的需求发生变化,它们没有提供合适的方法来更改属性的内部实现。更改内部实现需要修改 API,这可能会破坏最终用户的代码。

Python 中的 Getter 和 Setter 方法

从技术上讲,没有什么可以阻止您在 Python 中使用 getter 和 setter方法。以下是这种方法的外观:

# point.py

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

    def get_x(self):
        return self._x

    def set_x(self, value):
        self._x = value

    def get_y(self):
        return self._y

    def set_y(self, value):
        self._y = value

在此示例中,您Point使用两个非公共属性 创建._x._y保存手头点的笛卡尔坐标

注: Python没有概念访问修饰符,如privateprotectedpublic,限制访问的属性和方法。在 Python 中,区别在于公共非公共类成员。

如果要表示给定的属性或方法是非公开的,则必须使用众所周知的 Python约定,即在名称前加上下划线 ( _)。这就是命名属性._x._y.

请注意,这只是一个约定。它不会阻止您和其他程序员使用点表示法访问属性,如obj._attr. 但是,违反此约定是不好的做法。

要访问和改变._xor的值._y,您可以使用相应的 getter 和 setter 方法。来吧,保存的上述定义Point一个Python的模块导入类到你的交互shell

以下是您可以Point在代码中使用的方法:

>>>
>>> from point import Point

>>> point = Point(12, 5)
>>> point.get_x()
12
>>> point.get_y()
5

>>> point.set_x(42)
>>> point.get_x()
42

>>> # Non-public attributes are still accessible
>>> point._x
42
>>> point._y
5

使用.get_x()and .get_y(),您可以访问._xand的当前值._y。您可以使用 setter 方法在相应的托管属性中存储新值。从这段代码中,您可以确认 Python 不限制对非公共属性的访问。是否这样做取决于您。

Pythonic 方法

尽管您刚刚看到的示例使用了 Python 编码风格,但它看起来并不像 Pythonic。在该示例中,getter 和 setter 方法不使用._x和执行任何进一步处理._y。你可以Point用更简洁和 Pythonic 的方式重写:

>>>
>>> class Point:
...     def __init__(self, x, y):
...         self.x = x
...         self.y = y
...

>>> point = Point(12, 5)
>>> point.x
12
>>> point.y
5

>>> point.x = 42
>>> point.x
42

这段代码揭示了一个基本原则。在 Python 中,向最终用户公开属性是正常和常见的。你不需要一直用 getter 和 setter 方法来弄乱你的类,这听起来很酷!但是,您如何处理似乎涉及 API 更改的需求更改?

与 Java 和 C++ 不同,Python 提供了方便的工具,允许您在不更改公共 API 的情况下更改属性的底层实现。最流行的方法是将您的属性转换为属性

注意:提供托管属性的另一种常见方法是使用描述符。但是,在本教程中,您将了解属性。

属性表示普通属性(或字段)和方法之间的中间功能。换句话说,它们允许您创建行为类似于属性的方法。使用属性,您可以在需要时更改计算目标属性的方式。

例如,您可以将.x和 都.y转换为属性。通过此更改,您可以继续将它们作为属性进行访问。您还将拥有一个底层方法.x.y这将允许您在用户访问和改变它们之前修改它们的内部实现并对它们执行操作。

注意:属性不是 Python 独有的。语言作为这样的JavaScriptC# 科特林,和其他人还提供了工具和技术来创建属性类成员。

Python 属性的主要优点是它们允许您将属性作为公共 API 的一部分公开。如果您需要更改底层实现,那么您可以随时将属性转换为属性,而不会有太多痛苦。

在以下部分中,您将学习如何在 Python 中创建属性。

Python 入门 property()

Pythonproperty()是避免在代码中使用正式的 getter 和 setter 方法的 Pythonic 方式。此功能允许您将类属性转换为属性托管属性。由于property()是内置函数,因此您无需导入任何内容即可使用它。此外,在 Cproperty()实现以确保最佳性能。

注意:通常property()称为内置函数。但是,property是一个旨在作为函数而不是常规类工作的类。这就是为什么大多数 Python 开发人员称其为函数的原因。这也是为什么property()不遵循 Python命名 classes约定的原因。

本教程遵循调用property()函数而不是类的常见做法。但是,在某些部分中,您会看到它被称为类以方便解释。

使用property(),您可以将 getter 和 setter 方法附加到给定的类属性。这样,您就可以处理该属性的内部实现,而无需在 API 中公开 getter 和 setter 方法。您还可以指定处理属性删除的方法并为您的属性提供适当的文档字符串

这是 的完整签名property()

property(fget=None, fset=None, fdel=None, doc=None)

前两个参数采用函数对象,它们将扮演 getter ( fget) 和 setter ( fset) 方法的角色。下面总结了每个参数的作用:

争论 描述
fget 返回托管属性值的函数
fset 允许您设置托管属性值的函数
fdel 定义托管属性如何处理删除的函数
doc 表示属性的文档字符串的字符串

返回property()是托管属性本身。如果您访问托管属性(如 )obj.attr,则 Python 会自动调用fget(). 如果您为属性分配一个新值(如 )obj.attr = value,则 Pythonfset()使用输入value作为参数进行调用。最后,如果你运行一个del obj.attr语句,那么 Python 会自动调用fdel().

注意:property()取函数对象的前三个参数。您可以将函数对象视为没有调用括号对的函数名称。

您可以使用doc为您的属性提供适当的文档字符串。您和您的其他程序员将能够使用 Python 的help()doc当您使用支持文档字符串访问的代码编辑器和 IDE 时,该参数也很有用。

您可以将property()其用作函数装饰器来构建属性。在以下两节中,您将学习如何使用这两种方法。但是,您应该事先知道装饰器方法在 Python 社区中更受欢迎。

创建属性 property()

您可以通过property()使用一组适当的参数调用并将其返回值分配给类属性来创建属性。的所有参数property()都是可选的。但是,您通常至少提供一个setter function

以下示例显示了如何创建一个Circle具有方便属性的类来管理其半径:

# circle.py

class Circle:
    def __init__(self, radius):
        self._radius = radius

    def _get_radius(self):
        print("Get radius")
        return self._radius

    def _set_radius(self, value):
        print("Set radius")
        self._radius = value

    def _del_radius(self):
        print("Delete radius")
        del self._radius

    radius = property(
        fget=_get_radius,
        fset=_set_radius,
        fdel=_del_radius,
        doc="The radius property."
    )

在此代码片段中,您创建Circle. 类初始值设定项.__init__()将其radius作为参数并将其存储在名为 的非公共属性中._radius。然后定义三个非公共方法:

  1. ._get_radius() 返回当前值 ._radius
  2. ._set_radius()将其value作为参数并将其分配给._radius
  3. ._del_radius() 删除实例属性 ._radius

一旦你有了这三个方法,你就可以创建一个类属性,调用它.radius来存储属性对象。要初始化该属性,请将三个方法作为参数传递给property()。您还可以为您的财产传递一个合适的文档字符串。

在此示例中,您使用关键字参数来提高代码可读性并防止混淆。这样,您就可以确切地知道每个参数使用哪个方法。

Circle试一试,请在您的 Python shell 中运行以下代码:

>>>
>>> from circle import Circle

>>> circle = Circle(42.0)

>>> circle.radius
Get radius
42.0

>>> circle.radius = 100.0
Set radius
>>> circle.radius
Get radius
100.0

>>> del circle.radius
Delete radius
>>> circle.radius
Get radius
Traceback (most recent call last):
    ...
AttributeError: 'Circle' object has no attribute '_radius'

>>> help(circle)
Help on Circle in module __main__ object:

class Circle(builtins.object)
    ...
 |  radius
 |      The radius property.

.radius属性隐藏了非公共实例属性._radius,它现在是您在本示例中的托管属性。您可以.radius直接访问和分配。计算机内部,Python会自动调用._get_radius()._set_radius()在需要的时候。当您执行时del circle.radius,Python 会调用._del_radius(),这会删除底层的._radius.

使用lambda函数作为 Getter 方法显示隐藏

属性是管理实例属性的属性。您可以将属性视为捆绑在一起的方法的集合。如果您检查.radius仔细,那么你就可以找到你的所提供的原始方法fgetfset以及fdel参数:

>>>
>>> from circle import Circle

>>> Circle.radius.fget
<function Circle._get_radius at 0x7fba7e1d7d30>

>>> Circle.radius.fset
<function Circle._set_radius at 0x7fba7e1d78b0>

>>> Circle.radius.fdel
<function Circle._del_radius at 0x7fba7e1d7040>

>>> dir(Circle.radius)
[..., '__get__', ..., '__set__', ...]

您可以通过相应的访问的getter,setter方法,并删除器在给定的属性方法.fget.fset.fdel

属性也是覆盖描述符。如果您使用dir()检查给定属性的内部成员,那么您将在列表中找到.__set__().__get__()。这些方法提供了描述符协议的默认实现。

注意:如果你想更好地理解propertyas 类的内部实现,那么查看文档中描述的纯 PythonProperty

.__set__()例如,的默认实现在您不提供自定义 setter 方法时运行。在这种情况下,您会得到一个,AttributeError因为无法设置基础属性。

使用property()作为装饰

装饰器在 Python 中无处不在。它们是将另一个函数作为参数并返回具有附加功能的新函数的函数。使用装饰器,您可以将预处理和后处理操作附加到现有函数。

Python 2.2引入时property(),装饰器语法不可用。正如您之前学到的,定义属性的唯一方法是传递 getter、setter 和 deleter 方法。装饰器语法是在Python 2.4中添加的,现在,property()作为装饰器使用是 Python 社区中最流行的做法。

装饰器语法包括在要装饰的函数@定义之前放置带有前导符号的装饰器函数的名称:

@decorator
def func(a):
    return a

在这个代码片段中,@decorator可以是一个函数或类来装饰func(). 此语法等效于以下内容:

def func(a):
    return a

func = decorator(func)

The final line of code reassigns the name func to hold the result of calling decorator(func). Note that this is the same syntax you used to create a property in the section above.

Python’s property() can also work as a decorator, so you can use the @property syntax to create your properties quickly:

 1# circle.py
 2
 3class Circle:
 4    def __init__(self, radius):
 5        self._radius = radius
 6
 7    @property
 8    def radius(self):
 9        """The radius property."""
10        print("Get radius")
11        return self._radius
12
13    @radius.setter
14    def radius(self, value):
15        print("Set radius")
16        self._radius = value
17
18    @radius.deleter
19    def radius(self):
20        print("Delete radius")
21        del self._radius

This code looks pretty different from the getter and setter methods approach. Circle now looks more Pythonic and clean. You don’t need to use method names such as ._get_radius(), ._set_radius(), and ._del_radius() anymore. Now you have three methods with the same clean and descriptive attribute-like name. How is that possible?

用于创建属性的装饰器方法需要使用底层托管属性的公共名称定义第一个方法,.radius在本例中就是这样。此方法应实现 getter 逻辑。在上面的示例中,第 7 行到第 11 行实现了该方法。

第 13 到 16 行定义了 的 setter 方法.radius。在这种情况下,语法完全不同。您无需@property再次使用,而是使用@radius.setter. 为什么你需要这样做?再看一下dir()输出:

>>>
>>> dir(Circle.radius)
[..., 'deleter', ..., 'getter', 'setter']

此外.fget.fset.fdel,和一堆其他特殊的属性和方法,property还提供了.deleter().getter().setter()。这三个方法各自返回一个新属性。

当您.radius()使用@radius.setter(第 13 行)装饰第二个方法时,您创建了一个新属性并重新分配了类级名称.radius(第 8 行)来保存它。这个新属性包含第 8 行初始属性的相同方法集,并添加了第 14 行提供的新 setter 方法。最后,装饰器语法将新属性重新分配给.radius类级名称。

定义删除器方法的机制是类似的。这一次,您需要使用@radius.deleter装饰器。在该过程结束时,您将获得具有 getter、setter 和 deleter 方法的完整属性。

最后,当您使用装饰器方法时,如何为您的属性提供合适的文档字符串?如果您Circle再次检查,您会注意到您已经在第 9 行的 getter 方法中添加了一个文档字符串。

Circle实现的工作方式与上一节中的示例相同:

>>>
>>> from circle import Circle

>>> circle = Circle(42.0)

>>> circle.radius
Get radius
42.0

>>> circle.radius = 100.0
Set radius
>>> circle.radius
Get radius
100.0

>>> del circle.radius
Delete radius
>>> circle.radius
Get radius
Traceback (most recent call last):
    ...
AttributeError: 'Circle' object has no attribute '_radius'

>>> help(circle)
Help on Circle in module __main__ object:

class Circle(builtins.object)
    ...
 |  radius
 |      The radius property.

您不需要使用一对括号.radius()作为方法调用。相反,您可以.radius像访问常规属性一样访问,这是属性的主要用途。它们允许您将方法视为属性,并负责自动调用底层方法集。

以下是使用装饰器方法创建属性时要记住的一些要点:

  • @property装饰装修必须的getter方法
  • 文档字符串必须在getter 方法中
  • setter和删除器方法必须与getter方法加上的名字装饰.setter.getter分别。

到目前为止,您已经创建了property()用作函数和装饰器的托管属性。如果您检查Circle到目前为止的实现,那么您会注意到它们的 getter 和 setter 方法不会在您的属性之上添加任何真正的额外处理。

通常,您应该避免将不需要额外处理的属性转换为属性。在这些情况下使用属性可以使您的代码:

  • 不必要的冗长
  • 混淆其他开发者
  • 比基于常规属性的代码慢

除非您需要的不仅仅是访问裸属性,否则不要编写属性。它们是在浪费CPU时间,更重要的是,它们是在浪费您的时间。最后,您应该避免编写显式的 getter 和 setter 方法,然后将它们包装在一个属性中。相反,使用@property装饰器。这是目前最 Pythonic 的方式。

提供只读属性

可能最基本的用例property()是在您的类中提供只读属性。假设您需要一个不可变 Point类,它不允许用户改变其坐标的原始值,x并且y. 为了实现这个目标,你可以Point像下面的例子一样创建:

# point.py

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

    @property
    def x(self):
        return self._x

    @property
    def y(self):
        return self._y

在这里,您将输入参数存储在属性._x和 中._y。正如您已经了解到的,_在名称中使用前导下划线 ( ) 告诉其他开发人员它们是非公共属性,不应使用点表示法访问,例如在point._x. 最后,定义两个 getter 方法并用@property.

现在您有两个只读属性,.x.y作为您的坐标:

>>>
>>> from point import Point

>>> point = Point(12, 5)

>>> # Read coordinates
>>> point.x
12
>>> point.y
5

>>> # Write coordinates
>>> point.x = 42
Traceback (most recent call last):
    ...
AttributeError: can't set attribute

此处point.xpoint.y是只读属性的基本示例。它们的行为依赖于property提供的底层描述符。正如您已经看到的,当您没有定义正确的 setter 方法时,默认.__set__()实现会引发 an AttributeError

您可以Point更进一步地实现此实现,并提供显式的 setter 方法,这些方法会引发带有更详细和特定消息的自定义异常:

# point.py

class WriteCoordinateError(Exception):
    pass

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

    @property
    def x(self):
        return self._x

    @x.setter
    def x(self, value):
        raise WriteCoordinateError("x coordinate is read-only")

    @property
    def y(self):
        return self._y

    @y.setter
    def y(self, value):
        raise WriteCoordinateError("y coordinate is read-only")

在此示例中,您定义了一个名为 的自定义异常WriteCoordinateError。此异常允许您自定义实现不可变Point类的方式。现在,这两个 setter 方法都会通过更明确的消息引发您的自定义异常。来Point试试你的改进吧!

创建读写属性

您还可以使用property()提供具有读写功能的托管属性。在实践中,您只需要为您的属性提供适当的 getter 方法(“read”)和 setter 方法(“write”)即可创建读写托管属性。

假设你希望你的Circle类有一个.diameter属性。但是,在类初始值设定项中获取半径和直径似乎没有必要,因为您可以使用另一个来计算一个。这是一个Circle管理.radius.diameter作为读写属性的:

# circle.py

import math

class Circle:
    def __init__(self, radius):
        self.radius = radius

    @property
    def radius(self):
        return self._radius

    @radius.setter
    def radius(self, value):
        self._radius = float(value)

    @property
    def diameter(self):
        return self.radius * 2

    @diameter.setter
    def diameter(self, value):
        self.radius = value / 2

在这里,您创建一个Circle具有读写权限的类.radius。在这种情况下,getter 方法只返回半径值。setter 方法转换半径的输入值并将其分配给 non-public ._radius,这是您用来存储最终数据的变量。

在这个Circle和它的.radius属性的新实现中有一个微妙的细节需要注意。在这种情况下,类初始值设定项.radius直接将输入值分配给属性,而不是将其存储在专用的非公共属性中,例如._radius.

为什么?因为您需要确保作为半径提供的每个值,包括初始化值,都通过 setter 方法并转换为浮点数。

Circle还将.diameter属性实现为属性。getter 方法使用半径计算直径。setter 方法做了一些奇怪的事情。它不是将输入直径存储value在专用属性中,而是计算半径并将结果写入.radius.

以下是您的Circle工作方式:

>>>
>>> from circle import Circle

>>> circle = Circle(42)
>>> circle.radius
42.0

>>> circle.diameter
84.0

>>> circle.diameter = 100
>>> circle.diameter
100.0

>>> circle.radius
50.0

.radius.diameter在这些示例中都作为普通属性工作,为您的Circle类提供干净和 Pythonic 的公共 API 。

提供只写属性

您还可以通过调整实现属性的 getter 方法的方式来创建只写属性。例如,您可以让您的 getter 方法在用户每次访问基础属性值时引发异常。

以下是使用只写属性处理密码的示例:

# users.py

import hashlib
import os

class User:
    def __init__(self, name, password):
        self.name = name
        self.password = password

    @property
    def password(self):
        raise AttributeError("Password is write-only")

    @password.setter
    def password(self, plaintext):
        salt = os.urandom(32)
        self._hashed_password = hashlib.pbkdf2_hmac(
            "sha256", plaintext.encode("utf-8"), salt, 100_000
        )

的初始值设定项User将用户名和密码作为参数并将它们分别存储在.name和 中.password。您使用属性来管理您的类如何处理输入密码。AttributeError每当用户尝试检索当前密码时,getter 方法都会引发。这变成.password了只写属性:

>>>
>>> from users import User

>>> john = User("John", "secret")

>>> john._hashed_password
b'b\xc7^ai\x9f3\xd2g ... \x89^-\x92\xbe\xe6'

>>> john.password
Traceback (most recent call last):
    ...
AttributeError: Password is write-only

>>> john.password = "supersecret"
>>> john._hashed_password
b'\xe9l$\x9f\xaf\x9d ... b\xe8\xc8\xfcaU\r_'

在此示例中,您将创建john一个User具有初始密码的实例。setter 方法对密码进行哈希处理并将其存储在._hashed_password. 请注意,当您尝试.password直接访问时,您会得到一个AttributeError. 最后,分配一个新值来.password触发 setter 方法并创建一个新的散列密码。

在 setter 方法中.password,您使用os.urandom()生成一个 32 字节的随机字符串作为散列函数的salt。要生成散列密码,请使用hashlib.pbkdf2_hmac(). 然后将生成的散列密码存储在非公共属性中._hashed_password。这样做可确保您永远不会将明文密码保存在任何可检索的属性中。

将 Pythonproperty()付诸行动

到目前为止,您已经学习了如何使用 Python 的property()内置函数在类中创建托管属性。您用作property()函数和装饰器,并了解了这两种方法之间的差异。您还学习了如何创建只读、读写和只写属性。

在以下部分中,您将编写一些示例代码,以帮助您更好地实际理解property().

验证输入值

最常见的用例之一property()是构建托管属性,在存储甚至将其作为安全输入接受之前验证输入数据。数据验证是代码中的一个常见要求,它接受来自用户或您认为不可信的其他信息源的输入。

Pythonproperty()提供了一种快速可靠的工具来处理输入数据验证。例如,回想着Point例如,您可能需要的值.x,并.y为有效的数字。由于您的用户可以自由输入任何类型的数据,因此您需要确保您的点仅接受数字。

Point是管理此要求的实现:

# point.py

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    @property
    def x(self):
        return self._x

    @x.setter
    def x(self, value):
        try:
            self._x = float(value)
            print("Validated!")
        except ValueError:
            raise ValueError('"x" must be a number') from None

    @property
    def y(self):
        return self._y

    @y.setter
    def y(self, value):
        try:
            self._y = float(value)
            print("Validated!")
        except ValueError:
            raise ValueError('"y" must be a number') from None

使用 Python EAFP样式验证输入数据的 setter 方法.x.y使用try...except块。如果调用成功,则输入数据有效,您将进入屏幕。如果引发 a ,则用户会收到带有更具体消息的 a 。float()Validated!float()ValueErrorValueError

注意:在上面的示例中,您使用语法raisefrom None来隐藏与引发异常的上下文相关的内部详细信息。从最终用户的角度来看,这些细节可能会让人感到困惑,并使您的类看起来很粗糙。

查看文档中有关raise声明的部分,了解有关此主题的更多信息。

重要的是要注意,直接分配.x.y属性.__init__()可确保在对象初始化期间也进行验证。不这样做是property()用于数据验证时的常见错误。

以下是您的Point课程现在的工作方式:

>>>
>>> from point import Point

>>> point = Point(12, 5)
Validated!
Validated!
>>> point.x
12.0
>>> point.y
5.0

>>> point.x = 42
Validated!
>>> point.x
42.0

>>> point.y = 100.0
Validated!
>>> point.y
100.0

>>> point.x = "one"
Traceback (most recent call last):
     ...
ValueError: "x" must be a number

>>> point.y = "1o"
Traceback (most recent call last):
    ...
ValueError: "y" must be a number

如果赋值.x.yfloat()可以变成浮点数,则验证成功,该值被接受。否则,您将获得一个ValueError.

这种实现Point揭示了property(). 你发现了吗?

就是这样!您有遵循特定模式的重复代码。这种重复违反了DRY(不要重复自己)原则,因此您需要重构此代码以避免它。为此,您可以使用描述符抽象出重复的逻辑:

# point.py

class Coordinate:
    def __set_name__(self, owner, name):
        self._name = name

    def __get__(self, instance, owner):
        return instance.__dict__[self._name]

    def __set__(self, instance, value):
        try:
            instance.__dict__[self._name] = float(value)
            print("Validated!")
        except ValueError:
            raise ValueError(f'"{self._name}" must be a number') from None

class Point:
    x = Coordinate()
    y = Coordinate()

    def __init__(self, x, y):
        self.x = x
        self.y = y

现在你的代码有点短了。您通过定义在一个地方管理数据验证Coordinate描述符,设法删除了重复的代码。该代码的工作方式与您之前的实现方式相同。来试试看吧!

通常,如果您发现自己在代码周围复制和粘贴属性定义,或者发现像上面示例中那样重复的代码,那么您应该考虑使用适当的描述符。

提供计算属性

如果您需要一个在访问时动态构建其值的属性,那么property()这就是您要走的路。这些类型的属性通常称为计算属性。当您需要它们看起来像热切的属性时,它们会很方便,但您希望它们是惰性的

创建 Eager 属性的主要原因是在您经常访问属性时优化计算成本。另一方面,如果您很少使用给定的属性,那么惰性属性可以将其计算推迟到需要时,这可以使您的程序更高效。

以下是如何在类中property()创建计算属性的示例:.areaRectangle

class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height

    @property
    def area(self):
        return self.width * self.height

在此示例中,Rectangle初始化程序将widthheight作为参数并将它们存储在常规实例属性中。.area每次访问时,只读属性都会计算并返回当前矩形的面积。

属性的另一个常见用例是为给定属性提供自动格式化的值:

class Product:
    def __init__(self, name, price):
        self._name = name
        self._price = float(price)

    @property
    def price(self):
        return f"${self._price:,.2f}"

在此示例中,.price是格式化并返回特定产品价格的属性。要提供类似货币的格式,请使用带有适当格式选项的f 字符串

注意:此示例使用浮点数来表示货币,这是不好的做法。相反,你应该使用decimal.Decimal标准库

作为计算属性的最后一个示例,假设您有一个Point使用.x.y作为笛卡尔坐标的类。您想为您的点提供极坐标,以便您可以在一些计算中使用它们。极坐标系使用到原点的距离和与水平坐标轴的角度来表示每个点。

这是一个笛卡尔坐标Point类,它也提供计算的极坐标:

# point.py

import math

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    @property
    def distance(self):
        return round(math.dist((0, 0), (self.x, self.y)))

    @property
    def angle(self):
        return round(math.degrees(math.atan(self.y / self.x)), 1)

    def as_cartesian(self):
        return self.x, self.y

    def as_polar(self):
        return self.distance, self.angle

此示例说明如何Point使用给定对象.x.y笛卡尔坐标计算给定对象的距离和角度。下面是这段代码在实践中的工作方式:

>>>
>>> from point import Point

>>> point = Point(12, 5)

>>> point.x
12
>>> point.y
5

>>> point.distance
13
>>> point.angle
22.6

>>> point.as_cartesian()
(12, 5)
>>> point.as_polar()
(13, 22.6)

在提供计算属性或惰性属性时,这property()是一个非常方便的工具。但是,如果您要创建一个经常使用的属性,那么每次都计算它既昂贵又浪费。一个好的策略是在计算完成后缓存它们。

缓存计算属性

有时您有一个经常使用的给定计算属性。不断重复相同的计算可能是不必要和昂贵的。要解决此问题,您可以缓存计算值并将其保存在非公共专用属性中以供进一步重用。

为了防止意外行为,您需要考虑输入数据的可变性。如果您有一个根据常量输入值计算其值的属性,那么结果将永远不会改变。在这种情况下,您只需计算一次该值:

# circle.py

from time import sleep

class Circle:
    def __init__(self, radius):
        self.radius = radius
        self._diameter = None

    @property
    def diameter(self):
        if self._diameter is None:
            sleep(0.5)  # Simulate a costly computation
            self._diameter = self.radius * 2
        return self._diameter

即使这个实现Circle正确缓存了计算出的直径,它也有一个缺点,如果你改变了 的值.radius,那么.diameter将不会返回正确的值:

>>>
>>> from circle import Circle

>>> circle = Circle(42.0)
>>> circle.radius
42.0

>>> circle.diameter  # With delay
84.0
>>> circle.diameter  # Without delay
84.0

>>> circle.radius = 100.0
>>> circle.diameter  # Wrong diameter
84.0

在这些示例中,您将创建一个半径等于 的圆42.0。该.diameter属性仅在您第一次访问时计算其值。这就是为什么您在第一次执行中看到延迟而在第二次执行中没有延迟的原因。请注意,即使您更改了半径值,直径也保持不变。

如果计算属性的输入数据发生变异,则需要重新计算属性:

# circle.py

from time import sleep

class Circle:
    def __init__(self, radius):
        self.radius = radius

    @property
    def radius(self):
        return self._radius

    @radius.setter
    def radius(self, value):
        self._diameter = None
        self._radius = value

    @property
    def diameter(self):
        if self._diameter is None:
            sleep(0.5)  # Simulate a costly computation
            self._diameter = self._radius * 2
        return self._diameter

每次更改半径值时,.radius属性的 setter 方法都会重置._diameterNone。通过这个小更新,.diameter在每次更改 后第一次访问它时重新计算它的值.radius

>>>
>>> from circle import Circle

>>> circle = Circle(42.0)

>>> circle.radius
42.0
>>> circle.diameter  # With delay
84.0
>>> circle.diameter  # Without delay
84.0

>>> circle.radius = 100.0
>>> circle.diameter  # With delay
200.0
>>> circle.diameter  # Without delay
200.0

凉爽的!Circle现在工作正常!它会在您第一次访问它以及每次更改半径时计算直径。

创建缓存属性的另一个选项是functools.cached_property()从标准库中使用。此函数用作装饰器,允许您将方法转换为缓存属性。该属性仅计算一次其值,并在实例的生命周期内将其作为普通属性缓存:

# circle.py

from functools import cached_property
from time import sleep

class Circle:
    def __init__(self, radius):
        self.radius = radius

    @cached_property
    def diameter(self):
        sleep(0.5)  # Simulate a costly computation
        return self.radius * 2

在这里,.diameter在您第一次访问它时计算并缓存它的值。这种实现适用于那些输入值不会发生变化的计算。这是它的工作原理:

>>>
>>> from circle import Circle

>>> circle = Circle(42.0)
>>> circle.diameter  # With delay
84.0
>>> circle.diameter  # Without delay
84.0

>>> circle.radius = 100
>>> circle.diameter  # Wrong diameter
84.0

>>> # Allow direct assignment
>>> circle.diameter = 200
>>> circle.diameter  # Cached value
200

当您访问 时.diameter,您将获得它的计算值。从现在开始,该值保持不变。但是,与 不同的是property()cached_property()除非您提供适当的 setter 方法,否则不会阻止属性更改。这就是为什么您可以200在最后几行中将直径更新为。

如果要创建不允许修改的缓存属性,则可以在以下示例中使用property()and functools.cache()like:

# circle.py

from functools import cache
from time import sleep

class Circle:
    def __init__(self, radius):
        self.radius = radius

    @property
    @cache
    def diameter(self):
        sleep(0.5) # Simulate a costly computation
        return self.radius * 2

此代码堆叠@property@cache. 两个装饰器的组合构建了一个缓存属性,以防止突变:

>>>
>>> from circle import Circle

>>> circle = Circle(42.0)

>>> circle.diameter  # With delay
84.0
>>> circle.diameter  # Without delay
84.0

>>> circle.radius = 100
>>> circle.diameter
84.0

>>> circle.diameter = 200
Traceback (most recent call last):
    ...
AttributeError: can't set attribute

在这些示例中,当您尝试为 分配新值时.diameter,您会得到 ,AttributeError因为 setter 功能来自 的内部描述符property

记录属性访问和变异

有时您需要跟踪代码的作用以及程序的运行方式。在 Python 中这样做的一种方法是使用logging. 该模块提供了记录代码所需的所有功能。它将允许您不断观察代码并生成有关其工作方式的有用信息。

如果您需要跟踪访问和更改给定属性的方式和时间,那么您也可以利用property()它:

# circle.py

import logging

logging.basicConfig(
    format="%(asctime)s: %(message)s",
    level=logging.INFO,
    datefmt="%H:%M:%S"
)

class Circle:
    def __init__(self, radius):
        self._msg = '"radius" was %s. Current value: %s'
        self.radius = radius

    @property
    def radius(self):
        """The radius property."""
        logging.info(self._msg % ("accessed", str(self._radius)))
        return self._radius

    @radius.setter
    def radius(self, value):
        try:
            self._radius = float(value)
            logging.info(self._msg % ("mutated", str(self._radius)))
        except ValueError:
            logging.info('validation error while mutating "radius"')

在这里,您首先导入logging并定义一个基本配置。然后Circle使用托管属性实现.radius。每次.radius在代码中访问时,getter 方法都会生成日志信息。setter 方法记录您在 上执行的每个更改.radius。它还记录由于输入数据错误而出现错误的情况。

以下是您可以Circle在代码中使用的方法:

>>>
>>> from circle import Circle

>>> circle = Circle(42.0)

>>> circle.radius
14:48:59: "radius" was accessed. Current value: 42.0
42.0

>>> circle.radius = 100
14:49:15: "radius" was mutated. Current value: 100

>>> circle.radius
14:49:24: "radius" was accessed. Current value: 100
100

>>> circle.radius = "value"
15:04:51: validation error while mutating "radius"

记录来自属性访问和变异的有用数据可以帮助您调试代码。日志记录还可以帮助您识别有问题的数据输入的来源、分析代码的性能、发现使用模式等。

管理属性删除

您还可以创建实现删除功能的属性。这可能是 的罕见用例property(),但在某些情况下,有一种删除属性的方法可能会很方便。

假设您正在实现自己的数据类型。树是一种抽象数据类型,它在层次结构中存储元素。树组件通常称为节点。树中的每个节点都有一个父节点,除了根节点。节点可以有零个或多个子节点。

现在假设您需要提供一种方法来删除或清除给定节点的子节点列表。这是一个实现树节点的示例,该节点property()用于提供其大部分功能,包括清除手头节点的子节点列表的能力:

# tree.py

class TreeNode:
    def __init__(self, data):
        self._data = data
        self._children = []

    @property
    def children(self):
        return self._children

    @children.setter
    def children(self, value):
        if isinstance(value, list):
            self._children = value
        else:
            del self.children
            self._children.append(value)

    @children.deleter
    def children(self):
        self._children.clear()

    def __repr__(self):
        return f'{self.__class__.__name__}("{self._data}")'

在此示例中,TreeNode表示自定义树数据类型中的一个节点。每个节点将其子节点存储在 Python列表中。然后您将其实现.children为一个属性来管理子项的基础列表。deleter 方法调用.clear()子项列表以将它们全部删除:

>>>
>>> from tree import TreeNode

>>> root = TreeNode("root")
>>> child1 = TreeNode("child 1")
>>> child2 = TreeNode("child 2")

>>> root.children = [child1, child2]

>>> root.children
[TreeNode("child 1"), TreeNode("child 2")]

>>> del root.children
>>> root.children
[]

在这里,您首先创建一个root节点来开始填充树。然后创建两个新节点并.children使用列表将它们分配给它们。该del语句触发内部删除器方法.children并清除列表。

创建向后兼容的类 API

如您所知,属性将方法调用转换为直接属性查找。此功能允许您为您的类创建干净的 Pythonic API。您可以公开公开您的属性,而无需 getter 和 setter 方法。

如果您需要修改计算给定公共属性的方式,则可以将其转换为属性。属性可以执行额外的处理,例如数据验证,而无需修改您的公共 API。

假设您正在创建一个会计应用程序并且您需要一个基类来管理货币。为此,您创建了一个Currency公开两个属性的类,.units并且.cents

class Currency:
    def __init__(self, units, cents):
        self.units = units
        self.cents = cents

    # Currency implementation...

这个类看起来干净和 Pythonic。现在假设您的要求发生了变化,您决定存储美分的总数而不是单位和美分。从您的公共 API 中删除.units.cents使用类似的东西.total_cents会破坏多个客户端的代码。

在这种情况下,property()保持当前 API 不变是一个很好的选择。以下是您可以如何解决该问题并避免破坏客户代码的方法:

# currency.py

CENTS_PER_UNIT = 100

class Currency:
    def __init__(self, units, cents):
        self._total_cents = units * CENTS_PER_UNIT + cents

    @property
    def units(self):
        return self._total_cents // CENTS_PER_UNIT

    @units.setter
    def units(self, value):
        self._total_cents = self.cents + value * CENTS_PER_UNIT

    @property
    def cents(self):
        return self._total_cents % CENTS_PER_UNIT

    @cents.setter
    def cents(self, value):
        self._total_cents = self.units * CENTS_PER_UNIT + value

    # Currency implementation...

现在,您的类存储美分的总数,而不是独立的单位和美分。但是,您的用户仍然可以在他们的代码中访问和更改.units.cents并获得与以前相同的结果。来试试看吧!

当您编写许多人要在其上构建的内容时,您需要保证对内部实现的修改不会影响最终用户如何使用您的类。

覆盖子类中的属性

当您创建包含属性的 Python 类并在包或库中发布它们时,您应该期望您的用户使用它们做很多不同的事情。其中之一可能是对它们进行子类化以自定义其功能。在这些情况下,您的用户必须小心并注意一个微妙的问题。如果您部分覆盖了一个属性,那么您将失去未被覆盖的功能。

例如,假设您正在编写一个Employee类来管理公司内部会计系统中的员工信息。您已经有一个名为 的类Person,并且您考虑将其子类化以重用其功能。

Person具有作为.name属性实现的属性。的当前实现.name不满足以大写字母返回名称的要求。以下是您最终解决此问题的方法:

# persons.py

class Person:
    def __init__(self, name):
        self._name = name

    @property
    def name(self):
        return self._name

    @name.setter
    def name(self, value):
        self._name = value

    # Person implementation...

class Employee(Person):
    @property
    def name(self):
        return super().name.upper()

    # Employee implementation...

在 中Employee,您重写.name以确保在访问属性时,您获得大写的员工姓名:

>>>
>>> from persons import Employee, Person

>>> person = Person("John")
>>> person.name
'John'
>>> person.name = "John Doe"
>>> person.name
'John Doe'

>>> employee = Employee("John")
>>> employee.name
'JOHN'

伟大的!Employee根据您的需要工作!它使用大写字母返回名称。然而,随后的测试发现了一个意想不到的行为:

>>>
>>> employee.name = "John Doe"
Traceback (most recent call last):
    ...
AttributeError: can't set attribute

发生了什么?好吧,当您覆盖父类中的现有属性时,您将覆盖该属性的全部功能。在此示例中,您仅重新实现了 getter 方法。因此,.name失去了基类的其余功能。您不再有 setter 方法了。

这个想法是,如果您需要覆盖子类中的属性,那么您应该在手头的新版本的属性中提供您需要的所有功能。

结论

一个属性是一种特殊类型的类成员,它提供的功能是在常规属性和方法之间的某处。属性允许您在不更改类的公共 API 的情况下修改实例属性的实现。能够保持 API 不变有助于避免破坏用户在旧版本类之上编写的代码。

属性是在类中创建托管属性Pythonic方式。它们在现实世界的编程中有几个用例,使它们成为您作为 Python 开发人员技能集的一个很好的补充。

在本教程中,您学习了如何:

  • 使用 Python创建托管属性property()
  • 执行惰性属性评估并提供计算属性
  • 避免带有属性的settergetter方法
  • 创建read-onlyread-writewrite-only属性
  • 为您的类创建一致且向后兼容的 API

您还编写了几个实际示例,引导您了解property(). 这些示例包括输入数据验证、计算属性、记录代码等。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。