作用域规则与技巧

Beginner

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

简介

在这个实验中,你将了解 Python 的作用域规则,并探索处理作用域的高级技术。理解 Python 中的作用域对于编写简洁且可维护的代码至关重要,它有助于避免意外行为。

本实验的目标包括详细理解 Python 的作用域规则、学习用于类初始化的实用作用域技术、实现一个灵活的对象初始化系统,以及应用帧检查技术来简化代码。你将使用文件 structure.pystock.py

理解类初始化的问题

在编程领域,类是一个基本概念,它允许你创建自定义数据类型。在之前的练习中,你可能已经创建了一个 Structure 类。这个类是一个实用工具,可用于轻松定义数据结构。数据结构是一种组织和存储数据的方式,以便能够高效地访问和使用数据。Structure 类作为基类,会根据预定义的字段名列表来初始化属性。属性是属于对象的变量,而字段名则是我们赋予这些属性的名称。

让我们仔细看看 Structure 类的当前实现。为此,你需要在代码编辑器中打开 structure.py 文件。这个文件包含了 Structure 类的代码。以下是导航到项目目录并打开文件的命令:

cd ~/project
code structure.py

Structure 类为定义简单的数据结构提供了一个基本框架。当我们创建一个子类,如 Stock 类时,我们可以为该子类定义特定的字段。子类会继承其基类(在这种情况下是 Structure 类)的属性和方法。例如,在 Stock 类中,我们定义了字段 namesharesprice

class Stock(Structure):
    _fields = ('name', 'shares', 'price')

现在,让我们打开 stock.py 文件,看看 Stock 类在整个代码上下文中是如何实现的。这个文件可能包含使用 Stock 类并与之交互的代码。使用以下命令打开文件:

code stock.py

尽管使用 Structure 类及其子类的这种方法可行,但它有几个局限性。为了找出这些问题,我们将运行 Python 解释器,探索 Stock 类的行为。以下命令将导入 Stock 类并显示其帮助信息:

python3 -c "from stock import Stock; help(Stock)"

当你运行这个命令时,你会注意到帮助输出中显示的签名并不是很有用。它没有显示实际的参数名,如 namesharesprice,而只显示了 *args。这种缺乏清晰参数名的情况使得用户难以理解如何正确创建 Stock 类的实例。

让我们也尝试使用关键字参数来创建一个 Stock 实例。关键字参数允许你通过参数名来指定参数的值,这可以使代码更具可读性。运行以下命令:

python3 -c "from stock import Stock; s = Stock(name='GOOG', shares=100, price=490.1); print(s)"

你应该会得到如下错误信息:

TypeError: __init__() got an unexpected keyword argument 'name'

这个错误的出现是因为我们当前负责初始化 Stock 类对象的 __init__ 方法不处理关键字参数。它只接受位置参数,这意味着你必须按照特定的顺序提供值,而不能使用参数名。这是我们在这个实验中想要修复的一个局限性。

在这个实验中,我们将探索不同的方法,使我们的 Structure 类更加灵活和用户友好。通过这样做,我们可以提高 Stock 类和 Structure 类的其他子类的可用性。

使用 locals() 访问函数参数

在 Python 中,理解变量作用域至关重要。变量的作用域决定了它在代码中的可访问位置。Python 提供了一个内置函数 locals(),对于初学者理解作用域非常有用。locals() 函数会返回一个包含当前作用域中所有局部变量的字典。当你想要检查函数参数时,这会非常有用,因为它能让你清楚地看到代码特定部分中可用的变量。

让我们在 Python 解释器中进行一个简单的实验,看看它是如何工作的。首先,你需要导航到项目目录并启动 Python 解释器。你可以在终端中运行以下命令来完成:

cd ~/project
python3

进入 Python 交互式 shell 后,我们将定义一个 Stock 类。在 Python 中,类就像是创建对象的蓝图。在这个类中,我们将使用特殊的 __init__ 方法。__init__ 方法是 Python 中的构造函数,这意味着在创建类的对象时,它会自动被调用。在这个 __init__ 方法内部,我们将使用 locals() 函数来打印所有局部变量。

class Stock:
    def __init__(self, name, shares, price):
        print(locals())

现在,让我们创建这个 Stock 类的一个实例。实例是根据类蓝图创建的实际对象。我们将为 namesharesprice 参数传入一些值。

s = Stock('GOOG', 100, 490.1)

当你运行这段代码时,你应该会看到类似以下的输出:

{'self': <__main__.Stock object at 0x...>, 'name': 'GOOG', 'shares': 100, 'price': 490.1}

这个输出表明,locals() 为我们提供了一个包含 __init__ 方法中所有局部变量的字典。self 引用是 Python 类中的一个特殊变量,它指向类的实例本身。其他变量是我们创建 Stock 对象时传入的参数值。

我们可以使用 locals() 的这个功能来自动初始化对象属性。属性是与对象关联的变量。让我们定义一个辅助函数并修改我们的 Stock 类。

def _init(locs):
    self = locs.pop('self')
    for name, val in locs.items():
        setattr(self, name, val)

class Stock:
    def __init__(self, name, shares, price):
        _init(locals())

_init 函数接受从 locals() 获得的局部变量字典。它首先使用 pop 方法从字典中移除 self 引用。然后,它遍历字典中剩余的键值对,并使用 setattr 函数将每个变量设置为对象的属性。

现在,让我们使用位置参数和关键字参数来测试这个实现。位置参数按照函数签名中定义的顺序传递,而关键字参数则指定参数名进行传递。

## Test with positional arguments
s1 = Stock('GOOG', 100, 490.1)
print(s1.name, s1.shares, s1.price)

## Test with keyword arguments
s2 = Stock(name='AAPL', shares=50, price=125.3)
print(s2.name, s2.shares, s2.price)

现在这两种方法都应该可行!_init 函数使我们能够无缝处理位置参数和关键字参数。它还保留了函数签名中的参数名,这使得 help() 输出更加有用。Python 中的 help() 函数提供有关函数、类和模块的信息,完整的参数名会让这些信息更有意义。

当你完成实验后,你可以运行以下命令退出 Python 解释器:

exit()

探索栈帧检查

我们一直在使用的 _init(locals()) 方法虽然可行,但有一个缺点。每次定义 __init__ 方法时,我们都必须显式调用 locals()。这可能会有点麻烦,尤其是在处理多个类的时候。幸运的是,我们可以通过使用栈帧检查来让代码更简洁、更高效。这种技术允许我们自动访问调用者的局部变量,而无需显式调用 locals()

让我们在 Python 解释器中开始探索这种技术。首先,打开终端并导航到项目目录。然后,启动 Python 解释器。你可以通过运行以下命令来完成:

cd ~/project
python3

现在我们已经进入 Python 解释器,需要导入 sys 模块。sys 模块提供了对 Python 解释器使用或维护的一些变量的访问。我们将使用它来访问栈帧信息。

import sys

接下来,我们将定义一个改进版的 _init() 函数。这个新版本将直接访问调用者的帧,从而无需显式传递 locals()

def _init():
    ## Get the caller's frame (1 level up in the call stack)
    frame = sys._getframe(1)

    ## Get the local variables from that frame
    locs = frame.f_locals

    ## Extract self and set other variables as attributes
    self = locs.pop('self')
    for name, val in locs.items():
        setattr(self, name, val)

在这段代码中,sys._getframe(1) 会获取调用函数的帧对象。参数 1 表示我们要在调用栈中向上查找一层。一旦我们获得了帧对象,就可以使用 frame.f_locals 访问其局部变量。这会给我们一个包含调用者作用域中所有局部变量的字典。然后,我们提取 self 变量,并将其余变量设置为 self 对象的属性。

现在,让我们用新版本的 Stock 类来测试这个新的 _init() 函数。

class Stock:
    def __init__(self, name, shares, price):
        _init()  ## No need to pass locals() anymore!

## Test it
s = Stock('GOOG', 100, 490.1)
print(s.name, s.shares, s.price)

## Also works with keyword arguments
s = Stock(name='AAPL', shares=50, price=125.3)
print(s.name, s.shares, s.price)

如你所见,__init__ 方法不再需要显式传递 locals() 了。从调用者的角度来看,这让我们的代码更简洁、更易读。

栈帧检查的工作原理

当你调用 sys._getframe(1) 时,Python 会返回表示调用者执行帧的帧对象。参数 1 表示“从当前帧向上一层”(即调用函数)。

帧对象包含有关执行上下文的重要信息。这包括当前正在执行的函数、该函数中的局部变量以及当前正在执行的行号。

通过访问 frame.f_locals,我们可以获得调用者作用域中所有局部变量的字典。这与直接在该作用域中调用 locals() 返回的结果类似。

这种技术非常强大,但使用时需要谨慎。它通常被认为是 Python 的高级特性,可能会显得有点“神奇”,因为它超出了 Python 正常的作用域边界。

当你完成栈帧检查的实验后,可以运行以下命令退出 Python 解释器:

exit()

Structure 类中实现高级初始化

我们刚刚学习了两种强大的访问函数参数的技术。现在,我们将使用这些技术来更新我们的 Structure 类。首先,让我们了解一下为什么要这样做。这些技术将使我们的类更加灵活,更易于使用,特别是在处理不同类型的参数时。

在代码编辑器中打开 structure.py 文件。你可以在终端中运行以下命令来完成此操作。cd 命令用于将目录更改为项目文件夹,code 命令用于在代码编辑器中打开 structure.py 文件。

cd ~/project
code structure.py

将文件内容替换为以下代码。这段代码定义了一个包含多个方法的 Structure 类。让我们逐部分分析,了解它的功能。

import sys

class Structure:
    _fields = ()

    @staticmethod
    def _init():
        ## Get the caller's frame (the __init__ method that called this)
        frame = sys._getframe(1)

        ## Get the local variables from that frame
        locs = frame.f_locals

        ## Extract self and set other variables as attributes
        self = locs.pop('self')
        for name, val in locs.items():
            setattr(self, name, val)

    def __repr__(self):
        values = ', '.join(f'{name}={getattr(self, name)!r}' for name in self._fields)
        return f'{type(self).__name__}({values})'

    def __setattr__(self, name, value):
        if name.startswith('_') or name in self._fields:
            super().__setattr__(name, value)
        else:
            raise AttributeError(f'{type(self).__name__!r} has no attribute {name!r}')

以下是我们在代码中所做的操作:

  1. 我们移除了旧的 __init__() 方法。由于子类将定义自己的 __init__ 方法,因此我们不再需要旧的方法。
  2. 我们添加了一个新的 _init() 静态方法。此方法使用帧检查(frame inspection)自动捕获所有参数并将其设置为属性。帧检查允许我们访问调用方法的局部变量。
  3. 我们保留了 __repr__() 方法。此方法为对象提供了一个良好的字符串表示形式,这对于调试和打印非常有用。
  4. 我们添加了一个 __setattr__() 方法。此方法执行属性验证,确保只能为对象设置有效的属性。

现在,让我们更新 Stock 类。使用以下命令打开 stock.py 文件:

code stock.py

将其内容替换为以下代码:

from structure import Structure

class Stock(Structure):
    _fields = ('name', 'shares', 'price')

    def __init__(self, name, shares, price):
        self._init()  ## This magically captures and sets all parameters!

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

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

这里的关键更改是,我们的 __init__ 方法现在调用 self._init(),而不是手动设置每个属性。_init() 方法使用帧检查自动捕获所有参数并将其设置为属性。这使得代码更加简洁,更易于维护。

让我们通过运行单元测试来测试我们的实现。单元测试将帮助我们确保代码按预期工作。在终端中运行以下命令:

cd ~/project
python3 teststock.py

你应该会看到所有测试都通过了,包括之前失败的关键字参数测试。这意味着我们的实现是正确的。

让我们还查看一下 Stock 类的帮助文档。帮助文档提供了有关类及其方法的信息。在终端中运行以下命令:

python3 -c "from stock import Stock; help(Stock)"

现在你应该会看到 __init__ 方法的正确签名,显示所有参数名称。这使得其他开发人员更容易理解如何使用该类。

最后,让我们交互式地测试关键字参数是否按预期工作。在终端中运行以下命令:

python3 -c "from stock import Stock; s = Stock(name='GOOG', shares=100, price=490.1); print(s)"

你应该会看到 Stock 对象已使用指定的属性正确创建。这证实了我们的类初始化系统支持关键字参数。

通过这种实现,我们实现了一个更加灵活、用户友好的类初始化系统,它:

  1. 在文档中保留了正确的函数签名,使开发人员更容易理解如何使用该类。
  2. 支持位置参数和关键字参数,在创建对象时提供了更多的灵活性。
  3. 减少了子类中的样板代码,减少了你需要编写的代码量。

总结

在本次实验中,你学习了 Python 的作用域规则以及一些处理作用域的强大技术。首先,你探索了如何使用 locals() 函数来访问函数内的所有局部变量。其次,你学会了使用 sys._getframe() 检查栈帧,以访问调用者的局部变量。

你还应用这些技术创建了一个灵活的类初始化系统。该系统会自动捕获函数参数并将其设置为对象属性,在文档中保留正确的函数签名,并且支持位置参数和关键字参数。这些技术展示了 Python 的灵活性和自省能力。虽然栈帧检查是一种高级技术,使用时需要谨慎,但在适当使用的情况下,它可以有效减少样板代码。理解作用域规则和这些高级技术能让你拥有更多工具来编写更简洁、更易维护的 Python 代码。