了解描述符

Beginner

This tutorial is from open-source community. Access the source code

简介

在这个实验中,你将了解 Python 中的描述符(descriptor),这是一种用于自定义对象属性访问的强大机制。描述符允许你定义属性的访问、设置和删除方式,让你能够控制对象的行为并实现验证逻辑。

本实验的目标包括理解描述符协议、创建和使用自定义描述符、使用描述符实现数据验证,以及优化描述符的实现。在实验过程中,你将创建几个文件,包括 descrip.pystock.pyvalidate.py

这是一个实验(Guided Lab),提供逐步指导来帮助你学习和实践。请仔细按照说明完成每个步骤,获得实际操作经验。根据历史数据,这是一个 初级 级别的实验,完成率为 91%。获得了学习者 100% 的好评率。

理解描述符协议

在这一步中,我们将通过创建一个简单的 Stock 类来学习 Python 中描述符的工作原理。Python 中的描述符是一项强大的特性,它允许你自定义属性的访问、设置和删除方式。描述符协议由三个特殊方法组成:__get__()__set__()__delete__()。这些方法分别定义了在访问、赋值或删除属性时描述符的行为。

首先,你需要在项目目录中创建一个名为 stock.py 的新文件。这个文件将包含我们的 Stock 类。以下是你应该放在 stock.py 文件中的代码:

## stock.py

class Stock:
    __slots__ = ['_name', '_shares', '_price']

    def __init__(self, name, shares, price):
        self.name = name
        self.shares = shares
        self.price = price

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

    @name.setter
    def name(self, value):
        if not isinstance(value, str):
            raise TypeError('Expected a string')
        self._name = value

    @property
    def shares(self):
        return self._shares

    @shares.setter
    def shares(self, value):
        if not isinstance(value, int):
            raise TypeError('Expected an integer')
        if value < 0:
            raise ValueError('Expected a positive value')
        self._shares = value

    @property
    def price(self):
        return self._price

    @price.setter
    def price(self, value):
        if not isinstance(value, (int, float)):
            raise TypeError('Expected a number')
        if value < 0:
            raise ValueError('Expected a positive value')
        self._price = value

    def cost(self):
        return self.shares * self.price

    def sell(self, amount):
        self.shares -= amount

    def __repr__(self):
        return f'Stock({self.name!r}, {self.shares!r}, {self.price!r})'

在这个 Stock 类中,我们使用 property 装饰器为 namesharesprice 属性定义了 getter 和 setter 方法。这些 getter 和 setter 方法充当描述符,这意味着它们控制着这些属性的访问和设置方式。例如,setter 方法会验证输入值,以确保它们的类型正确且在可接受的范围内。

现在我们的 stock.py 文件已经准备好了,让我们打开一个 Python 交互式 shell 来试验 Stock 类,看看描述符是如何实际工作的。为此,请打开你的终端并运行以下命令:

cd ~/project
python3 -i stock.py

python3 命令中的 -i 选项告诉 Python 在执行 stock.py 文件后启动一个交互式 shell。这样,我们就可以直接与刚刚定义的 Stock 类进行交互。

在 Python 交互式 shell 中,让我们创建一个股票对象并尝试访问它的属性。你可以这样做:

s = Stock('GOOG', 100, 490.10)
s.name      ## Should return 'GOOG'
s.shares    ## Should return 100

当你访问 s 对象的 nameshares 属性时,Python 实际上是在幕后调用描述符的 __get__ 方法。我们类中的 property 装饰器是使用描述符实现的,这意味着它们以可控的方式处理属性的访问和赋值。

让我们仔细查看类字典,以查看描述符对象。类字典包含类中定义的所有属性和方法。你可以使用以下代码查看类字典的键:

Stock.__dict__.keys()

你应该会看到类似于以下的输出:

dict_keys(['__module__', '__slots__', '__init__', 'name', '_name', 'shares', '_shares', 'price', '_price', 'cost', 'sell', '__repr__', '__doc__'])

namesharesprice 表示由 property 装饰器创建的描述符对象。

现在,让我们通过手动调用描述符的方法来研究描述符是如何工作的。我们将以 shares 描述符为例。你可以这样做:

## Get the descriptor object for 'shares'
q = Stock.__dict__['shares']

## Use the __get__ method to retrieve the value
q.__get__(s, Stock)  ## Should return 100

## Use the __set__ method to set a new value
q.__set__(s, 75)
s.shares  ## Should now be 75

## Try to set an invalid value
try:
    q.__set__(s, '75')  ## Should raise TypeError
except TypeError as e:
    print(f"Error: {e}")

当你访问像 s.shares 这样的属性时,Python 会调用描述符的 __get__ 方法来获取值。当你给属性赋值,如 s.shares = 75 时,Python 会调用描述符的 __set__ 方法。然后,描述符可以验证数据,如果输入值无效则会引发错误。

当你完成对 Stock 类和描述符的实验后,你可以通过运行以下命令退出 Python 交互式 shell:

exit()

创建自定义描述符

在这一步中,我们将创建自己的描述符类。但首先,让我们了解一下什么是描述符。描述符是实现了描述符协议的 Python 对象,该协议由 __get____set____delete__ 方法组成。这些方法允许描述符管理属性的访问、设置和删除方式。通过创建自己的描述符类,我们可以更好地理解这个协议的工作原理。

在项目目录中创建一个名为 descrip.py 的新文件。这个文件将包含我们的自定义描述符类。以下是代码:

## descrip.py

class Descriptor:
    def __init__(self, name):
        self.name = name

    def __get__(self, instance, cls):
        print(f'{self.name}:__get__')
        ## In a real descriptor, you would return a value here

    def __set__(self, instance, value):
        print(f'{self.name}:__set__ {value}')
        ## In a real descriptor, you would store the value here

    def __delete__(self, instance):
        print(f'{self.name}:__delete__')
        ## In a real descriptor, you would delete the value here

Descriptor 类中,__init__ 方法使用一个名称来初始化描述符。当访问属性时会调用 __get__ 方法,当设置属性时会调用 __set__ 方法,当删除属性时会调用 __delete__ 方法。

现在,让我们创建一个测试文件来试验我们的自定义描述符。这将帮助我们了解描述符在不同场景下的行为。创建一个名为 test_descrip.py 的文件,代码如下:

## test_descrip.py

from descrip import Descriptor

class Foo:
    a = Descriptor('a')
    b = Descriptor('b')
    c = Descriptor('c')

## Create an instance and try accessing the attributes
if __name__ == '__main__':
    f = Foo()
    print("Accessing attribute f.a:")
    f.a

    print("\nAccessing attribute f.b:")
    f.b

    print("\nSetting attribute f.a = 23:")
    f.a = 23

    print("\nDeleting attribute f.a:")
    del f.a

test_descrip.py 文件中,我们从 descrip.py 导入 Descriptor 类。然后我们创建一个 Foo 类,它有三个属性 abc,每个属性都由一个描述符管理。我们创建一个 Foo 类的实例,并执行访问、设置和删除属性等操作,以查看描述符方法是如何被调用的。

现在让我们运行这个测试文件,看看描述符是如何工作的。打开你的终端,导航到项目目录,并使用以下命令运行测试文件:

cd ~/project
python3 test_descrip.py

你应该会看到如下输出:

Accessing attribute f.a:
a:__get__

Accessing attribute f.b:
b:__get__

Setting attribute f.a = 23:
a:__set__ 23

Deleting attribute f.a:
a:__delete__

如你所见,每次你访问、设置或删除由描述符管理的属性时,相应的魔术方法(__get____set____delete__)都会被调用。

让我们也以交互式的方式检查我们的描述符。这将允许我们实时测试描述符并立即看到结果。打开你的终端,导航到项目目录,并使用 descrip.py 文件启动一个交互式 Python 会话:

cd ~/project
python3 -i descrip.py

现在在交互式 Python 会话中输入以下命令,看看描述符协议是如何工作的:

class Foo:
    a = Descriptor('a')
    b = Descriptor('b')
    c = Descriptor('c')

f = Foo()
f.a         ## Should call __get__
f.b         ## Should call __get__
f.a = 23    ## Should call __set__
del f.a     ## Should call __delete__
exit()

这里的关键要点是,描述符提供了一种拦截和自定义属性访问的方式。这使得它们在实现数据验证、计算属性和其他高级行为方面非常强大。通过使用描述符,你可以更好地控制类属性的访问、设置和删除方式。

使用描述符实现验证器

在这一步中,我们将使用描述符创建一个验证系统。但首先,让我们了解一下什么是描述符以及为什么要使用它们。描述符是实现了描述符协议的 Python 对象,该协议包括 __get____set____delete__ 方法。它们允许你自定义对象属性的访问、设置或删除方式。在我们的例子中,我们将使用描述符创建一个验证系统,以确保数据的完整性。这意味着存储在我们对象中的数据将始终满足某些条件,例如属于特定类型或具有正值。

现在,让我们开始创建验证系统。我们将在项目目录中创建一个名为 validate.py 的新文件。这个文件将包含实现我们验证器的类。

## validate.py

class Validator:
    def __init__(self, name):
        self.name = name

    @classmethod
    def check(cls, value):
        return value

    def __set__(self, instance, value):
        instance.__dict__[self.name] = self.check(value)


class String(Validator):
    expected_type = str

    @classmethod
    def check(cls, value):
        if not isinstance(value, cls.expected_type):
            raise TypeError(f'Expected {cls.expected_type}')
        return value


class PositiveInteger(Validator):
    expected_type = int

    @classmethod
    def check(cls, value):
        if not isinstance(value, cls.expected_type):
            raise TypeError(f'Expected {cls.expected_type}')
        if value < 0:
            raise ValueError('Expected a positive value')
        return value


class PositiveFloat(Validator):
    expected_type = float

    @classmethod
    def check(cls, value):
        if not isinstance(value, (int, float)):
            raise TypeError('Expected a number')
        if value < 0:
            raise ValueError('Expected a positive value')
        return float(value)

validate.py 文件中,我们首先定义了一个名为 Validator 的基类。这个类有一个 __init__ 方法,它接受一个 name 参数,该参数将用于标识要验证的属性。check 方法是一个类方法,它只是返回传递给它的值。__set__ 方法是一个描述符方法,当在对象上设置属性时会调用它。它调用 check 方法来验证值,然后将验证后的值存储在对象的字典中。

然后,我们定义了 Validator 的三个子类:StringPositiveIntegerPositiveFloat。每个子类都重写了 check 方法以执行特定的验证检查。String 类检查值是否为字符串,PositiveInteger 类检查值是否为正整数,PositiveFloat 类检查值是否为正数(整数或浮点数)。

现在我们已经定义了验证器,让我们修改 Stock 类以使用这些验证器。我们将创建一个名为 stock_with_validators.py 的新文件,并从 validate.py 文件中导入验证器。

## stock_with_validators.py

from validate import String, PositiveInteger, PositiveFloat

class Stock:
    name = String('name')
    shares = PositiveInteger('shares')
    price = PositiveFloat('price')

    def __init__(self, name, shares, price):
        self.name = name
        self.shares = shares
        self.price = price

    def cost(self):
        return self.shares * self.price

    def sell(self, amount):
        self.shares -= amount

    def __repr__(self):
        return f'Stock({self.name!r}, {self.shares!r}, {self.price!r})'

stock_with_validators.py 文件中,我们定义了 Stock 类,并将验证器用作类属性。这意味着每当在 Stock 对象上设置属性时,相应验证器的 __set__ 方法将被调用来验证值。__init__ 方法初始化 Stock 对象的属性,costsell__repr__ 方法提供了额外的功能。

现在,让我们测试基于验证器的 Stock 类。我们将打开一个终端,导航到项目目录,并以交互模式运行 stock_with_validators.py 文件。

cd ~/project
python3 -i stock_with_validators.py

一旦 Python 解释器运行起来,我们可以尝试一些命令来测试验证系统。

## Create a stock with valid values
s = Stock('GOOG', 100, 490.10)
print(s.name)    ## Should return 'GOOG'
print(s.shares)  ## Should return 100
print(s.price)   ## Should return 490.1

## Try changing to valid values
s.shares = 75
print(s.shares)  ## Should return 75

## Try setting invalid values
try:
    s.shares = '75'  ## Should raise TypeError
except TypeError as e:
    print(f"Error setting shares to string: {e}")

try:
    s.shares = -50  ## Should raise ValueError
except ValueError as e:
    print(f"Error setting negative shares: {e}")

exit()

在测试代码中,我们首先创建一个具有有效值的 Stock 对象,并打印其属性以验证它们是否设置正确。然后,我们尝试将 shares 属性更改为有效值,并再次打印以确认更改。最后,我们尝试将 shares 属性设置为无效值(字符串和负数),并捕获验证器引发的异常。

注意我们的代码现在变得多么简洁。Stock 类不再需要实现所有那些属性方法——验证器处理所有的类型检查和约束。

描述符使我们能够创建一个可重用的验证系统,该系统可以应用于任何类属性。这是一种在应用程序中维护数据完整性的强大模式。

改进描述符的实现

在这一步中,我们将增强描述符的实现。你可能已经注意到,在某些情况下,我们会重复指定名称。这会让代码变得有些杂乱,也更难维护。为了解决这个问题,我们将使用 __set_name__ 方法,这是 Python 3.6 引入的一个实用特性。

__set_name__ 方法会在类定义时自动调用。它的主要作用是为我们设置描述符的名称,这样我们就不必每次都手动设置了。这会让我们的代码更简洁、更高效。

现在,让我们更新 validate.py 文件,加入 __set_name__ 方法。更新后的代码如下:

## validate.py

class Validator:
    def __init__(self, name=None):
        self.name = name

    def __set_name__(self, cls, name):
        ## This gets called when the class is defined
        ## It automatically sets the name of the descriptor
        if self.name is None:
            self.name = name

    @classmethod
    def check(cls, value):
        return value

    def __set__(self, instance, value):
        instance.__dict__[self.name] = self.check(value)


class String(Validator):
    expected_type = str

    @classmethod
    def check(cls, value):
        if not isinstance(value, cls.expected_type):
            raise TypeError(f'Expected {cls.expected_type}')
        return value


class PositiveInteger(Validator):
    expected_type = int

    @classmethod
    def check(cls, value):
        if not isinstance(value, cls.expected_type):
            raise TypeError(f'Expected {cls.expected_type}')
        if value < 0:
            raise ValueError('Expected a positive value')
        return value


class PositiveFloat(Validator):
    expected_type = float

    @classmethod
    def check(cls, value):
        if not isinstance(value, (int, float)):
            raise TypeError('Expected a number')
        if value < 0:
            raise ValueError('Expected a positive value')
        return float(value)

在上述代码中,Validator 类的 __set_name__ 方法会检查 name 属性是否为 None。如果是,它会将 name 设置为类定义中使用的实际属性名。这样,我们在创建描述符类的实例时就不必显式指定名称了。

现在我们已经更新了 validate.py 文件,可以创建一个改进版的 Stock 类。这个新版本不需要我们重复指定名称。以下是改进后的 Stock 类的代码:

## improved_stock.py

from validate import String, PositiveInteger, PositiveFloat

class Stock:
    name = String()  ## No need to specify 'name' anymore
    shares = PositiveInteger()
    price = PositiveFloat()

    def __init__(self, name, shares, price):
        self.name = name
        self.shares = shares
        self.price = price

    def cost(self):
        return self.shares * self.price

    def sell(self, amount):
        self.shares -= amount

    def __repr__(self):
        return f'Stock({self.name!r}, {self.shares!r}, {self.price!r})'

在这个 Stock 类中,我们只需创建 StringPositiveIntegerPositiveFloat 描述符类的实例,而无需指定名称。Validator 类中的 __set_name__ 方法会自动处理名称的设置。

让我们测试一下改进后的 Stock 类。首先,打开终端并导航到项目目录。然后,以交互模式运行 improved_stock.py 文件。以下是相应的命令:

cd ~/project
python3 -i improved_stock.py

进入交互式 Python 会话后,你可以尝试以下命令来测试 Stock 类的功能:

s = Stock('GOOG', 100, 490.10)
print(s.name)    ## Should return 'GOOG'
print(s.shares)  ## Should return 100
print(s.price)   ## Should return 490.1

## Try changing values
s.shares = 75
print(s.shares)  ## Should return 75

## Try invalid values
try:
    s.shares = '75'  ## Should raise TypeError
except TypeError as e:
    print(f"Error: {e}")

try:
    s.price = -10.5  ## Should raise ValueError
except ValueError as e:
    print(f"Error: {e}")

exit()

这些命令创建了一个 Stock 类的实例,打印其属性,更改属性值,然后尝试设置无效值,以查看是否会引发相应的错误。

__set_name__ 方法会在类定义时自动设置描述符的名称。这让你的代码更简洁,减少了冗余,因为你不再需要两次指定属性名。

这一改进展示了 Python 的描述符协议是如何不断发展的,让编写简洁、可维护的代码变得更加容易。

总结

在本次实验中,你学习了 Python 描述符,这是一项强大的特性,能让你自定义类中属性的访问方式。你探索了描述符协议,包括 __get____set____delete__ 方法。你还创建了一个基本的描述符类来拦截属性访问,并使用描述符实现了一个确保数据完整性的验证系统。

此外,你使用 __set_name__ 方法改进了描述符,以减少代码冗余。描述符在 Python 库和框架(如 Django 和 SQLAlchemy)中被广泛使用。理解描述符能让你更深入地了解 Python,并帮助你编写更优雅、更易维护的代码。