超简单的Python教程系列——异步

Posted 程序员老华

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了超简单的Python教程系列——异步相关的知识,希望对你有一定的参考价值。

Python 3.5 引入了两个新关键字:​ ​async​ ​​和​ ​await​ ​​。这些看似神奇的关键字完全可以在没有任何线程的情况下实现类似线程的并发。在本教程中,我们将介绍异步编程的原因,并通过构建我们自己的小型异步类框架来说明Python的​ ​async/await​ ​关键字如何在内部工作。

为什么要异步编程?

要了解异步编程的动机,我们首先必须了解是什么限制了我们的代码运行速度。理想情况下,我们希望我们的代码以光速运行,立即跳过我们的代码,没有任何延迟。然而,由于两个因素,实际上代码运行速度要慢得多:

  • CPU时间(处理器执行指令的时间)
  • IO时间(等待网络请求或存储读/写的时间)

当我们的代码在等待 IO 时,CPU 基本上是空闲的,等待某个外部设备响应。通常,内核会检测到这一点并立即切换到执行系统中的其他线程。因此,如果我们想加快处理一组 IO 密集型任务,我们可以为每个任务创建一个线程。当其中一个线程停止,等待 IO 时,内核将切换到另一个线程继续处理。

这在实践中效果很好,但有两个缺点:

  • 线程有开销(尤其是在 Python 中)
  • 我们无法控制内核何时选择在线程之间切换

例如,如果我们想要执行 10,000 个任务,我们要么必须创建 10,000 个线程,这将占用大量 RAM,要么我们需要创建较少数量的工作线程并以较少的并发性执行任务。此外,最初生成这些线程会占用 CPU 时间。

由于内核可以随时选择在线程之间切换,因此我们代码中的任何时候都可能出现相互竞争。

引入异步

在传统的基于同步线程的代码中,内核必须检测线程何时是IO绑定的,并选择在线程之间随意切换。使用 Python 异步,程序员使用关键字​ ​await​ ​确认声明 IO 绑定的代码行,并确认授予执行其他任务的权限。例如,考虑以下执行Web请求的代码:

async def request_google():
    reader, writer = await asyncio.open_connection('google.com', 80)
    writer.write(b'GET / HTTP/2\\n\\n')
    await writer.drain()
    response = await reader.read()
    return response.decode()

在这里,在这里,我们看到该代码在两个地方​ ​await​ ​​。​​因此,在等待我们的字节被发送到服务器(​ ​writer.drain()​ ​​)时,在等待服务器用一些字节(​ ​reader.read()​ ​)回复时,我们知道其他代码可能会执行,全局变量可能会更改。然而,从函数开始到第一次等待,我们可以确保代码逐行运行,而不会切换到运行程序中的其他代码。这就是异步的美妙之处。

​ ​asyncio​ ​是一个标准库,可以让我们用这些异步函数做一些有趣的事情。例如,如果我们想同时向Google执行两个请求,我们可以:

async def request_google_twice():
    response_1, response_2 = await asyncio.gather(request_google(), request_google())
    return response_1, response_2

当我们调用​ ​request_google_twice()​ ​时,神奇的​ ​asyncio.gather​ ​会启动一个函数调用,但是当我们调用时​ ​await writer.drain()​ ​,它会开始执行第二个函数调用,这样两个请求就会并行发生。然后,它等待第一个或第二个请求的​ ​writer.drain()​ ​调用完成并继续执行该函数。

最后,有一个重要的细节被遗漏了:​ ​asyncio.run​ ​。要从常规的 [同步] Python 函数实际调用异步函数,我们将调用包装在​ ​asyncio.run(...)​ ​:

async def async_main():
    r1, r2 = await request_google_twice()
    print('Response one:', r1)
    print('Response two:', r2)
    return 12

return_val = asyncio.run(async_main())

请注意,如果我们只调用​ ​async_main()​ ​而不调用​ ​await ...​ ​或者 ​ ​asyncio.run(...)​ ​,则不会发生任何事情。这只是由异步工作方式的性质所限制的。

那么,异步究竟是如何工作的,这些神奇的​ ​asyncio.run​ ​​和​ ​asyncio.gather​ ​函数有什么作用呢?阅读下文以了解详情。

异步是如何工作的

要了解​ ​async​ ​的魔力​,我们首先需要了解一个更简单的 Python 构造:生成器(在前面《 生成器和协程 》,如果你没看过,可以去我的主页看看这篇文章,再回来学习这个就会很容易)。

生成器

生成器是 Python 函数,它逐个返回一系列值(可迭代)。例如:

def get_numbers():
    print("|| get_numbers begin")
    print("|| get_numbers Giving 1...")
    yield 1
    print("|| get_numbers Giving 2...")
    yield 2
    print("|| get_numbers Giving 3...")
    yield 3
    print("|| get_numbers end")

print("| for begin")
for number in get_numbers():
    print(f"| Got number.")
print("| for end")
| for begin
|| get_numbers begin
|| get_numbers Giving 1...
| Got 1.
|| get_numbers Giving 2...
| Got 2.
|| get_numbers Giving 3...
| Got 3.
|| get_numbers end
| for end

因此,我们看到,对于for循环的每个迭代,我们在生成器中只执行一次。我们可以使用Python的​ ​next()​ ​函数更明确地执行此迭代:

In [3]: generator = get_numbers()                                                                                                                                                            

In [4]: next(generator)                                                                                                                                                                      
|| get_numbers begin
|| get_numbers Giving 1...
Out[4]: 1

In [5]: next(generator)                                                                                                                                                                      
|| get_numbers Giving 2...
Out[5]: 2

In [6]: next(generator)                                                                                                                                                                      
|| get_numbers Giving 3...
Out[6]: 3

In [7]: next(generator)                                                                                                                                                                      
|| get_numbers end
---------------------------------------
StopIteration       Traceback (most recent call last)
<ipython-input-154-323ce5d717bb> in <module>
----> 1 next(generator)

StopIteration:

这与异步函数的行为非常相似。正如异步函数从函数开始直到第一次等待时连续执行代码一样,我们第一次调用​ ​next()​ ​​时,生成器将从函数顶部执行到第一个​ ​yield​ ​ 语句。然而,现在我们只是从生成器返回数字。我们将使用相同的思想,但返回一些不同的东西来使用生成器创建类似异步的函数。

使用生成器进行异步

让我们使用生成器来创建我们自己的小型异步框架。

但是,为简单起见,让我们将实际 IO 替换为睡眠(即。​ ​time.sleep​ ​)。让我们考虑一个需要定期发送更新的应用程序:

def send_updates(count: int, interval_seconds: float):
    for i in range(1, count + 1):
        time.sleep(interval_seconds)
        print('[] Sending update /.'.format(interval_seconds, i, count))

因此,如果我们调用​ ​send_updates(3, 1.0)​ ​,它将输出这三条消息,每条消息间隔 1 秒:

[1.0] Sending update 1/3.
[1.0] Sending update 2/3.
[1.0] Sending update 3/3.

现在,假设我们要同时运行几个不同的时间间隔。例如,​ ​send_updates(10, 1.0)​ ​​,​ ​send_updates(5, 2.0)​ ​​和​ ​send_updates(4, 3.0)​ ​。我们可以使用线程来做到这一点,如下所示:

threads = [
    threading.Thread(target=send_updates, args=(10, 1.0)),
    threading.Thread(target=send_updates, args=(5, 2.0)),
    threading.Thread(target=send_updates, args=(4, 3.0))
]
for i in threads:
    i.start()
for i in threads:
    i.join()

这可行,在大约 12 秒内完成,但使用具有前面提到的缺点的线程。让我们使用生成器构建相同的东西。

在演示生成器的示例中,我们返回了整数。为了获得类似异步的行为,而不是返回任意值,我们希望返回一些描述要等待的IO的对象。在我们的例子中,我们的“IO”只是一个计时器,它将等待一段时间。因此,让我们创建一个计时器对象,用于此目的:

class AsyncTimer:
    def __init__(self, duration: float):
        self.done_time = time.time() + duration

现在,让我们从我们的函数中产生这个而不是调用​ ​time.sleep​ ​:

def send_updates(count: int, interval_seconds: float):
    for i in range(1, count + 1):
        yield AsyncTimer(interval_seconds)
        print('[] Sending update /.'.format(interval_seconds, i, count))

现在,每次我们调用​ ​send_updates(...)​ ​​时调用​ ​next(...)​ ​​,我们都会得到一个​ ​AsyncTimer​ ​对象,告诉我们直到我们应该等待什么时候:

generator = send_updates(3, 1.5)
timer = next(generator)  # [1.5] Sending update 1/3.
print(timer.done_time - time.time())  # 1.498...

由于我们的代码现在实际上并没有调用​ ​time.sleep​ ​​,我们现在可以同时执行另一个​ ​send_updates​ ​调用。

所以,为了把这一切放在一起,我们需要退后一步,意识到一些事情:

  • 生成器就像部分执行的函数,等待一些 IO(计时器)。
  • 每个部分执行的函数都有一些 IO(计时器),它在继续执行之前等待。
  • 因此,我们程序的当前状态是每个部分执行的函数(生成器)和该函数正在等待的 IO(计时器)对的对列表
  • 现在,要运行我们的程序,我们只需要等到某个 IO 准备就绪(即我们的一个计时器已过期),然后再向前一步执行相应的函数,得到一个阻塞该函数的新 IO。

实现此逻辑为我们提供了以下信息:

# Initialize each generator with a timer of 0 so it immediately executes
generator_timer_pairs = [
    (send_updates(10, 1.0), AsyncTimer(0)),
    (send_updates(5, 2.0), AsyncTimer(0)),
    (send_updates(4, 3.0), AsyncTimer(0))
]

while generator_timer_pairs:
    pair = min(generator_timer_pairs, key=lambda x: x[1].done_time)
    generator, min_timer = pair

    # Wait until this timer is ready
    time.sleep(max(0, min_timer.done_time - time.time()))
    del generator_timer_pairs[generator_timer_pairs.index(pair)]

    try:  # Execute one more step of this function
        new_timer = next(generator)
        generator_timer_pairs.append((generator, new_timer))
    except StopIteration:  # When the function is complete
        pass

有了这个,我们有了一个使用生成器的类似异步函数的工作示例。请注意,当生成器完成时,它会引发​ ​StopIteration​ ​,并且当我们不再有部分执行的函数(生成器)时,我们的函数就完成了

现在,我们把它包装在一个函数中,我们得到了类似于​ ​asyncio.run​ ​的东西。结合​ ​asyncio.gather​ ​运行:

def async_run_all(*generators):
    generator_timer_pairs = [
        (generator, AsyncTimer(0))
        for generator in generators
    ]
    while generator_timer_pairs:
        pair = min(generator_timer_pairs, key=lambda x: x[1].done_time)
        generator, min_timer = pair

        time.sleep(max(0, min_timer.done_time - time.time()))
        del generator_timer_pairs[generator_timer_pairs.index(pair)]

        try:
            new_timer = next(generator)
            generator_timer_pairs.append((generator, new_timer))
        except StopIteration:
            pass

async_run_all(
    send_updates(10, 1.0),
    send_updates(5, 2.0),
    send_updates(4, 3.0)
)

使用 async/await 进行异步

实现我们的caveman版本的​ ​asyncio​ ​​的最后一步是支持Python 3.5中引入的​ ​async/await​ ​​语法。​ ​await​ ​​的行为类似于​ ​yield​ ​,只是它不是直接返回提供的值,而是返回​ ​next((...).__await__())​ ​。​ ​async​ ​​函数返回“协程”,其行为类似于生成器,但需要使用​ ​.send(None)​ ​而不是​ ​next()​ ​(请注意,正如生成器在最初调用时不返回任何内容一样,异步函数在逐步执行之前不会执行任何操作,这解释了我们前面提到的)。

因此,鉴于这些信息,我们只需进行一些调整即可将我们的示例转换为​ ​async/await​ ​。以下是最终结果:

class AsyncTimer:
    def __init__(self, duration: float):
        self.done_time = time.time() + duration
    def __await__(self):
        yield self

async def send_updates(count: int, interval_seconds: float):
    for i in range(1, count + 1):
        await AsyncTimer(interval_seconds)
        print('[] Sending update /.'.format(interval_seconds, i, count))

def _wait_until_io_ready(ios):
    min_timer = min(ios, key=lambda x: x.done_time)
    time.sleep(max(0, min_timer.done_time - time.time()))
    return ios.index(min_timer)

def async_run_all(*coroutines):
    coroutine_io_pairs = [
        (coroutine, AsyncTimer(0))
        for coroutine in coroutines
    ]
    while coroutine_io_pairs:
        ios = [io for cor, io in coroutine_io_pairs]
        ready_index = _wait_until_io_ready(ios)
        coroutine, _ = coroutine_io_pairs.pop(ready_index)

        try:
            new_io = coroutine.send(None)
            coroutine_io_pairs.append((coroutine, new_io))
        except StopIteration:
            pass

async_run_all(
    send_updates(10, 1.0),
    send_updates(5, 2.0),
    send_updates(4, 3.0)
)

我们有了它,我们的迷你异步示例完成了,使用​ ​async/await​ ​​. 现在,您可能已经注意到我将 timer 重命名为 io 并将查找最小计时器的逻辑提取到一个名为​ ​_wait_until_io_ready​ ​. 这是有意将这个示例与最后一个主题联系起来:真实 IO。

在这里,我们完成了我们的小型异步示例,使用了​ ​async/await​ ​​。现在,你可能已经注意到我将​ ​timer​ ​​重命名为io,并将用于查找最小计时器的逻辑提取到一个名为​ ​_wait_until_io_ready​ ​的函数中。这是为了将本示例与最后一个主题: 真正的IO ,连接起来。

真正的 IO(而不仅仅是定时器)

所以,所有这些例子都很棒,但是它们与真正的 asyncio 有什么关系,我们希望在真正 IO 上等待 TCP 套接字和文件读/写?嗯,美丽就在那个​ ​_wait_until_io_ready​ ​​功能中。为了让真正的 IO 正常工作,我们所要做的就是创建一些​ ​AsyncReadFile​ ​​类似于​ ​AsyncTimer​ ​​包含​ ​文件描述符​ ​​的新对象。然后,​ ​AsyncReadFile​ ​​我们正在等待的对象集对应于一组文件描述符。最后,我们可以使用函数 (syscall) ​ ​select()​ ​等待这些文件描述符之一准备好。由于 TCP/UDP 套接字是使用文件描述符实现的,因此这也涵盖了网络请求。

所以,所有这些例子都很好,但它们与真正的异步IO有什么关系呢?我们希望等待实际的IO,比如TCP套接字和文件读/写?好吧,其优点在于​ ​_wait_until_io_ready​ ​​函数。要使真正的IO工作,我们需要做的就是创建一些新的​ ​AsyncReadFile​ ​​,类似于​ ​AsyncTimer​ ​,它包含一个 文件描述符 。然后,我们正在等待的一组​ ​AsyncReadFile​ ​​对象对应于一组文件描述符。最后,我们可以使用函数(​ ​syscall​ ​​)​ ​select()​ ​等待这些文件描述符之一准备好。由于TCP/UDP套接字是使用文件描述符实现的,因此这也涵盖了网络请求。

总结

我们有了它,Python 异步从头开始​​。虽然我们深入研究了它,但仍有许多细微之处没有涉及。例如,要从另一个生成器函数调用类似生成器异步的函数,我们将使用​ ​yield from​ ​​,我们可以通过将参数传递到​ ​.send(...)​ ​​来从​ ​async​ ​​函数返回值。关于异步IO特定构造还有很多其他主题,还有很多其他的微妙之处,比如异步生成器和取消任务,但我们就把它交给你们下来细细研究了。

 

超简单的Python教程系列——第3篇:项目结构和导入

超简单的Python教程系列——第3篇:项目结构和导入_python

教程最糟糕的部分总是它们的简单性,不是吗?你很少会找到一个包含多个文件的文件,很少会找到包含多个目录的文件。

我发现构建 Python 项目是语言教学中最常被忽视的组成部分之一。更糟糕的是,许多开发人员都弄错了,在一堆常见的错误中跌跌撞撞,直到他们找到至少可以工作的东西。

好消息是:你不必成为其中之一!

在 超简单的Python教程 系列的这一部分中,我们将探索​​import​​语句、模块、包,以及如何将所有内容组合在一起而不费力气。我们甚至会涉及 VCS、PEP 和 the Zen of Python。系好安全带!


设置存储库

在我们深入研究实际的项目结构之前,让我们先来了解一下它是如何融入我们的版本控制系统 [VCS] 的……从你需要VCS 的事实开始!有几个原因...

  • 跟踪你所做的每一个更改,
  • 弄清楚你什么时候改错了代码,
  • 能够查看旧版本的代码,
  • 备份你的代码,
  • 与他人合作。

你有很多选择。Git是最明显的,特别是如果你不知道还可以使用什么。你可以在 GitHub、GitLab、Bitbucket 或 Gitote 等平台上免费托管你的 Git 存储库。如果你想要 Git 以外的东西,还有很多其他选项,包括 Mercurial、Bazaar、Subversion(尽管如果你使用最后一个,你可能会被同行视为原始人。)

我会悄悄地假设你在本指南的其余部分使用 Git,因为这是我专门使用的。

创建存储库并将本地副本克隆到计算机后,你就可以开始设置项目了。至少,你需要创建以下内容:

  • ​README.md​​:对你的项目及其目标的描述。
  • ​LICENSE.md​​:你的项目的许可证,如果它是开源的。
  • ​.gitignore​​: 一个特殊的文件,告诉 Git 要忽略哪些文件和目录。(如果你使用的是另一个 VCS,则此文件具有不同的名称。)
  • 带有项目名称的目录。

没错……我们的 Python 代码文件实际上属于一个单独的子目录!这非常重要,因为我们的存储库的根目录将被构建文件、打包脚本、虚拟环境以及所有其他实际上不是源代码一部分的东西弄得乱七八糟。

只是为了举例,我们称我们的虚构项目为​​awesomething​​。


PEP 8 和命名

Python 风格主要由一组称为Python Enhancement Proposals的文档管理,缩写为PEP。当然,并非所有 PEP 都被实际采用——这就是它们被称为“提案”的原因——但有些是。你可以在 Python 官方网站上浏览主 PEP 索引。该索引正式称为​​PEP 0​​。

目前,我们主要关注PEP 8,它由 Python 语言创建者 Guido van Rossum 在 2001 年首次撰写。该文档正式概述了所有 Python 开发人员通常应遵循的编码风格。把它作为标准!学习它,遵循它,鼓励其他人也这样做。

(旁注:PEP 8 指出样式规则总是有例外。它是一个指南,而不是一个命令。)

现在,我们主要关注标题为“包和模块名称”的部分......

模块应该有简短的全小写名称。如果提高可读性,可以在模块名称中使用下划线。Python 包也应该有简短的全小写名称,尽管不鼓励使用下划线。

稍后我们将了解模块的确切含义,但现在,请了解模块由文件名命名,而包由其目录名命名

换句话说,文件名应该全部小写,如果这样可以提高可读性,则使用下划线。同样,目录名称应全部小写,如果可以避免,则不带下划线。换句话说...

  • 正确:​​awesomething/data/load_settings.py​
  • 错误:​​awesomething/Data/LoadSettings.py​

虽然命名确实很冗长,但这是PEP的规范,能够让你的代码更规范。


包和模块

这会让人觉得虎头蛇尾,但这里是那些承诺的定义:

任何 Python( .py) 文件都是一个模块,一个目录中的一堆模块就是一个

你必须对目录执行另一件事以使其成为包,那就是将调用的文件粘贴​​__init__.py​​到其中。你实际上不必将任何内容放入该文件中,但它必须在那里。

你还可以使用其他很酷的东西​​__init__.py​​​,但这超出了本指南的范围。

如果你确实忘记​​__init__.py​​了你的包,它会做一些比失败更奇怪的事情,因为这使它成为一个隐式命名空间包。你可以用这种特殊类型的包做一些漂亮的事情,但我不在这里讨论。像往常一样,你可以通过阅读官方文档了解更多信息。

所以,如果我们看一下我们的项目结构,​​awesomething​​​其实是一个包,它可以包含其他包。因此,我们可以调用​​awesomething​​我们的顶级包,以及它的子包下的所有。一旦我们开始导入东西,这将非常重要。

让我们看一下我的项目接口的截图​​omission​​,以了解我们如何构建......

omission-git
├── LICENSE.md
├── omission
├── app.py
├── common
├── classproperty.py
├── constants.py
├── game_enums.py
└── __init__.py
├── data
├── data_loader.py
├── game_round_settings.py
├── __init__.py
├── scoreboard.py
└── settings.py
├── game
├── content_loader.py
├── game_item.py
├── game_round.py
├── __init__.py
└── timer.py
├── __init__.py
├── __main__.py
├── resources
└── tests
├── __init__.py
├── test_game_item.py
├── test_game_round_settings.py
├── test_scoreboard.py
├── test_settings.py
├── test_test.py
└── test_timer.py
├── pylintrc
├── README.md
└── .gitignore

你会看到我有一个名为 的顶级包​​omission​​​,其中包含四个子包:​​common​​​、​​data​​​、​​game​​​和​​tests​​​。我也有目录​​resources​​​,但只包含游戏音频、图像等(为简洁起见,此处省略)。​​resources​​​不是包,因为它不包含​​__init__.py​​.

我的顶级包中还有另一个特殊文件:​​__main__.py​​​. 这是当我们直接通过执行我们的顶级包时运行的文件​​python -m omission​​​。我们稍后会讨论其中​​__main__.py​​的内容。


导入的工作原理

如果你以前编写过任何有意义的 Python 代码,那么你几乎可以肯定熟悉该​​import​​语句。例如...

import re

知道当我们导入一个模块时,我们实际上是在运行它是很有帮助的。这意味着​​import​​模块中的任何语句也在运行。

例如,re.py​它有几个自己的 import 语句,当我们说​​import re​​​. 这并不意味着它们可用于我们从中导入的文件​​re​​ 但这确实意味着这些文件必须存在。如果(由于某种不太可能的原因)​​enum.py​​​在你的环境中被删除,并且你运行​​import re​​,它将失败并出现错误......

Traceback (most recent call last):

File "weird.py", line 1, in

import re

File "re.py", line 122, in

import enum

ModuleNotFoundError: No module named enum

看到这里,你可能会有些困惑。有人问我为什么外部模块找不到(在本例中为​​re​​​ )。有些人又想知道为什么要导入内部模块(这儿是​​enum​​​),因为他们没有直接在代码中要求它。答案很简单:我们导入​​re​​​了 ,而​​re​​​导入了​​enum​​。

当然,上面的场景是虚构的:在正常情况​​import enum​​​下​​import re​​永远不会失败,因为这两个模块都是 Python 核心库的一部分。


导入注意事项

实际上有多种导入方式,但大多数都应该很少使用,如果曾经使用过的话。

对于下面的所有示例,我们将假设我们有一个名为 的文件​​smart_door.py​​:

# smart_door.py
def close():
print("Ahhhhhhhhhhhh.")

def open():
print("Thank you for making a simple door very happy.")

例如,我们将在 Python 交互式 shell 中运行本节中的其余代码,与​​smart_door.py​​.

如果我们想运行这个函数​​open()​​​,我们必须先导入模块​​smart_door​​。最简单的方法是...

import smart_door
smart_door.open()
smart_door.close()

我们实际上会说这​​smart_door​​是and的命名空间。Python 开发人员真的很喜欢命名空间,因为它们让函数和诸如此类的来源变得一目了然。​​open()​​​​close()​

(顺便说一句,不要将命名空间隐式命名空间包混淆。它们是两个不同的东西。)

Zen of Python,也称为PEP 20,定义了 Python 语言的原理。最后一行有一个声明解决了这个问题:

Namespaces are one honking great idea -- lets do more of those!

然而,在某些时候,命名空间可能会变得很痛苦,尤其是对于嵌套包。​​foo.bar.baz.whatever.doThing()​​只是丑陋。值得庆幸的是,我们有办法避免每次调用函数时都必须使用命名空间。

如果我们希望能够使用该​​open()​​函数而不必经常在其前面加上其模块名称,我们可以这样做...

from smart_door import open
open()

但是请注意,在最后一种情况下,两者​​close()​​​都​​smart_door.close()​​不会起作用,因为我们没有直接导入函数。要使用它,我们必须将代码更改为...

from smart_door import open, close
open()
close()

在之前那个可怕的嵌套包噩梦中,我们现在可以说​​from foo.bar.baz.whatever import doThing​​​,然后直接使用​​doThing()​​​。或者,如果我们想要一点命名空间,我们可以说​​from foo.bar.baz import whatever​​​,然后说​​whatever.doThing()​​。

这样的​​import​​系统非常灵活。

不过,不久之后,你可能会发现说“但我的模块中有数百个函数,我想全部使用它们!” 这是许多开发人员偏离轨道的地方,通过这样做......

from smart_door import *

这是非常非常糟糕的!简单来说就是直接导入模块中的所有东西,这是个问题。想象一下下面的代码......

from smart_door import *
from gzip import *
open()

你认为会发生什么?答案是,​​gzip.open()​​​将是被调用的函数,因为这​​open()​​​是在我们的代码中导入并定义的最后一个版本。​​smart_door.open()​​已被覆盖- 我们不能将其称为​​open()​​,这意味着我们实际上根本无法调用它。

当然,由于我们通常不知道,或者至少不记得,每个被导入的模块中的每个函数、类和变量,我们很容易陷入一大堆混乱。

Zen of Python解决了这种情况......

显式优于隐式。

你永远不必猜测函数或变量的来源。文件中的某处应该是明确告诉我们它来自哪里的代码。前两个场景证明了这一点。

我还应该提到,早期的​​foo.bar.baz.whatever.doThing()​​场景是 Python 开发人员不喜欢看到的。同样来自Zen of Python...

直接优于嵌套。

一些包的嵌套是可以的,但是当你的项目开始看起来像一组精心制作的俄罗斯套娃时,你就做错了。将你的模块组织成包,但要保持相当简单。


在你的项目中导入

我们之前创建的那个项目文件结构即将派上用场。回想一下我的​​omission​​项目...

omission-git
├── LICENSE.md
├── omission
├── app.py
├── common
├── classproperty.py
├── constants.py
├── game_enums.py
└── __init__.py
├── data
├── data_loader.py
├── game_round_settings.py
├── __init__.py
├── scoreboard.py
└── settings.py
├── game
├── content_loader.py
├── game_item.py
├── game_round.py
├── __init__.py
└── timer.py
├── __init__.py
├── __main__.py
├── resources
└── tests
├── __init__.py
├── test_game_item.py
├── test_game_round_settings.py
├── test_scoreboard.py
├── test_settings.py
├── test_test.py
└── test_timer.py
├── pylintrc
├── README.md
└── .gitignore

在我​​game_round_settings​​​定义的模块中​​omission/data/game_round_settings.py​​​,我想使用我的​​GameMode​​​类。该类定义在​​omission/common/game_enums.py​​. 我怎么去呢?

因为我定义​​omission​​​为一个包,并将我的模块组织成子包,所以实际上很容易。在​​game_round_settings.py​

from omission.common.game_enums import GameMode

这称为绝对导入。它从顶级包开始​​omission​​​,然后向下进入​​common​​​包,在其中查找​​game_enums.py​​.

一些开发人员带着类似的 import 语句来找我​​from common.game_enums import GameMode​​​,想知道为什么它不起作用。简单地说,​​data​​​包(所在的​​game_round_settings.py​​地方)不知道它的兄弟包。

然而,它确实知道它的父级。正因为如此,Python 有一种叫做相对导入的东西,它可以让我们做同样的事情......

from ..common.game_enums import GameMode

意思是“这个​​..​​​包的直接父包”,在这种情况下是​​omission​​​. 因此,导入后退一级,走进​​common​​​,并找到​​game_enums.py​​。

关于是使用绝对导入还是相对导入存在很多争论。就个人而言,我更喜欢尽可能使用绝对导入,因为它使代码更具可读性。但是,你可以自己决定。唯一重要的部分是结果是显而易见的——任何东西的来源都不应该是神秘的。

这里还有另一个潜伏的陷阱!在​​omission/data/settings.py​​,我有这一行:

from omission.data.game_round_settings import GameRoundSettings

当然,由于这两个模块都在同一个包中,我们应该可以说​​from game_round_settings import GameRoundSettings​​,对吧?

这是错误的!它实际上将无法找到​​game_round_settings.py​​​. 这是因为我们正在运行顶级包​​omission​​,这意味着搜索路径(Python 查找模块的位置,以及以什么顺序)的工作方式不同。

但是,我们可以使用相对导入:

from .game_round_settings import GameRoundSettings

在这种情况下,single 的​​.​​意思是“这个包”。

如果你熟悉经典的 Linux 文件系统,那么这应该开始有意义了。​​..​​​意思是“后一级”,​​.​​​意思是“当前位置”。当然,Python 更进一步:​​...​​​意思是“后两级”,​​....​​是“后三级”,等等。

但是,请记住,这些“级别”不仅仅是简单的目录,在这里。它们是包裹。如果n你在一个不是包的普通目录中有两个不同的包,则不能使用相对导入从一个跳转到另一个。为此,你必须使用 Python 搜索路径,这超出了本指南的范围。


__main__.py

还记得我提到​​__main__.py​​​在我们的顶级包中创建一个吗?那是一个特殊的文件,当我们直接用 Python 运行包时会执行它。我的​​omission​​​包可以从我的存储库的根目录运行​​python -m omission​​。

这是该文件的内容:

from omission import app

if __name__ == __main__:
app.run()

是的,实际上就是这样!我正在​​app​​​从顶级 package导入我的模块​​omission​​。

请记住,我也可以说​​from . import app​​​。或者,如果我只想说​​run()​​​而不是​​app.run()​​​,我可以做​​from omission.app import run​​​or ​​from .app import run​​。最后,只要代码可读,我如何进行导入并没有太大的技术差异。

(PS:我们可以讨论​​app.py​​​为我的主要​​run()​​功能单独设置是否合乎逻辑,但我有我的理由......它们超出了本指南的范围。)

一开始让大多数人感到困惑的部分是整个​​if __name__ == __main__​​声明。Python 没有太多样板——代码必须非常普遍地使用,几乎不需要修改——但这是那些罕见的部分之一。

​__name__​​​是每个 Python 模块的特殊字符串属性。如果我将这一行​​print(__name__)​​​放在 的顶部​​omission/data/settings.py​​,当该模块被导入(并因此运行)时,我们会看到“omission.data.settings”打印出来。

当一个模块直接通过 运行时​​python -m some_module​​​,该模块被分配一个特殊的值​​__name__​​:“ main ”。

因此,​​if __name__ == __main__:​​实际上是检查模块是否作为模块执行。如果是,则在条件下运行代码。

你可以通过另一种方式看到这一点。​​app.py​​如果我将以下内容添加到...的底部

if __name__ == __main__:
run()

然后我可以直接通过 执行该模块​​python -m omission.app​​​,结果与​​python -m omission​​​一样. 现在​​__main__.py​​​被完全忽略,而​​omission/app.py​​​的​​__name__​​​​是​​"__main__.py"​​。

同样的,如果我只是运行​​python -m omission​​​,​​app.py​​​则忽略其中的特殊代码,因为它的​​__name__​​​现在是​​omission.app​​。


总结

让我们回顾一下:

  • 每个项目都应该使用 VCS,例如 Git。有很多选择可供选择。
  • 每个 Python 代码文件 (​​.py​​) 都是一个模块
  • 将你的模块组成。每个包必须包含一个特殊​​__init__.py​​文件。
  • 你的项目通常应该由一个顶级包组成,通常包含子包。该顶级包通常与你的项目共享名称,并作为目录存在于项目存储库的根目录中。
  • 永远不要​*​​​在导入语句中使用。在你接受一个可能的例外之前,Zen of Python指出“其他情况不足以违反这个规则”。
  • 使用绝对或相对导入来引用项目中的其他模块。
  • 可执行项目应该​​__main__.py​​​在顶级包中有一个。然后,你可以直接使用​​python -m myproject​​.

当然,我们可以在构建 Python 项目时使用更多高级概念和技巧,但我们不会在这里讨论。

以上是关于超简单的Python教程系列——异步的主要内容,如果未能解决你的问题,请参考以下文章

超简单的Python教程系列——第3篇:项目结构和导入

超简单的Python教程系列——第5篇:类

超简单的Python教程系列——第16篇:多进程

超简单的Python教程系列——第4篇:数据类型和不变性

超简单的Python教程系列——第6篇:错误异常

超简单的Python教程系列——第8篇:迭代工具