PyCon-2018-会议笔记-全-
PyCon 2018 会议笔记(全)
001:Pipenv - Python依赖管理的未来




在本节课中,我们将学习Python依赖管理的历史、当前面临的挑战以及未来的解决方案——Pipenv。我们将从早期的手动管理方式开始,逐步了解虚拟环境和requirements.txt的引入,最后深入探讨Pipenv如何通过Pipfile和Pipfile.lock来统一和简化整个工作流程。
依赖管理的历史演变

上一节我们介绍了课程概述,本节中我们来看看Python依赖管理是如何一步步发展到今天的。

早期手动管理阶段
过去,安装Python包是一个完全手动的过程。开发者需要从互联网下载压缩包,解压后运行setup.py install命令,将包安装到系统的site-packages目录中。有时甚至直接解压文件到该目录。
核心操作:
python setup.py install

这种方法存在明显问题:包托管分散、依赖全局安装、无法同时安装同一库的不同版本,导致项目间依赖冲突。

EasyInstall的出现
为了解决手动安装的繁琐,社区引入了EasyInstall工具。它可以直接从Python包索引(PyPI)抓取并安装包,实现了自动化安装。
然而,EasyInstall只能安装,不能卸载包,功能上存在缺陷。


现代标准:Pip与Virtualenv
大约从2010年开始,Pip取代EasyInstall成为包管理的事实标准。同时,Virtualenv被广泛采用,它为每个项目创建独立的Python环境,解决了全局依赖冲突的问题。项目依赖通常被记录在requirements.txt文件中。
核心操作:
# 创建虚拟环境
python -m venv myenv
# 激活环境(Linux/macOS)
source myenv/bin/activate
# 使用pip安装依赖
pip install -r requirements.txt
虽然这套组合(Pip + Virtualenv + requirements.txt)很强大,但它由多个独立工具组成,对新用户不够友好,且requirements.txt文件本身存在歧义。
当前工作流程的挑战
上一节我们回顾了历史,本节中我们来看看当前主流的Pip和Virtualenv工作流程存在哪些具体问题。
Virtualenv的复杂性
Virtualenv要求用户理解“虚拟环境”这一抽象概念,增加了学习成本。其手动创建和激活的过程对新手不够直观。
Requirements.txt文件的歧义
requirements.txt文件存在一个根本性的矛盾:它既是期望的依赖声明(如Flask),又常被用作确定的依赖锁定文件(通过pip freeze生成包含所有传递依赖的扁平列表)。
以下是两种情况的对比:
-
声明期望的依赖(可读性好,但构建不确定):
Flask pytest这仅声明了项目直接依赖,但未锁定传递依赖(如Werkzeug、Jinja2)的具体版本,可能导致不同时间或环境下安装的版本不同。
-
锁定所有依赖(构建确定,但可读性差):
pip freeze > requirements.txt生成的
requirements.txt包含所有包及其精确版本,确保了确定性构建,但难以区分直接依赖和传递依赖。
这种“期望”与“需要”之间的不匹配,是当前工作流程的一个核心问题。其他语言社区(如Node.js的package-lock.json、Ruby的Gemfile.lock)普遍采用锁文件机制来解决此问题,而Python原生缺乏这一机制。
未来的解决方案:Pipfile与Pipenv
上一节我们分析了现有问题,本节中我们来看看社区提出的未来标准——Pipfile,以及能让我们立即用上此标准的工具——Pipenv。


Pipfile:新的依赖声明标准
Pipfile旨在取代requirements.txt,它使用TOML格式,清晰地将依赖分为不同组,最典型的是[packages](生产依赖)和[dev-packages](开发依赖)。
一个Pipfile示例:
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"


[packages]
flask = "*"

[dev-packages]
pytest = "*"

Pipfile.lock:确定的锁文件
Pipfile.lock是由工具自动生成的锁文件,采用JSON格式。它包含了所有依赖(直接和传递)的确切版本以及哈希值,确保了每次安装都是完全确定和可验证的。


锁文件的核心价值:
- 确定性构建:在任何机器、任何时间都能安装完全相同的依赖树。
- 安全性:通过哈希校验,确保安装的包未被篡改。
- 快速安装:锁文件明确指出了每个包的具体文件,避免了复杂的依赖解析。
Pipenv:官方推荐的一体化工具
Pipenv集成了包管理(Pip)和虚拟环境(Virtualenv)的功能,并原生支持Pipfile和Pipfile.lock,是Python官方推荐的工具。它提供了简洁的命令行接口,自动化了大部分工作流程。
以下是Pipenv的主要功能演示:


初始化项目并安装依赖:
# 安装Pipenv
pip install pipenv



# 为项目安装一个包(如requests),会自动创建虚拟环境和Pipfile
pipenv install requests


# 安装一个开发依赖
pipenv install pytest --dev
管理环境与依赖:
# 进入项目所在的虚拟环境Shell
pipenv shell


# 查看依赖关系树
pipenv graph


# 检查依赖中的已知安全漏洞
pipenv check


# 卸载一个包
pipenv uninstall requests
# 根据Pipfile.lock安装所有依赖(用于生产环境)
pipenv install --deploy


同步与转换:
# 将虚拟环境的状态与Pipfile.lock同步
pipenv sync
# 从Pipfile.lock生成一个requirements.txt文件
pipenv lock -r
Pipenv通过将依赖声明(Pipfile)和依赖锁定(Pipfile.lock)分离,并自动化环境管理,完美解决了之前提到的工作流程挑战。
总结
本节课中我们一起学习了Python依赖管理的演进历程。我们从手动管理、EasyInstall,走到了当前主流的Pip与Virtualenv组合,并分析了其存在的复杂性和requirements.txt文件的歧义性问题。


最终,我们探讨了未来的解决方案:通过Pipfile清晰声明依赖,通过Pipfile.lock确保确定性构建,并通过Pipenv这一体化工具来优雅地管理整个生命周期。Pipenv代表了Python依赖管理的未来方向,它降低了入门门槛,提升了开发体验和项目安全性,是值得所有Python开发者学习和采用的新工具。
002:我们如何错误地定义身份



概述
在本节课中,我们将学习在软件开发中如何处理用户的身份信息。我们将探讨姓名、性别、地址等常见身份数据的复杂性,理解为什么许多常见的处理方式是错误的,并学习如何以更尊重用户、更灵活、更安全的方式来设计和存储这些信息。

什么是身份?🤔
身份是将个体与他人区分开来的特征集合。它由内部属性和外部属性组成。
- 内部属性通常相对不变,例如个性、性别认同和生活经历。
- 外部属性则可能频繁变化,例如地址、国籍,甚至姓名。
作为开发者,我们经常需要存储用户信息。常见的存储项包括姓名、地址、出生日期、国籍、性别和社会安全号码等。
姓名:远比想象中复杂 📛
上一节我们介绍了身份的基本概念,本节中我们来看看最常被误解的身份数据之一:姓名。我们经常错误地假设姓名结构简单且固定不变。
常见的错误假设
以下是程序员对姓名的一些常见误解:
- 假设姓名由“名+中间名+姓”组成:这在许多英语国家常见,但并非全球通用。
- 假设中间名只有一个且长度正常:例如,美国总统 Harry S. Truman 的中间名就是单个字母“S”。而有些人可能有多个中间名。
- 假设姓氏是单个词:许多人的姓氏包含空格或连字符(例如 “van der Berg” 或 “Smith-Jones”)。
- 假设姓名不会改变:人们可能因结婚、性别转换或个人喜好而更改姓名。
- 假设每个人都有名和姓:有些人只有一个单一的名字(例如,歌手 “Cher”)。
姓名结构的多样性
- 英语国家:格式通常为
[头衔] [名] [中间名] [姓] [后缀]。例如:Lady Augusta Ada King, Countess of Lovelace。 - 中文和日语:姓在前,名在后。不能假设字符串的第一部分是名。
- 其他情况:有些人有多个中间名,或由多个词组成且无分隔符的姓氏。
如何正确存储姓名
首先,需要明确存储姓名的目的:
- 用于政府系统验证(如医疗、银行):应严格遵循政府文件上的格式。
- 用于日常沟通(如寄信、网站显示):一个单独的“全名”字段通常更合适、更灵活。
如果需要同时满足两种需求,最佳实践是明确区分:
- 询问“法定姓名”(用于官方验证)。
- 提供一个选项,询问“您的常用姓名是否与法定姓名不同?”,然后允许用户填写“常用姓名”。
核心原则:不要尝试从用户提供的单一字符串中自动解析出名和姓。如果确实需要分开的部分,必须明确地向用户询问。


推荐阅读:经典文章 《Falsehoods Programmers Believe About Names》 详细列举了关于姓名的各种误解。
性别与代词:询问前请三思 ♀️♂️

处理完姓名,我们来看另一个敏感领域:性别。首先要问的关键问题是:你为什么要收集这些信息?

明确收集目的
- 为了使用正确的代词称呼用户:那么你应该直接询问“代词”(Pronouns),并提供选项如“他/她”、“她/她”、“他们/他们”以及“自定义”输入框。
- 为了提供性别化产品或服务(如卫生用品):这时询问“性别”可能是合理的。
- 用于统计或合规:需谨慎处理,并确保匿名化。

最佳实践
- 优先询问代词而非性别。
- 如果必须询问性别,请提供非二元选项和“不愿透露”选项。
- 使用自由格式文本框或包含“其他”选项的下拉菜单,允许用户自行定义。
- 默认情况下不公开此信息,让用户控制其可见性。
- 反思是否真的需要:在许多场景下,完全可以避免使用性别化的语言。

地址:世界不是只有美国格式 🗺️
了解了个人标识信息后,我们来看看地理位置标识——地址。许多系统的地址设计严重以美国为中心,这给全球用户带来了困扰。
常见的国际地址问题
- 邮政编码:并非全是数字(如英国邮编包含字母)。
- 州/省:不要向非美国用户展示美国的州列表。
- 邮政信箱:在许多国家(如澳大利亚),邮政信箱是有效的、常用的投递地址。
- 长地名:某些城市或街道名称可能很长或包含空格。
- 非标准地址:在某些地区,地址可能非常简略甚至不存在标准格式。
如何改进地址字段
- 研究目标市场:为你计划服务的国家/地区设计特定的地址格式。
- 使用灵活的多行文本框:有时,一个简单的多行文本框让用户自由填写,比强制分字段更有效,邮政系统通常能处理。
- 避免过度验证:对地址格式的严格验证常常会拒绝有效地址。
扩展阅读:类似地,也存在 《Falsehoods Programmers Believe About Addresses》 这样的文章。
数据安全与伦理责任 🔒
在收集了各种身份数据后,我们必须意识到随之而来的巨大责任。存储个人信息伴随着严重的风险和伦理考量。
为什么数据安全至关重要
- 个人数据如同“武器级钚”:它危险、持久,一旦泄露就无法收回。
- 现实后果:数据泄露会给用户带来真实的经济损失和风险(例如身份盗用)。2017年的Equifax泄露事件影响了数亿人。
- 法律风险:如欧盟的GDPR规定,对数据泄露可处以巨额罚款(全球营业额的4%或2000万欧元,取较高者)。
- 政治与伦理风险:你收集的数据可能被滥用,用于追踪特定族群或观点的人群。
核心安全准则
- 数据最小化原则:如果不需要某项信息,就绝对不要收集和存储它。
- 为最坏情况做打算:思考如果数据被黑客窃取或被政府传票,会发生什么。
- 软件应帮助人:最终,软件是为人服务的。糟糕的身份数据处理会剥夺用户的人性,使其感到被边缘化。
总结与实践建议 🎯
本节课中我们一起学习了身份数据处理的复杂性、常见误区及最佳实践。

黄金法则:不要做假设
你的个人经验并非普世标准。进入新市场时,必须进行研究并与当地用户交流。
给开发者的具体建议
- 设计灵活的系统:例如,在Django中,默认的User模型要求名字和姓氏,这可能不适用。考虑创建更灵活的用户档案模型。
- 全面支持Unicode:确保从表单到数据库再到邮件,整个系统都能正确处理各种字符,避免出现乱码。
- 允许更改:姓名、地址等信息可能会变,系统应允许用户更新。
- 保持谦逊并迭代:不要期望第一次就完美。当用户指出问题时,积极修复。
最终目标
构建尊重所有用户、具有包容性且安全可靠的软件。通过更谨慎地处理身份数据,我们不仅能避免技术错误和法律责任,更能真正地帮助和服务于每一个独特的个体。

记住:我们不是在处理字符串,而是在处理人。
003:核心概念与实现




在本教程中,我们将学习奇异值分解在推荐系统中的应用,并掌握如何使用Python库surprise来实现它。我们将从推荐系统的基本概念讲起,逐步深入到SVD的原理和实际代码操作。

概述
推荐系统是现代数字服务的核心,它能根据用户的历史行为提供个性化内容。本节课我们将重点学习一种名为“协同过滤”的推荐技术,并深入探讨其核心算法之一——奇异值分解。我们将了解SVD如何工作,并通过Python代码实践,学习如何训练模型并进行预测。
什么是推荐引擎?
上一节我们提到了推荐系统的重要性,本节我们来具体看看什么是推荐引擎。
搜索引擎根据输入的关键词返回结果,而推荐引擎则更进一步,它为每个用户提供个性化的结果。推荐引擎会随着用户与应用的互动不断学习,从而提供更精准、更多样化的推荐。这能显著提升用户参与度。据统计,亚马逊35%的收入和Netflix超过75%的电影观看都源于推荐引擎。
协同过滤简介
理解了推荐引擎的价值后,我们来看看实现它的一种常见策略:协同过滤。

协同过滤是一种通过分析相似用户的偏好来为新用户提供推荐的方法。它的核心包含三个要素:
- 用户:系统中的参与者。
- 产品:被评分的对象(如电影、歌曲)。
- 评分:量化用户与产品互动程度的数值(如1-5星,或点赞/点踩)。

为了更好地理解,请看以下用户-电影评分矩阵示例:
| 用户 | 红色电影 | 绿色电影 | 橙色电影 | 蓝色电影 |
|---|---|---|---|---|
| 用户1 | 喜欢 | - | 不喜欢 | ? |
| 用户2 | 不喜欢 | 喜欢 | 不喜欢 | 喜欢 |
| 用户3 | 喜欢 | 喜欢 | - | 不喜欢 |
| 用户4 | - | 不喜欢 | 喜欢 | - |
| 用户5 | 不喜欢 | 喜欢 | 不喜欢 | ? |
假设我们要预测用户5对蓝色电影的评分。协同过滤会寻找与用户5口味相似的用户(例如,用户2和用户3),然后根据这些相似用户对蓝色电影的评分(用户2喜欢,用户3不喜欢)来综合预测。由于用户5与用户2的评分模式更一致,系统可能会预测用户5也会喜欢蓝色电影。
奇异值分解:协同过滤的利器
协同过滤有多种实现方式,本节我们将聚焦于其中一种强大且业界广泛使用的算法——奇异值分解。
SVD是一种矩阵分解技术,它能从用户-产品评分矩阵中自动学习出“潜在特征”。这些特征不是人为定义的(如年龄、电影类型),而是算法从数据中自行发现的、对预测评分有高度影响力的抽象属性。
SVD的核心公式可以表示为:
原始评分矩阵 R (m x n) ≈ 用户潜在特征矩阵 P (m x k) × 产品潜在特征矩阵 Q^T (k x n)
其中,m是用户数,n是产品数,k是我们选择的潜在特征数量(k远小于m和n)。通过调整k的大小,我们可以在预测精度和计算成本之间取得平衡。
实战:使用Python实现SVD
理解了SVD的理论后,本节我们来看看如何用Python代码实现它。我们将使用surprise库和MovieLens开源数据集。
以下是实现SVD推荐的四个核心步骤:
步骤1:导入库并定义数据格式
首先,我们需要导入必要的库,并定义一个Reader来告知程序评分的范围。
from surprise import SVD, Dataset, Reader
import pandas as pd
# 定义评分尺度,MovieLens数据集的评分是1到5分
reader = Reader(rating_scale=(1, 5))
步骤2:加载并准备数据
接着,我们加载数据并将其转换为surprise库所需的格式。
# 假设df是一个包含‘user_id’, ‘item_id’, ‘rating’三列的DataFrame
data = Dataset.load_from_df(df[['user_id', 'item_id', 'rating']], reader)
# 将数据划分为训练集和测试集
trainset = data.build_full_trainset()
步骤3:训练SVD模型
现在,我们可以初始化SVD模型并指定潜在特征的数量(例如100),然后在训练集上进行训练。
# 初始化模型,n_factors指定潜在特征的数量
model = SVD(n_factors=100)
model.fit(trainset)
步骤4:进行预测与推荐
模型训练完成后,我们可以用它来预测评分或寻找相似产品。
# 预测特定用户对特定产品的评分
user_id = ‘123‘
item_id = ‘Toy Story (1995)‘
prediction = model.predict(user_id, item_id)
print(f‘预测评分: {prediction.est}‘)
# 获取产品的潜在特征向量,用于计算相似度
# model.qi 存储了所有产品的潜在特征向量
item_inner_id = model.trainset.to_inner_iid(item_id)
item_vector = model.qi[item_inner_id]
SVD的应用场景
训练好模型并得到潜在特征后,我们可以将其应用于多种场景。
1. 预测新评分
通过计算用户潜在特征向量和产品潜在特征向量的点积,可以预测任何用户-产品对的评分。公式如下:
预测评分 = 用户向量 · 产品向量

2. 寻找相似产品
通过计算产品潜在特征向量之间的余弦相似度,可以找到口味相似的产品。余弦相似度越高,产品越相似。这可以用来实现“看了这个的人也看了……”的功能。
from sklearn.metrics.pairwise import cosine_similarity
# 计算两部电影向量的余弦相似度
similarity = cosine_similarity([vector1], [vector2])[0][0]
总结
本节课我们一起学习了推荐系统中的奇异值分解。
- 我们首先了解了推荐引擎和协同过滤的基本概念。
- 然后,我们深入探讨了SVD的原理,它通过矩阵分解自动学习潜在特征,是协同过滤的核心算法之一。
- 接着,我们通过Python的
surprise库,一步步实现了SVD模型的训练、评分预测和产品相似度计算。 - 最后,我们探讨了SVD在生成个性化推荐和发现相似内容方面的实际应用。

SVD是一种强大且实用的工具,即使没有深厚的数学背景,借助Python生态中优秀的库,开发者也能快速上手并将其应用于实际项目,从而构建出更智能、更个性化的推荐系统。
004:通过 importlib.resources 更快获取资源

在本节课中,我们将学习如何在 Python 运行时高效地读取与代码打包在一起的静态文件(如模板、测试数据、证书等)。我们将重点介绍 Python 3.7 引入的新标准库 importlib.resources,它旨在替代传统方法,提供更清晰、更高效的 API。
概述:为什么需要新的资源访问方式?
在开发 Python 库或应用时,我们经常需要读取一些与代码一同分发的静态文件。传统方法,如直接使用 __file__ 属性或 pkg_resources 库,存在一些问题:
__file__在代码被打包(如放入 zip 文件)时可能失效。pkg_resources在导入时会产生性能开销,且 API 设计较为陈旧。
importlib.resources 基于 Python 高度优化的导入系统构建,旨在解决这些问题,提供更现代、更高效的解决方案。
传统方法及其局限性
上一节我们概述了资源访问的需求,本节中我们来看看两种传统方法的具体实现和它们各自的缺点。
方法一:使用 __file__ 属性
这是一种直观的方法,通过模块的 __file__ 属性定位文件路径。
import package
with open(os.path.join(os.path.dirname(package.__file__), ‘data’, ‘example.dat’), ‘rb’) as fp:
example_data = fp.read()
局限性:当你的包被放入一个 zip 文件或其他归档格式时,__file__ 可能不再指向一个真实的文件系统路径,上述代码会抛出异常。


方法二:使用 pkg_resources
pkg_resources 库提供了一个更通用的 API,可以处理文件系统和压缩包内的资源。
from pkg_resources import resource_string as resource_bytes
example_data = resource_bytes(‘package.data’, ‘example.dat’)
局限性:
- 导入时性能开销:
pkg_resources在导入时会扫描sys.path中的所有条目,建立工作集,即使你后续不使用它,也会产生这个固定成本。 - API 设计陈旧:例如,
resource_string在 Python 3 中返回的是字节(bytes),但名称却叫“string”,容易造成混淆。 - 临时文件管理:其
resource_filenameAPI 在需要时会创建临时文件,但由于该库早于with语句,无法保证临时文件会被及时清理。
解决方案:importlib.resources
鉴于 pkg_resources 的种种问题,Python 3.7 在标准库中引入了 importlib.resources 模块。它基于 Python 的导入系统,设计更清晰,性能更优。
核心概念定义
在深入 API 之前,我们需要明确两个核心概念:
- 包:任何拥有
__path__属性的可导入模块。通常,你可以将其理解为一个包含__init__.py文件的目录(尽管它不一定在物理文件系统上)。 - 资源:包内任何可以被读取的对象(如文件)。重要:子目录本身不是资源,命名空间包(PEP 420)不能包含资源。
主要 API 介绍
以下是 importlib.resources 提供的主要函数。它们都接受两个参数:package(包名或模块对象)和 resource(资源名)。
1. 读取资源内容
当你需要一次性获取资源的全部内容时,可以使用以下函数。
以二进制模式读取:
from importlib import resources
data = resources.read_binary(‘package.data’, ‘example.dat’) # 返回 bytes
以文本模式读取:
from importlib import resources
text = resources.read_text(‘package.data’, ‘template.txt’, encoding=‘utf-8’) # 返回 str
API 明确区分了二进制和文本模式,使意图更清晰。
2. 获取资源文件句柄
如果你希望像操作普通文件一样流式读取资源,可以使用以下函数,它们返回一个可在 with 语句中使用的上下文管理器。
打开二进制文件:
from importlib import resources
with resources.open_binary(‘package.data’, ‘example.dat’) as fp:
chunk = fp.read(1024)
打开文本文件:
from importlib import resources
with resources.open_text(‘package.data’, ‘template.txt’, encoding=‘utf-8’) as fp:
line = fp.readline()
3. 获取文件系统路径
某些第三方库(如加载 .so 文件或某些 SSL 证书)要求一个真实的文件系统路径。as_file 函数提供了这个保证。
from importlib import resources
with resources.as_file(resources.files(‘package.data’) / ‘cert.pem’) as cert_path:
# cert_path 是一个 pathlib.Path 对象,指向一个真实文件
# 如果资源在压缩包内,这里会是一个临时文件
use_certificate(str(cert_path))
# 退出 with 语句后,临时文件(如果创建了)会被自动清理
注意:resources.files() 是 Python 3.9+ 引入的更面向对象的 API。在 3.7-3.8 中,你可以直接使用 as_file 配合资源路径字符串。
4. 列出包内容
你可以像使用 os.listdir() 一样,列出一个包目录下的所有条目。
from importlib import resources
contents = resources.contents(‘package’)
print(contents) # 可能输出 [‘__init__.py‘, ‘data‘, ‘__pycache__‘]
contents() 返回的是字符串列表,其中可能包含子包、资源以及像 __pycache__ 这样的目录。
5. 判断是否为资源
为了区分 contents() 返回列表中的资源和非资源(如 __pycache__),可以使用 is_resource() 函数。
from importlib import resources
if resources.is_resource(‘package’, ‘__pycache__’):
print(“This is a resource”)
else:
print(“This is NOT a resource (e.g., a directory)”)
高级主题:底层机制与向后兼容
底层加载器 API
importlib.resources 的高级 API 建立在 Python 导入系统的“加载器”机制之上。这意味着任何自定义加载器(例如,从数据库或网络加载模块)都可以通过实现一个简单的抽象基类(ABC)来支持资源访问,从而使高级 API 无需修改即可工作。对于大多数使用者来说,无需关心此底层细节。
向后兼容与性能
- 向后移植:
importlib.resources的核心 API 已被向后移植到 PyPI 上的importlib-resources库,支持 Python 2.7 及 3.4+。这意味着你无需升级到 Python 3.7 即可开始使用它。 - 性能提升:在实际案例中(如 LinkedIn 的内部工具),用
importlib.resources替换pkg_resources,并结合现代打包工具(如shiv替代PEX),使得命令行工具的启动时间提升了 25% 到 50%。
总结
本节课中我们一起学习了 Python 中管理打包资源的最佳实践。我们回顾了传统方法(__file__ 和 pkg_resources)的缺点,并深入探讨了 Python 3.7 引入的现代解决方案 importlib.resources。
它的核心优势在于:
- 清晰的 API:明确区分二进制/文本操作,使用上下文管理器自动管理资源。
- 优异的性能:基于高效的导入系统,避免了
pkg_resources的导入时开销。 - 更好的兼容性:能正确处理文件系统和压缩包内的资源。
- 广泛的可用性:通过
importlib-resources包支持旧版 Python。


建议在新项目中直接使用 importlib.resources,并在旧项目中逐步迁移,以提升代码的清晰度和运行效率。
005:Systemd入门指南 🐍⚙️

在本教程中,我们将学习Systemd的基础知识,并探讨为什么作为Python开发者,了解和使用Systemd是有益的。我们将从服务管理器的概念讲起,逐步深入到Systemd的核心特性,如Cgroups和套接字激活,最后介绍如何通过Python库与Systemd进行交互。
什么是服务管理器?

服务管理器是负责管理服务生命周期的工具。它本身不是服务,而是用于启动、停止、重启和重新加载服务的程序。此外,它还负责管理服务之间的依赖关系,例如确保某个服务在数据库服务准备就绪后才启动。
在Systemd出现之前,Linux系统通常使用自定义的Shell脚本来管理服务。这些脚本包含大量样板代码,用于参数解析、状态打印等,而实际启动服务的核心代码只占很小一部分。每个服务都需要编写和维护这样的脚本,效率较低。

Systemd的引入
随着服务器规模扩大和对更快启动时间的需求,Systemd应运而生。它逐渐成为许多主流Linux发行版的标准服务管理器。


Systemd的核心改变在于,它使用单元文件来定义服务,而不是可执行的Shell脚本。单元文件以声明式的方式描述了服务应该如何运行。例如,一个服务单元文件(.service)会指定要执行的命令、运行用户、环境变量等。
要管理服务,你不再直接执行脚本,而是通过systemctl命令与Systemd交互。例如:
systemctl start myapp.service
systemctl stop myapp.service
systemctl status myapp.service
Systemd的一个关键优势是能提供详细的服务状态信息,因为它直接跟踪由它启动的进程。
核心概念:Cgroups

Cgroups是Linux内核的一个功能,允许对一组进程进行资源限制和隔离。Systemd为每个服务自动创建一个Cgroup。
这意味着你可以为服务设置CPU、内存、I/O等资源的使用上限。例如,在单元文件中,你可以这样限制资源:
[Service]
CPUQuota=20%
MemoryLimit=10M
此外,Cgroups确保了服务的所有子进程(即使是守护进程化的)都能被Systemd追踪和管理。这使得彻底停止一个服务及其所有相关进程成为可能,只需删除其Cgroup即可。


核心概念:套接字激活
套接字激活是Systemd的另一个强大特性。在传统模式下,应用程序启动后自行打开并监听网络端口。


在套接字激活模式下,这个逻辑被反转:
- Systemd首先启动并监听指定的网络端口。
- 当第一个连接请求到达该端口时,Systemd才启动对应的应用程序服务。
- Systemd将已连接的套接字文件描述符传递给应用程序,应用程序只需处理读写操作。
这种方式带来了多重好处:
- 按需启动:节省资源,服务只在有请求时才被激活。
- 权限分离:Systemd以root身份监听特权端口(如80),而应用程序可以以普通用户身份运行,提升了安全性。
- 无缝升级:结合
SO_REUSEPORT等内核选项,可以实现不同版本应用同时监听同一端口,便于A/B测试和零停机升级。
实现套接字激活需要两个单元文件:一个套接字单元(.socket)定义监听规则,一个服务单元(.service)定义要启动的应用。在Python代码中,你需要从Systemd传递的文件描述符中获取套接字,而不是自己创建。
使用Python与Systemd交互
为了方便Python开发者,可以使用pystemd库与Systemd的D-Bus接口进行交互。

首先安装该库:
pip install pystemd

然后,你可以在Python代码中导入并使用它来管理服务:
from pystemd.systemd1 import Unit

# 加载一个服务单元
myapp_unit = Unit(b‘myapp.service’)
myapp_unit.load()

# 启动服务
myapp_unit.start()
# 获取服务的主进程PID
print(myapp_unit.Properties.MainPID)
# 获取服务的所有进程列表
print(myapp_unit.get_processes())


pystemd提供了类型安全的接口,比通过Shell命令解析文本输出更可靠、更高效。

Systemd的瞬态单元功能

除了管理预定义的持久化服务,Systemd还可以通过Python动态创建和管理“瞬态单元”。这类似于运行一个子进程,但具有更多优势。
使用subprocess.call运行命令时,子进程与父进程共享用户权限、Cgroup视图,并且父进程退出会导致子进程退出。
而使用pystemd的瞬态单元功能,则可以独立运行任务:
from pystemd.run import run



# 以独立服务的形式运行`sleep 100`命令
run([b‘sleep’, b‘100’])
这样运行的进程:
- 可以指定不同的运行用户。
- 拥有独立的Cgroup,资源限制与父进程分离。
- 生命周期独立于启动它的Python进程。
这为在Python应用中安全、可控地运行后台任务或批处理作业提供了强大工具。你还可以在运行时指定资源限制、文件系统视图、网络访问策略等,实现高度的隔离和控制。
安全与隔离特性
Systemd提供了丰富的安全沙箱选项,可以限制服务的权限,增强安全性。以下是一些关键选项:

- ProtectHome:使服务无法访问
/home、/root等用户目录,保护敏感数据。 - ProtectSystem:将整个根文件系统挂载为只读,防止服务写入系统文件。
- ReadWritePaths / ReadOnlyPaths / InaccessiblePaths:精细控制服务对特定目录的访问权限。
- PrivateTmp:为服务提供私有的
/tmp目录,与其他服务隔离。 - TemporaryFileSystem:可以为任何目录创建临时的内存文件系统,服务停止后数据自动清除。
- BindPaths / BindReadOnlyPaths:可以将主机上的特定路径(文件或目录)以绑定挂载的方式提供给服务,甚至可以重命名路径。
- IPAddressDeny / IPAddressAllow:为服务配置内置的防火墙规则,限制其网络访问,例如只允许访问特定IP。
资源限制实践

我们之前提到了在单元文件中使用Cgroups进行资源限制。以下是一个例子,限制服务只能使用20%的CPU、10MB内存,并且最多只能创建5个进程:
[Service]
CPUQuota=20%
MemoryLimit=10M
TasksMax=5

你可以编写一个消耗CPU的Python程序来测试这个限制:
# cpu_stress.py
while True:
pass
当这个服务运行时,你会发现它的CPU使用率会被限制在20%左右。如果它尝试分配超过10MB的内存,Systemd会终止该进程。
在本教程中,我们一起学习了Systemd作为现代服务管理器的基础。我们了解了它如何通过单元文件取代复杂的Shell脚本,并深入探讨了其两大核心机制:用于资源控制和进程管理的Cgroups,以及实现按需启动和权限分离的套接字激活。

我们还介绍了如何通过pystemd库在Python代码中方便地与Systemd交互,管理服务状态、动态创建瞬态单元。最后,我们探讨了Systemd提供的多种安全隔离与资源限制选项,这些功能能帮助Python开发者构建更安全、更稳定、更易于管理的生产级应用。

掌握Systemd,能让你的Python应用更好地融入现代Linux生态系统,充分利用操作系统提供的管理、安全和隔离能力。
006:代码如何随数据增长而减速 🐌


在本节课中,我们将要学习一个对软件工程师至关重要的概念——大O符号。我们将探讨如何用它来描述代码的运行时间如何随着数据量的增长而增加,并学习如何分析简单代码片段的复杂度。理解这个概念能帮助我们编写出在处理大量数据时依然高效的代码。
什么是大O符号?🤔


上一节我们介绍了课程概述,本节中我们来看看大O符号的核心定义。大O符号是一种描述算法“时间复杂度”的方法,它关注的是当输入数据量(通常用 N 表示)增长时,算法运行时间增长的趋势。
核心公式:O(f(N))
这里的 O 代表“阶”(Order),f(N) 是一个关于数据量 N 的数学函数。它不是一个真正的函数调用,而是一种表示增长趋势的符号。
现实世界中的复杂度例子 🥫
为了理解不同复杂度级别的含义,让我们看几个生活中的例子。
以下是两个计算豆子数量的方法:
- 方法A(逐个计数):打开罐子,一颗一颗地数豆子。如果豆子数量(N)增加十倍,所需时间也大约增加十倍。这被称为 O(N),或线性时间。
- 方法B(查看标签):罐子外面贴好了豆子数量的标签。无论罐子多大,看一眼标签就能知道数量,所需时间恒定。这被称为 O(1),或常数时间。

在编程中,Python的 len(list) 操作就像方法B,是 O(1);而遍历列表的每个元素就像方法A,是 O(N)。
更多复杂度类别 📚
上一节我们看到了O(1)和O(N)的例子,本节中我们来看看另一种常见的复杂度。
假设你需要在书中查找一个特定的词。
- 在小说中顺序查找:你需要从头开始阅读,直到找到那个词。这平均需要检查一半的页数,时间与书的总页数 N 成正比,是 O(N)。
- 在按字母排序的百科全书中查找:你可以使用“二分查找法”。先翻到中间,根据词汇顺序决定向前或向后查找,每次都能排除一半的页数。这种方法的复杂度是 O(log N),增长速度远慢于 O(N)。
数据的组织方式(数据结构)决定了你能使用的算法,从而极大地影响代码的效率。
关键术语与图表 📈
在深入分析前,我们先统一一些术语并可视化不同复杂度的差异。
- O(1):常数时间。运行时间不随 N 变化。
- O(N):线性时间。运行时间与 N 成正比。
- O(N²):二次时间。如果 N 增加10倍,运行时间可能增加100倍。
- O(log N):对数时间。运行时间随 N 增长非常缓慢。
这些概念有时也被称为“时间复杂度”、“算法复杂度”或“渐近复杂度”。
以下是不同大O复杂度的增长趋势示意图:
时间
↑
| ....(O(N²))
| ...
| ...
| ...(O(N))
| ...
| ...
| ...(O(log N))
| ...
| ...(O(1))
|...
+——————————————————————————————> 数据量 (N)
可以看到,O(N²) 随着数据量增长急剧上升,而 O(1) 则保持平坦。
如何确定代码的大O?🔍
现在我们来学习分析一段代码并确定其大O复杂度的实用步骤。
以下是分析的四个步骤:
- 明确代码段:确定你要分析的是哪个具体的函数或代码块。
- 定义 N:找出代码中代表“数据量”的变量是什么(例如:列表长度、字符串长度、记录条数)。
- 计算典型情况下的步骤数:模拟代码在典型数据上运行一次,计算其执行的基本“步骤”数。将步骤数表达为关于 N 的公式(如
3N + 2)。 - 简化表达式:只保留公式中最高阶的项,并忽略它的系数。例如,
3N² + 5N + 10简化为 O(N²)。因为当 N 非常大时,低阶项和系数的影响微乎其微。
代码分析实例 👩👧
让我们通过两个具体的Python函数来实践上述步骤。
实例1:查找母亲(O(N))
def find_mom(moms, child_name):
for child, mom in moms: # 这个循环是关键
if child == child_name:
return mom
return None
- N:列表
moms的长度。 - 分析:在典型情况下(名字在列表中随机出现),循环平均需要运行
N/2次。每次循环包含几个固定步骤(读取元组、比较等)。总步骤数可表示为a * (N/2) + b,简化后为 O(N)。
实例2:统计祖母数量(O(N²))
def count_grandmas(moms):
count = 0
for child, mom in moms: # 外层循环:O(N)
if find_mom(moms, mom): # 内层调用:O(N)
count += 1
return count
- N:列表
moms的长度。 - 分析:外层循环运行 N 次。每次循环都调用
find_mom函数,而我们已经知道find_mom是 O(N) 的操作。因此,总复杂度是 N * O(N) = O(N²)。
Python常见操作的时间复杂度 🐍
了解常用数据结构操作的基础复杂度至关重要,这能帮助我们在编程时做出明智的选择。
以下是Python列表、字典和集合的部分关键操作复杂度:
- 列表 (List):
- 按索引访问/赋值 (
list[i]): O(1) - 追加 (
list.append(x)): O(1) (摊销时间) - 查找值 (
x in list): O(N)
- 按索引访问/赋值 (
- 字典/集合 (Dict/Set):
- 查找键/值 (
key in dict,x in set): O(1) (典型情况) - 赋值/添加 (
dict[key]=val,set.add(x)): O(1) (典型情况)
- 查找键/值 (
重要提示:用集合 (set) 的 O(1) 查找替代列表 (list) 的 O(N) 查找,通常是显著的性能优化。但需注意,将列表转换为集合本身是 O(N) 操作。因此,如果只做一次查找,转换可能得不偿失;如果需要多次查找,则转换是值得的。
权衡、误区与高级话题 ⚖️
在应用大O分析时,需要保持全局视角并理解其局限性。
关注大局:优化一段 O(N) 的代码固然好,但如果它只被调用一次,而程序的主要时间消耗在一个 O(N²) 的循环中,那么优化前者收效甚微。始终先找到并优化性能瓶颈。

当N很小时:大O描述的是 N 趋近于无穷大时的趋势。当数据量很小时,被忽略的常数项和低阶项可能起主导作用。一个 O(N²) 但常数很小的算法,在 N<100 时可能比一个 O(N) 但常数很大的算法更快。正如 Rob Pike 所说:“花哨的算法在 n 很小时很慢,而 n 通常很小。”
高级话题速览:
- 摊销分析:像
list.append()这样的操作,绝大多数时间是 O(1),但偶尔在列表需要扩容时会有一次 O(N) 的复制。从长期平均来看,其时间复杂度仍是 O(1),这就是“摊销 O(1)”。 - 最坏情况:我们之前主要分析“典型情况”。某些算法(如哈希表查询)在典型数据下是 O(1),但在特定恶意构造的数据下可能退化为 O(N)。这也是Python为字典引入哈希随机化的原因之一。
总结 🎯
本节课中我们一起学习了:
- 大O符号 (
O(f(N))) 是一种描述代码运行时间随输入数据量 N 增长而变化的趋势的工具。 - 常见的复杂度有 O(1)(常数)、O(log N)(对数)、O(N)(线性)和 O(N²)(二次),其增长速率依次加快。
- 分析代码复杂度的步骤是:明确代码段、定义 N、估算步骤数、简化表达式。
- 掌握 Python 基础数据结构(列表、字典、集合)的核心操作复杂度,能帮助我们写出更高效的代码。
- 大O分析是强大的工具,但应用时需注意权衡(如转换成本)、关注瓶颈,并记住在数据量很小时,常数因素可能比复杂度级别更重要。

希望本教程能帮助你消除对大O符号的畏惧,并将其作为一个实用的、非数学的工具应用到日常的软件工程实践中。
007:从失败中学习的事后分析


在本节课中,我们将学习如何通过“事后分析”这一系统性方法,从软件系统的操作故障中学习,并推动改进。我们将探讨事后分析的核心哲学、具体步骤以及如何将其融入团队文化,目标是构建更具韧性的系统。
什么是操作性故障?

上一节我们介绍了学习的目标是“操作性故障”。本节中我们来看看它的具体定义。
操作性故障是指影响系统正常运行的事件,例如:
- 网站停机。
- 数据泄露。
- 生产环境出现严重问题。

一个简单的判断标准是:如果这件事会在你的服务水平协议中衡量,那么它很可能就是一次操作性故障。我们将重点讨论如何从这类故障中学习。
核心哲学:从指责到学习
当故障发生时,团队通常有两种反应:一是思考如何防止问题再次发生,二是寻找该为此负责的人。我们需要明确区分这两者。
关键公式:追究责任 ≠ 系统性改进。事实上,它们常常是互相排斥的。
建立“无责文化”是有效事后分析的基础。无责文化并非否认个人的行为,而是基于一个信念:几乎所有问题都有系统性的解决方案,追求这些解决方案远比追究个人责任更重要。
例如:
- 看似无责但隐含指责:“运行了一个删除了半个生产集群的脚本。”(使用了被动语态,暗示“如果知道是谁,就会指责他”)
- 真正的无责文化:“我,亚历克斯,运行了一个删除了半个生产集群的脚本。”(承认个人行为,但团队共同寻找系统层面的改进点)
真正的无责文化提供了心理安全,让人们能够自由、充分地沟通发生了什么,从而理解问题的根本原因。
复杂系统与多重原因
在深入具体步骤前,我们需要理解复杂系统故障的特性。一个由网络服务器、数据库、负载均衡器等组成的系统就是一个复杂系统。
复杂系统的任何操作故障都不止一个原因。一次故障通常是多个潜在错误在特定条件下被同时触发的结果。我们的任务不仅是修复那个直接的触发点,更要认真对待所有先前已存在、只是在此刻才显现的“潜在错误”。
事后分析详解:从事件到行动
上一节我们建立了事后分析的基本理念,本节中我们来看看具体如何执行一次有效的事后分析。
事后分析通常以会议和书面文档的形式进行,目标是将一次具体的故障转化为可执行的改进措施。它应该由亲身参与事件处理、负责系统运营的团队共同完成,而不是由管理层或外部人员代为整理。
一个有效的事后分析文档应包含以下要素:
1. 摘要
对事件和其影响进行简明扼要的总结。
- 发生了什么:网站因数据库连接池耗尽而停机。
- 用户可见影响:服务完全不可用持续17分钟,之后部分功能异常持续24分钟。

2. 时间线
按时间顺序记录事件从发生到解决的全过程。这是评估监控、告警和应急响应过程有效性的关键。
09:00 - 监控显示错误率飙升。
09:02 - 值班工程师亚历克斯收到告警。
09:05 - 亚历克斯登录服务器,发现数据库连接数异常高。
09:15 - 团队在Slack上建立紧急频道协作。
09:30 - 确定原因为新部署的代码存在连接泄漏。
09:45 - 回滚部署,服务开始恢复。
核心原则:没有“过多信息”这一说,尽可能详细记录。
3. 原因分析
列出所有导致或加剧事件的直接和潜在原因。记住,原因通常是多重的。
- 直接触发:新部署的v1.2版本代码中存在数据库连接未正确释放的Bug。
- 潜在原因1:自动化测试未覆盖该连接释放逻辑。
- 潜在原因2:数据库连接数监控告警阈值设置过高,未能提前预警。
- 潜在原因3:回滚流程不够自动化,耗时较长。
4. 处理过程评估
分析在应对事件时,哪些做得好,哪些有待改进。
- 做得好的:监控系统及时告警;团队沟通顺畅,快速建立了协作频道。
- 待改进的:缺乏清晰的应急指挥角色;诊断工具不足,定位根本原因耗时过长。

5. 后续行动项
这是将分析转化为改进的关键。行动项应具体、可分配、可追踪。
- 短期(本周):
- 修复v1.2版本中的连接泄漏Bug。(负责人:张三)
- 优化数据库连接数监控,降低告警阈值。(负责人:李四)
- 长期(本季度):
- 为数据库连接管理相关代码增加自动化测试覆盖率。(负责人:王五)
- 制定并演练标准化的服务回滚流程。(负责人:赵六)
- 调研并引入更高效的分布式追踪工具。(负责人:团队)
实战练习与融入流程
现在我们已经了解了事后分析的完整结构,本节中我们通过一个练习来巩固理解,并探讨如何将事后分析融入团队日常。
练习:一次扩容故障
事件描述:工程师运行脚本将Kubernetes集群扩容20%,但误操作导致集群被缩容到只剩20个节点。自动扩展组试图补救,但因同时启动服务器过多导致Docker仓库被压垮。最终手动恢复,服务不可用超过一小时。
以下是你可能发现的一些系统性改进点:
- 脚本安全性:
scale.sh脚本是否缺少关键操作确认(如删除大量节点时)?其参数设计是否容易让人误解? - 弹性设计:自动扩展策略是否存在缺陷?是否应设置启动速率限制,避免“惊群”效应?
- 依赖韧性:基础设施(如Docker仓库)是否足够健壮以应对自动扩展带来的压力?
- 监控与恢复:是否有监控能立即显示集群容量骤降?手动恢复流程是否高效?
如何开始实践事后分析?
- 决定并执行:下次发生故障时,立即安排事后分析会议。
- 进行演练:通过“桌面推演”模拟故障场景,讨论应对流程。
- 主动引发故障:在可控环境(如预发布环境)进行“混沌工程”实验,例如随机关闭实例,测试系统韧性。
- 重新定义“事件”:将未达成的性能目标(如延迟超标)也纳入事后分析范围,主动寻找优化点。
行业借鉴与核心要点
软件工程并非事后分析的发明者。其他高风险行业早已有成熟实践:
- 军队:称为“事后回顾”(After Action Review)。
- 医疗:称为“发病率和死亡率会议”(Morbidity and Mortality Conference)。
- 航空:美国国家运输安全委员会(NTSB) 是典范。其使命是“确定事故可能原因,并提出安全建议”。关键是其调查享有法律特权,不能用于法庭举证,这彻底分离了“安全改进”与“责任追究”。
为什么要投入精力做事后分析?
- 预防系统性退化:不修复根本问题,同类错误会以更多形式出现,系统可靠性会持续下降。
- 提升工程能力:这是宝贵的实践学习机会。识别一类错误模式后,可以在未来项目开始时避免它。
- 成本效益:长远来看,系统性修复比没完没了地“打地鼠”式修复单个Bug更便宜。

总结
本节课中我们一起学习了如何通过事后分析从失败中学习。关键要点总结如下:
- 目的纯粹:事后分析是为了学习和系统性改进,而非指责。
- 文化基石:建立真正的无责文化,提供心理安全,鼓励坦诚。
- 理解系统:承认复杂系统故障具有多重原因,需全面分析。
- 结构清晰:遵循摘要、时间线、原因分析、过程评估、行动项的框架来组织事后分析。
- 核心警示:“人为错误”不是根本原因,它提示我们去寻找背后失效的系统性防护。
- 付诸实践:抓住每次故障的机会进行学习,并通过演练主动提升。

故障已经发生,不要浪费这次学习的机会。具体的失败,远比抽象的推测更具教育意义。
008:终结所有代码生成器的代码生成器 🚀



在本教程中,我们将学习 Python 3.7 中引入的 dataclasses 模块。我们将探讨它如何作为一个强大的代码生成器,自动为我们编写类中的样板代码,从而简化数据持有者和复杂类的创建过程。我们将从基础概念开始,逐步深入到高级用法和定制选项。




概述 📋
代码生成器接收一系列规范,并自动生成相应的代码。如果规范良好且生成结果符合预期,这将非常高效。dataclasses 模块就是一个这样的工具,它旨在减少编写类时的重复性工作。与 namedtuple 等工具相比,它功能更丰富,学习曲线稍高,但提供了极大的灵活性和强大的定制能力。
数据类主要服务于两种世界观:一是作为纯粹的数据持有者(类似其他语言中的 struct),二是作为帮助开发者专注于业务逻辑、自动处理模板代码的类生成器。本教程将引导你理解这两种视角,并掌握如何高效地使用数据类。
常见用例:基础数据类 🎨
上一节我们介绍了数据类的基本概念,本节中我们来看看如何创建一个简单的数据类。我们将以一个表示 HSL 颜色系统的类为例。
from dataclasses import dataclass
@dataclass
class Color:
hue: int
saturation: float = 0.5
lightness: float = 0.5
这段代码定义了一个 Color 类,包含色调(hue)、饱和度(saturation)和亮度(lightness)三个字段。后两个字段设有默认值 0.5。其语法简洁,直接使用类型注解来声明字段。
如何使用基础数据类
创建实例和使用方式非常直观:
# 创建实例,使用默认值
c = Color(33, 0.75)
print(c) # 输出: Color(hue=33, saturation=0.75, lightness=0.5)


# 按名称访问字段
print(c.hue) # 输出: 33
# 替换字段值生成新实例
from dataclasses import replace
c2 = replace(c, hue=66)
print(c2) # 输出: Color(hue=66, saturation=0.75, lightness=0.5)


# 转换为字典
print(dataclasses.asdict(c2))


# 转换为元组
print(dataclasses.astuple(c2))



数据类默认是可变的,并且提供了一个清晰易读的 __repr__ 方法。与 namedtuple 相比,数据类的方法(如 replace、asdict)是模块级函数,而非实例方法。




对比 namedtuple ⚖️


为了理解数据类的优势,我们将其与熟悉的 namedtuple 进行对比。两者在简单情况下看起来很相似,但存在关键差异。
以下是使用 namedtuple 实现相同功能的代码:


from collections import namedtuple



ColorNT = namedtuple('Color', ['hue', 'saturation', 'lightness'], defaults=[0.5, 0.5])
c_nt = ColorNT(33, 0.75)

主要差异总结如下:
| 特性 | dataclass (默认) |
namedtuple |
|---|---|---|
| 可变性 | 可变 | 不可变 |
| 哈希性 | 默认不可哈希(除非冻结) | 可哈希 |
| 比较方法 | 默认无,需显式开启 | 有,像元组一样排序 |
| 字段访问速度 | 较快(约33纳秒/次) | 较慢(约61纳秒/次) |
| 内存占用 | 较大(约168字节) | 较小(约72字节) |
replace 功能 |
replace() 函数 |
._replace() 方法 |
| 转换为字典 | asdict() 函数,返回普通dict |
._asdict() 方法,返回OrderedDict |
| 解包 | 需先调用 astuple() |
可直接解包 |
数据类在功能丰富性和定制化上更胜一筹,而 namedtuple 在内存和速度(特定操作)上有其优势。选择哪种取决于你的具体需求。




生成的代码与质量提升 ✨


了解工具背后生成了什么代码至关重要。数据类自动生成的代码通常比手动编写的更健壮、更遵循最佳实践。
对于之前定义的 Color 类,数据类装饰器大致会生成以下代码:
class Color:
hue: int
saturation: float = 0.5
lightness: float = 0.5
def __init__(self, hue: int, saturation: float = 0.5, lightness: float = 0.5) -> None:
self.hue = hue
self.saturation = saturation
self.lightness = lightness
def __repr__(self) -> str:
return f'Color(hue={self.hue!r}, saturation={self.saturation!r}, lightness={self.lightness!r})'
def __eq__(self, other) -> bool:
if other.__class__ is self.__class__:
return (self.hue, self.saturation, self.lightness) == (other.hue, other.saturation, other.lightness)
return NotImplemented
def __hash__(self) -> None:
# 默认设置为 None,使实例不可哈希
# 这是为了避免在字段可能不可哈希时意外出错
pass


质量提升体现在:
- 类型特定的
__eq__:生成的__eq__方法会先检查比较对象是否为同一类,防止了跨类型意外比较。 - 安全的
__hash__:默认将__hash__设为None,防止了在包含不可哈希字段时错误地将对象用作字典键。 - 完整的
__repr__:提供了包含所有字段名称和值的清晰表示。 - 类型注解:
__init__方法包含了完整的类型注解,利于静态类型检查和文档生成。
虽然你只写了4行代码,但它生成了约10行高质量代码,不仅节省了时间,还提升了代码的健壮性。



进阶定制:冻结与排序 ❄️➡️🔥


数据类的强大之处在于其高度的可定制性。默认行为可能不满足所有场景,但你可以轻松地调整它们。


创建可哈希、可排序的不可变数据类



假设我们需要一个不可变的 Color 类,并且希望它能被排序和哈希(例如,用于放入集合或作为字典键)。

from dataclasses import dataclass


@dataclass(order=True, frozen=True)
class FrozenColor:
hue: int
saturation: float = 0.5
lightness: float = 0.5



通过设置 order=True 和 frozen=True,我们得到了一个完全不同的类:
frozen=True:使实例不可变(尝试修改字段会引发异常),并自动生成__hash__方法。order=True:自动生成__lt__,__le__,__gt__,__ge__等比较方法。
# 使用进阶数据类
colors = [
FrozenColor(120, 0.8, 0.6),
FrozenColor(120, 0.8, 0.6), # 重复项
FrozenColor(90, 0.7, 0.5),
FrozenColor(150, 0.9, 0.7)
]

# 现在可以排序了
print(sorted(colors))
# 可以放入集合去重
unique_colors = set(colors)
print(unique_colors)


这节省了大量手动编写比较和哈希方法的工作,并且实现方式(通过属性描述符阻止赋值)比常见的只读属性实现更优雅。




复杂用例:高度定制化的数据类 🏗️


现在,让我们看一个更复杂的现实例子,展示数据类如何处理具有不同需求的字段。我们将创建一个 Employee 类。



以下是该类的需求:
- 有些字段需要默认值工厂(如每次创建新实例时生成一个新列表)。
- 需要添加自定义方法。
- 只有部分字段应参与哈希计算(用于字典键)。
- 某些敏感字段不应出现在
__repr__输出中。 - 某些字段不应参与比较。
- 需要为字段附加元数据。



from dataclasses import dataclass, field
from typing import List
import time


@dataclass(order=True, unsafe_hash=True) # 启用排序,并手动控制哈希
class Employee:
# 基本字段
emp_id: int
gender: str
# 不希望在 repr 中显示,也不参与哈希
salary: float = field(default=10.0, repr=False, hash=False, metadata={'unit': 'bitcoin'})
# 参与比较,但不参与哈希
age: int = field(default=30, compare=True, hash=False)
# 默认工厂:每次创建实例都生成新列表。不参与比较和 repr。
viewers: List[str] = field(default_factory=list, compare=False, repr=False)
def view_record(self, viewer_id: str):
"""自定义方法:记录查看者及其查看时间"""
timestamp = time.strftime('%Y-%m-%d %H:%M:%S')
self.viewers.append(f"{viewer_id} at {timestamp}")


# 使用复杂的 Employee 类
e1 = Employee(101, 'F', age=0x22) # 年龄用十六进制表示
e1.view_record('admin')
e1.view_record('auditor')



print(e1) # 输出: Employee(emp_id=101, gender='F', age=34)
print(e1.viewers) # 输出: ['admin at ...', 'auditor at ...']


# 可以排序(基于 emp_id, gender, age)
e2 = Employee(100, 'M', age=40)
print(sorted([e1, e2]))


# 可以用作字典键(基于 emp_id, gender,因为 salary 和 age 被排除在 hash 外)
responsibilities = {e1: "Gather requirements", e2: "Write tests"}
print(responsibilities[e1])




# 检查字段元数据
salary_field = Employee.__dataclass_fields__['salary']
print(salary_field.metadata) # 输出: {'unit': 'bitcoin'}

这个例子充分展示了数据类声明式语法的强大和清晰。通过 field() 函数,我们可以精细地控制每个字段的行为,将复杂的业务逻辑约束转化为简洁、易读的规范。



总结与展望 🎯
在本教程中,我们一起学习了 Python dataclasses 模块的核心概念和用法。


我们涵盖了:
- 基础创建:如何使用
@dataclass装饰器快速定义数据持有者。 - 核心对比:理解了数据类与
namedtuple在可变性、哈希、比较、性能等方面的关键区别。 - 代码生成:了解了数据类背后生成的代码如何提升质量,例如类型特定的相等比较和安全的哈希处理。
- 进阶定制:学会了通过
order和frozen参数创建可排序、不可变的数据类。 - 高度定制化:掌握了使用
field()函数对单个字段进行精细控制,包括默认工厂、元数据、以及在repr、比较、哈希中的包含与否。
数据类是一个旨在与开发者共同成长的工具。对于简单需求,它开箱即用;对于复杂场景,它提供了丰富的扩展点。虽然它在某些方面(如与 __slots__ 的兼容、处理仅关键字参数)存在限制,但其设计极大地减少了类定义中的样板代码,让开发者能更专注于业务逻辑。


展望未来,我们可能会看到来自社区(如 attrs 库)的更多功能被整合进来,以及对 __slots__ 更好的支持。现在,你已经掌握了利用数据类提升 Python 代码简洁性和健壮性的关键技能。
009:闪电演讲与主旨演讲


在本节课中,我们将学习 PyCon 2018 周日早晨闪电演讲和主旨演讲中的核心内容。我们将探讨几种实用的 Python 调试技巧、强大的命令行工具构建库、以及关于开源社区协作的重要理念。课程内容经过整理,旨在让初学者也能轻松理解。



1️⃣ 使用覆盖率进行高效调试


上一节我们介绍了课程概述,本节中我们来看看一种高效的调试技巧:利用代码覆盖率工具进行调试。

覆盖率模块通常用于衡量测试套件的质量,但它也是一个强大的调试工具。它可以跟踪并报告在程序执行过程中哪些代码行被实际运行了。



核心概念:通过生成覆盖率报告,你可以直观地看到在复现某个问题时,程序实际执行的代码路径,从而快速定位问题所在。



以下是使用覆盖率进行调试的基本步骤:



- 在复现问题(如运行一个失败的单元测试)时,启用覆盖率收集。
- 运行结束后,生成覆盖率报告(通常是 HTML 格式)。
- 查看报告,被执行的代码行会以绿色高亮,未执行的以红色高亮。这能清晰地展示出程序的实际执行流。



代码示例:假设你有一个函数,其逻辑分支复杂,导致输出错误。
# 使用 coverage.py 运行你的脚本或测试
# coverage run your_script.py
# coverage html # 生成 HTML 报告
然后打开生成的 htmlcov/index.html 文件查看详细报告。
优势:
- 直观清晰:避免了在代码中到处插入
print语句的混乱。 - 路径明确:对于复杂的条件分支或长函数,能一目了然地看到实际进入了哪个分支。
- 纠正假设:当你怀疑问题在某个函数时,报告可能显示该函数根本未被调用,从而快速调整调试方向。
提示:此方法不仅限于单元测试,你可以用装饰器包装任何可重复执行的代码块(如 API 端点)来收集覆盖率数据。





2️⃣ 使用 Prompt Toolkit 2.0 构建强大命令行应用
上一节我们学习了利用覆盖率调试,本节中我们来看看如何构建用户体验卓越的命令行应用。
Prompt Toolkit 是一个用于构建命令行界面(CLI)应用程序的 Python 库。它使得创建支持自动补全、语法高亮、历史记录等高级功能的交互式命令行工具变得非常简单。

核心概念:Prompt Toolkit 提供了高级的抽象,让开发者能轻松为终端应用添加丰富的交互特性,同时保持 API 的简洁和友好。


以下是 Prompt Toolkit 的一些关键特性和用途:


- 行输入增强:为简单的输入提示添加补全、高亮和多行编辑等功能。
- 全屏应用:可以构建更复杂的全屏终端应用,如自定义的文本编辑器或管理界面。
- 对话框与小部件:提供了现成的 UI 组件,如确认对话框、进度条等。
- 跨平台支持:对 Windows 10 及更高版本的控制台提供了良好的支持。

代码示例:创建一个支持语法高亮的简单输入提示。
from prompt_toolkit import prompt
from prompt_toolkit.lexers import PygmentsLexer
from pygments.lexers import PythonLexer



text = prompt(‘>>> ‘, lexer=PygmentsLexer(PythonLexer()))
print(‘You entered:’, text)

Prompt Toolkit 2.0 版本是一次重大更新,专注于提升构建全屏应用的能力和 API 的易用性。许多知名项目如 IPython、pgcli 等都基于它构建。


3️⃣ Chaps:简化 Pants 构建系统的使用



上一节我们介绍了构建命令行界面的工具,本节中我们来看一个用于简化特定构建系统使用的工具。


Chaps 是一个封装了 Pants 构建系统的命令行工具。Pants 是一个用于 Python(和 Java、Scala 等)的构建和打包系统,但在大型单体代码库中,指定构建目标路径可能很繁琐。

核心概念:Chaps 通过上下文感知,允许开发者在项目子目录中直接使用构建目标名称,而无需输入冗长的完整路径,从而提升开发效率。
Chaps 解决了以下问题:

- 路径冗余:在大型代码库中,不同目录下的相似目标(如
:binary)需要不同的完整路径前缀。 - 简化命令:它允许你在当前工作目录下,直接运行如
chaps test这样的命令,它会自动推断并递归测试当前目录下的所有相关目标。

基本用法:
chaps list:列出当前目录下可用的 Pants 目标。chaps binary :target_name:构建指定的二进制目标。chaps test:递归测试当前目录下的所有目标。
Chaps 本质上是一个 PEX 格式的 Python 二进制文件,它利用 Git 信息计算相对路径,将简短命令转换为完整的 Pants 命令。它最适合与 Shell 辅助函数结合使用,形成肌肉记忆。


4️⃣ Variants 库:改进 API 设计的约定
上一节我们看到了简化构建流程的工具,本节中我们探讨一个旨在改善库 API 设计的模式。
Variants 是一个 Python 库,它促进了一种清晰的 API 设计模式:为一个核心函数提供多个命名的“变体”形式,以处理不同的输入类型或行为模式,而不是使用令人困惑的参数或改变返回类型。
核心概念:使用 @variants.primary 装饰器标记主要函数,然后用 @主函数名.variant 装饰器注册替代实现。这使 API 更清晰、对 Tab 补全更友好,并且能将相关功能分组。
传统问题:API 设计中常出现以下模式:
- 函数有一个参数
filepath_or_buffer,根据传入的是字符串还是流对象来执行不同操作。 - 函数大部分时间返回单个值,但有时需要额外信息,于是通过一个标志参数改为返回元组,破坏了类型一致性。
Variants 解决方案:
import variants

@variants.primary
def process_text(text: str):
"""主要实现:处理字符串文本。"""
return text.upper()
@process_text.variant
def from_file(path: str):
"""变体:从文件路径读取文本并处理。"""
with open(path) as f:
return process_text(f.read())

@process_text.variant
def from_stream(stream):
"""变体:从流对象读取文本并处理。"""
return process_text(stream.read())
# 使用方式
process_text(“hello”) # 使用主变体
process_text.from_file(“data.txt”) # 使用文件变体
优势与用例:
- 显式调度:调用者明确选择所需的行为,而非依赖隐式的类型检查。
- 返回类型变体:例如,可以有一个惰性生成的变体和一个急切求值并返回列表的变体。
- 行为变体:如异步与同步版本,带缓存与不带缓存的版本。
Variants 库鼓励显式、清晰的 API 设计,使代码更易于理解和维护。
5️⃣ 开源协作:以善意为基础
上一节我们讨论了改进代码 API 的工具,本节我们进入一个更宏观但至关重要的主题:开源社区的可持续协作。
开源项目的健康运行依赖于社区成员之间的有效协作。核心维护者布雷特·坎农提出,将开源互动视为一系列“未经请求的善意”,是维持项目活力、避免维护者倦怠的关键。
核心理念:
- 认识到开源是有成本的:维护者和贡献者投入的是时间、精力和情感。每一次 Issue、PR 或邮件都在请求对方从有限的时间中抽出一部分。
- 互动即善意:贡献者提交 PR 是向项目表达善意;维护者审查、反馈也是善意。这不应被视为一场交易。
- 三方友好握手:基于 Python 软件基金会行为准则的“开放、体贴、尊重”。
- 开放:对来自他人的善意(如 PR、反馈)保持开放。
- 体贴:理解他人的时间、意图和项目的约束。
- 尊重:在给予和接收反馈时保持尊重,即使需要说“不”。
如何实践:
- 对贡献者:提交 PR 时,理解维护者可能因项目全局考量(如兼容性、维护负担)而拒绝。这不是对你个人的否定。
- 对维护者:拒绝 PR 或请求修改时,应感谢对方的努力,并清晰、友好地解释原因。
- 对所有人:在线上沟通时,措辞要谨慎。设想你的老板、家人或朋友会读到这条信息。给予他人怀疑的空间,如果感到言辞冒犯,可以礼貌地指出。

目标:通过共同践行以善意为基础的互动,减少冲突和倦怠,让开源参与对所有人来说都成为一段可持续、愉快且有收获的经历,从而保障像 Python 这样伟大的项目能够长久繁荣。
总结

本节课中我们一起学习了 PyCon 2018 闪电演讲和主旨演讲中的精华内容:
- 覆盖率调试法:将覆盖率工具从测试指标转变为强大的调试助手,可视化代码执行路径。
- Prompt Toolkit:使用该库可以轻松构建具备自动补全、语法高亮等高级功能的命令行应用程序。
- Chaps 工具:通过封装 Pants 构建系统,简化在大型单体代码库中的构建命令。
- Variants 库:采用一种清晰的模式来设计 API,为核心功能提供显式的命名变体,提升代码的清晰度和可用性。
- 开源协作理念:成功的开源社区依赖于成员间将每一次互动都视为“善意”,并以“开放、体贴、尊重”的原则进行沟通,这是项目可持续发展的基石。

希望这些工具和理念能帮助你在 Python 开发和开源参与中更加高效、愉快。
010:深入探讨无缝日志记录


概述

在本节课中,我们将要学习Python标准库中的logging模块。我们将探讨为什么日志记录很重要,它是如何工作的,以及如何有效地配置和使用它。通过理解其核心组件和设计理念,你将能够为自己的应用程序或库实现强大且灵活的日志记录功能。

为什么需要日志记录?📝

日志记录是开发人员为运行中的应用程序编写的“文档”。当代码在生产环境中运行时,它就像一个黑箱。日志记录和监控使你能够观察应用程序的行为,而无需直接调试运行中的代码。这有助于在出现问题时进行故障排除,而无需中断服务或联系正在度假的开发者。

与简单的print语句相比,logging模块提供了更强大的功能:
- 线程安全:在多线程环境中,
print语句的输出可能会交错混乱,而日志记录是线程安全的。 - 分类与分级:可以对日志消息进行分类(如
DEBUG,INFO,WARNING,ERROR,CRITICAL)和过滤。 - 关注点分离:日志记录的设计将记录什么(由库或应用代码决定)与如何记录(由最终用户或部署环境配置)分离开。这使得库开发者可以自由地添加日志点,而无需关心最终的输出目的地(控制台、文件、网络等)。
日志模块如何工作?🔧
上一节我们介绍了日志记录的重要性,本节中我们来看看logging模块的核心架构。它主要包含四个关键组件,协同工作。
以下是日志记录的核心流程组件:
- 记录器 (Logger):这是开发者交互的主要接口。你通过
logging.getLogger(name)获取一个记录器,然后调用其方法(如.info(),.warning())来产生日志。 - 日志记录 (LogRecord):当调用记录器方法时,会自动创建一个
LogRecord对象。这个对象不仅包含你的消息,还包含了丰富的上下文信息,如时间戳、模块名、函数名、行号等。 - 处理器 (Handler):处理器负责将
LogRecord输出到指定的目的地。标准库提供了多种处理器,例如:StreamHandler:输出到流(如控制台)。FileHandler:输出到文件。SMTPHandler:通过邮件发送。HTTPHandler:通过HTTP发送。
- 格式化器 (Formatter):格式化器负责将
LogRecord对象转换成最终的文本字符串。你可以自定义输出的格式。
基本工作流程:
# 伪代码示意流程
logger.info(“User %s logged in”, username)
# 1. Logger 创建 LogRecord 对象
# 2. Logger 将 LogRecord 传递给所有关联的 Handler
# 3. Handler 使用 Formatter 将 LogRecord 格式化为字符串
# 4. Handler 将字符串写入其目标(如控制台、文件)
此外,还有一个可选组件:
- 过滤器 (Filter):可以提供比日志级别更精细的控制,用于决定是否让某条
LogRecord通过。
记录器的层次结构与传播 🌳
理解了单个记录器的工作流程后,我们需要认识一个关键概念:记录器层次结构。这解释了日志配置如何被继承和共享。
记录器通过名称形成一个层次结构,类似于Python的包路径。例如,名为”a.b”的记录器是名为”a”的记录器的子记录器,而”a”又是根记录器(名为””)的子记录器。
这个层次结构带来了两个重要特性:
- 级别继承:如果子记录器没有显式设置级别,它将继承父记录器的级别。
- 传播 (Propagation):这是关键且易混淆的一点。默认情况下,记录器的
propagate属性为True。这意味着当一条日志在一个记录器上被处理时,它还会传递给所有祖先记录器的处理器。但请注意,它调用的是父记录器的处理器,而不是父记录器的日志方法本身。
公式:
- 设记录器
L的父记录器为P。 - 当
L.propagate == True时,L产生的LogRecord会经过L自身的处理器后,继续传递给P的处理器(以及更上层的处理器)进行处理。

这种设计允许你在根记录器上配置一个通用的处理器(如写入文件),而在模块特定的子记录器上配置额外的处理器(如发送错误警报),所有日志最终都会汇集到根处理器的目标中。



如何使用记录器?💻
现在我们已经了解了理论,本节中我们来看看在代码中实际如何使用记录器。


首先,获取一个记录器。最佳实践是使用 __name__ 作为记录器名称,这样会自动创建与模块结构对应的层次结构。
import logging


# 获取当前模块的记录器
logger = logging.getLogger(__name__)

然后,使用不同级别的方法记录消息。
logger.debug(“This is a debug message”) # 最低级别,用于开发细节
logger.info(“User %s logged in successfully”, username) # 普通信息
logger.warning(“Disk space is low.”) # 警告信息
logger.error(“Failed to connect to database.”) # 错误信息
logger.critical(“System is out of memory!”) # 最高级别,严重错误
重要技巧:
- 避免先格式化字符串:不要使用
logger.info(“User ” + username + ” logged in”)或logger.info(f”User {username} logged in”)。应该传递模板和参数,如logger.info(“User %s logged in”, username)。这样只有在消息真正需要输出时才会进行格式化,且能利用格式化器的特性。 - 记录异常信息:使用
logger.exception()或在日志方法中设置exc_info=True参数,可以自动捕获并记录异常的堆栈跟踪,这对于调试至关重要。try: # 可能出错的代码 risky_operation() except Exception: logger.exception(“An error occurred during the operation.”) # 等同于 logger.error(“An error occurred…”, exc_info=True)
如何配置日志记录?⚙️
logging 模块的强大之处在于其灵活的配置方式。你可以将配置代码与业务逻辑完全分离。主要有三种配置方法。
以下是三种主要的配置方式:

- 使用
basicConfig进行简单配置:适用于脚本或简单应用。它提供了一种快速设置根记录器级别、格式和输出目标的方法。import logging logging.basicConfig( level=logging.INFO, format=‘%(asctime)s - %(name)s - %(levelname)s - %(message)s’, filename=‘app.log’ ) # 注意:basicConfig 通常在程序开始处调用一次,多次调用只有第一次生效。



- 使用字典或文件进行高级配置:这是最强大和推荐的方式,尤其对于复杂应用。你可以通过字典详细定义记录器、处理器、过滤器和格式化器。
你也可以将配置写在JSON或YAML文件中,然后用import logging.config config_dict = { ‘version’: 1, ‘formatters’: { ‘detailed’: { ‘format’: ‘%(asctime)s %(module)s %(levelname)s %(message)s’ }, }, ‘handlers’: { ‘console’: { ‘class’: ‘logging.StreamHandler’, ‘level’: ‘INFO’, ‘formatter’: ‘detailed’, ‘stream’: ‘ext://sys.stdout’, }, ‘file’: { ‘class’: ‘logging.FileHandler’, ‘filename’: ‘errors.log’, ‘level’: ‘ERROR’, ‘formatter’: ‘detailed’, }, }, ‘loggers’: { ‘my_module’: { ‘level’: ‘DEBUG’, # 注意:此记录器没有直接分配处理器,日志会传播到根记录器 }, }, ‘root’: { ‘level’: ‘INFO’, ‘handlers’: [‘console’, ‘file’] } } logging.config.dictConfig(config_dict)dictConfig加载。



- 通过代码直接配置:直接实例化并组装
Logger,Handler,Formatter对象。这种方式最灵活,但也最繁琐。




实用配方与进阶主题 🍳


掌握了基本配置后,让我们看一些解决特定问题的实用“配方”。


以下是几个有用的日志记录模式:



- 结构化日志记录(如JSON):便于日志收集系统(如ELK Stack, Splunk)进行解析和索引。你可以创建一个自定义的格式化器来输出JSON字符串。
- 上下文信息(如请求ID):在Web应用中,为同一个请求的所有日志添加一个唯一的关联ID,便于追踪。这可以通过自定义过滤器或从Python 3.2开始引入的
LoggerAdapter,或更好的,从Python 3.6引入的logging.setLogRecordFactory来实现。 - 缓冲处理器:仅在发生错误时,才输出错误发生前的一些低级别(如
INFO)日志,这对于复现问题场景非常有用。 - 队列处理器用于多进程:在
multiprocessing环境中,多个进程不能安全地写入同一个文件。QueueHandler和QueueListener可以将日志消息放入一个队列,由一个单独的进程负责处理写入,从而避免冲突。 - 分离标准输出和标准错误:像许多Unix工具一样,将
INFO及以上级别日志输出到stderr,将DEBUG日志输出到stdout。这可以通过自定义过滤器实现。


总结
本节课中我们一起深入探讨了Python的logging模块。我们从日志记录的重要性开始,逐步剖析了其核心架构:记录器(Logger)、处理器(Handler)、格式化器(Formatter) 和可选的过滤器(Filter)。我们学习了记录器的层次结构和传播机制,这是理解复杂配置的关键。
我们实践了如何获取记录器并记录不同级别的消息,强调了传递格式字符串参数而非预先格式化的最佳实践。最后,我们介绍了从简单的basicConfig到强大的基于字典的配置等多种配置方法,并了解了一些高级用例和实用配方。

记住,良好的日志记录是生产级应用程序的基石。花时间设计你的日志策略,将为未来的调试和监控带来巨大便利。当你遇到疑惑时,logging模块本身的源代码由于其优秀的设计,也是很好的学习资料。
011:Pieter Hooimeijer - PyCon 2018



📖 概述
在本教程中,我们将学习如何利用类型系统来构建强大的静态分析工具,特别是用于发现代码中的安全漏洞。我们将从理解一个实际的安全问题示例开始,逐步探讨从简单的代码扫描工具到复杂的深度静态分析技术,并了解如何将类型检查器扩展为能够进行过程间数据流分析的工具。最后,我们将看到这种技术在大型代码库(如Facebook和Instagram)中的实际应用效果。

🔍 第一部分:从一个安全问题开始
上一节我们介绍了本教程的主题。本节中,我们来看一个具体的代码安全问题示例,它展示了我们希望通过静态分析来检测的问题类型。


以下是一个简化的、类似Django的Python代码片段,它模拟了一个实际产品中曾出现过的安全漏洞。
def get_group_thumbnail(request):
group_id = request.GET.get(‘id‘) # 步骤1:从用户输入获取组ID
data = load_group_data(group_id, request.user) # 步骤2:加载并检查用户权限
if data is None:
raise Http404(f“Group {group_id} not found”) # 问题所在:基于用户控制输入抛出404
return render_thumbnail(data)
这段代码的功能是获取用户组的缩略图。问题在于,当load_group_data检查发现当前用户无权访问该组时,它会返回None,代码随后抛出一个包含用户提供的group_id的404错误。
核心问题:攻击者可以构造一个恶意网页,通过图片标签的onload和onerror事件处理器来探测这个端点。通过观察请求成功(加载图片)或失败(触发404错误),攻击者就能推断出当前访问恶意网站的用户是否属于某个特定的私密群组,从而造成信息泄露。
这个例子引出了我们的目标:能否静态地(即在代码运行前)自动检测出这类问题?



🛠️ 第二部分:静态分析工具光谱
上一节我们看到了一个具体的安全漏洞。本节中,我们来了解一下用于发现这类问题的各种静态分析工具,并将它们放在一个从简单到复杂的“光谱”上进行对比。
我们将工具按以下维度排列:
- 左侧:快速、易用、简单。
- 右侧:缓慢、难用、复杂。
以下是几种典型的静态分析工具:

-
正式验证 (Formal Verification)
- 位置:光谱最右端。
- 描述:需要为代码和待证明的属性编写完整的形式化规范,并构造冗长的证明脚本。它能提供最高级别的保证。
- 挑战:极其复杂,需要专业知识,通常不实用。例如,验证5行代码可能需要1300行证明脚本。
- 公式/代码表示:
证明脚本行数 >> 代码行数
-
Grep
- 位置:光谱最左端。
- 描述:在代码文本中搜索特定模式(如关键词“dangerous”)。它是语言无关的。
- 挑战:精度很低,会产生大量误报和漏报。例如,它无法理解代码的语义,只能进行文本匹配。
-
Linter (如 Pylint, Flake8)
- 位置:光谱中间偏左。
- 描述:基于抽象语法树进行代码检查,可以编写相对简单的规则来发现代码风格或简单模式问题。
- 挑战:对于需要跟踪数据在函数间如何流动的复杂问题(如信息泄露),能力仍然不足。



- 类型检查器 (如 Mypy, Pyre)
- 位置:光谱中间。
- 描述:要求开发者为代码添加类型注解,然后检查类型的一致性。能发现一大类错误。
- 挑战:本身并不直接检测安全漏洞或复杂的数据流。
本教程的核心前提:我们可以在现有类型系统的基础上,构建专注于安全的数据流分析工具,而无需像形式化验证那样复杂的规范。我们利用代码库已有的类型注解作为基础。
🏗️ 第三部分:类型检查器架构基础
上一节我们比较了各类静态分析工具。本节中,我们深入了解一下现代类型检查器(如Hack和Pyre)的高层架构,这是构建深度分析的基础。
类型检查器的核心任务是验证代码中的类型是否一致。考虑以下代码:
def bar(x: int) -> int:
return x + 1



def foo(x: str) -> str:
return bar(x) # 类型错误:`str` 不能传递给期望 `int` 的 `bar`
为了检查foo函数体中的bar(x)调用,类型检查器需要:
- 查找
x在foo签名中的类型(str)。 - 查找
bar第一个参数的类型(int)。 - 检查两者是否匹配(不匹配,发现错误)。
- 检查
bar(x)的返回类型是否与foo的返回类型匹配。
关键观察:为了检查bar的函数体,我们只需要知道它的签名(参数和返回类型),而不需要知道调用它的函数foo的任何信息。这意味着对foo和bar的类型检查可以独立并行进行。
因此,像Hack和Pyre这样的类型检查器采用高度并行的架构:
- 启动多个工作进程。
- 将待分析的文件分配给这些进程。
- 每个进程独立解析函数,并将函数签名缓存起来供其他进程查询。
- 进程间协调成本较高,因此偶尔的重复工作(如两个进程都解析了同一个函数)是可接受的代价,以换取整体速度。
这种并行、基于缓存的架构为构建更复杂的分析提供了良好的性能基础。

🎯 第四部分:构建深度静态分析器
上一节我们介绍了类型检查器的并行架构。本节中,我们来看看如何在此基础上构建一个能够发现安全漏洞的“深度”静态分析器。
我们回到最初的安全漏洞例子。我们想要静态检测的属性是:用户控制的输入是否会影响基于隐私决策的异常抛出分支。

在静态分析术语中:
- 源:用户控制的输入(如
request.GET.get(‘id‘))。 - 汇:危险的操作点(如抛出包含敏感信息的
Http404)。 - 污点分析:跟踪数据从“源”到“汇”的流动过程。
构建这样一个分析器需要三个主要步骤:
-
构建调用图
- 定义:调用图是对代码库中所有可能发生的函数调用的一个过度近似。
- 为什么需要:为了跟踪数据在函数间的流动,我们必须知道一个函数可能调用哪些其他函数。
- 示例:对于面向对象代码和动态分发,我们需要考虑类继承。如果函数
foo接收一个B类实例并调用其get_data方法,那么调用图需要包含从foo到B.get_data的边,同时也可能包含到B的子类C.get_data的边,因为运行时foo可能接收到一个C的实例。
-
计算函数摘要
- 定义:摘要是对函数行为的精炼描述,比类型签名包含更多细节。对于污点分析,摘要描述了两件事:
- 输入摘要:函数的哪个参数会流入其内部调用的危险函数(汇)。
- 输出摘要:函数返回的值是否来自某个受污染的源。
- 示例:
def foo(puc: str) -> str: # 签名:str -> str return sync(puc) # 摘要:参数1 -> 流入 `sync`;返回值 <- 来自 `sync`
- 定义:摘要是对函数行为的精炼描述,比类型签名包含更多细节。对于污点分析,摘要描述了两件事:

- 自底向上的摘要拼接
- 过程:利用调用图,从被调用的函数开始分析,生成摘要,然后将这些摘要“拼接”到调用它们的函数中,从而发现跨越多个函数调用的长距离数据流。
- 示例:如果
buzz的摘要显示其参数流入sink,bar的摘要显示其参数流入buzz,那么foo的摘要就能显示其参数最终会流入sink。

分析器流水线:整个分析过程是阶段化的(解析 -> 构建调用图 -> 污点分析),并且每个阶段内部都可以高度并行化。调用图的构建结果会指导污点分析调度器以高效的顺序分析函数(例如,先分析被深度调用的函数)。

核心优势:这种方法不需要任何新的代码注解,它完全建立在现有的类型系统之上。安全工程师只需要定义他们关心的“源”和“汇”即可。


📊 第五部分:实际应用与结果
上一节我们从理论上探讨了如何构建深度分析器。本节中,我们来看看这种技术在实际大型代码库中的应用效果,以证明其可行性。

我们在Facebook内部基于Hack类型检查器构建了这样的安全分析工具,并正在为Pyre(Python)规划类似功能。

Instagram (Python) 代码库:
- 规模:约一百万行代码。
- 类型检查:使用
Pyre,全量检查约需1分钟,增量更新仅需200毫秒。 - 深度分析:计划中。
Facebook (PHP/Hack) 代码库:
- 规模:数千万行代码。
- 深度安全分析工具:已部署并每日使用。
- 运行方式:在每次代码变更(Diff)时自动运行,包括提交前。
- 性能:端到端分析约需20分钟(无缓存情况下),检查大量安全属性。
- 效果:该工具定期发现并防止安全问题,已成为安全工程师的首选工具,替代了像
grep这样的简单方法。
这些结果表明,将类型检查与过程间静态分析相结合,可以为安全团队提供强大、可扩展且高效的问题检测能力。
✅ 总结
在本教程中,我们一起学习了如何利用类型系统来赋能深度静态分析。
- 我们从一个实际的安全漏洞示例出发,明确了静态检测这类问题的目标。
- 我们回顾了静态分析工具的光谱,从简单的
grep到复杂的正式验证,并确立了在类型检查器基础上构建的折中路线。 - 我们探讨了现代类型检查器的并行架构,这是实现高效深度分析的基础。
- 我们深入介绍了构建深度静态分析器的关键组件:调用图、函数摘要和自底向上的摘要拼接,从而能够发现跨函数的数据流问题。
- 最后,我们看到了这种技术在Facebook和Instagram等超大规模代码库中的成功应用,证明了其有效性和实用性。

核心思想是:渐进式的类型注解不仅能改善代码质量和开发体验,还能作为跳板,使安全团队能够进行深度、精准且高效的代码审计,自动捕捉复杂的安全漏洞。
012:现实世界中的类型检查


概述
在本节课中,我们将学习如何在现实世界中对 Python 代码进行类型检查。我们将探讨类型检查的好处、Python 类型系统的基础知识、如何为代码添加类型注解,以及如何利用渐进式类型检查来管理大型遗留代码库。



为什么要进行类型检查? 🧐
如果你来自静态类型语言背景,可能会好奇没有静态类型的 Python 如何工作。但作为 Python 开发者,我们更常问的是:我用了 Python 很多年,一直很好,为什么需要类型注解?
考虑一个方法,它接受一个 items 参数。在 Python 的鸭子类型下,items 可以是任何可迭代对象,其中的每个项目需要有一个 value 属性,而该 value 又需要一个 id 属性。这种灵活性很好,但合同是隐式的。
代码只写一次,却要维护很久。六个月后,你可能需要重新阅读每一行代码来理解这个隐式合同。或者,你如何知道代码库的每个角落都遵守了这个合同?如果某个地方传入了一个 value 可能为 None 的对象,就会导致运行时属性错误。类型注解能明确解决这些问题,让你确切知道函数期待什么。
开发者多年来一直将类型信息放在文档字符串或注释里。但文档字符串的问题是,总会有人更新了函数签名却忘了更新文档,导致信息过时。类型注解可以被自动检查正确性,因此必须与代码保持同步。
你可能会想:“我可以通过测试来捕捉这些问题。” 测试和类型检查是互补的。测试覆盖了输入空间中的特定点,而类型注解通过声明参数类型,立即排除了整个无效的输入区域(例如,声明参数为整数,就排除了所有字符串、列表等输入),让你可以更专注于核心逻辑的测试。


如何进行类型检查? 🛠️
上一节我们讨论了类型检查的价值,本节我们来看看具体如何操作。首先,了解 Python 类型注解的基本语法。


一个简单的平方函数,接受一个整数,返回其平方值:
def square(x: int) -> int:
return x * x
参数后加冒号和类型,函数定义后加箭头和返回类型。

多次调用这个函数:
square(3)
square("a string") # 类型错误
square(4) + " and a string" # 类型错误
使用 mypy 进行类型检查:
pip install mypy
mypy your_file.py
运行后会报告类型错误,例如将 square 应用于字符串。这不需要任何测试,静态分析器就能基于你声明的函数签名验证假设。
类型检查器可以推断很多信息。例如,在这个类中:
class Photo:
def __init__(self, width: int, height: int) -> None:
self.width = width
self.height = height
def dimensions(self) -> Tuple[str, str]:
return self.width, self.height # 错误:返回的是整数,不是字符串
检查器知道 self.width 和 self.height 是整数。
对于容器类型,如果创建一个 Photo 对象列表并尝试添加字符串,检查器会报错(它假设你希望列表是同质的)。如果你想明确列表类型,可以添加变量注解(Python 3.6+):
from typing import List
my_list: List[str] = []
回顾一下,通常你需要注解函数签名(参数和返回值)。只有在类型检查器无法推断时,才需要注解变量,以避免冗余。
处理复杂类型 🔄
上一节介绍了基础类型注解,本节我们来看看如何处理更复杂的场景,比如函数可以接受或返回多种类型。
最简单的方法是使用 Union 类型:
from typing import Union
def get_response() -> Union[Foo, Bar]:
...
一个非常常见的情况是函数可能返回一个值或 None,为此有特殊的 Optional 类型:
from typing import Optional
def get_foo(foo_id: Optional[int]) -> Optional[Foo]:
...
Optional[Foo] 等价于 Union[Foo, None]。
但使用 Optional 作为返回类型有个问题:调用者必须检查返回值是否为 None,否则类型检查器会报错。即使你知道在某些情况下返回值不会是 None,检查器也不知道。
更好的选择是使用 @overload 装饰器,向类型检查器提供更多信息:
from typing import overload
@overload
def get_foo(foo_id: None) -> None: ...
@overload
def get_foo(foo_id: int) -> Foo: ...
def get_foo(foo_id: Optional[int]) -> Optional[Foo]:
if foo_id is None:
return None
return lookup_foo(foo_id)
@overload 定义只为类型检查器提供信息,运行时只使用最后一个实际定义。这样,调用 get_foo(5) 时,检查器就知道返回的是 Foo,无需额外检查。
我们还可以使用泛型来编写更灵活、类型安全的函数。定义一个类型变量作为占位符:
from typing import TypeVar
AnyStr = TypeVar('AnyStr', str, bytes) # 限制为 str 或 bytes
def concat(a: AnyStr, b: AnyStr) -> AnyStr:
return a + b
类型变量确保在一次调用中,a 和 b 必须是同一种类型(都是 str 或都是 bytes)。调用 concat("hello", b"world") 会报错,而 concat("a", "b") 的返回类型会被推断为 str。
AnyStr 在 typing 模块中已内置,无需自己定义。
总结一下,可以适度使用 Union 和 Optional。@overload 和泛型允许我们向类型检查器教授更多关于函数不变性的知识,使函数对调用者更友好。
鸭子类型与协议 🦆
你可能在想:我的鸭子类型呢?我喜欢编写能接受任何具有特定方法或属性的对象的函数。
例如,一个调用对象 render 方法的函数。这类似于 Python 的内置协议(如 len 调用 __len__)。如何为其添加类型?
尝试使用 object 类型不行,因为 object 没有 render 属性。使用 Any 类型(一个“逃生口”,与所有类型兼容)虽然能让检查通过,但意味着失去了类型安全性,可能传入没有 render 方法的对象。
解决方案是使用 协议(Protocol),它提供了结构子类型(structural subtyping)。你需要从 typing_extensions 导入(未来会进入 typing):
from typing_extensions import Protocol
class Renderable(Protocol):
def render(self) -> str: ...
def render_it(obj: Renderable) -> str:
return obj.render()
class MyWidget:
def render(self) -> str:
return "rendered!"
render_it(MyWidget()) # 类型检查通过
只要一个类具有符合协议定义的结构(这里是有返回 str 的 render 方法),类型检查器就认为它是该协议的子类型,无需显式继承。这完美支持了鸭子类型。
类型系统的“逃生通道” 🚪
严格的静态类型检查适合大部分简单、直接的代码。但在某些情况下,你需要利用 Python 的动态特性,或者处理大量遗留代码。Python 类型系统提供了一些“逃生通道”。

Any类型:我们已经见过。它是所有类型的子类型和超类型,关闭了类型检查。例如,一个包装器代理可能不知道被代理对象的具体属性和类型,可以声明返回Any。cast函数:让你对类型检查器“撒谎”,断言某个表达式的类型。
你需要确保断言是正确的。from typing import cast, Dict config = get_config_var() # 类型为 Any specific_config = cast(Dict[str, int], config) # 告诉检查器这是 Dict[str, int]# type: ignore:忽略某一行的所有类型错误。应保留给无法解决的类型检查器限制或错误。使用时最好加上解释性注释。- 存根文件(.pyi):用于为 C 扩展或无法直接分析的模块提供类型信息。例如,为
fastmath编译模块创建fastmath.pyi:
这样类型检查器就能理解这些函数和类的接口。# fastmath.pyi def square(x: int) -> int: ... class Complex: real: float imag: float

渐进式类型与工具 🚀

我们已经开始触及渐进式类型。它意味着即使程序不是完全类型化的,也可以进行类型检查。Any 类型就是一个例子。
更重要的是,渐进式类型允许我们逐步向代码库添加类型。规则是:只有带有类型注解的函数会被检查。没有注解的函数被认为可以接受和返回任何类型,其函数体甚至不会被检查。
这允许我们逐个函数、逐个模块地引入类型。从最核心、最常用的函数开始,收益最大。使用持续集成(CI)来保护进展,防止新的类型错误被引入。

mypy 提供了严格性选项,可以对已完全类型化的模块设置更严格的规则(例如,禁止未类型化的函数或 Any 类型)。
但是,为大型遗留代码库手动添加类型注解可能非常耗时和痛苦。你需要追溯所有调用路径来理解类型。
为此,Instagram 开发并开源了 MonkeyType 工具。它通过在运行时追踪实际传入的类型来自动生成类型存根。
以下是使用 MonkeyType 的步骤:
pip install monkeytype- 使用
monkeytype run your_script.py运行你的代码(可以是测试或生产流量采样)来收集类型信息。 - 使用
monkeytype stub your.module查看生成的类型存根。 - 使用
monkeytype apply your.module将类型注解自动应用到你的源代码中。
这极大地加速了为遗留代码添加类型注解的过程。

未来与总结 🌟

Python 类型检查正在不断发展。例如,Python 3.7 的 from __future__ import annotations 可以避免字符串形式的前向引用。未来可能允许直接使用小写 dict、list 等作为类型注解。PEP 已标准化了如何将类型存根与第三方包捆绑分发。
类型检查的 Python 已经可用且有效。在 Instagram,类型检查阻止了带有类型错误的代码进入生产环境,开发者们积极使用它,类型覆盖率有机增长。MonkeyType 帮助我们将数百万行代码的一半进行了类型注解。

除了 mypy,还有更快的类型检查器如 Facebook 的 pyre,适合超大型代码库。
本节课总结

在本节课中,我们一起学习了:
- 为什么要对 Python 进行类型检查:提高代码可读性、可维护性,并与测试互补,提前捕获错误。
- 如何进行类型检查:使用
mypy等工具,学习基础注解语法、Union/Optional、泛型和@overload。 - 如何用 协议(Protocol) 为鸭子类型代码添加类型安全。
- 了解类型系统的 “逃生通道”(
Any,cast,# type: ignore, 存根文件)以处理动态代码或遗留代码。 - 利用 渐进式类型 规则逐步改造代码库,并使用 MonkeyType 等工具自动化类型注解过程。


类型检查的 Python 是一个强大的工具,能显著提升大型项目的开发体验和代码质量。
013:PyCon 2018 演讲内容整理


在本教程中,我们将学习如何利用 Rust 这门系统编程语言来为 Python 模块提供性能提升。我们将探讨为何需要本地扩展、现有解决方案的局限性,并详细介绍 Rust 的核心概念及其与 Python 的集成方法。
概述:为何需要本地扩展?




作为 Python 开发者,我们有时会遇到性能瓶颈。常见的性能优化法则包括:使用性能分析工具定位慢速代码、借鉴社区已有解决方案,或者在必要时使用非 Python 的替代方案。本次教程将重点探讨第三种方案——使用 Rust 来提升 Python 模块的性能。
我们需要本地扩展的主要原因有三个:
- 利用现有的优秀静态类型库。
- 获得 Python 无法提供的更灵活的内存管理控制。
- 更好地与硬件交互或处理高性能计算任务。

Python 社区现有的解决方案

Python 社区已经提供了许多成熟的解决方案。以下是使用其他语言编写的著名 Python 库示例:
- NumPy:包含 53% 的 C 代码。
- TensorFlow:包含 48% 的 C++ 代码。
- CPython 解释器:本身包含大量系统编程代码。
- Pillow:图像处理库,包含大量 C 代码。
然而,直接使用 C/C++ 编写扩展存在挑战:开发难度高、需要专业知识、时间成本大,并且需要手动处理内存安全和安全漏洞问题。
除了 C/C++,还有其他选择:
- Cython:Python 的超集,简化了 C 函数和数据类型调用。
- Numba:使用 JIT 编译器优化机器代码。
- PyPy:使用 JIT 编译器的 Python 实现。

但这些工具有时会让开发者感觉失去了对底层代码的控制。

引入 Rust:一种现代系统编程语言
上一节我们探讨了现有方案的局限性,本节中我们来看看一个强大的替代方案:Rust。Rust 被设计为兼具 C++ 的控制能力、Python 的生产力以及强大的安全性。
Rust 的核心目标是:
- 无畏并发:能够编写没有数据竞争等错误的并发代码。
- 零成本抽象:高级抽象在运行时不会引入额外开销。
- 稳定而不停滞:新版本发布不会破坏现有代码。


Rust 的主要特性包括:
- 内存安全无需垃圾回收:通过所有权和借用系统在编译期保障内存安全。
- 强大的类型系统:使代码易于理解和推理。
- 高级迭代器:提供了类似 Python 的流畅数据处理体验。
- 友好热情的社区:拥有强大且不断增长的库生态系统。

Rust 语法快速入门


为了理解如何用 Rust 编写扩展,我们需要先了解一些基础语法。Rust 的语法设计力求清晰明了。
以下是 Rust 的一些基本语法元素:



变量与常量
- 使用
let关键字创建变量绑定。变量默认不可变。 - 使用
let mut创建可变变量绑定。 - 使用
const声明常量,必须显式标注类型。
函数
- 函数需要显式声明参数和返回值的类型。
- 最后一个表达式的值默认为函数的返回值。


流程控制
if/else用于条件分支。match用于模式匹配,功能类似switch。loop用于无限循环。while用于条件循环。for用于遍历可迭代对象。


包管理
Cargo是 Rust 的构建系统和包管理器,功能类似 Python 的pip,但更为强大。

Rust 的核心概念:所有权与借用


理解所有权是掌握 Rust 的关键。这与 Python 的垃圾回收机制有根本不同。

在 Python 中,垃圾回收器自动管理内存。例如,创建对象后,解释器会跟踪引用并在适当时机释放内存。



在 Rust 中,每个值都有一个被称为其 所有者 的变量。值的生命周期与其所有者的作用域绑定。当所有者离开作用域,这个值将被丢弃,内存立即被释放。这就是 Rust 无需垃圾回收器的原因。


当需要传递值时,会涉及所有权的转移。将一个变量赋值给另一个变量,或将变量传入函数,都会转移所有权,原变量将失效。


为了在不转移所有权的情况下使用值,Rust 引入了 借用 的概念。通过引用(&)来借用值。
- 不可变引用 (
&T):允许多个只读借用,但不能修改数据。 - 可变引用 (
&mut T):只允许一个可变借用,并且不能同时存在不可变引用。


这个系统在编译期强制执行规则,从而杜绝了数据竞争和悬垂指针等问题。


使用 Rust 编写 Python 扩展


现在,我们进入实践环节,看看如何将 Rust 代码集成到 Python 中。关键是通过 外部函数接口(FFI)来实现。
我们可以把 Python 比作托尼·斯塔克,Rust 就是他的钢铁侠战甲,而 Rust 编译器则是辅助他的智能助手贾维斯。三者协作,能发挥巨大威力。

以下是创建 Rust Python 扩展的基本步骤:


- 创建 Rust 库项目:使用
cargo new --lib创建一个库类型的项目。 - 定义 FFI 函数:使用
#[no_mangle]属性确保函数名在编译后不被更改,并使用extern "C"指定使用 C 语言的调用约定。 - 使用
ctypes或cffi:在 Python 端,可以使用标准库的ctypes或第三方库cffi来加载编译好的动态库(如.so或.dll文件)并调用其中的函数。 - 简化工具:为了更方便地处理类型转换和异常,推荐使用
milksnake或rust-cpython等专用工具。rust-cpython库提供了在 Rust 中操作 Python 对象的友好接口。


让我们看一个简单示例:在 Rust 中实现一个字符串处理函数,并在 Python 中调用它。


Rust 端代码 (lib.rs):
use cpython::{PyResult, Python};

#[no_mangle]
pub extern "C" fn count_doubles(_py: Python, val: &str) -> PyResult<u64> {
let mut total = 0u64;
for (c1, c2) in val.chars().zip(val.chars().skip(1)) {
if c1 == c2 {
total += 1;
}
}
Ok(total)
}


Python 端调用:
import cffi

ffi = cffi.FFI()
ffi.cdef("unsigned long long count_doubles(const char*);")
C = ffi.dlopen("./target/release/libmylib.so") # 加载编译好的库
result = C.count_doubles(b"some string")
print(result)


通过基准测试,这样的 Rust 实现可能比纯 Python 实现快 10 到 25 倍,具体取决于任务类型。
总结与适用场景

本节课中,我们一起学习了如何使用 Rust 来提升 Python 模块的性能。我们从探讨本地扩展的需求开始,回顾了现有方案的优缺点,然后深入介绍了 Rust 语言的核心优势、基本语法及其关键的所有权系统。最后,我们讲解了通过 FFI 将 Rust 代码集成到 Python 中的实践方法。
何时考虑使用 Rust?
- 当你有一段计算密集型的复杂代码,可以从静态类型中受益。
- 当你需要直接访问硬件或操作系统底层 API。
- 当你需要实现高级并发模式,并希望编译期保障安全。
Rust 带来的价值
- C++ 的现代替代品:提供了同等级别的控制力,但学习曲线更平缓,安全性更高。
- 优秀的学习工具:通过学习 Rust,可以深入理解内存管理、并发等计算机科学核心概念。
- 强大的工具链和社区:拥有
Cargo、rustfmt、clippy等优秀工具,以及活跃、友好的社区。
记住,跨语言调用存在一定开销,因此最好将性能关键且计算量大的部分用 Rust 实现,而不是频繁地进行细粒度调用。

希望本教程能为你打开一扇门,让你在享受 Python 开发效率的同时,也能拥有系统级语言的性能威力。
014:日常 Python 问题的优雅解决方案 🐍


在本节课中,我们将学习如何利用 Python 的一些高级特性,如魔法方法、迭代器、装饰器和上下文管理器,来编写更简洁、更优雅、更易维护的代码。我们将通过具体的代码示例,了解这些工具如何解决日常开发中的常见问题。

概述:什么是优雅的代码? 🤔
优雅的代码取决于我们想要解决的问题。Python 语言提供了一些创新的特性,它们感觉是语言的自然组成部分,而非强行加入。我们将遵循《Python之禅》的原则,如“美胜于丑”、“简单胜于复杂”,来探索如何选择合适的工具。
1:魔法方法(Magic Methods)✨

上一节我们介绍了优雅代码的理念,本节中我们来看看如何通过魔法方法让自定义对象表现得像内置类型。

魔法方法通常以双下划线开始和结束,也称为“dunder”方法。通过实现它们,可以让你的对象支持加法、索引访问等操作。
核心概念公式/代码:
class MyClass:
def __add__(self, other): # 实现加法操作
# 实现逻辑
pass
def __getitem__(self, key): # 实现索引/键访问
# 实现逻辑
pass
以下是一个货币类的示例,它通过实现 __add__ 方法支持不同货币的加法运算:

class Currency:
def __init__(self, symbol, amount):
self.symbol = symbol
self.amount = amount
self.conversion = {'USD': 1, 'EUR': 0.88}
def __str__(self):
return f"{self.symbol}{self.amount}"
def __add__(self, other):
# 转换为相同货币后相加
total = self.amount + other.to(self.symbol).amount
return Currency(self.symbol, total)
def to(self, target_symbol):
rate = self.conversion[target_symbol] / self.conversion[self.symbol]
return Currency(target_symbol, self.amount * rate)
soda_cost = Currency('$', 1.50)
pizza_cost = Currency('€', 10.00)
print(soda_cost + pizza_cost) # 自动转换并相加
2:自定义迭代器与生成器 🔄
上一节我们介绍了如何通过魔法方法定制对象行为,本节中我们来看看如何让对象支持循环遍历,即实现迭代器。
为了使一个类可迭代,它需要实现 __iter__ 方法,该方法返回一个迭代器。迭代器本身需要实现 __next__ 方法,并在没有更多元素时引发 StopIteration 异常。
核心概念公式/代码:
class MyIterator:
def __iter__(self):
return self
def __next__(self):
if no_more_items:
raise StopIteration
# 返回下一个元素
以下是遍历服务器活跃服务的迭代器示例:
class Server:
def __init__(self):
self.services = [
{'protocol': 'ftp', 'port': 21, 'is_active': False},
{'protocol': 'ssh', 'port': 22, 'is_active': True},
{'protocol': 'http', 'port': 80, 'is_active': True},
]
self._pos = 0
def __iter__(self):
self._pos = 0 # 重置位置
return self
def __next__(self):
while self._pos < len(self.services):
service = self.services[self._pos]
self._pos += 1
if service['is_active']:
return service['protocol'], service['port']
raise StopIteration
# 使用 for 循环遍历
server = Server()
for protocol, port in server:
print(f"{protocol} on port {port}")

然而,如果迭代逻辑简单且无需维护复杂状态,使用生成器是更优雅的选择。生成器使用 yield 关键字,它会自动暂停和恢复执行。

class ServerWithGenerator:
def __init__(self):
self.services = [...] # 同上
def active_services(self):
for service in self.services:
if service['is_active']:
yield service['protocol'], service['port']
server = ServerWithGenerator()
for protocol, port in server.active_services():
print(f"{protocol} on port {port}")
生成器表达式是创建简单生成器的快捷方式:
my_gen = (x for x in range(10) if x % 2 == 0)
print(next(my_gen)) # 输出 0
3:方法魔法与动态调用 🪄

上一节我们学习了如何创建可迭代对象,本节中我们来看看 Python 中方法的动态特性,例如创建别名和动态获取。

方法在 Python 中也是对象,可以赋值给变量或动态获取。
核心概念公式/代码:
# 方法别名
new_name = obj.method_name

# 动态获取方法
method = getattr(obj, 'method_name', default_value)
以下是使用 getattr 构建简单命令行工具的示例:

class Operations:
def say_hi(self, name):
print(f"Hello {name}")
def say_bye(self, name):
print(f"Goodbye {name}")
def default(self, name):
print(f"Operation not supported for {name}")
def main():
ops = Operations()
user_input = input("Enter command and name: ").split()
if len(user_input) == 2:
cmd, name = user_input
# 动态获取方法,如果不存在则使用 default 方法
method = getattr(ops, cmd, ops.default)
method(name)
else:
print("Invalid input")


if __name__ == "__main__":
main()
另一个有用的工具是 functools.partial,它可以“冻结”函数的部分参数,创建新的可调用对象。
from functools import partial
# 原始函数需要指定基数
int('1010', base=2) # 输出 10
# 使用 partial 创建专用于二进制转换的新函数
base_two = partial(int, base=2)
print(base_two('1010')) # 输出 10
print(base_two('1111')) # 输出 15
4:上下文管理器与资源管理 🧹
上一节我们探讨了方法的动态调用,本节中我们来看看如何优雅地管理资源,例如文件的打开关闭或临时状态的设置,这可以通过上下文管理器实现。
上下文管理器通过 __enter__ 和 __exit__ 魔法方法定义,通常与 with 语句一起使用。
核心概念公式/代码:
class MyContextManager:
def __enter__(self):
# 进入 with 块时执行,例如打开资源
return resource
def __exit__(self, exc_type, exc_val, exc_tb):
# 退出 with 块时执行,例如关闭资源
pass
以下是一个特性标志的上下文管理器示例,用于临时开启某个功能:
class FeatureFlag:
def __init__(self, name):
self.name = name
self._enabled = False
def is_on(self):
return self._enabled
def toggle(self, value):
self._enabled = value
class enable_feature:
def __init__(self, flag):
self.flag = flag
self.old_value = None
def __enter__(self):
self.old_value = self.flag.is_on()
self.flag.toggle(True)
return self.flag
def __exit__(self, exc_type, exc_val, exc_tb):
self.flag.toggle(self.old_value)

# 使用示例
beta_flag = FeatureFlag('beta')
print(beta_flag.is_on()) # False
with enable_feature(beta_flag):
print(beta_flag.is_on()) # True,仅在 with 块内生效

print(beta_flag.is_on()) # False,已恢复
使用 contextlib.contextmanager 装饰器可以更简洁地创建上下文管理器:
from contextlib import contextmanager
@contextmanager
def enable_feature_v2(flag):
old_value = flag.is_on()
flag.toggle(True)
yield flag # 将控制权交给 with 块内的代码
flag.toggle(old_value) # with 块结束后执行清理
with enable_feature_v2(beta_flag):
print(beta_flag.is_on())
5:装饰器:增强函数功能 🎀
上一节我们学习了如何管理上下文,本节中我们来看看装饰器,它是一种强大的工具,可以在不修改原函数代码的情况下,为其添加新功能(如日志、验证、计时)。
装饰器本质上是一个接受函数作为参数并返回一个新函数的高阶函数。
核心概念公式/代码:
def my_decorator(func):
def wrapper(*args, **kwargs):
# 在调用原函数前执行的操作
result = func(*args, **kwargs)
# 在调用原函数后执行的操作
return result
return wrapper
@my_decorator
def my_function():
pass

以下是一个强制用户身份验证的装饰器示例:
def enforce_authentication(func):
def wrapper(user, *args, **kwargs):
if not user.is_authenticated:
raise PermissionError("User must be authenticated.")
return func(user, *args, **kwargs)
return wrapper

class User:
def __init__(self, name, is_authenticated):
self.name = name
self.is_authenticated = is_authenticated
@enforce_authentication
def display_profile_page(user):
print(f"Displaying profile for {user.name}")
# 使用
authenticated_user = User("Nina", True)
unauthenticated_user = User("Guest", False)
display_profile_page(authenticated_user) # 正常执行
display_profile_page(unauthenticated_user) # 抛出 PermissionError

为了保留被装饰函数的元信息(如名字、文档字符串),可以使用 functools.wraps:
from functools import wraps

def enforce_authentication_v2(func):
@wraps(func)
def wrapper(user, *args, **kwargs):
if not user.is_authenticated:
raise PermissionError("User must be authenticated.")
return func(user, *args, **kwargs)
return wrapper
上下文装饰器结合了装饰器和上下文管理器的功能,从 Python 3.2 开始可用。一个著名的库是 freezegun,它可以“冻结”测试中的时间。

from freezegun import freeze_time
import datetime
@freeze_time("2023-01-01")
def test_new_years_day():
assert datetime.datetime.now() == datetime.datetime(2023, 1, 1)

# 或者用作上下文管理器
def test_some_date():
with freeze_time("2023-01-01"):
assert datetime.datetime.now() == datetime.datetime(2023, 1, 1)
6:命名元组:轻量级数据结构 📦
上一节我们介绍了功能强大的装饰器,本节中我们最后来看一种轻量级的数据结构——命名元组。

collections.namedtuple 创建带有命名字段的元组子类,它比普通类更节省内存,并且不可变。

核心概念公式/代码:
from collections import namedtuple
Point = namedtuple('Point', ['x', 'y'])
p = Point(10, y=20)
print(p.x, p.y) # 通过名称访问
命名元组可以设置默认值,并且可以被子类化以添加方法:

from collections import namedtuple

# 方式1:通过 `__new__.__defaults__` 设置默认值
Route = namedtuple('Route', ['prefix', 'queue_name', 'wait_time'])
Route.__new__.__defaults__ = (None, None, 20) # 为最后三个字段设置默认值
r1 = Route('/api')
print(r1) # Route(prefix='/api', queue_name=None, wait_time=20)
# 方式2:通过 `_replace` 基于原型创建
prototype = Route('', '', 30)
r2 = prototype._replace(prefix='/user', queue_name='user_queue')
print(r2)

# 子类化命名元组以添加方法
class Person(namedtuple('Person', ['name', 'age'])):
__slots__ = () # 防止创建实例字典以节省内存
def __str__(self):
return f"{self.name} is {self.age} years old"
p = Person('Alice', 30)
print(p) # Alice is 30 years old
总结 📝
本节课中我们一起学习了多种让 Python 代码更优雅的工具和技术:

- 魔法方法:通过实现如
__add__、__getitem__等方法,让自定义对象像内置类型一样工作。 - 迭代器与生成器:使用
__iter__、__next__或yield关键字创建可遍历对象,生成器是更简洁的选择。 - 方法魔法:利用
getattr进行动态方法调用,使用functools.partial固定函数参数。 - 上下文管理器:通过
__enter__/__exit__或@contextmanager优雅管理资源生命周期和临时状态。 - 装饰器:使用
@decorator语法增强函数功能,如添加验证、日志,并用@wraps保留元信息。 - 命名元组:使用
collections.namedtuple创建高效、轻量且带有命名字段的不可变数据结构。
记住,强大的能力伴随重大的责任。应谨慎而恰当地使用这些高级特性,始终以代码的清晰性和简洁性为目标。完美的代码并非无法再添加内容,而是无法再减少任何内容。深入理解 Python,选择合适的工具,你就能编写出如诗歌般优雅的代码。

浙公网安备 33010602011771号