日常 Python 问题的优雅解决方案

3 个工具,让你的 Python 代码更优雅、可读、直观且易于维护。
345 位读者喜欢这篇文章。
Dinner place setting

Didriks。由 Opensource.com 修改。CC BY-SA 4.0

Python 提供了一套独特的工具和语言特性,可以帮助你使代码更优雅、可读和直观。通过为正确的问题选择正确的工具,你的代码将更容易维护。在本文中,我们将研究其中三个工具:魔术方法 (magic methods)、迭代器和生成器 (iterators and generators) 以及方法魔术 (method magic)。

魔术方法

魔术方法可以被认为是 Python 的管道 (plumbing)。它们是为某些内置方法、符号和操作“在底层”调用的方法。你可能熟悉的常见魔术方法是 __init__(),它在我们想要初始化一个类的新实例时被调用。

你可能已经见过其他常见的魔术方法,例如 __str____repr__。魔术方法的世界非常广阔,通过实现其中的一些方法,我们可以极大地修改对象的行为,甚至使其表现得像内置数据类型 (datatype),例如数字、列表或字典。

让我们以这个 Money 类为例

class Money:

    currency_rates = {
        '$': 1,
        '€': 0.88,
    }

    def __init__(self, symbol, amount):
        self.symbol = symbol
        self.amount = amount

    def __repr__(self):
        return '%s%.2f' % (self.symbol, self.amount)

    def convert(self, other):
        """ Convert other amount to our currency """
        new_amount = (
            other.amount / self.currency_rates[other.symbol]
            * self.currency_rates[self.symbol])

        return Money(self.symbol, new_amount)

该类为给定的符号和汇率定义了货币汇率,指定了初始化器(也称为构造函数),并实现了 __repr__,因此当我们打印该类时,我们会看到一个不错的表示形式,例如对于具有货币符号和金额的实例 Money('$', 2.00),显示为 $2.00。最重要的是,它定义了一个方法,允许你在具有不同汇率的不同货币之间进行转换。

使用 Python shell,假设我们已经定义了两种不同货币的两种食品的价格,如下所示

>>> soda_cost = Money('$', 5.25)
>>> soda_cost
    $5.25

>>> pizza_cost = Money('€', 7.99)
>>> pizza_cost
    €7.99

我们可以使用魔术方法来帮助这个类的实例相互交互。假设我们希望能够将这个类的两个实例加在一起,即使它们使用不同的货币。为了实现这一点,我们可以在我们的 Money 类上实现 __add__ 魔术方法

class Money:

    # ... previously defined methods ...

    def __add__(self, other):
        """ Add 2 Money instances using '+' """
        new_amount = self.amount + self.convert(other).amount
        return Money(self.symbol, new_amount)

现在我们可以以非常直观的方式使用这个类

>>> soda_cost = Money('$', 5.25)

>>> pizza_cost = Money('€', 7.99)

>>> soda_cost + pizza_cost
    $14.33

>>> pizza_cost + soda_cost
    €12.61

当我们把两个实例加在一起时,我们会得到以第一个定义的货币表示的结果。所有的转换都在底层无缝完成。如果我们愿意,我们也可以实现 __sub__ 用于减法,__mul__ 用于乘法,以及更多。阅读关于模拟数值类型,或者阅读这篇魔术方法指南,了解更多。

我们了解到 __add__ 映射到内置运算符 +。其他魔术方法可以映射到像 [] 这样的符号。例如,要按索引或键(在字典的情况下)访问项目,请使用 __getitem__ 方法

>>> d = {'one': 1, 'two': 2}

>>> d['two']
2
>>> d.__getitem__('two')
2

一些魔术方法甚至映射到内置函数,例如 __len__(),它映射到 len()

class Alphabet:
    letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'

    def __len__(self):
        return len(self.letters)


>>> my_alphabet = Alphabet()
>>> len(my_alphabet)
    26

自定义迭代器

自定义迭代器 (custom iterators) 是一个非常强大但不幸的是对 Python 新手和经验丰富的开发者来说都很困惑的主题。

许多内置类型,例如列表、集合和字典,已经实现了允许它们在底层被迭代的协议。这使我们能够轻松地循环遍历它们。

>>> for food in ['Pizza', 'Fries']:
         print(food + '. Yum!')

Pizza. Yum!
Fries. Yum!

我们如何迭代我们自己的自定义类呢?首先,让我们澄清一些术语。

  • 要成为可迭代的 (iterable),一个类需要实现 __iter__()
  • __iter__() 方法需要返回一个迭代器 (iterator)
  • 要成为一个迭代器,一个类需要实现 __next__()(或 Python 2 中的 next()),当没有更多项目可以迭代时,它必须引发 StopIteration 异常。

哇!听起来很复杂,但是一旦你记住了这些基本概念,你就可以在睡梦中迭代了。

我们什么时候可能想要使用自定义迭代器?让我们想象一个场景,我们有一个 Server 实例在不同的端口上运行不同的服务,例如 httpssh。其中一些服务具有 active 状态,而另一些服务处于 inactive 状态。

class Server:

    services = [
        {'active': False, 'protocol': 'ftp', 'port': 21},
        {'active': True, 'protocol': 'ssh', 'port': 22},
        {'active': True, 'protocol': 'http', 'port': 80},
    ]

当我们循环遍历我们的 Server 实例时,我们只想循环遍历 active 服务。让我们创建一个新类,IterableServer

class IterableServer:

    def __init__(self):
        self.current_pos = 0

    def __next__(self):
        pass  # TODO: Implement and remember to raise StopIteration

首先,我们将当前位置初始化为 0。然后,我们定义一个 __next__() 方法,它将返回下一个项目。我们还将确保在没有更多项目要返回时引发 StopIteration。到目前为止一切顺利!现在,让我们实现这个 __next__() 方法。

class IterableServer:

    def __init__(self):
        self.current_pos = 0.  # we initialize our current position to zero

    def __iter__(self):  # we can return self here, because __next__ is implemented
        return self

    def __next__(self):
        while self.current_pos < len(self.services):
            service = self.services[self.current_pos]
            self.current_pos += 1
            if service['active']:
                return service['protocol'], service['port']
        raise StopIteration

    next = __next__  # optional python2 compatibility

我们继续循环遍历我们列表中的服务,只要我们的当前位置小于服务的长度,但仅在服务处于活动状态时才返回。一旦我们用完要迭代的服务,我们就引发 StopIteration 异常。

因为我们实现了一个 __next__() 方法,当它耗尽时会引发 StopIteration,所以我们可以从 __iter__() 返回 self,因为 IterableServer 类遵守 iterable 协议。

现在我们可以循环遍历 IterableServer 的实例,这将允许我们查看每个活动服务,就像这样

>>> for protocol, port in IterableServer():
        print('service %s is running on port %d' % (protocol, port))

service ssh is running on port 22
service http is running on port 21

这很棒,但我们可以做得更好!在像这样的实例中,我们的迭代器不需要维护大量状态,我们可以简化我们的代码并使用生成器 (generator) 来代替。

class Server:

    services = [
        {'active': False, 'protocol': 'ftp', 'port': 21},
        {'active': True, 'protocol': 'ssh', 'port': 22},
        {'active': True, 'protocol': 'http', 'port': 21},
    ]

    def __iter__(self):
        for service in self.services:
            if service['active']:
                yield service['protocol'], service['port']

yield 关键字到底是什么?Yield 在定义生成器函数时使用。它有点像 return。虽然 return 在返回值后退出函数,但 yield 会暂停执行,直到下次被调用。这允许你的生成器函数维护状态,直到它恢复。查看 yield 的文档 以了解更多信息。使用生成器,我们不必通过记住我们的位置来手动维护状态。生成器只知道两件事:它现在需要做什么以及它需要做什么来计算下一个项目。一旦我们达到不再调用 yield 的执行点,我们就知道停止迭代。

这之所以有效,是因为一些内置的 Python 魔术。在 __iter__() 的 Python 文档 中,我们可以看到,如果 __iter__() 被实现为生成器,它将自动返回一个迭代器对象,该对象提供 __iter__()__next__() 方法。阅读这篇精彩的文章,深入了解迭代器、可迭代对象和生成器

方法魔术

由于其独特的方面,Python 作为语言的一部分提供了一些有趣的方法魔术。

其中一个例子是别名函数 (aliasing functions)。由于函数只是对象 (objects),我们可以将它们分配给多个变量。例如

>>> def foo():
       return 'foo'

>>> foo()
'foo'

>>> bar = foo

>>> bar()
'foo'

稍后我们将看到这如何有用。

Python 提供了一个方便的内置函数,称为 getattr(),它接受 object, name, default 参数,并返回 object 上的属性 name。这允许我们以编程方式访问实例变量和方法。例如

>>> class Dog:
        sound = 'Bark'
        def speak(self):
            print(self.sound + '!', self.sound + '!')

>>> fido = Dog()

>>> fido.sound
'Bark'
>>> getattr(fido, 'sound')
'Bark'

>>> fido.speak
<bound method Dog.speak of <__main__.Dog object at 0x102db8828>>
>>> getattr(fido, 'speak')
<bound method Dog.speak of <__main__.Dog object at 0x102db8828>>


>>> fido.speak()
Bark! Bark!
>>> speak_method = getattr(fido, 'speak')
>>> speak_method()
Bark! Bark!

很酷的技巧,但我们如何在实践中使用 getattr 呢?让我们看一个例子,它可以让我们编写一个微型的命令行工具来动态处理命令。

class Operations:
    def say_hi(self, name):
        print('Hello,', name)

    def say_bye(self, name):
        print ('Goodbye,', name)

    def default(self, arg):
        print ('This operation is not supported.')

if __name__ == '__main__':
    operations = Operations()

    # let's assume we do error handling
    command, argument = input('> ').split()
    func_to_call = getattr(operations, command, operations.default)
    func_to_call(argument)

我们脚本的输出是

$ python getattr.py

> say_hi Nina
Hello, Nina

> blah blah
This operation is not supported.

接下来,我们将看看 partial。例如,functool.partial(func, *args, **kwargs) 允许你返回一个新的 partial object(偏函数对象),它的行为类似于使用 argskwargs 调用的 func。如果传入更多 args,它们将被附加到 args。如果传入更多 kwargs,它们将扩展并覆盖 kwargs。让我们通过一个简短的例子来看看它的实际应用

>>> from functools import partial
>>> basetwo = partial(int, base=2)
>>> basetwo
<functools.partial object at 0x1085a09f0>

>>> basetwo('10010')
18

# This is the same as
>>> int('10010', base=2)

让我们看看这种方法魔术如何在一些示例代码中结合在一起,这些代码来自我喜欢使用的库 agithub,它是一个(名称不佳的)REST API 客户端,具有透明的语法,允许你以最少的配置快速原型化任何 REST API(不仅仅是 GitHub)。我发现这个项目很有趣,因为它非常强大,但只有大约 400 行 Python 代码。你可以在大约 30 行配置代码中添加对任何 REST API 的支持。agithub 了解它需要了解的关于协议(RESTHTTPTCP)的一切,但它对上游 API 没有做任何假设。让我们深入研究实现。

这是一个简化版本,说明我们如何为 GitHub API 和任何其他相关的连接属性定义端点 URL。查看完整代码

class GitHub(API):

    def __init__(self, token=None, *args, **kwargs):
        props = ConnectionProperties(api_url = kwargs.pop('api_url', 'api.github.com'))
        self.setClient(Client(*args, **kwargs))
        self.setConnectionProperties(props)

然后,一旦你的 access token(访问令牌) 配置好,你就可以开始使用 GitHub API 了。

>>> gh = GitHub('token')
>>> status, data = gh.user.repos.get(visibility='public', sort='created')
>>> # ^ Maps to GET /user/repos
>>> data
... ['tweeter', 'snipey', '...']

请注意,你需要正确拼写所有内容。没有对 URL 进行验证。如果 URL 不存在或发生任何其他错误,将返回 API 抛出的错误。那么,这一切是如何运作的呢?让我们弄清楚。首先,我们将查看 API 的简化示例

class API:

    # ... other methods ... 

    def __getattr__(self, key):
        return IncompleteRequest(self.client).__getattr__(key)
    __getitem__ = __getattr__

API 类的每次调用都会将调用传递到 IncompleteRequest,用于指定的 key

class IncompleteRequest:

    # ... other methods ... 

    def __getattr__(self, key):
        if key in self.client.http_methods:
            htmlMethod = getattr(self.client, key)
            return partial(htmlMethod, url=self.url)
        else:
            self.url += '/' + str(key)
            return self
    __getitem__ = __getattr__


class Client:
    http_methods = ('get')  # ... and post, put, patch, etc.

    def get(self, url, headers={}, **params):
        return self.request('GET', url, None, headers)

如果最后一次调用不是 HTTP 方法(如 'get'、'post' 等),它将返回一个带有附加路径的 IncompleteRequest。否则,它将从 Client 获取指定 HTTP 方法的正确函数,并返回一个 partial

如果我们给出一个不存在的路径会发生什么?

>>> status, data = this.path.doesnt.exist.get()
>>> status
... 404

并且因为 __getitem__ 被别名为 __getattr__

>>> owner, repo = 'nnja', 'tweeter'
>>> status, data = gh.repos[owner][repo].pulls.get()
>>> # ^ Maps to GET /repos/nnja/tweeter/pulls
>>> data
.... # {....}

现在这才是真正的方法魔术!

了解更多

Python 提供了大量的工具,使你的代码更优雅,更易于阅读和理解。挑战在于为工作找到合适的工具,但我希望本文为你的工具箱添加了一些新工具。而且,如果你想更进一步,你可以在我的博客 nnja.io 上阅读关于装饰器 (decorators)、上下文管理器 (context managers)、上下文生成器 (context generators) 和 NamedTuple 的内容。随着你成为一名更好的 Python 开发者,我鼓励你走出去,阅读一些架构良好的项目的源代码。RequestsFlask 是两个很好的代码库,可以从它们开始。


要了解更多关于这些主题的信息,以及装饰器、上下文管理器、上下文装饰器和 NamedTuples,请参加 Nina Zakharenko 在 PyCon Cleveland 2018 上的演讲,日常 Python 问题的优雅解决方案

User profile image.
Nina Zakharenko 是微软的云开发者倡导者,专注于 Python。在加入微软之前,她是一位拥有十年经验的软件工程师,她磨练了自己的技术能力,为 Reddit、meetup 和 HBO 等公司编写软件。在业余时间,她喜欢滑雪、徒步旅行和在她在俄勒冈州波特兰的家骑自行车。

2 条评论

很棒的文章 - 谢谢,我学到了一些新东西。
在接近结尾时,我迷失了方向... 从这里开始:"为 GitHub API 定义一个端点 URL"

不错。但我确实注意到一个似乎是(复制?)的错别字。
第一个 'class services' 显示 'http' 在端口 '80'
但是当您使用 '__iter__' 重新定义它时,您将 'http' 放在端口 '21' 上,复制了 'ftp' 端口号。

© . All rights reserved.