超简单的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 项目是语言教学中最常被忽视的组成部分之一。更糟糕的是,许多开发人员都弄错了,在一堆常见的错误中跌跌撞撞,直到他们找到至少可以工作的东西。
好消息是:你不必成为其中之一!
在 超简单的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教程系列——异步的主要内容,如果未能解决你的问题,请参考以下文章