从 Python 到 Rust:破解 3 大障碍
原文:
towardsdatascience.com/python-to-rust-breaking-down-3-big-obstacles-094eb99e331d
Python 高手到 Rust 新手——一名数据科学家的过渡故事
·发表于 Towards Data Science ·阅读时间 8 分钟·2023 年 12 月 12 日
–
图 1:蛇和螃蟹。(螃蟹:Romina BM;蛇:Mohan Moolepetlu;由作者编排)。
我周围的每个人都知道我是一个忠实的🐍 Python 粉丝。我大约 15 年前开始使用 Python,当时我对Mathworks Matlab感到厌倦。尽管 Matlab 的想法看起来不错,但在掌握 Python后,我再也没有回头。我甚至在我的大学成为了 Python 的一种布道者,并“传播这个消息”。
编写代码的能力并不代表你是一个软件开发人员。
在我目前的雇主TenneT——荷兰和德国的大型传输系统运营商——我们正在与约 10 人的团队一起构建一个文档解析和验证解决方案。构建这样的解决方案,尤其是在团队中,比我想象的要困难得多。这也让我对软件工程的正确范式更感兴趣。我一直认为我的代码还不错,但在看到我软件工程师朋友的工作后:天哪,还有很多需要学习的地方!
当我学习强类型、SOLID 原则和一般编程架构等主题时,我也略微了解了其他语言以及它们如何解决问题。特别是Rust吸引了我的注意,因为我经常看到基于 Rust 的 Python 包(例如:Polars)。
为了更好地了解 Rust,我跟随了官方 Rustlings 课程,这是一个包含 96 个小编程问题的本地 Git 仓库。虽然它是完全可以做到的,但 Rust 与 Python 非常不同。Rust 编译器非常严格,无法接受也许的答案。以下是我认为 Rust 和 Python 之间的三个主要区别。
免责声明:虽然我对 Python 非常熟练,但我的其他语言有点生疏(双关语)。我仍在学习 Rust,并且可能有些部分理解不完全。
图 2:我们成功抵达终点(截图由作者提供)。
1. 所有权、借用和生命周期
所有权和借用可能是 Rust 编程语言中最基本的方面。它旨在确保内存安全,无需所谓的垃圾回收器。这是 Rust 的独特概念,我还没有在其他语言中看到过。
让我们从一个例子开始,我们将值42
赋给变量answer_of_life
。Rust 现在将在内存中分配一些空间(这稍微复杂一些,但我们现在保持简单),并将*“所有权”*附加到该变量。重要的是要知道一次只能有一个所有者。一些操作“转移所有权”,使得之前的变量引用无效。这通过防止双重释放内存、数据竞争和悬挂引用等问题来确保内存安全。
来源 1:所有权、所有权转移和作用域。
在其他语言中也使用的一个术语是作用域。这可以被视为代码*“存在”的某种区域。每次代码离开一个作用域时,所有拥有所有权的变量都会被解除分配。这是 Python 中根本不同的东西。Python 使用垃圾回收器,在没有对变量的引用时解除分配变量。在来源 1*的例子中,从变量s1
到s2
的所有权转移之后,变量s1
就不再可用了。
作为 Python 用户,所有权可能会让人感到困惑,特别是在开始时确实是一个挑战。
在来源 1的例子中有些过于简单。Rust 强制要求你思考变量是如何创建的以及它应该如何被转移。例如,当你将参数传递给函数时,所有权可以被转移,如来源 2中所示:
来源 2:一个函数获取所有权,从而使原始变量无效。
仅仅转移所有权可能会很麻烦,甚至对于一些使用场景来说可能无法实现,因此 Rust 提出了一个所谓的借用系统。变量通过借用同一个变量来避免转移所有权,而原变量仍然是所有者。默认情况下,借用的变量是不可变的,即只读,但通过添加 mut
关键字,借用可以变成可变的。虽然可以有无限多个不可变借用,但只允许有一个可变借用。在源 3中,我展示了两个不可变借用和一个可变借用的例子。当函数超出作用域时,所有变量将被移除。
源 3:两个不可变和一个可变借用。
生命周期是 Rust 中与借用和所有权相关的概念,帮助编译器强制执行引用有效的时长。你可能会遇到创建一个使用两个借用的结构或函数的情况。这意味着现在函数或结构的结果可能依赖于之前的输入。为了使这一点更明确,我们可以通过注释生命周期来表达关系。在源 4中查看示例:
源 4:生命周期语法初看可能令人困惑,但最终会有所帮助。
所有权、借用和生命周期虽然不易处理,但确实迫使你编写更好的代码。至少,当你能够通过编译器的检查时(-:
2. Rust 不接受 None
作为答案
在 Python 中非常常见的事情在 Rust 中是不可能的:设置一个值为 None
。这是一个与 Rust 的安全性、可预测性和零成本抽象目标一致的设计选择。
安全性方面类似于 Rust 的所有权、借用和生命周期方面:防止引用指向未分配的内存。通过不允许返回 None
,将导致更高的可预测性,因为它迫使开发者显式处理可能缺少数字的情况。由于内存安全和可预测行为,Rust 可以实现所有高级语言功能而不会牺牲性能。
“None shall not pass” — 甘道夫灰袍
仅仅拒绝 None
会使 Rust 成为一个糟糕的语言,因此创作者提出了一个不错的替代方案:枚举 Option
和 Result
。通过这些枚举,我们可以显式地表示值的存在或缺失。这也使得错误处理非常优雅。让我们考虑源 5中的 Option
示例。
源 5:使用 Option 返回可选答案并处理特殊情况。
等一下! 你不是说没有 None
吗?这也是第一次让我感到困惑的地方,不过这里的 None
是一个特殊的枚举结构体,不接受参数。Some
也是一个特殊的结构体,但可以接受参数。我们的函数*divide()*返回这些可能的枚举值之一,随后我们可以检查它是什么,并据此采取行动。
没有
None
并且强制返回值使得 Rust 非常可预测。
主函数使用 match 结构来处理结果,这非常方便。它有点类似于其他语言中的 switch/case 结构(参见图 2 中 Guido 的回应)。match 检查是否是 Enum Some
或 Enum None
并执行相关操作。
图 3:Guido van Rossum 对 switch/case 的推文/反应。
Option
枚举是一种特殊的结构,用于处理可能返回值或不返回值的函数。对于可以返回值或错误的函数,Rust 有一个更为明确的枚举,称为 Result
。它们的思想完全相同,主要区别在于 Option
有一个默认的“错误”值 None
,而 Result
需要一个明确的“错误”类型。这个类型可以是简单的字符串,也可以是更明确的结构体来标识错误。在 来源 6 中,divide 函数使用 Result
重新编写。
来源 6:Result
枚举是一种很好的返回值或错误的方式。
Rust 开发者发现 match 结构有时可能有些繁琐,因此添加了 if let
和 while let
操作符。这些操作符类似于 match,但提供了一些漂亮的语法糖和丰富的装饰。甚至还有一个非常酷的 ?
操作符(这里未展示),它为丰富的装饰添加了一个樱桃在上面!
来源 7:if let
和 while let
正在创造美丽的语法糖!
使用 Python 时,我学会了使用 Optional 关键字来为结果类型定义值或 None。但我不得不承认 Rust 在这方面处理得非常精妙。我可以想象,Python 社区也会朝着这种风格发展,类似于强类型化的趋势。
3. 类在哪里?
Python 和 Rust 都可以用于两种编程范式:函数式编程(FP)和面向对象编程(OOP)。然而,Rust 实现这些所谓的对象的方式有所不同。在 Python 中,我们有一个典型的 class
对象,可以关联变量和方法。像许多其他语言(如 Java)一样,我们现在可以将这个方法作为基础,通过创建继承父类方法和变量的新对象来扩展功能。
在 Rust 中,没有 class
关键字,对象与 Python 的根本不同。Rust 使用特质系统来实现代码重用和多态,这可以带来与多重继承相同的好处,但没有多重继承所带来的问题。多重继承通常用于结合或共享多个类的各种功能,但它可能使代码变得复杂和模糊。一个著名的问题是 钻石 问题,如 来源 8 所示:
来源 8:钻石问题:不清楚使用了哪个方法。
虽然我认为我们可以很容易地解决这个问题,但如果我创建一种新的语言,我也会尝试以不同的方式来做。对于多重继承,目标主要是与其他对象共享相似的功能。在 Rust 中,通过使用 Trait 系统,这种方法做得更优雅。这种方法并不是 Rust 独有的,类似的系统也在 Scala、Kotlin 和 Haskell 中使用。
Rust 中的类是由枚举(Enums)和结构体(Structs)创建的。单独来看,这些只是数据结构,但我们可以向这些类添加功能。我们可以直接这样做,然而,通过使用特征,这些功能可以与多个“类”共享。使用特征的一个大好处是我们可以提前检查某个特征是否被实现。请参见以下示例:
Source 9: 为两个结构体添加一个共享特征。
在这个例子中,我们有一个Speaker
特征,表示能够说话的角色。我们为两种类型Jedi
和Droid
实现了这个特征。每种类型都提供了自己对speak
方法的实现。
introduce
函数接受任何实现了Speaker
特征的类型,并调用speak
方法。在main
函数中,我们创建了Jedi
(Obi-Wan Kenobi)和Droid
(R2-D2)的实例,并将它们传递给introduce
函数,展示了多态性。
对我来说,作为一个 Pythonista 🐍,Rust 的特征系统非常令人困惑。我花了一段时间才欣赏到其语法的优雅。
总结
Rust 是一门非常酷的语言,但绝对不是一门容易学习的语言。Rustlings 课程让我了解了一些基础知识,但我远远没有足够熟练来承担大型项目。但我真的很喜欢 Rust 如何迫使你编写更好、更安全的代码。
Python 仍然是我的日常使用语言。在工作中,我们的文档管道完全用 Python 构建,而且在机器学习领域,我看不到所有的东西都换成另一种语言。Python 实在是太容易学习了,即使你是一个糟糕的开发者(当然不是我 (-😉),你也可以完成工作。
然而,Rust 的势头正在小幅上升。当然,一些包如 Polars 和 Pydantic 是使用 Rust 构建的,但 HuggingFace 也发布了他们自己的第一个用 Rust 构建的机器学习框架版本,名为 Candle。所以我认为学习一点 Rust 并不是一个坏主意!
我下一步(或实际上是当前)Rust 的旅程是使用 Rust 参与Advent of Code 2023。我还在研究 Leptos,并计划创建一个个人网站。还有很多东西需要学习!
如果你有任何意见,请告诉我!欢迎在LinkedIn上联系。
从 Python 到 Rust:你必须了解的虚拟环境的一切
从 Python 专家到 Rust 新手——一位数据科学家的过渡故事
·发表于 Towards Data Science ·7 分钟阅读·2023 年 12 月 26 日
–
图 1:货物仓库里的蛇和螃蟹。 (螃蟹; 蛇; 集装箱; 由作者编排)
从 Python 转到 Rust 的旅程就像把一个可靠的光剑换成一种新的刀刃——既令人兴奋又略显令人生畏。作为一个对 Python 的特性非常熟悉的数据科学家,进入 Rust 的世界是一个令人激动的新挑战。在本文中,我将分享我的经历和见解,比较这两种强大语言如何处理软件开发的一个关键方面——特别是关注(虚拟)环境和依赖管理。
在使用 Python 时,你首先学到的事情之一就是在所谓的虚拟环境中工作。这是一个管理依赖关系和隔离项目特定包的关键工具,以避免它们干扰其他项目或系统范围的 Python 安装。我几年前写了一篇关于如何管理 Python 的文章,但它仍然适用(它稍微变化了一些,涉及到micromamba和poetry ,如果需要,我可以写一篇关于这方面的文章)。
TLDR: 只需使用cargo,大多数情况下你就会没问题——Dennis
在使用rustup安装 Rust 之后,我的第一个问题是:我应该如何创建一个虚拟环境? 对我来说,这是一个非常有意义的问题,因为 Rust 也可以使用许多包(称为 Crates)作为依赖项。事实上,cargo 非常优雅地解决了这个问题。以下是我在比较虚拟环境和 pip 与 Rust 的 cargo 构建系统时的发现。
免责声明:在我探索这些 Rust 领域时,我对语言的熟练程度可能仍有些生疏(玩笑话)。加入我,在这个学习冒险中揭开 Cargo 的细微差别,告别虚拟环境的熟悉拥抱。
1. 包的单一全局位置
Python 中的虚拟环境是使用像venv、virtualenv或conda这样的工具按项目创建的。在底层,这些系统创建一个单独的文件夹,该文件夹包含 Python 发行版及其所有包。现在,当我们使用 pip 或 conda 安装一个包时,该包及其所有依赖项会被安装在这个隔离的文件夹中。这些虚拟环境工具所做的事情类似于“chroot”,但针对 Python 安装。
解释型语言如 Python,依赖关系解析通常发生在运行时。
对于像 Python 这样的解释型语言,依赖关系解析通常发生在运行时。这意味着当 Python 脚本执行时,解释器需要动态解析和加载所需的依赖项。虚拟环境帮助管理这些依赖关系,为项目提供了一个干净的隔离,以避免冲突。以下是 Python 中的典型工作流程:
# create a virtual environment with a specific Python version
conda create -n my_environment python=3.12
# activate the virtual environment
conda activate my_environment# Install a package
pip install pandas
另一方面,Rust 有一个叫做cargo的包管理器,它使用一个全球唯一的位置,即没有用户特定的虚拟环境。它之所以能做到这一点,是因为 cargo 构建系统。当你使用 cargo 创建一个项目时,它围绕 Cargo.toml
文件展开。这是所谓的项目文件,定义了项目的详细信息,包括其依赖项及语义版本控制。使用 cargo add <crate>
你将依赖项添加到这个项目文件中,这些依赖项会在构建过程中下载。由于我们使用 cargo 来构建,并且 cargo 负责选择/下载正确的依赖项,因此不需要像 Python 虚拟环境中的 chroot-like 机制。
使用 cargo,一切都已经在虚拟环境中。
使用 cargo 创建新项目的工作流程看起来与 Python 非常相似,但在底层,它确实要聪明一些:
# create a new project folder using cargo
cargo new my_project
# go into the new project folder
cd my_project# Install a package
cargo add rand
在使用 cargo 构建期间,需要的正确版本的包从 Cargo.toml
读取并从全局注册表中加载(默认情况下在 $HOME/.cargo
)。这主要是因为 Rust 是编译语言,而在运行时需要解析依赖的 Python 实现起来要困难得多。
2. 内置的依赖解析
看看 Python,没有内置的依赖解析系统。是的,使用 pip freeze
你可以获得已安装包的概述,但没有保证它也能捕获所有间接依赖。这意味着它不能捕获环境的完整复杂性。
Pip freeze 可能不足以捕获完整的环境
为了解决这个问题,其他语言如 Ruby 和 JavaScript 的依赖解析器开始使用所谓的 锁文件。这些锁文件捕获了所有依赖项及其依赖项的版本信息。Python 通过 Pipenv 或我个人最喜欢的 Python Poetry 获得类似的功能,但在下载 Python 时没有内置工具。
Rust 的 Cargo 通过使用锁文件具有内置的依赖解析功能。当你使用 cargo build
或 cargo run
时,它会检查 Cargo.lock
文件,以确保使用所有依赖项的确切版本。这个锁文件捕获了整个依赖树,包括传递依赖,形成了项目环境的全面且确定性的表示。
Cargo.lock
文件作为特定时间点的依赖快照。它包含了不仅是 Cargo.toml
文件中指定的直接依赖的准确版本信息,还有所有传递依赖的版本信息。
例如,如果项目 A 依赖于库 B 版本 1.0.0,而库 B 依赖于库 C 版本 2.1.0,则这两个版本都会记录在 Cargo.lock
文件中。这确保了所有参与项目的人员,无论其环境如何,都得到完全相同的依赖集合。Cargo 非常灵活,可以支持即使在同一编译目标中也能有多个版本的相同依赖。
图 2:在构建阶段,Cargo 收集所有必需的依赖。在运行阶段,依赖项已被链接到可执行文件中。(图由作者提供)。
使用 Cargo 的锁文件消除了开发者手动管理和同步不同环境中依赖版本的需要。它提供了一个一致且可重现的构建环境,使得协作和部署更加可靠。这是编译语言的一大优势,我们可以认为这是一种不公平的比较。
3. 包和 Rust 自身的兼容性
在软件工程中,兼容性是确保项目在各种环境下顺利运行的基石。当我们比较 Rust 的 cargo 与 Python 的 pip 时,可以清楚地看到 Rust 在这方面经过了精心考虑,而 Python 则是随着时间的推移逐渐发展到现在的状态。
Rust 中的兼容性不仅仅是一个考虑因素,它是一种文化承诺。社区非常重视主要版本的应用程序编程接口(API)兼容性。这在 cargo 包管理器中得到了清晰体现,它强制执行 语义化版本控制。这使得开发环境可靠且可预测,其中依赖项预期能够良好配合。
与此相比,Python 生态系统中的兼容性有时可能是一个微妙的问题。升级 Python 或其依赖项可能会导致意外的问题,这些问题可能只在运行时显现。与 Rust 不同,Rust 在构建时更容易识别潜在问题,而 Python 开发者通常只有在部署后才会发现这些问题。
示例场景:将 Python 3.7 升级到 Python 3.9
想象一下你有一个运行在 Python 3.7 上的 Python 项目。该项目包含一个严重依赖字典的脚本。在 Python 3.7 中,字典的插入顺序作为实现细节被保留,但这并没有得到正式保证。你决定将 Python 环境升级到 Python 3.9,以便获得性能改进和新语言特性。
升级后,你会注意到你的脚本表现不同。在 Python 3.7 中,你可能无意中依赖了字典中项目的顺序来进行某些操作,即使这并未正式成为语言规范的一部分。如果你的代码依赖于字典中元素的顺序,并且在编写时没有意识到这种行为在 3.7 中并不被保证,那么如果在 Python 3.9 中实现有任何细微变化,它可能会表现得不可预测或中断。
这个例子说明了即使在同一主要版本的 Python(Python 3.x)内升级也可能导致意外的问题,特别是当代码依赖于未正式指定在语言中的行为时,而这些行为只是某一特定实现的副产品。在这个例子中,我们忽略了在次版本中添加的许多功能,这些功能常常改变了首选的工作流程。同时,也忽略了被弃用的函数。例如,一些方法和函数在 Python 中被移除,即使在次版本中。
Rust 对次版本中稳定 API 维护的强烈关注确保了兼容性,并减少了与升级相关的问题。其严格的语义版本控制和 Cargo 的依赖管理最小化了意外变化。这使得 Rust 的更新对于开发者来说更具可预测性和较少干扰。
总结
学习和使用 Rust 真的突显了每种语言在环境和依赖管理上的巨大差异。Python 的悠久历史促成了各种工具的发展,如 venv 和 Poetry,它们都在应对语言的动态特性和运行时依赖解决挑战。尽管这些工具有效,但它们往往更像是必要的变通方法,而不是语言的集成组件。
相比之下,Rust 通过 Cargo 的简化方法展示了其对更集成和用户友好体验的承诺。Cargo 高效的依赖管理,无需外部工具或‘PATH’操作,展示了 Rust 现代化的软件开发方法。
学习 Python 和 Rust 确实突显了每种语言的独特之处,并让我们窥见了软件开发的未来。我认为 Python 和 Rust 仍然有不同的目标,但可以看到它们越来越趋同。同时,随着机器学习社区逐渐向 Rust 迈进,Rust 的语言特性也被引入 Python,用于更成熟的产品。我对 Python 和 Rust 的未来充满期待!
我很期待听到你对从 Python 到 Rust 的这段旅程的看法和反馈。让我们在LinkedIn上联系,并继续交流!
Python 元组,真相大白,只有真相:你好,元组!
原文:
towardsdatascience.com/python-tuple-the-whole-truth-and-only-the-truth-hello-tuple-12a7ab9dbd0d
PYTHON 编程
学习元组的基础知识及其使用方法
·发布在Towards Data Science ·阅读时长 16 分钟·2023 年 1 月 21 日
–
元组通常被视为记录。照片由Samuel Regan-Asante提供,来自Unsplash
元组是 Python 中的一种不可变集合类型。它是 Python 中三种最流行的集合类型之一,另外两种是列表和字典。虽然我认为许多初学者和中级开发者对这两种类型了解颇多,但他们可能在真正理解元组是什么以及如何工作上存在问题。即使是高级 Python 开发者也不必了解所有关于元组的知识——鉴于这种类型的特殊性,我对此并不感到惊讶。
作为一个初学者甚至中级 Python 开发者,我对元组了解不多。让我给你一个例子;想象一下我写了一段类似于以下的代码:
from pathlib import Path
ROOT = Path(__file__).resolve().parent
basic_names = [
"file1",
"file2",
"file_miss_x56",
"xyz_settings",
]
files = [
Path(ROOT) / f"{name}.csv"
for name in basic_names
]
如你所见,我使用了列表字面量来定义basic_names
列表——但为什么不使用元组字面量呢?它看起来会是下面这样:
basic_names = (
"file1",
"file2",
"file_miss_x56",
"xyz_settings",
)
关于元组,我们知道的主要事情是它是不可变的——代码本身表明basic_names
容器将不会改变。因此,元组在这里似乎比列表更自然,对吧?那么,两种方法之间是否存在实际差异?比如性能、安全性或其他方面?
知识上的这些空白使我们成为更差的程序员。本文旨在通过帮助你了解 Python 中一个非常重要但许多人不了解的数据类型:元组,从而帮助你成为更好的程序员。我的目标是使这篇文章从实际角度尽可能详尽。因此,例如,我们不会讨论元组的 C 语言实现细节,但会讨论在 Python 中使用元组的细节。
元组是一个丰富的话题。因此,我将把关于它的知识分为两部分——和两篇文章。以下是我将在第一部分中覆盖的主题——也就是这里:
-
元组的基础。
-
使用元组:元组解包和元组方法。
因此,我们将在这里专注于基础知识。在第二部分,我将覆盖元组的更多高级主题,例如继承自元组、元组性能和元组推导。你可以在这里找到它:
[## Python 元组,完全的真相和唯一的真相:让我们深入探讨]
了解元组的复杂性
towardsdatascience.com
元组的基础知识
元组是一个值的容器,类似于列表。在他伟大的著作《流畅的 Python》中,L. Ramalho 解释说,元组是为了成为不可变的列表而创建的,这个术语很好地描述了元组的本质。但他也提到,元组不仅仅是不可变的列表;它们远不止于此。
特别是,元组可以用作没有字段名称的记录。这意味着我们可以有一个包含几个未命名字段的记录。当然,这种基于元组的记录只有在每个字段的含义明确时才有意义。
当你想在 Python 中使用元组字面量创建元组时,你需要使用圆括号 ()
而不是方括号 []
,就像创建列表时一样¹:
>>> x_tuple_1 = (1, 2, 3)
>>> x_tuple_1
(1, 2, 3)
>>> x_tuple_2 = ([1, 2], 3)
>>> x_tuple_2
([1, 2], 3)
这里,x_tuple_1 = (1, 2, 3)
创建了一个包含数字 1
、2
和 3
的三元素元组;x_tuple_2 = ([1, 2], 3)
创建了一个包含两个值的两元素元组:一个列表 [1, 2]
和数字 3
。如你所见,你可以在元组中使用任何类型的对象。你甚至可以创建一个空元组的元组:
>>> tuple((tuple(), tuple()))
((), ())
尽管,说实话,我不知道你为什么会想这样做。
好的,我们上面使用了元组字面量。创建元组的第二种方法是使用内置的 tuple()
类。只需提供一个可迭代对象作为参数,这将把可迭代对象转换为元组:
>>> tuple([1, 2, 5])
(1, 2, 5)
>>> tuple(i for i in range(5))
(0, 1, 2, 3, 4)
要访问元组中的值,你可以使用典型的索引:x_tuple_1[0]
将返回 1
,而 x_tuple_2[0]
将返回一个列表 [1, 2]
。注意,因为 x_tuple_2[0]
是一个列表,所以你可以使用它的索引来访问它的元素——因此,你将使用多个(在这里是双重)索引;例如,x_tuple_2[0][0]
将返回 1
,而 x_tuple_2[0][1]
将返回 2
。
列表和元组之间最大的区别在于列表是可变的,所以你可以改变它们,而元组是不可变的,所以你不能改变它们:
>>> x_list = [1, 2, 3]
>>> x_tuple = (1, 2, 3)
>>> x_list[0] = 10
>>> x_list
[10, 2, 3]
>>> x_tuple[0] = 10
Traceback (most recent call last):
...
TypeError: 'tuple' object does not support item assignment
如你所见,你不能对元组进行项赋值。这一特性使得元组比列表更不容易出错,因为你可以确定(实际上,几乎可以确定,我们将下文讨论)元组不会改变。然而,你可以确定的是,它们的长度不会改变。
有一个关于元组的常见面试问题:由于元组是不可变的,你不能改变它们的值,对吗? 对这个问题的回答是:嗯…
这是因为你可以改变元组中可变元素的值:
>>> x_tuple = ([1, 2], 3)
>>> x_tuple[0][0] = 10
>>> x_tuple
([10, 2], 3)
>>> x_tuple[1] = 10
Traceback (most recent call last):
...
TypeError: 'tuple' object does not support item assignment
所以,尽管元组是不可变的,但如果它们的元素不是,你可以改变这些元素,因此,至少间接地,你可以改变元组。这使得改变一个不可变的东西成为可能…
如果你感到困惑,至少要意识到你并不孤单。你只是其中之一。然而,这种不可变性至少在理论上是有意义的,所以让我解释一下这里发生了什么。
整个真相在于以下几点。像其他集合一样,元组不包含对象,而是包含对它们的引用;不可变意味着在这些引用方面是不可变的。因此,一旦创建,元组将始终包含相同的引用集合。
-
理论上,当一个元组引用的对象发生变化时,元组保持不变:它仍然是完全相同的元组,具有完全相同的引用。
-
实际上(也就是说,从我们典型/自然的角度来看),当一个元组引用的对象发生变化时,元组似乎已经改变:尽管引用完全相同,一个对象发生了变化,因此,从实际情况来看,元组看起来与变化前不同。但在理论上,元组(一个引用的集合)没有发生任何变化。
像其他集合一样,元组不包含对象,而是包含对它们的引用;不可变意味着在这些引用方面是不可变的。
好了,现在我们知道了元组的不可变性是如何工作的,我们应该记住也要以这种方式来看待元组。但知道某件事并不意味着习惯它会很容易。以这种方式思考不可变性并不容易。记住,从现在开始,你应该记住元组是对对象的不可变引用集合,而不是对象的不可变集合。元组包含的对象的值实际上可以改变——但对象必须保持不变……已经觉得头疼了吗?这只是开始…
让我们考虑一下典型的元组长度。然而,为了增加一些背景,我们应该考虑它在列表中的表现。我认为可以安全地说,短列表和长列表都经常使用。你可以通过多种方法创建列表,比如字面量、for
循环、list()
方法和列表推导。
元组是不可变的,它们并不像那样工作。你不能在for
循环中更新它们(除非你在更新它们的可变元素)或在推导式中更新它们。你可以用两种方式创建一个元组,使用元组字面量,比如这里:
>>> x = (1, 56, "string")
或调用tuple()
类(tuple()
是一个可调用类)对一个可迭代对象:
>>> x = tuple(x**.5 for x in range(100))
我猜前一种用法要频繁得多。也许元组最常见的用法是从函数中返回值,特别是当返回两个或三个值时(你很少(如果有的话)会为十个值这么做)。
当元组字面量很短时,通常会省略括号:
>>> x = 1, 56, "string"
这种方法通常与 return
语句一起使用,但不仅限于此。带括号和不带括号的两种方式中哪一种更好?一般来说,没有哪一种更好;但这要视情况而定。有时,括号会使代码更清晰,有时则不需要括号。
请记住非括号元组,因为它们可能成为难以发现的错误来源;见这里:
即使是最小的字符也可能引发大问题
简而言之,当你忘记在行末添加逗号时,你可能会将一个对象作为元组而不是单独的对象来使用:
>>> x = {10, 20, 50},
你可能认为 x
是一个包含三个元素的集合,但实际上它是一个包含一个元素的元组:
>>> x
({10, 20, 50},)
正如你所见,这一个单独的逗号放在右大括号后面,而不是前面,使得 x
成为了一个一元素的元组。
元组的实际应用
元组提供的方法比列表少,但仍然有不少。有些方法比其他方法更为人所知;有些方法甚至非常少为人知晓且使用得不频繁。在本节中,我们将探讨使用元组的两个重要方面:元组方法和元组解包。
解包
元组的一个极好的特性是 元组解包。你可以用它将一个元组的值一次性赋给多个变量。例如:
>>> my_tuple = (1, 2, 3,)
>>> a, b, c = my_tuple
在这里,a
将变为 1
,b
将变为 2
,而 c
将变为 3
。
考虑以下示例:
>>> x_tuple = ([1, 2], 3)
>>> x, y = x_tuple
>>> x
[1, 2]
>>> y
3
你还可以使用带有星号 *
的特殊解包语法:
>>> x_tuple = (1, 2, 3, 4, 5)
>>> a, b* = x_tuple
>>> a
1
>>> b
[2, 3, 4, 5]
>>> *a, b = x_tuple
>>> a
[1, 2, 3, 4]
>>> b
5
>>> a, *b, c = x_tuple
>>> a
1
>>> b
[2, 3, 4]
>>> c
5
正如你所见,当你将星号 *
附加到一个变量名时,就像是在说:“将这个项及所有接下来的项解包到这个名字中。”所以:
-
a, b*
意味着将第一个元素解包到a
,所有剩余的元素解包到b
。 -
*a, b
意味着将最后一个元素解包到b
,所有之前的元素解包到a
。 -
a, *b, c
意味着将第一个元素解包到a
,最后一个元素解包到c
,所有中间的元素解包到b
。
当元组中的元素更多时,你可以考虑更多场景。想象一下你有一个包含七个元素的元组,而你对前两个和最后一个感兴趣。你可以用解包的方式将它们获取并赋值给变量,如下所示:
>>> t = 1, 2, "a", "ty", 5, 5.1, 60
>>> a, b, *_, c = t
>>> a, b, c
(1, 2, 60)
这里还要注意一点。我使用了 *_
,因为我只需要提取这三个值,其他值可以忽略。这里,下划线字符 _
正是表示这一点:我不关心这些元组中的其他值,因此让我们忽略它们。如果你使用名称,代码的读者会认为该名称在代码中被使用——但你的 IDE 也会对分配给一个在作用域中未被使用的名称而发出警告²。
元组解包可以用于各种场景,但当你赋值时,特别是从返回元组的函数或方法中获得值时,它特别有用。下面的例子展示了从函数/方法返回值中解包的有用性。
首先,让我们创建一个 Rectangle
类:
>>> @dataclass
... class Rectangle:
... x: float
... y: float
... def area(self):
... return self.x * self.y
... def perimeter(self):
... return 2*self.x + 2*self.y
... def summarize(self):
... return self.area(), self.perimeter()
>>> rect = Rectangle(20, 10)
>>> rect
Rectangle(x=20, y=10)
>>> rect.summarize()
(200, 60)
如你所见,Rectangle.summarize()
方法返回两个组织在元组中的值:矩形的面积和周长。如果我们想将这些值分配给名称,我们可以这样做:
>>> results = rect.summarize()
>>> area = result[0] # poor!
>>> perimeter = result[1] # poor!
然而,上述方法并不是一个好的选择,尤其是出于清晰性考虑,我们可以使用元组解包更有效地完成这个任务:
>>> area, perimeter = rect.summarize()
>>> area
200
>>> perimeter
60
如你所见,它更加清晰简洁:只需一行而不是三行。此外,它不使用索引来从元组中获取值。索引降低了可读性,使用名称而非位置会更好。我们将在下面的部分讨论,从 tuple
类继承和命名元组。但请记住,当一个函数/方法返回一个元组——这是一种相当常见的情况——你应该解包这些值,而不是直接使用元组索引分配它们。
另一个例子,也使用 dataclass
³:
>>> from dataclasses import dataclass
>>> KmSquare = float
>>> @dataclass
... class City:
... lat: float
... long: float
... population: int
... area: KmSquare
... def get_coordinates(self):
... return self.lat, self.long
>>> Warsaw = City(52.2297, 21.0122, 1_765_000, 517.2)
>>> lat, long = Warsaw.get_coordinates()
>>> lat
52.2297
>>> long
21.0122
上述示例展示了元组解包的最常见用例。然而,有时我们可能需要从基于元组的嵌套数据结构中解包值。考虑以下例子。假设我们有一个如上所示的城市列表,每个城市由一个字典中的列表表示,而不是 dataclass
:
>>> cities = {
... "Warsaw": [(52.2297, 21.0122), 1_765_000, 517.2],
... "Prague": [(50.0755, 14.4378), 1_309_000, 496],
... "Bratislava": [(48.1486, 17.1077), 424_428_000, 367.6],
... }
如你所见,我们将城市的坐标组织成了列表中的元组。我们可以使用嵌套解包来获取这些坐标:
>>> (lat, long), *rest = cities["Warsaw"]
>>> lat
52.2297
>>> long
21.0122
或者我们可能还需要面积:
>>> (lat, long), _, area = cities["Warsaw"]
>>> lat, long, area
(52.2297, 21.0122, 517.2)
再次,我使用了下划线字符 _
来分配我们不需要的值。
请注意,我们对 *args
所做的正是解包。通过将 *args
放在函数的参数中,你让用户知道他们可以使用任何参数:
>>> def foo(*args):
... return args
>>> foo(50, 100)
(50, 100)
>>> foo(50, "Zulu Gula", 100)
(50, 'Zulu Gula', 100)
在这里,*args
将所有位置参数(而非关键字参数)收集到 args
元组中。这个 return
语句使我们能够查看 args
元组中的这些参数。
还有一点:解包不仅限于元组,你也可以将它用于其他可迭代对象:
>>> a, *_, b = [i**2 for i in range(100)]
>>> a, b
(0, 9801)
>>> x = (i for i in range(10))
>>> a, b, *c = x
>>> c
[2, 3, 4, 5, 6, 7, 8, 9]
元组方法
Python 初学者很快就会了解元组。随着时间的推移,他们会多了解一些,主要是它们的不变性及其后果。但许多开发者不知道tuple
类提供的所有方法。说实话,在写这篇文章之前,当我认为自己是一个相当高级的开发者时,我也不知道这些方法。不过了解这些方法是好的——这一小节旨在帮助你学习这些方法。
这并不意味着你需要使用所有这些操作。但例如,记住可以在元组上使用就地操作及其结果是好的。这些知识足以让你回忆起,元组只有两种就地操作:就地拼接和就地重复拼接。
为了学习这些方法,我们再看看*《流畅的 Python》*。我们将找到一个比较列表和元组方法的漂亮表格,从中我们可以提取出元组的方法。因此,下面你将找到tuple
类的完整方法列表,每个方法附有一个或多个简单示例。
获取长度:len(x)
>>> len(y)
7
拼接:x + y
>>> x = (1, 2, 3)
>>> y = ("a", "b", "c")
>>> z = x + y
>>> z
(1, 2, 3, 'a', 'b', 'c')
重复拼接:x * n
>>> x = (1, 2, 3)
>>> x * 3
(1, 2, 3, 1, 2, 3, 1, 2, 3)
反向重复拼接:n * x
>>> x = (1, 2, 3)
>>> 3 * x
(1, 2, 3, 1, 2, 3, 1, 2, 3)
就地拼接:x += y
>>> x = (1, 2, 3)
>>> y = ("a", "b", "c")
>>> x += y
>>> x
(1, 2, 3, 'a', 'b', 'c')
就地拼接的语法可能会暗示我们在处理相同的对象:我们从等于(1, 2, 3)
的元组x
开始;在拼接y
之后,x
仍然是一个元组,但包含了六个值:(1, 2, 3, "a", "b", "c")
。由于我们讨论了元组的不变性,我们知道x
之前和x
之后是两个不同的对象。
我们可以通过以下简单测试轻松检查这一点。它使用两个对象的id
:如果它们有相同的id
,那么它们是同一个对象;但如果id
不同,那么在就地拼接之前和之后的x
是两个不同的对象。我们来做一下测试:
>>> x = (1, 2, 3)
>>> first_id = id(x)
>>> y = ("a", "b", "c")
>>> x += y
>>> second_id = id(x)
>>> first_id == second_id
False
两个id
不同,这意味着在就地操作之后的x
与之前的x
是不同的对象。
就地重复拼接:x *= n
>>> x = (1, 2, 3)
>>> x *= 3
>>> x
(1, 2, 3, 1, 2, 3, 1, 2, 3)
我上面写的同样适用在这里:尽管我们在这里看到的只有一个名字,x
,但实际上有两个对象:x
之前的和x
之后的。
包含:in
>>> x = (1, 2, 3)
>>> 1 in x
True
>>> 100 in x
False
计算元素出现的次数:x.count(element)
>>> y = ("a", "b", "c", "a", "a", "b", "C")
>>> y.count("a")
3
>>> y.count("b")
2
获取指定位置的项:x[i]
(x.__getitem__(i)
)
>>> y[0]
'a'
>>> y[4], y[5]
('a', 'b')
查找第一次出现的 element
的位置:x.index(element)
>>> y = ("a", "b", "c", "a", "a", "b", "C")
>>> y.index("a")
0
>>> y.index("b")
1
获取迭代器:iter(x)
(x.__iter__()
)
>>> y_iter = iter(y)
>>> y_iter # doctest: +ELLIPSIS
<tuple_iterator object at 0x7...>
>>> next(y_iter)
'a'
>>> next(y_iter)
'b'
>>> for y_i in iter(y):
... print(y_i, end=" | ")
a | b | c | a | a | b | C |
支持使用 pickle
优化序列化:x.__getnewargs__()
这个方法不像上面那些方法那样直接使用。相反,它在 pickle 序列化过程中用于优化元组的序列化,如下面的玩具示例所示:
>>> import pickle
>>> with open("x.pkl", "wb") as f:
... pickle.dump(x, f)
>>> with open("x.pkl", "rb") as f:
... x_unpickled = pickle.load(f)
>>> x_unpickled
(1, 2, 3)
在他那本精彩的书*《流畅的 Python》(第 2 版)中,Luciano Ramalho 列出了 15 个列表有而元组没有的方法——但这个优化序列化的方法是元组独有的,是列表没有的唯一*方法。
“元组”一词在不同语言中的表达。图片由作者提供。
结论
在这篇文章中,我们讨论了 Python 中最常见的集合类型之一——元组的基础知识。希望你喜欢这篇文章——如果喜欢,请注意,我们讨论的不仅仅是基础知识,还可以说是非争议性的。
然而,元组还有更多内容,其中一些内容并不像我们从这篇文章中学到的那样清晰。我们将在这篇文章的后续部分讨论这些内容。你会看到,元组并不像你读完这篇文章后可能想象的那样简单。在我看来,元组比任何其他内置类型都更具争议性。也许元组甚至被过度使用了——但在读完下一篇文章后,我让你自己决定。老实说,我对元组有些不满。实际上,我会对元组有点苛刻……甚至可能有些过头?
我希望我已经足够引起你的兴趣,让你阅读这篇文章的续集。你可以在这里找到它:
Python 元组,全面的真相与唯一真相:让我们深入探讨
了解元组的复杂性
towardsdatascience.com
感谢阅读。如果你喜欢这篇文章,你可能也会喜欢我写的其他文章;你可以在这里查看。如果你想加入 Medium,请使用下面的推荐链接:
## 通过我的推荐链接加入 Medium — Marcin Kozak
阅读 Marcin Kozak 的每一个故事(以及 Medium 上的其他数千位作者的故事)。你的会员费直接支持……
脚注
¹ 请注意,在许多代码块中,如上面所示,我使用了doctest
测试,以确保示例正确运行。你可以在模块的文档和这篇在Towards Data Science上发布的介绍文章中了解更多关于doctest
的信息。
² 请注意,我写的是“在范围内”,而不是“在代码中”。这是因为虽然我们在这里不需要这些值,但我们可能在代码的其他地方,在某个其他范围内需要它们(例如,在另一个函数中)。在特定范围内使用特定解包只会影响这个范围;因此,我们可以在另一个范围内再次解包相同的可迭代对象,这种解包可能会有所不同。
在代码块中,你会发现 KmSquare
类型别名。我使用它来提高定义城市时浮点数的可读性。你可以在这里阅读更多关于类型提示和类型别名的内容。
资源
## Python 文档测试,使用 doctest:简单的方法
doctest 允许进行文档、单元和集成测试,以及测试驱动开发。
了解列表推导(listcomps)、集合推导(setcomps)、字典推导等的复杂性…
## 找到 Python 代码中的 bug:小细节产生大问题
即使是最小的字符也可能引入大问题
Python 的简洁性使你可以迅速变得高效,但这通常意味着你并没有充分利用它的所有功能…
## Python 文档测试,使用 doctest:简单的方法
Python 元组,真相和唯一的真相:深入探讨
原文:
towardsdatascience.com/python-tuple-the-whole-truth-and-only-truth-lets-dig-deep-24d2bf02971b
PYTHON PROGRAMMING
学习元组的复杂性。
·发表于 Towards Data Science ·阅读时间 24 分钟 ·2023 年 1 月 27 日
–
元组的不可变性可能令人困惑且令人头痛。照片由 Aarón Blanco Tejedor 提供,来源于 Unsplash
在上一篇文章中,我们讨论了元组的基础知识:
## Python Tuple, the Whole Truth, and Only the Truth: Hello, Tuple!
学习元组的基础知识及其使用方法
towardsdatascience.com
我向你展示了元组是什么,它提供了哪些方法,以及最重要的是,我们讨论了元组的不可变性。但元组远不止这些,这篇文章将对上一篇文章进行扩展。你将在这里学习元组类型的以下方面:
-
元组的复杂性:不可变性对复制元组的影响以及元组类型提示。
-
从元组继承。
-
元组性能:执行时间和内存。
-
元组相较于列表的优势(?):清晰度、性能以及元组作为字典键的使用。
-
元组推导(?)
-
命名元组
元组的复杂性
元组最重要的复杂性可能就是它的不可变性。但由于这定义了这种类型的本质,即使是初学者也应该了解这种不可变性是如何工作的,以及它在理论和实践中的意义。因此,我们在上述提到的上一篇文章中讨论了这一点。在这里,我们将讨论元组的其他重要复杂性。
不可变性对复制元组的影响
这将会很有趣!
一位理论家可能会对我大喊,称只有一种元组的不可变性,那就是我们在上一篇文章中讨论的那个。好吧,这是事实,但……但 Python 本身区分了两种不同的不可变性!而 Python 必须 做出这种区分。这是因为只有真正不可变的对象才是可哈希的。在下面的代码中,你会看到第一个元组是可哈希的,而第二个元组则不是:
>>> hash((1,2))
-3550055125485641917
>>> hash((1,[2]))
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'list'
一个对象是否可哈希会影响到各种事情——这也是为什么 Python 区分可哈希和不可哈希的元组;前者是我称之为真正不可变的元组。我将展示 Python 如何处理这两种元组,包括元组复制的工作原理和将元组用作字典键的情况。
首先,让我们看看在元组复制中它是如何工作的。为此,我们创建一个完全不可变的元组,并使用所有可用的方法进行复制:
>>> import copy
>>> a = (1, 2, 3)
>>> b = a
>>> c = tuple(a)
>>> d = a[:]
>>> e = copy.copy(a) # a shallow copy
>>> f = copy.deepcopy(a) # a deep copy
由于 a
是一个完全不可变的元组,原始元组 (a
) 及其所有副本应该指向同一个对象:
>>> a is b is c is d is e is f
True
正如预期的那样——也应该是完全不可变类型的情况——所有这些名称都指向同一个对象;它们的 id
是相同的。这就是我所称的真正或完全不可变性。
现在我们用第二种类型的元组做同样的事情;也就是说,一个包含一个或多个可变元素的元组:
>>> import copy
>>> a = ([1], 2, 3)
>>> b = a
>>> c = tuple(a)
>>> d = a[:]
>>> e = copy.copy(a) # a shallow copy
>>> f = copy.deepcopy(a) # a deep copy
从 b
到 e
的副本是浅复制,因此它们将引用与原始名称相同的对象:
>>> a is b is c is d is e
True
这就是我们需要深度复制的原因。深度复制应该覆盖所有对象,包括嵌套在内部的对象。由于 a
元组内部有一个可变对象,因此与之前不同的是,这次深度复制 f
将不会指向相同的对象:
>>> a is f
False
元组的第一个元素(索引 0
)是 [1]
,所以它是可变的。当我们创建 a
的浅复制时,元组 a
到 e
的第一个元素指向相同的列表:
>>> a[0] is b[0] is c[0] is d[0] is e[0]
True
但创建深度复制意味着创建一个新的列表:
>>> a[0] is f[0]
False
现在让我们看看这两种不可变性在将元组用作字典键时的工作差异:
>>> d = {}
>>> d[(1, 2)] = 3
>>> d[(1, [2])] = 4
Traceback (most recent call last):
...
TypeError: unhashable type: 'list'
所以,如果你想将一个元组用作字典键,它必须是可哈希的——也就是说,它必须真正不可变。
所以,如果有人告诉你 Python 元组只有一种不可变性,你会知道这并不完全正确——因为在不可变性方面有两种类型的元组:
-
完全不可变的元组,仅包含不可变元素;这在引用和值两个方面都表现为不可变性;
-
从引用角度看不可变但从值角度看可变的元组,即包含可变元素的元组。
如果不区分这两者,你将无法理解元组复制的工作原理。
元组类型提示
类型提示在 Python 中变得越来越重要。有些人说现代 Python 代码中没有类型提示是不可能的。正如我在另一篇文章中所写的那样,我不会在这里重复。如果你感兴趣,请阅读它:
[## Python 的类型提示:朋友、敌人,还是只是个头疼的问题?
类型提示在 Python 社区中的受欢迎程度不断上升。这会将我们引向何处?我们能做些什么来利用它……
在这里,我们简要讨论如何处理元组的类型提示。我将展示现代版本的元组类型提示,即 Python 3.11。由于类型提示在不断变化,请注意,并非所有旧版本的 Python 都能以相同的方式工作。
从 Python 3.9 开始,事情变得更简单,因为可以使用内置的 tuple
类型,并用方括号 []
指示字段。以下是你可以做的几个示例。
tuple[int, ...]
、tuple[str, ...]
等等
这意味着对象是 int
/ str
/ 等等元素的元组,长度不限。省略号 ...
表明元组可以有任意长度;无法固定长度。
tuple[int | float, ...]
如上所述,但元组可以包含 int
和 float
类型的元素。
tuple[int, int]
与上述不同,这个元组是两个整数的记录。
tuple[str, int|float]
再次是一个两项记录,第一项是字符串,第二项是整数或浮点数。
tuple[str, str, tuple[int, float]]
一个包含三项的记录,前两项是字符串,第三项是包含一个整数和一个浮点数的二元素元组。
tuple[Population, Area, Coordinates]
这是一个特定的记录,包含三种特定类型的元素。这些类型,Population
、Area
、Coordinates
,是命名元组或先前定义的数据类型,或类型别名。正如我在上述文章中所解释的,使用这些类型别名比使用内置类型如 int
、float
等更具可读性。
这些只是几个示例,但我希望它们能帮助你了解你可以用元组的类型提示做些什么。我只提到了 命名元组,因为我将在下面的另一个部分讨论这种类型。不过,请记住,在类型提示的背景下,命名元组也非常有用,因为借助命名元组,你可以获得一个自定义的类型别名,它也是一个数据容器——这是一个强大的组合。
从 tuple
继承
你可以从 list
继承,尽管有时从 collections.UserList
继承更好。那么,我们是否可以对元组做同样的事情?我们可以从 tuple
类继承吗?
基本上,忘掉创建类似元组的通用类型的想法。tuple
没有自己的.__init__()
方法,因此你不能像继承自列表那样调用super().__init__()
。没有这一点,你几乎没有任何功能,因为tuple
类继承的是object.__init__()
。
然而,这并不意味着你不能从tuple
继承。你可以,但不是为了创建通用类型,而是特定类型。你还记得City
类吗?我们可以做类似的事情,但要注意,这可能并不有趣。
>>> class City(tuple):
... def __new__(self, lat, long, population, area):
... return tuple.__new__(City, (lat, long, population, area))
我们有一个类似元组的City
类:
>>> Warsaw = City(52.2297, 21.0122, 1_765_000, 517.2)
>>> Warsaw
(52.2297, 21.0122, 1765000, 517.2)
>>> Warsaw[0]
52.2297
这个类确切地接受四个参数,既不多也不少:
>>> Warsaw = City(52.2297, 21.0122, 1_765_000)
Traceback (most recent call last):
...
TypeError: __new__() missing 1 required positional argument: 'area'
>>> Warsaw = City(52.2297, 21.0122, 1_765_000, 517.2, 50)
Traceback (most recent call last):
...
TypeError: __new__() takes 5 positional arguments but 6 were given
请注意,在当前版本中,我们可以使用参数名称,但不必这样做,因为它们是位置参数。
>>> Warsaw_names = City(
... lat=52.2297,
... long=21.0122,
... population=1_765_000,
... area=517.2
... )
>>> Warsaw == Warsaw_names
True
但是我们不能通过名称访问值:
>>> Warsaw.area
Traceback (most recent call last):
...
AttributeError: 'City' object has no attribute 'area'
我们可以通过两种方式来改变这一点。一种是使用collections
或typing
模块中的命名元组;我们稍后会讨论它们。但我们也可以使用我们的City
类来实现相同的效果,感谢operator
模块:
>>> import operator
>>> City.lat = property(operator.itemgetter(0))
>>> City.long = property(operator.itemgetter(1))
现在我们可以按名称访问lat
和long
属性:
>>> Warsaw.lat
52.2297
>>> Warsaw.long
21.0122
然而,由于我们只对lat
和long
进行了上述操作,我们将无法按名称访问population
和area
:
>>> Warsaw.area
Traceback (most recent call last):
...
AttributeError: 'City' object has no attribute 'area'
我们当然可以改变这一点:
>>> City.population = property(operator.itemgetter(2))
>>> City.area = property(operator.itemgetter(3))
>>> Warsaw.population
1765000
>>> Warsaw.area
517.2
不过,我从未做过这样的事情。如果你想要这样的功能,你应该使用命名元组。
元组性能
执行时间
为了基准测试使用元组的各种操作,以及作为比较的列表,我使用了附录中接近文章末尾的脚本。你还会在那里找到运行代码的结果。我提供代码不仅仅是为了记录,也为了让你可以扩展实验。
总体而言,无论其大小和执行的操作是什么,列表总是更快。我常听说创建元组的原因之一是它们较小的内存消耗。我们的这个小实验远未确认这一观点。虽然有时元组确实使用了稍少的内存,但通常它们使用的内存稍多。因此,我对 5 百万和 1000 万整数项的非常长的列表和元组进行了实验。结果是,列表通常消耗的内存更少……
那么,这些小内存消耗的元组在哪里呢?也许这与元组和相应列表所占的磁盘空间有关?让我们检查一下:
>>> from pympler.asizeof import asizeof
>>> for n in (3, 10, 100, 1000, 1_000_000, 5_000_000, 10_000_000):
... print(
... f"tuple, n of {n: 9}: {asizeof(tuple(range(n))):10d}"
... "\n"
... f" list, n of {n: 9}: {asizeof(list(range(n))):10d}"
... "\n"
... f"{'-'*33}"
... )
tuple, n of 3: 152
list, n of 3: 168
---------------------------------
tuple, n of 10: 432
list, n of 10: 448
---------------------------------
tuple, n of 100: 4032
list, n of 100: 4048
---------------------------------
tuple, n of 1000: 40032
list, n of 1000: 40048
---------------------------------
tuple, n of 1000000: 40000032
list, n of 1000000: 40000048
---------------------------------
tuple, n of 5000000: 200000032
list, n of 5000000: 200000048
---------------------------------
tuple, n of 10000000: 400000032
list, n of 10000000: 400000048
---------------------------------
仅在小元组及其相应列表的情况下,内存使用差异才是明显的——例如,152
与168
。但我认为你会同意,400_000_032
与400_000_048
实际上并没有小那么多,对吧?
我在过去的实验中观察到的另一件事(代码未展示)。Python 编译器以特殊方式处理元组字面量,因为它将它们保存在静态内存中——所以它们是在编译时创建的。其他方式创建的列表和元组都不能保存在静态内存中——它们总是使用动态内存,这意味着它们是在运行时创建的。这个话题复杂到足以值得单独的文章,因此我们就此停下。
我将这些基准留给你。如果你想扩展它们,请随意。如果你学到了新且意外的东西,请在评论中分享。
我学到的是,几乎不要仅仅因为性能而使用元组。但的确,如果我们需要一个简单的类型来存储非常小的记录,比如由两个或三个元素组成,元组可能是一个有趣的选择。如果字段名称有帮助,而且字段更多,我宁愿使用其他东西,命名元组是一个选择,dataclasses.dataclass
是另一个选择。
一个列表和一个元组。作者提供的图像。
元组相对于列表的优势(?)
在流畅的 Python中,L. Ramalho 提到元组相对于列表的两个优势:清晰度和性能。老实说,我找不到其他优势,但这两个优势可能已经足够。因此,让我们逐一讨论它们,并决定它们是否确实在某些方面使元组优于列表。
清晰度
正如 L. Ramalho 所写,当你使用元组时,你知道它的长度永远不会改变——这增加了代码的清晰度。我们已经讨论过元组长度可能发生的情况。的确,由于不可变性带来的清晰度是很棒的,我们确实知道任何元组的长度永远不会改变,但……
正如 L. Ramalho 自己警告的那样,包含可变项的元组可能是难以发现的错误来源。你还记得我之前提到的与原地操作相关的内容吗?一方面,我们可以确定一个元组,比如x
,它的长度永远不会改变。我同意这是一个在清晰度方面很有价值的信息。然而,当我们对x
进行原地操作时,这个元组将不再是同一个元组,即便它仍然是一个名为x
的元组——但,请让我重复,是一个不同的名为x
的元组。
因此,我们应该按如下方式修订上述清晰度优势:
- 我们可以确定一个特定的
id
的元组长度永远不会改变。
或者:
- 我们可以确定,如果我们定义一个特定长度的元组,它的长度不会改变,但我们应该记住,如果我们使用任何原地操作,那么这个元组就不是我们之前所指的那个元组。
听起来有点疯狂?我完全同意:这确实很疯狂。对我来说,这不是清晰;这是清晰的对立面。有人这样想吗?想象一下你在一个函数中定义了一个元组 x
。然后你执行原地连接操作,例如 x += y
,这看起来就像 y
保持不变但 x
发生了变化。我们知道这不是真的——因为这个原始的 x
已经不存在,我们有一个全新的 x
——但这就是它看起来的样子,尤其是因为我们仍然有一个元组 x
,其第一个元素与原始 x
元组中的元素完全相同。
当然,我知道从 Python 的角度来看这一切都是有意义的。但当我编码时,我不希望我的思维被这种方式占据。为了使代码清晰,我更倾向于让它在不需要做出这样的假设的情况下保持清晰。这正是为什么对我来说,元组并不意味着清晰;它们意味着比我在列表中看到的清晰度要低。
这还不是元组清晰度的全部。在代码方面,我特别喜欢列表中的一个特性,但不喜欢元组中的这个特性。用于创建列表的方括号 []
使得它们在代码中显得突出,因为没有其他容器使用方括号。看看字典:它们使用大括号 {}
,集合也可以使用这些大括号。元组使用圆括号 ()
,而圆括号不仅在生成器表达式中使用,而且在代码中的许多不同地方使用,因为 Python 代码使用圆括号的目的非常多。因此,我喜欢列表在代码中显得突出——而不喜欢元组的不突出。
性能
L. Ramalho 写道,元组使用的内存比对应的列表少,Python 可以对这两者进行相同的优化。我们已经分析了内存性能,因为我们知道这并不总是如此——实际上,元组所用的磁盘内存确实比对应的列表要小,但这种差异可能微不足道。
这种知识,加上列表在执行时间上的更好性能,使我认为性能不使元组成为更好的选择。在执行时间方面,列表更好。在内存使用方面,元组确实可以更好——但现在,随着现代计算机的出现,这些差异真的很小。此外,当我需要一个真正节省内存的容器来收集大量数据时,我不会选择列表或元组——而是选择生成器。
另一件事:元组作为字典键
除了这两个方面,还有一个值得考虑的第三个方面,我们已经提到过——你不能将列表用作字典中的键,但可以使用元组。或者说,你可以使用真正不可变(即,可哈希)的元组。原因在于前者的可变性和后者的不可变性。
与前两个优势不同,这个优势在特定情况下可能非常显著,即使这种情况比较少见。
元组推导(?)
如果你希望从这一节中了解到 Python 中存在元组推导,或者希望学到一些能让你的 Python 爱好者同伴惊叹的惊人技巧——我很抱歉!我并不想制造虚假的希望。今天没有元组推导;没有让人震撼的语法。
你可能还记得,在我关于 Python 推导的文章中,我并没有提到元组推导:
学习列表推导(listcomps)、集合推导(setcomps)、字典推导的复杂性…
towardsdatascience.com
这是因为没有元组推导。但我不想让你空手而归,我为你准备了一个安慰奖。我会向你展示一些元组推导的替代方案。
首先,记住生成器表达式不是元组推导。我认为许多 Python 初学者会混淆这两者。我特别记得在学习列表推导后第一次看到我的生成器表达式。我的第一反应是,“嗯,这就是了。一个元组推导。”我很快意识到,虽然前者确实是列表推导,但后者不是元组推导:
>>> listcomp = [i**2 for i in range(7)] # a list comprehension
>>> genexp = (i**2 for i in range(7)) # NOT a tuple comprehension
我花了一些时间——如果不是浪费的话——才了解到有列表推导、集合推导、字典推导和生成器表达式——但没有元组推导。不要重蹈我的覆辙。不要花几个小时去寻找元组推导。它们在 Python 中不存在。
这就是我的安慰奖——两个元组推导的替代方案。
替代方案 1: tuple()
+ genexp
>>> tuple(i**2 for i in range(7))
(0, 1, 4, 9, 16, 25, 36)
你有没有注意到你不需要先创建一个列表推导然后是元组?确实,在这里我们创建了一个生成器表达式,并用tuple()
类来转换它。这自然会给我们一个元组。
替代方案 2: genexp
+ 生成器解包
>>> *(i**2 for i in range(7)),
(0, 1, 4, 9, 16, 25, 36)
一个不错的小技巧,不是吗?它使用了扩展的可迭代解包,它返回一个元组。你可以用它来处理任何可迭代对象,既然生成器是其中之一,它就有效!让我们检查它是否也对列表有效:
>>> x = [i**2 for i in range(7)]
>>> *x,
(0, 1, 4, 9, 16, 25, 36)
你可以不赋值给x
而做同样的事情:
>>> *[i**2 for i in range(7)],
(0, 1, 4, 9, 16, 25, 36)
它适用于任何可迭代对象——但别忘了行末的逗号;没有它,这个技巧将无法奏效:
>>> *[i**2 for i in range(7)]
File "<stdin>", line 1
SyntaxError: can't use starred expression here
让我们检查集合:
>>> x = {i**2 for i in range(7)}
>>> *x,
(0, 1, 4, 9, 16, 25, 36)
它有效!并且要注意,通常,解包提供一个元组。这就是为什么扩展的可迭代解包看起来有点像元组推导。虽然它确实像一个不错的小技巧,但其实不是:这是 Python 提供的工具之一,尽管它确实是一个边缘情况。
但我不会使用替代方案 2。我会选择替代方案 1,它使用tuple()
。我们大多数人喜欢像第二个替代方案这样的技巧,但它们很少清晰——而且替代方案 2,与替代方案 1相比,远不如前者清晰。不过,任何 Python 爱好者都会看到替代方案 1中的内容,即使他们没有看到其中隐藏的生成器表达式。
命名元组
元组是未命名的——但这并不意味着 Python 中没有命名元组。恰恰相反,确实存在——而且,毫无意外,它们被称为……命名元组。
你有两种方法来使用命名元组:collections.namedtuple
和typing.NamedTuple
。命名元组顾名思义:它们的元素(称为字段)具有名称。你可以在附录中的基准测试脚本中看到前者的实际应用。
就个人而言,我认为它们在许多不同情况下都非常有帮助。它们不会提高性能;甚至可能会降低性能。但在清晰性方面,它们可以更清楚,无论是对开发者还是对代码的用户。
因此,尽管我经常使用常规元组,有时我会选择命名元组——这正是因为它的清晰性。
命名元组提供了丰富的可能性,值得专门为它们写一篇文章。因此,我在这里仅仅讲述这些——但我计划写一篇专门讨论这种强大类型的文章。
“元组”在各种语言中的表示。图像由作者提供。
结论
本文以及上一篇文章旨在提供关于元组、它们的用例、优缺点及其复杂性的深入信息。尽管元组的使用非常普遍,但在开发者中,尤其是那些经验较少的 Python 开发者中,它们并不那么知名。这就是为什么我想将关于这个有趣类型的丰富信息集中在一个地方——希望你从阅读中学到了一些东西,甚至像我从写作中学到的一样多。
说实话,在开始写关于元组的内容时,我以为会发现更多的优势。我从开始使用 Python 的第一天起就一直在使用元组。尽管我使用列表的频率要高得多,但我还是喜欢元组,尽管对它们了解不多——所以我在这篇文章中包含的一些信息对我来说是新的。
然而,在写完这篇文章后,我对元组的喜爱已经不那么强烈了。我仍然认为它们是处理小记录的有价值类型,但它们的扩展——命名元组——或数据类似乎是更好的方法。而且,元组似乎也不是特别有效。它们比列表要慢,而且只节省了少量内存。那么,我为什么还要使用它们呢?
也许是因为它们的不可变性?也许。如果你喜欢基于不可变性概念的函数式编程,你肯定会更喜欢元组而不是列表。我曾多次使用这个论点来说服自己在这种或那种情况下应该更喜欢元组而不是列表。
但元组所提供的不可变性,如我们讨论的那样,并不是那么明确。假设x
是一个不可变类型的项的元组。我们知道这个元组确实是不可变的,对吗?如果是这样,我不喜欢以下代码,这在 Python 中是完全正确的:
>>> x = (1, 2, 'Zulu Minster', )
>>> y = (4, 4, )
>>> x += y
>>> x
(1, 2, 'Zulu Minster', 4, 4)
>>> x *= 2
>>> x
(1, 2, 'Zulu Minster', 4, 4, 1, 2, 'Zulu Minster', 4, 4)
我知道这在 Python 中是正确的,我知道这甚至是 Pythonic 的代码——但我不喜欢它。我不喜欢我可以用 Python 元组做这样的事情。它根本没有元组不可变性的感觉。依我看,如果你有一个不可变类型,你应该能够复制它,你应该能够连接两个实例等等——但你不应该能够通过就地操作将一个新元组赋给旧名称。你想让这个名称保持不变?你的选择。所以,我对以下情况没问题:
>>> x = x + y
因为这意味着将x + y
赋值给x
,这基本上意味着覆盖这个名称。如果你选择覆盖x
的先前值,这是你的选择。但就我而言,就地操作至少没有不可变性的感觉。我更愿意在 Python 中不能做到这一点。
如果没有不可变性,那么也许其他的因素应该说服我更常使用元组?但是什么呢?性能?元组的性能较差,因此这并不能说服我。在执行时间方面,毫无争议;它们确实比相应的列表慢。你可以说在内存方面。确实,它们占用的磁盘空间更少,但差异微妙,对于长容器来说——完全可以忽略。RAM 内存使用?这个论点也没有太成功,因为通常列表的效率和元组一样——有时甚至更高。如果我们有一个巨大的集合,生成器在内存方面会表现更好。
尽管如此,元组在 Python 中确实有其存在的意义。它们非常频繁地被用来从函数或方法中返回两个或三个项——所以,作为小型未命名记录。它们被用作可迭代解包的输出。它们构成了命名元组的基础——collections.namedtuple
和typing.NamedTuple
——这些是元组的强大兄弟,可以用作具有命名字段的记录。
总的来说,我不再像写这篇文章之前那样喜欢元组了。我曾把它们视为一个重要的 Python 类型;现在在我眼中它们不再那么重要——但我接受它们在 Python 中的各种使用场景,甚至喜欢其中一些。
我对元组是否不公平?也许。如果你这么认为,请在评论中告诉我。我总是很享受与读者的有益讨论。
感谢阅读。如果你喜欢这篇文章,你可能也会喜欢我写的其他文章;你可以在这里查看。如果你想加入 Medium,请使用下面的推荐链接:
[## 使用我的推荐链接加入 Medium — Marcin Kozak
阅读 Marcin Kozak 的每一个故事(以及 Medium 上的其他成千上万的作者)。你的会员费直接支持…
资源
了解列表推导(listcomps)、集合推导(setcomps)、字典推导的细节…
towardsdatascience.com [## PEP 3132 — 扩展的可迭代解包
这个 PEP 提议对可迭代解包语法进行更改,允许指定一个“全能”名称来接收…
peps.python.org [## Fluent Python, 第 2 版
Python 的简洁性让你能够迅速提高生产力,但这通常意味着你没有充分利用它所具备的所有功能…
附录
在这个附录中,你将找到我用来基准测试元组与列表的脚本。我使用了perftester
包,你可以在这篇文章中阅读相关信息:
## 轻松进行 Python 函数基准测试:perftester
你可以使用 perftester 轻松对 Python 函数进行基准测试
towardsdatascience.com
这是代码:
import perftester
from collections import namedtuple
from typing import Callable, Optional
Length = int
TimeBenchmarks = namedtuple("TimeBenchmarks", "tuple list better")
MemoryBenchmarks = namedtuple("MemoryBenchmarks", "tuple list better")
Benchmarks = namedtuple("Benchmarks", "time memory")
def benchmark(func_tuple, func_list: Callable,
number: Optional[int] = None) -> Benchmarks:
# time
t_tuple = perftester.time_benchmark(func_tuple, Number=number)
t_list = perftester.time_benchmark(func_list, Number=number)
better = "tuple" if t_tuple["min"] < t_list["min"] else "list"
time = TimeBenchmarks(t_tuple["min"], t_list["min"], better)
# memory
m_tuple = perftester.memory_usage_benchmark(func_tuple)
m_list = perftester.memory_usage_benchmark(func_list)
better = "tuple" if m_tuple["max"] < m_list["max"] else "list"
memory = MemoryBenchmarks(m_tuple["max"], m_list["max"], better)
return Benchmarks(time, memory)
def comprehension(n: Length) -> Benchmarks:
"""List comprehension vs tuple comprehension.
Here, we're benchmarking two operations:
* creating a container
* looping over it, using a for loop; nothing is done in the loop.
"""
def with_tuple(n: Length):
x = tuple(i**2 for i in range(n))
for _ in x:
pass
def with_list(n: Length):
x = [i**2 for i in range(n)]
for _ in x:
pass
number = int(10_000_000 / n) + 10
return benchmark(lambda: with_tuple(n), lambda: with_list(n), number)
def empty_container() -> Benchmarks:
"""List vs tuple benchmark: creating an empty container."""
return benchmark(lambda: tuple(), lambda: [], number=100_000)
def short_literal() -> Benchmarks:
"""List vs tuple benchmark: tuple literal."""
return benchmark(lambda: (1, 2, 3), lambda: [1, 2, 3], number=100_000)
def long_literal() -> Benchmarks:
"""List vs tuple benchmark: tuple literal."""
return benchmark(
lambda: (1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,),
lambda: [1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,],
number=100_000)
def func_with_range(n: Length) -> Benchmarks:
"""List vs tuple benchmark: func(range(n))."""
def with_tuple(n: Length):
return tuple(range(n))
def with_list(n: Length):
return list(range(n))
number = int(10_000_000 / n) + 10
return benchmark(lambda: with_tuple(n), lambda: with_list(n), number)
def concatenation(n: Length) -> Benchmarks:
"""List vs tuple benchmark: func(range(n))."""
def with_tuple(x: tuple):
x += x
return x
def with_list(y: list):
y += y
return y
number = int(10_000_000 / n) + 10
return benchmark(lambda: with_tuple(tuple(range(n))),
lambda: with_list(list(range(n))),
number)
def repeated_concatenation(n: Length) -> Benchmarks:
"""List vs tuple benchmark: func(range(n))."""
def with_tuple(x: tuple):
x *= 5
return x
def with_list(y: list):
y *= 5
return y
number = int(10_000_000 / n) + 10
return benchmark(lambda: with_tuple(tuple(range(n))),
lambda: with_list(list(range(n))), number)
if __name__ == "__main__":
n_set = (3, 10, 20, 50, 100, 10_000, 1_000_000)
functions = (
comprehension,
empty_container,
short_literal,
long_literal,
func_with_range,
concatenation,
repeated_concatenation,
)
functions_with_n = (
comprehension,
func_with_range,
concatenation,
repeated_concatenation,
)
results = {}
for func in functions:
name = func.__name__
print(name)
if func in functions_with_n:
results[name] = {}
for n in n_set:
results[name][n] = func(n)
else:
results[name] = func()
perftester.pp(results)
以下是结果:
{'comprehension': {3: Benchmarks(time=TimeBenchmarks(tuple=9.549e-07, list=8.086e-07, better='list'), memory=MemoryBenchmarks(tuple=15.62, list=15.63, better='tuple')),
10: Benchmarks(time=TimeBenchmarks(tuple=2.09e-06, list=1.94e-06, better='list'), memory=MemoryBenchmarks(tuple=15.64, list=15.64, better='list')),
20: Benchmarks(time=TimeBenchmarks(tuple=4.428e-06, list=4.085e-06, better='list'), memory=MemoryBenchmarks(tuple=15.64, list=15.65, better='tuple')),
50: Benchmarks(time=TimeBenchmarks(tuple=1.056e-05, list=9.694e-06, better='list'), memory=MemoryBenchmarks(tuple=15.69, list=15.69, better='list')),
100: Benchmarks(time=TimeBenchmarks(tuple=2.032e-05, list=1.968e-05, better='list'), memory=MemoryBenchmarks(tuple=15.7, list=15.7, better='list')),
10000: Benchmarks(time=TimeBenchmarks(tuple=0.002413, list=0.002266, better='list'), memory=MemoryBenchmarks(tuple=15.96, list=16.04, better='tuple')),
1000000: Benchmarks(time=TimeBenchmarks(tuple=0.2522, list=0.2011, better='list'), memory=MemoryBenchmarks(tuple=54.89, list=54.78, better='list'))},
'concatenation': {3: Benchmarks(time=TimeBenchmarks(tuple=3.38e-07, list=3.527e-07, better='tuple'), memory=MemoryBenchmarks(tuple=31.45, list=31.45, better='list')),
10: Benchmarks(time=TimeBenchmarks(tuple=4.89e-07, list=4.113e-07, better='list'), memory=MemoryBenchmarks(tuple=31.45, list=31.45, better='list')),
20: Benchmarks(time=TimeBenchmarks(tuple=5.04e-07, list=4.368e-07, better='list'), memory=MemoryBenchmarks(tuple=31.45, list=31.45, better='list')),
50: Benchmarks(time=TimeBenchmarks(tuple=7.542e-07, list=6.22e-07, better='list'), memory=MemoryBenchmarks(tuple=31.45, list=31.45, better='list')),
100: Benchmarks(time=TimeBenchmarks(tuple=1.133e-06, list=9.005e-07, better='list'), memory=MemoryBenchmarks(tuple=31.45, list=31.45, better='list')),
10000: Benchmarks(time=TimeBenchmarks(tuple=0.0001473, list=0.000126, better='list'), memory=MemoryBenchmarks(tuple=31.7, list=31.7, better='list')),
1000000: Benchmarks(time=TimeBenchmarks(tuple=0.04862, list=0.04247, better='list'), memory=MemoryBenchmarks(tuple=123.5, list=125.4, better='tuple'))},
'empty_container': Benchmarks(time=TimeBenchmarks(tuple=1.285e-07, list=1.107e-07, better='list'), memory=MemoryBenchmarks(tuple=23.92, list=23.92, better='list')),
'func_with_range': {3: Benchmarks(time=TimeBenchmarks(tuple=3.002e-07, list=3.128e-07, better='tuple'), memory=MemoryBenchmarks(tuple=23.92, list=23.92, better='list')),
10: Benchmarks(time=TimeBenchmarks(tuple=4.112e-07, list=3.861e-07, better='list'), memory=MemoryBenchmarks(tuple=23.92, list=23.92, better='list')),
20: Benchmarks(time=TimeBenchmarks(tuple=4.228e-07, list=4.104e-07, better='list'), memory=MemoryBenchmarks(tuple=23.93, list=23.93, better='list')),
50: Benchmarks(time=TimeBenchmarks(tuple=5.761e-07, list=5.068e-07, better='list'), memory=MemoryBenchmarks(tuple=23.93, list=23.94, better='tuple')),
100: Benchmarks(time=TimeBenchmarks(tuple=7.794e-07, list=6.825e-07, better='list'), memory=MemoryBenchmarks(tuple=23.94, list=23.94, better='list')),
10000: Benchmarks(time=TimeBenchmarks(tuple=0.0001536, list=0.000159, better='tuple'), memory=MemoryBenchmarks(tuple=24.67, list=24.67, better='list')),
1000000: Benchmarks(time=TimeBenchmarks(tuple=0.03574, list=0.03539, better='list'), memory=MemoryBenchmarks(tuple=91.7, list=88.45, better='list'))},
'long_literal': Benchmarks(time=TimeBenchmarks(tuple=1.081e-07, list=8.712e-07, better='tuple'), memory=MemoryBenchmarks(tuple=23.92, list=23.92, better='list')),
'repeated_concatenation': {3: Benchmarks(time=TimeBenchmarks(tuple=3.734e-07, list=3.836e-07, better='tuple'), memory=MemoryBenchmarks(tuple=31.83, list=31.83, better='list')),
10: Benchmarks(time=TimeBenchmarks(tuple=4.594e-07, list=4.388e-07, better='list'), memory=MemoryBenchmarks(tuple=31.83, list=31.83, better='list')),
20: Benchmarks(time=TimeBenchmarks(tuple=5.975e-07, list=5.578e-07, better='list'), memory=MemoryBenchmarks(tuple=31.83, list=31.83, better='list')),
50: Benchmarks(time=TimeBenchmarks(tuple=9.951e-07, list=8.459e-07, better='list'), memory=MemoryBenchmarks(tuple=31.83, list=31.83, better='list')),
100: Benchmarks(time=TimeBenchmarks(tuple=1.654e-06, list=1.297e-06, better='list'), memory=MemoryBenchmarks(tuple=31.83, list=31.83, better='list')),
10000: Benchmarks(time=TimeBenchmarks(tuple=0.0002266, list=0.0001945, better='list'), memory=MemoryBenchmarks(tuple=31.83, list=31.83, better='list')),
1000000: Benchmarks(time=TimeBenchmarks(tuple=0.09504, list=0.08721, better='list'), memory=MemoryBenchmarks(tuple=169.4, list=169.4, better='tuple'))},
'short_literal': Benchmarks(time=TimeBenchmarks(tuple=1.048e-07, list=1.403e-07, better='tuple'), memory=MemoryBenchmarks(tuple=23.92, list=23.92, better='list'))}
我决定对更大的n
进行内存使用基准测试,即 500 万和 1000 万。我不会在这里展示代码,如果你有时间,可以基于上面的脚本写一个代码,这将是一个不错的练习。
如果你只想查看代码,你可以在这里找到。请注意,我可以改进代码,例如将两个实验的代码合并。我决定不这样做,以保持两个脚本的简单性。
这是结果:
{'comprehension': {5000000: MemoryBenchmarks(tuple=208.8, list=208.8, better='list'),
10000000: MemoryBenchmarks(tuple=402.2, list=402.2, better='tuple')},
'concatenation': {5000000: MemoryBenchmarks(tuple=285.4, list=247.2, better='list'),
10000000: MemoryBenchmarks(tuple=554.8, list=478.5, better='list')},
'func_with_range': {5000000: MemoryBenchmarks(tuple=400.4, list=396.4, better='list'),
10000000: MemoryBenchmarks(tuple=402.2, list=402.2, better='list')},
'repeated_concatenation': {5000000: MemoryBenchmarks(tuple=399.8, list=361.7, better='list'),
10000000: MemoryBenchmarks(tuple=783.7, list=707.4, better='list')}}
如你所见,对于我们研究的操作,元组要么占用相同的内存,要么占用更多的内存——有时甚至显著更多(例如,比较554.8
与478.5
或783.7
与707.4
)。
Python 类型提示:鸭子类型兼容性和与一致
原文:
towardsdatascience.com/python-type-hinting-duck-type-compatibility-and-consistent-with-72e8b348d8ac
PYTHON PROGRAMMING
当你在提示float
时,你不必提示int
,当你在提示tuple
时,也不必提示namedtuple
。为什么?
·发布于Towards Data Science ·阅读时间 8 分钟·2023 年 6 月 6 日
–
由Markus Winkler在Unsplash提供的照片
有时,Python 类型提示可以使事情变得更简单。确实,并不总是如此——但至少在我看来,通常它确实能做到——前提是明智地使用它。有些人不同意,但我不打算与他们争论:在我看来,这是一个相当主观的话题。
我在以下文章中写了我对 Python 类型提示的看法,如何使用它来提高代码可读性,以及如何不使用它以达到其他目的:
[## Python 的类型提示:朋友、敌人还是仅仅是个头疼的问题?
类型提示在 Python 社区中的受欢迎程度正在上升。这会将我们带到哪里?我们可以做些什么来使用它……
betterprogramming.pub](https://betterprogramming.pub/pythons-type-hinting-friend-foe-or-just-a-headache-73c7849039c7?source=post_page-----72e8b348d8ac--------------------------------)
今天,我们将讨论在 Python 类型中,与一致(consistent-with)和鸭子类型兼容性(duck-type compatibility)的含义。
想象一下你在提示使用float
,就像下面的函数:
from collections.abc import Sequence
def sum_of_squares(x: Sequence[float]) -> float:
n, s = len(x), sum(x)
return sum((x_i - s/n)**2 for x_i in x)
这是一个典型的统计函数,用于计算一个变量的平方和。它接受一个浮点数的容器并返回一个浮点数。
正如你所见,为了注释这个函数,我使用了Sequence
,这是一个从collections.abc
(在 Python 3.9 之前,你需要使用typing.Sequence
)中提供的通用抽象基类。这意味着你可以提供一个列表或一个元组——但你不能提供,例如,一个生成器¹。
好的,所以这是一个统计函数,它期望一个浮点数的序列。这是有道理的,对吧?但在实际生活中,相当多的定量变量是整数,比如每个芽的螨虫数量、销售的物品数量、人口数量,仅举几例。
那么我们是不是应该对函数做一些调整,以考虑到这个事实呢?我们都知道,动态情况下,函数对整数是完全有效的,而且动态情况下,我们可以轻松地将整数和浮点数结合在 x
中。但是类型提示和静态检查器呢?
对于这个函数,使用 int
是否合适,还是我们应该更清楚地说明它也接受 int
值?我们应该像下面这样做吗?
def sum_of_squares(x: Sequence[float | int]) -> float:
n, s = len(x), sum(x)
return sum((x_i - s/n)**2 for x_i in x)
这很明显:你可以使用浮点数或整数的序列,函数会返回一个浮点数。从类型提示的角度来看,这个版本不是更好吗?
为了回答这个问题,我们回到之前的版本,没有 int
。静态类型检查器对此有何看法?
一句话也不说!看看 Pylance
(在 Visual Studio Code 中)对它的说法:
来自 Visual Studio Code 的截图:Pylance 没有指出任何错误。图片由作者提供
没有!如果 Pylance
发现静态错误,我们会看到它被红色下划线标出。在这里,这是 mypy
的看法:
Mypy 说当你使用 int
进行浮点数注解时一切正常。图片由作者提供
为什么你可以用 int
代替 float
?
我们已经进入了本文的主要话题。简而言之,当你提示 float
时,你可以使用 int
代替。
首先,我们来看看 mypy
文档中描述鸭子类型兼容性的网页:
[## 鸭子类型兼容性 - mypy 1.3.0 文档
在 Python 中,某些类型即使不是彼此的子类,仍然是兼容的。例如,对象是…
这就是我们将在那里看到的内容之一:
在 Python 中,某些类型即使不是彼此的子类,仍然是兼容的。例如,
int
对象在期望float
对象的地方是有效的。Mypy 通过 鸭子类型兼容性 支持这种惯用法。
哈!
不用担心,这不会过多扩展你对类型提示的知识:
这对于一小部分内置类型是被支持的:
int
是与float
和complex
兼容的鸭子类型。
float
是与complex
兼容的鸭子类型。
bytearray
和memoryview
是与bytes
兼容的鸭子类型。
所以现在我们知道了。当我们已经提示使用 float
时,不必再提示 int
。这将和 float | int
(或 Union[float, int]
)完全一样。这意味着提示中的 | int
部分是多余的。
就像 int
与 float
是鸭子类型兼容的,它也与 complex
是鸭子类型兼容的,float
与 complex
是鸭子类型兼容的,同时 bytearray
和 memoryview
也与 bytes
是鸭子类型兼容的。
好的,那是 mypy
。现在,让我们看看我最喜欢的 Python 书籍,我在文章中经常提到的那本书:Fluent Python,第 2 版,由 Luciano Ramalho 编写:
一个无障碍友好的 Hugo 主题,从原始的 Cupper 项目移植过来。
www.fluentpython.com](https://www.fluentpython.com/?source=post_page-----72e8b348d8ac--------------------------------)
要了解这里发生了什么,我们应该转到 Luciano 解释 consistent-with 意思的地方。他写道,我们不需要将 int
添加到 float
类型提示中,因为 int
是 consistent-with float
。
那么 consistent-with 是什么意思呢?(是的,Luciano 每次都使用连字符和斜体来表示 consistent-with,这与 PEP 484 不同。)
正如他解释的那样,当 T1
是 T2
的子类型时,T2
是 consistent-with T1
。换句话说,一个子类是 consistent-with 所有它的超类——尽管有一些例外,这些例外扩展了 consistent-with 的定义。根据 PEP 484 的这一部分,Luciano 解释说,这一定义还包括了上述提到的数字场景。
当我们添加与类型 consistent-with bytes
的场景时,我们将有以下 consistent-with 的定义:
当 T2
是 consistent-with T1
时:
-
T1
是T2
的子类型,或者 -
T1
与T2
是鸭子类型兼容的。
我们需要记住的是,如果一种类型是 consistent-with 另一种类型,它要么是其子类型(子类),要么是与之鸭子类型兼容的——这归结为一个事实:只需对后者进行类型提示即可;你可以简单地省略前者。
说实话,我经常犯这样的错误——我的意思是,我做了这种多余的事情,类似如下:
from typing import Iterable
def sum_of_squares(x: Iterable[float | int]) -> float:
n, s = len(x), sum(x)
return sum((x - s/n)**2)
我一直认为通过澄清 x
可以包含整数和浮点数,我是在让用户的生活更轻松。
我吗?我不知道。确实,我使代码变得冗长。一个不知道int
是float
的鸭子类型的人可能会想,为什么只有float
?另一方面,我们不应该以让那些不了解的人容易理解的方式编写代码。当然,有一些限制,但我认为这种情况并没有越界。此外,任何稍微懂一点 Python 的人应该知道,在期望float
的地方,可以使用int
;这是一种相当普遍的知识。无论如何,这也是我写这篇文章的原因之一——让我的读者知道,不仅int
可以动态地代替float
,从静态检查器的角度来看这也是可以的。
让我们回到sum_of_squares()
函数。当你了解鸭子类型兼容性时,简洁版是一样清晰但更短,因此更干净:
from typing import Iterable
def sum_of_squares(x: Iterable[float]) -> float:
n, s = len(x), sum(x)
return sum((x - s/n)**2)
所以,我可以说,我对 Python 知识的缺乏让我认为我是在为我的代码用户做好事——现在我知道我不是。
命名元组
对于collection.namedtupes
和typing.NamedTuples
,情况类似,但有一点小差别。这两种类型都是常规tuple
类型的子类型,这就是它们与…一致的原因。
这就是为什么下面的注释是……嗯,它不是最好的:
from collections import namedtuple
from typing import NamedTuple
def join_names(names: tuple | namedtuple | NamedTuple) -> str:
return " ".join(names)
这个函数本身在我写过的函数中不是最聪明的,但这不是重点。重点是,如果你想接受一个tuple
、一个namedtuple
和一个NamedTuple
,你可以这样做:
def join_names(names: tuple) -> str:
return " ".join(names)
然而,如果你只想接受两种命名元组中的一个,你可以进行类型提示,例如:
from collections import namedtuple
def join_names(names: namedtuple) -> str:
return " ".join(names)
在这里,只能使用collections.namedtuple
及其子类的实例。你当然可以以相同的方式指明typing.NamedTuple
,这样collections.namedtuple
就不能使用了。记住,如果T1
与T2
一致,并不意味着T2
也一致于T1
。
记住,如果
T1
与T2
一致,并不意味着T2
也一致于T1
。
结论
我们了解了与…一致和鸭子类型兼容性的含义。不要害怕在代码中使用这些知识。你知道如何回应以下问题:“为什么只有float
?如果我想使用int
呢?”
脚注
¹ sum_of_squares()
以这种方式定义不接受生成器是有充分理由的。要理解原因,请分析函数的主体,并考虑生成器是如何工作的。
注意,计算len(x)
会消耗生成器——所以,函数将无法计算x
的和。看:
>>> sum_of_squares((i for i in (1., 2, 34)))
Traceback (most recent call last):
...
n, s = len(x), sum(x)
^^^^^^
TypeError: object of type 'generator' has no len()
Pylance
大喊:
mypy
也不喜欢:
error: Argument 1 to "sum_of_squares" has incompatible type
"Generator[float, None, None]"; expected "Sequence[Union[float, int]]"
[arg-type]
你是否看到使用静态类型检查器可以帮助你捕捉那些否则会在运行时被发现的错误?
所以,类型提示值得称赞?是的——但要称赞好的类型提示!
感谢阅读。如果你喜欢这篇文章,你可能也会喜欢我写的其他文章;你可以在这里看到它们。如果你想加入 Medium,请使用下面的推荐链接:
[## 使用我的推荐链接加入 Medium - Marcin Kozak
阅读 Marcin Kozak 的每个故事(以及 Medium 上的其他成千上万位作家的故事)。你的会员费将直接支持…
medium.com](https://medium.com/@nyggus/membership?source=post_page-----72e8b348d8ac--------------------------------)
Python 类型提示:从类型别名到类型变量和新类型
PYTHON 编程
查看类型别名、类型变量和新类型的实际应用
·发表于 Towards Data Science ·15 分钟阅读·2023 年 4 月 26 日
–
Python 提供了类型提示。选择权仍在你手中。图片来自 William Felker Unsplash
正如我在下面的文章中所写,如果你想在 Python 中使用类型提示,请以正确的方式进行:
[## Python 的类型提示:朋友、敌人还是只是个麻烦?
类型提示在 Python 社区中的受欢迎程度正在增加。这会把我们带向何方?我们可以做些什么来使用它……
betterprogramming.pub](https://betterprogramming.pub/pythons-type-hinting-friend-foe-or-just-a-headache-73c7849039c7?source=post_page-----a4a9e0400b6b--------------------------------)
什么是 正确的方式?简单来说,就是使你的代码从静态类型检查器的角度看起来 可读 和 正确 的方式。所以,两件事:可读 和 正确。
在上面的文章中我提到的事情之一是创建 类型别名 是提高可读性的好方法。我们将从它们开始讨论,重点讨论它们何时确实能提供帮助。然后,我们转向使用类型变量 (typing.TypeVar
) 和新类型 (typing.NewType
),这些将帮助我们实现常规类型别名无法实现的目标。
我将使用 Python 3.11 和 mypy
版本 1.2.0。
简而言之,使用类型别名的目的有两个:
-
以相对简单的方式让用户知道参数应该是什么类型(应该,因为我们仍在谈论 类型提示),以及
-
让静态检查器满意。
让静态检查器满意也应该让我们满意:一个不满意的类型检查器通常意味着错误,或至少是一些不一致性。
对于一些用户来说,第二点是唯一值得提及的——因为静态检查是他们使用类型提示的唯一原因。它帮助他们避免错误。
当然,这很棒——但这不是全部。类型提示可以帮助我们做更多的事情。并且请注意,如果我们的唯一目标是满足静态检查器,类型别名将没有用,因为它们根本不帮助静态检查器。它们帮助的是用户。
对我来说,这两个方面同样重要。如今,当我阅读一个函数时,我会特别注意其注释。注释写得好,它们能帮助我理解函数;注释写得不好——更不用说写得错误了——它们会使函数的可读性不如没有注释时那样好。
我们从类型别名开始。我会向你展示它们的两个主要用例。接着,我们将看到类型别名在相对简单的情况下如何提供帮助,有时我们需要更多的东西。在我们的案例中,类型变量和新类型将提供帮助。
复杂注释的类型别名
类型别名提供了一种简单而强大的工具,使类型提示更清晰。我将在这里重用Python 文档中的类型别名中的一个很好的且有说服力的例子:
from collections.abc import Sequence
ConnectionOptions = dict[str, str]
Address = tuple[str, int]
Server = tuple[Address, ConnectionOptions]
def broadcast_message(message: str,
servers: Sequence[Server]
) -> None:
...
正如文档所说,上述servers
的类型签名正好等于下面使用的签名:
def broadcast_message(
message: str,
servers: Sequence[tuple[tuple[str, int], dict[str, str]]]
) -> None:
...
正如你所见,等价性并不完全:虽然这两个签名在代码上确实是等效的,但它们在可读性上有所不同。关键在于这个类型签名:
servers: Sequence[tuple[tuple[str, int], dict[str, str]]]
尽管阅读和理解起来比较困难,但通过使用几个类型别名将其重定义为Sequence[Server]
后,已经变得更加清晰。类型别名在签名中传达的信息很有帮助。良好的命名可以产生奇迹。
请注意,我们可以通过添加一个更多的类型别名来使这个类型签名有所不同:
Servers = Sequence[Server]
servers: Servers
对我来说,Sequence[Server]
比Servers
要好得多,因为我立刻看到我处理的是一个实现了Sequence
协议的对象。它可以是一个列表。例如,我们已经有了参数的名称servers
,所以创建一个类型别名Servers
似乎是多余的。
当然,理解这个签名的每一个细节,使用这些类型别名并不简单:
ConnectionOptions = dict[str, str]
Address = tuple[str, int]
Server = tuple[Address, ConnectionOptions]
servers: Sequence[Server]
但由于类型别名ConnectionOptions
、Address
和Server
及其明确的含义,这比理解以下签名要简单得多:
servers: Sequence[tuple[tuple[str, int], dict[str, str]]]
简而言之,面对如此复杂的类型,原始的类型签名虽然让静态检查器满意——但不太可能让用户的生活变得更轻松。类型别名可以帮助实现这一点——它们有助于将关于变量、函数、类或方法的附加信息传达给用户。它们充当了一种沟通工具。
类型别名作为沟通工具:进一步的考虑
好吧,让我们跳到另一个例子。这次,我们将尝试利用类型别名来改善与用户的沟通,在一个比之前更简单的情况中。
正如我们所见,最重要的沟通工具是良好的命名。一个好的函数、类或方法名称应该明确表明其责任。当你看到 calculate_distance()
的名称时,你知道这个函数会计算距离;你会对看到一个返回两个字符串的元组的函数感到惊讶。当你看到 City
类时,你知道这个类会以某种方式表示一个城市——而不是一个动物、一辆车或一只海狸。
注释可以传达比函数(类、方法)及其参数名称更多的信息。换句话说,我们希望类型提示不仅能提示应该使用哪些类型,还能帮助用户理解我们的函数和类——并帮助他们提供正确的值。正如之前提到的,这可以通过命名良好的类型别名来实现。
让我们从一个简单的例子开始,这次使用变量类型提示。假设我们有如下的东西:
length = 73.5
当然,我们知道这个变量表示某物的长度。但这就是我们所知道的。首先,是什么长度?一个更好的名字可能会有所帮助:
length_of_parcel = 73.5
现在清楚了。想象一下你是一名送货员,你需要决定包裹是否能放进你的车里。那么,它能放进去吗?
如果有人根据上述知识做出了决定,他要么是那种“我会处理任何包裹”的人,要么是“最好不要冒险”的人。在这两种情况下,这都不是一个经过深思熟虑的决定。我们缺少单位,不是吗?
length_of_parcel = 73.5 # in cm
更好!但这仍然只是一个注释,如果代码本身提供这些信息会更好;上面没有提供,但这里提供了:
Cm = float
length_of_parcel: Cm = 73.5
我们再次使用了类型别名。但请记住,这只是一个类型别名,对于 Python 来说,length_of_parcel
仍然只是一个 float
,别无其他。然而,对我们来说,这意味着很多——这个包裹的长度是 73.5 厘米。
让我们进入一个更复杂的情况,即从变量注释到函数注释。假设我们想实现一个计算矩形周长的函数。我们从没有注释开始:
def get_rectangle_circumference(x, y):
return 2*x + 2*y
简单。符合 Python 习惯¹。正确。
我们已经熟悉了这个问题:没有注释,用户不知道函数期望什么样的数据。厘米?英寸?米?公里?实际上,函数将处理字符串:
>>> get_rectangle_circumference("a", "X")
'aaXX'
嗯。确实,这有效——但没有意义。我们希望用户能够用我们的函数处理这样的东西吗?我们希望用户说:
嘿,他们的函数告诉我,当我用边长为
"a"
和"X"
的矩形时,这个矩形的周长是"aaXX"
,哈哈!
不,还是不行。确实,函数的名称说明了函数的作用,但如果能让用户知道函数期望什么样的数据会更有帮助。然后我们可以回应:
嘿,你不能读吗?难道你看不出这个函数期望浮点数吗?或者你认为字符串是浮点数,哈哈?
我认为最好避免这种哈哈式讨论。所以,类型提示是一个大好的选择。我们继续吧。
好的,我们有一个矩形,它有四条边,x
和y
是它们的长度。用户提供什么单位并不重要,因为函数适用于任何长度单位;它可以是厘米、英寸、公里,任何长度单位。真正重要的是——实际上,区别很大——是x
和y
都必须使用相同单位。否则,函数将无法正确工作。这是可以的:
>>> x = 10 # in cm
>>> y = 200 # in cm
>>> get_rectangle_circumference(x, y) # in cm
420
但这不是:
>>> x = 10 # in cm
>>> y = 2 # in m
>>> get_rectangle_circumference(x, y) # incorrect!
24
问题是,即使这个调用毫无意义,我们也知道这一点,但从 Python 的角度来看,它是正确的——两者都一样。
-
动态地:我们会得到
24
;以及 -
静态地:
x
和y
都是浮点数。
问题是,我们没有让用户——以及 Python——知道两个参数x
和y
应该是相同单位的,只是他们应该使用浮点数。对于 Python 而言,浮点数就是浮点数,它不区分公里和英寸,更不用说千克了。
让我们检查一下是否可以使用类型提示来做些事情。换句话说:我们能否使用类型提示让用户知道他们应该为两个参数使用相同的类型,并且返回值也是这种类型呢?
最简单的注解是使用浮点数:
def get_rectangle_circumference(
x: float,
y: float) -> float:
return 2*x + 2*y
这个函数签名比没有注解的稍好,因为至少用户知道他们应该使用float
。但还是,英寸?厘米?米?实际上,为什么不使用千克?
那么,让我们尝试一个类型别名:
Cm = float
def get_rectangle_circumference(x: Cm, y: Cm) -> Cm:
return 2*x + 2*y
清楚了吧?mypy
会鼓掌:
Pylance
也是如此。用户知道他们应该提供厘米,并且函数会以厘米为单位返回周长。Cm
是一个类型别名,这基本上意味着它仍然是float
,Cm
和float
之间没有区别。但关键是,用户知道。
然而,静态检查器在这种情况下不会太有帮助。你可以提供一个float
的额外类型别名,它将与Cm
以及任何float
一样被对待:
Cm = float
M = float
def get_rectangle_circumference(x: Cm, y: Cm) -> Cm:
return 2*x + 2*y
x: Cm = 10
y: M = 10
get_rectangle_circumference(x, y)
类型检查器对此完全没问题,因为Cm
和M
只是相同类型的别名,即float
。基本上,对于静态检查器而言,Cm
不仅等同于float
,也等同于M
。因此,如果你想在这种情况下使用类型别名,你必须记住它们只是……别名——仅此而已!
我相信你已经注意到使用Cm
类型别名的上面签名的另一个大缺点。为什么用户要用厘米提供x
和y
,而他们的单位是英寸或其他单位?转换?然后怎么办,转换回来?那简直疯狂!
嗯……也许我们可以创建一个与距离(或长度)相关的float
别名?
DistanceUnit = float
def get_rectangle_circumference(
x: DistanceUnit,
y: DistanceUnit
) -> DistanceUnit:
return 2*x + 2*y
mypy
将再次发出警告,因为我们唯一更改的是名称。但这并没有改变其他任何东西:用户仍然可以犯提供不同单位值的相同错误,这些值都将是DistanceUnit
,如厘米和英寸。至少用户知道他们不应该提供千克。
正如你所见,类型别名无法帮助我们解决这个问题。一方面,我认为我们可以假设使用 Python 的人应该知道在计算矩形周长时,应该以相同的单位提供边的长度。这不是 Python 知识。这是简单的数学。
然而,在一些其他场景中,你可能想要让事情变得清晰,因为并非所有事情都像计算矩形周长那样清晰。我们知道类型别名没有帮助,所以让我们转向typing
的其他两个工具:类型变量(TypeVar
)和新类型(NewType
)。它们会有帮助吗?
类型变量和新类型
如果你真的想实现如此详细的类型提示,你可以这么做。然而,请注意,这会使代码变得更复杂。为此,typing.NewType
和typing.TypeVar
可以提供帮助。
让我们从NewType
开始。这是一个typing
工具,用于创建具有最小运行时开销的新类型(参见附录 1)。以这种方式创建的类型提供的功能很有限,因此当你只需要明确的类型提示和将值转换到这种类型的可能性时,你应该优先使用它们。它的优点是它与静态检查工具兼容(正如我们稍后将看到的)。它的缺点——在我看来,这是一个相当大的缺点——是使用typing.NewType
创建的类型不被isinstance
视为类型(至少在 Python 3.11.2 中如此——我希望将来版本会有所改变):
Python 3.11.2 的截图:typing.NewType
类型不被isinstance()
视为类型。图片由作者提供。
对我来说,这是一个严重的问题。但正如你将看到的,typing.NewType
类型仍然非常有用,开销较小(如附录 1 所示)。
因此,我们想要创建代表我们距离相关单位的类型。问题是,我们需要创建的类型数量与我们要考虑的单位数量相同。为了简化,让我们将它们限制为几个基于国际单位制(SI 单位)的最重要的长度单位。这是你在处理项目时的做法,其中类型数量有限。然而,当你在开发一个供他人使用的框架时,你应该创建更多的类型。
在我们的情况下,四种类型就足够了:
from typing import NewType
Mm = NewType("Mm", float)
Cm = NewType("Cm", float)
M = NewType("M", float)
Km = NewType("Km", float)
NewType
创建子类型——因此,Mm
、Cm
、M
和 Km
都是 float 的子类型。它们可以在任何 float
可以使用的地方使用,但静态检查器将不接受任何这些四种子类型应使用的普通 float
值。你需要将这样的 float
值转换为所需的类型;例如,你可以执行 distance = Km(30.24)
,意味着距离为 30
公里和 240
米。
让我们看看用于注解这个简单函数的类型:
def km_to_mm(x: Km) -> Mm:
return x * 1_000_000
Pylance
听到:
来自 VSCode 的 Pylance 截图。图片由作者提供
这是因为 x / 1_000_000
给出一个浮点数,而我们指明函数返回 Mm
类型的值。为实现这一点,我们需要将返回值转换为预期的类型:
def km_to_mm(x: Km) -> Mm:
return Mm(x * 1_000_000)
如你所见,使用 typing.NewType
创建的类型可以作为可调用对象(在 Python 3.10 之前它们是函数;现在它们是类)用于将值转换为它们的类型。这在这种情况下非常方便。
但这将如何帮助我们处理 get_rectangle_circumference()
函数?我们仍然有四种不同的 float
子类型,我们希望函数返回其 x
和 y
参数的确切类型。
现在是引入新 typing
工具——类型变量,或 typing.TypeVar
的时候了。事实证明,类型变量可以帮助我们实现所需的功能:
from typing import NewType, TypeVar
Mm = NewType("Mm", float)
Cm = NewType("Cm", float)
M = NewType("M", float)
Km = NewType("Km", float)
DistanceUnit = TypeVar("DistanceUnit", Mm, Cm, M, Km)
def get_rectangle_circumference(
x: DistanceUnit,
y: DistanceUnit) -> DistanceUnit:
t = type(x)
return t(2*x + 2*y)
与之前使用类型别名时不同,这次你不能混合不同的类型。让我们看看静态类型检查器 Pylance
如何处理此函数的三种不同调用:
浮点数无效:
(1) 浮点数无效。图片由作者提供
你不能混合不同的类型:
(2) 两种不同的类型无效。图片由作者提供
函数通过静态检查的唯一方法是对两个长度使用相同类型:
(3) 仅相同类型的两个参数有效。图片由作者提供
当然,返回值的类型将与两个参数的类型匹配——例如,当你提供米时,你会得到米。这就是为什么我们需要 t = type(x)
行的原因。我们可以使函数稍微简短一些:
更短版本的函数。图片由作者提供
对于中级和高级 Python 使用者,两种版本的可读性可能相当;然而,对于初学者来说,前者可能更容易理解。
注意,DistanceUnit
类型别名不会以相同方式工作:
DistanceUnit 的类型别名无法按要求工作。图片由作者提供
在这里,你可以在调用 get_rectangle_circumference()
时混合不同类型,这正是我们想要避免的;而类型变量帮助我们实现了这一点。
所以,我们达到了我们想要的目标。尽管任务看起来不算复杂,但类型别名并不足以实现我们的目的。然而,typing
的类型变量(TypeVar
)和新类型(NewType
)提供了帮助。
结论
类型提示在 Python 中不是必需的;它们是可选的。有时最好完全省略它们。然而,当你不得不使用它们时,应该明智地使用它们:让它们对你和你的代码用户有所帮助,而不是成为障碍。
我希望你现在已经准备好在自己的项目中使用 typing
的类型别名、类型变量和新类型,至少在类似的、相对简单的场景中使用。在这样做时,请记住不要过度使用这些工具。老实说,我很少决定使用类型变量和新类型。因此,在决定打开这些门之前,请三思。你的代码肯定会变得复杂得多,所以你必须有充分的理由去做这个决定。
我们已经涵盖了在 Python 类型提示系统中使用类型别名、类型变量和新类型的基本概念。这个话题还有很多内容,因为 Python 的静态检查系统仍在发展,但这种更多会带来更大的复杂性。今天就先说到这里,我们以后会在准备好专注于 Python 类型提示的更高级方面时再回到这个话题。
脚注
¹ 如果有人想对我大喊这不是Pythonic,因为函数没有注解,那么请让我提醒这个人,类型提示在 Python 中是可选的。如果某样东西是可选的,它不能作为声明代码是否 Pythonic 的决定性因素。
附录 1
与例如基于浮点数的自定义类相比,typing.NewType
的时间开销明显更小。下面的简单代码片段使用 perftester
来基准测试这两个方面:
-
使用
typing.NewType
或自定义类创建新类型哪个更快? -
哪种类型的使用更快(具体来说,将浮点值转换为该类型)?
import perftester
from typing import NewType
def typing_type_create():
TypingFloat = NewType("TypingFloat", float)
def class_type_create():
class ClassFloat(float): ...
TypingFloat = NewType("TypingFloat", float)
class ClassFloat(float): ...
def typing_type_use(x):
return TypingFloat(x)
def class_type_use(x):
return ClassFloat(x)
if __name__ == "__main__":
perftester.config.set_defaults("time", Number=1_000_000)
t_typing_create = perftester.time_benchmark(typing_type_create)
t_class_create = perftester.time_benchmark(class_type_create)
t_typing_use = perftester.time_benchmark(
typing_type_use, x = 10.0034
)
t_class_use = perftester.time_benchmark(
class_type_use, x = 10.0034
)
perftester.pp(dict(
create=dict(typing=t_typing_create["min"],
class_=t_class_create["min"]),
use=dict(typing=t_typing_use["min"],
class_=t_class_use["min"]),
))
这是我在我的机器上得到的结果:
基准测试结果:基于 typing 的方法更快。图片作者提供。
显然,typing.NewType
创建新类型的速度显著比自定义类快一个数量级。然而,它们在创建新类实例方面的速度差异不大。
上面的基准测试代码很简单,表明 perftester
提供了一个非常简单的 API。如果你想了解更多,阅读下面的文章:
基准测试 Python 函数的简单方法:perftester [## 基准测试 Python 函数的简单方法:perftester
你可以使用 perftester 以简单的方式基准测试 Python 函数
你当然可以使用 timeit
模块进行这种基准测试:
最受欢迎的 Python 代码时间基准测试工具,内置的 timeit 模块提供了超出大多数工具的功能…
towardsdatascience.com
感谢阅读。如果你喜欢这篇文章,你可能也会喜欢我写的其他文章;你可以在这里看到。如果你想加入 Medium,请使用我下面的推荐链接:
[## 使用我的推荐链接加入 Medium - Marcin Kozak
阅读 Marcin Kozak 的每一个故事(以及 Medium 上成千上万其他作家的故事)。你的会员费直接支持…
medium.com](https://medium.com/@nyggus/membership?source=post_page-----a4a9e0400b6b--------------------------------)
Python 类型提示在数据科学项目中:必须、可能还是不推荐?
PYTHON 编程
我们应该在 Python 实现的数据科学项目中使用类型提示吗?
·发表于 Towards Data Science ·阅读时间 6 分钟·2023 年 9 月 26 日
–
无论你是否是 Python 类型提示的满意用户,你都需要了解这些概念以及如何使用它们。照片由 Kerin Gedge 拍摄,来源于 Unsplash
我们应该在 Python 实现的数据科学项目中使用类型提示吗?
想要免责声明吗?请看这里:这要看情况。在概念验证类型的项目中,通常是不必要的。在生产项目中,至少在 2023 年,这还是有必要的。但再次说明,这要看情况。
我会尽量简明扼要,尽快切入重点。我不想花费数小时考虑所有的利弊,原因很简单,因为数据科学市场对我们的工作有明确的期望。我的目标是向你展示这些期望,而不是详细讨论它们。
让我们从显而易见的事情开始。首先,Python 中的类型提示是 可选的。可选的意思是 你不必在 Python 中使用类型提示。如果是这样,那么我们主要问题的唯一答案是:你可以,但不,您不必在数据科学项目中使用类型提示!
那么…就这样吗?我们完成了吗?
等一下。我们确实陈述了显而易见的内容,但我们并没有触及任何超出显而易见的内容。
我们应该在 Python 实现的数据科学项目中使用类型提示吗?这要看情况。在概念验证类型的项目中,这并不是必要的。在生产项目中,至少在 2023 年,这还是有必要的。
举个例子。假设你是一个在私人公司工作的 Python 开发者。公司有自己关于 Python 开发的规则和建议。其中一条规则是:使用类型提示。就这么简单——不管你偏好什么,你都必须使用它们。如果这只是一个建议,你可能不需要使用它们。然而,由于这是一个规则,你必须使用可选类型提示。
好的,这点说得很好。但我们讨论的是一般数据科学项目中的类型提示,而不是某个特定公司里的情况。那么,是可选的,对吗?你不一定非得使用它们?
在回答之前,让我告诉你我在 Python 中使用类型提示的方式、时间和原因。
我在这里写了我对类型提示的看法:
[## Python 的类型提示:朋友、敌人还是仅仅是个麻烦?
类型提示在 Python 社区中的受欢迎程度不断上升。这会把我们带向何方?我们可以做些什么来使用它……
简而言之,我尝试以一种使代码更具可读性的方法来使用类型提示。此外,多亏了类型提示,静态检查器可以帮助保持代码的正确性。
我们应该记住,Python 的核心在于动态类型。
同时,我们应该记住,Python 的核心在于动态类型。当我们在实现类型提示上花费大量精力时,我们有点像是在剥离 Python 的动态类型。那么剩下的是什么?剩下的就是没有核心的 Python。
当我们在实现类型提示上花费大量精力时,我们有点像是在剥离 Python 的动态类型。那么剩下的是什么?剩下的就是没有核心的 Python。
我认为,有些情况下不应该使用类型提示。例如快速原型设计。我经常这样做:为了看看某些东西如何工作,我实现一个简单的原型。有时我可能会使用一些类型提示,只是为了展示所需的参数类型,尤其是当这些类型是自定义类型时。记住,我说的是原型设计,如果说明一个特定的函数返回dict[str, tuple[int, str]]
类型的对象很重要,那我会在类型提示中说明它。不是为了让静态检查器满意,而是为了展示需要展示的内容。
但在原型设计阶段,我更常忽略类型提示。到时自然会用到它们。现在重要的是代码能够动态运行。这时静态类型并不那么有用。
但当我编写数据科学软件产品时,现在我总是使用类型提示。我会坦白地说。是的,它们可以(动态地)帮助捕捉一些静态错误——但它们也可能成为很大的障碍。有时候,我觉得实现良好的类型提示比其他所有工作加起来还要花费更多的时间。代码会变得更长、更复杂。在高级项目中,良好的类型提示可能很难实现,主要是由于代码的复杂性。更糟糕的是,许多类型检查器仍然远未达到最佳状态,可能会在类型提示正确时显示错误。确实,你可以保持类型提示过于简单(x: dict
),但通常不应该这样做。在生产项目中,你应该更详细,因此,不应做如下操作:
from typing import Optional
def foo(x: dict) -> Optional[dict]:
...
比如,你可能需要做如下操作:
def foo(x: dict[str, dict[str, float]]) -> Optional[dict[str, str]]:
...
或者,相当于:
from typing import Optional
Params = dict[str, dict[str, float]]
Descriptions = dict[str, str]
def foo(x: params) -> Optional[Descriptions]:
...
没有类型提示,代码变成了:
def foo(x):
...
哪种更好?你不会惊讶于我的答案:好吧,这要看情况。
让我们总结一下上述提供的选项:
-
无类型提示:基于鸭子类型的快速编码。
-
过于简化的类型提示:编码稍慢,但这种类型提示的优势相当有限。
-
更详细的类型提示:静态检查器提供了很大的帮助,但编码速度大幅降低,同时静态检查器失败的风险也大大增加;通常,鸭子类型变得隐蔽,如果不是被遗忘的话;代码可读性下降。
-
基于类型别名的更详细类型提示:与上述类似,但代码更易读。
-
极其详细的类型提示,细节到最深层次:对我来说,绝对是一种过于狂热的方法,没有优势且有许多缺点,和详细的类型提示一样——但在一种夸张的形式中。
-
专用工具如
[pylance](https://pypi.org/project/pylance/)
或[typeguard](https://pypi.org/project/typeguard/)
。但请记住,这些工具可能会导致运行时效率成本,如果你决定在运行时类型检查中使用它们——而类型提示本身是一个静态检查工具,对运行时没有影响。
如常,最佳选择通常在这些选项之间的某个地方,你可以称之为黄金选择。但这个选择的位置取决于项目的多个方面,如客户、公司、项目负责人以及——最重要的——项目的类型。
在原型设计过程中,你通常会选择不使用类型提示,特别是当你不是类型提示的忠实粉丝时。然而,当你是时,你会发现自己更经常地使用它们——除非截止日期太紧迫,甚至无法再多花一分多余的时间。那是多余的吗?好吧,你已经知道答案了……这要看情况。
上述方法是我在日常数据科学工作中遵循的做法。一些细节因项目而异,但大体上几乎都是一样的。我认为这些是大多数进行数据科学项目的公司遵循的规则,因此你也可能希望遵循这些规则——除非公司或项目的规则非常严格。否则,你可能没有选择的余地。
无论你是否是一个快乐的类型提示用户,你都应该了解它是如何工作的以及如何使用它。如今,一个不知道如何使用类型提示的 Python 开发者… 已经不再是 Python 开发者了。接受这一点:你必须了解类型提示,并且要了解得很透彻。
不过请记住,Python 的类型提示系统仅在一定复杂度下才有帮助。即使你作为代码的作者了解你实现的复杂类型提示的所有细节,其他开发者也可能需要花费很多时间来理解代码。如果我被迫越过这一界限,我可能会认为是时候换一种语言了。被迫走到这一步,我会认为像 Go 这样的静态类型语言可能会更好。
感谢阅读。如果你喜欢这篇文章,你也可能喜欢我写的其他文章;你可以在这里查看。如果你想加入 Medium,请使用下面的推荐链接:
## 通过我的推荐链接加入 Medium - Marcin Kozak
作为 Medium 的会员,你的一部分会员费用会流向你阅读的作者,你可以完全访问每一个故事…
Python 类型提示与字面量
原文:
towardsdatascience.com/python-type-hinting-with-literal-03c60ce42750
PYTHON 编程
比看起来更强大:使用typing.Literal
创建字面类型
·发布于 Towards Data Science ·15 min read·Nov 28, 2023
–
typing.Literal
创建具有选定选项的类型。图片由Caleb Jones提供,来源于Unsplash
我承认:我并不总是喜欢typing.Literal
,这是在 Python 中创建字面类型的一种形式。实际上,我不仅低估了字面类型,还完全忽视了它们,拒绝使用它们。出于某种原因,即使今天我也不太明白,我找不到字面类型的实际价值。
我有多么错误。我没有认识到这个简单工具的强大,我的代码因此受到了影响。如果你像我一样忽略了字面类型,我敦促你阅读这篇文章。我希望说服你,尽管它很简单,typing.Literal
可以成为你 Python 编程工具库中的一个非常有用的工具。
即使你已经认识到字面类型的价值,也不要停止阅读。虽然我们不会深入探讨typing.Literal
的所有细节,但这篇文章将提供比官方 Python 文档更全面的介绍,而不会像PEP 586那样陷入细节。
字面类型非常直接,可以使代码比没有字面类型的代码更清晰、更易读。这种简单性既是typing.Literal
的优点,也是其缺点,因为它不提供任何额外的功能。然而,我将向你展示如何自行实现附加功能。
这篇文章的目标是介绍typing.Literal
并讨论其在 Python 编程中的价值。在过程中,我们将探讨何时使用typing.Literal
——同样重要的是,何时不要使用它。
字面类型
字面量类型是通过PEP 586引入到 Python 类型系统中的。这个 PEP 提供了对字面量类型提案的全面探讨,是一个丰富的信息来源。相比之下,typing.Literal
类型的官方文档故意简洁,反映了它的直接性质。本文弥补了这两个资源之间的差距,提供了关于字面量类型的基本信息,同时深入探讨了我认为对所讨论用例至关重要的细节。
如PEP 586中所述,字面量类型在 API 根据参数值返回不同类型的场景中特别有用。我会进一步扩展这一说法,指出字面量类型允许创建一个涵盖特定值的类型,这些值不一定都是同一类型的。这并不排除所有值具有相同类型的可能性。
字面量类型提供了一种极其简单的方法来定义和利用具有特定值的类型,这些值是唯一可能的值。这种简单性远远超过任何替代方法。虽然确实可以使用其他方法实现相同的结果,但这些替代方案通常会带来更复杂的实现和潜在的更丰富功能。例如,创建你自己的类型(类)需要仔细考虑设计和实现,而创建字面量类型时可以完全忽略这些问题。
使用typing.Literal
通常提供了一个更简单的解决方案,往往简单得多,但功能可能有所减少。因此,在做出决定之前,必须仔细权衡两种方法的优缺点。本文可以帮助你做出明智的选择。
字面量中可接受的类型
要创建一个typing.Literal
类型,可以使用以下值:
-
一个
int
、bool
、str
或bytes
的字面量值 -
一个枚举值
-
None
像float
或自定义(非枚举)类的实例是不接受的。
字面量类型:用例
现在我们将探讨几个我认为字面量类型是绝佳选择(往往是最佳选择)的用例。我们还将检视一些可能更合适的替代解决方案。每个用例都假设需要一个只接受特定值的类型,这些值不一定都是同一类型的。typing.Literal
不会创建空类型,因此Literal[]
是无效的。然而,它可以创建具有单一值的字面量类型。
下述讨论的用例并不构成情境的详尽列表,而是作为示例,其中一些可能会重叠。这个非排他性列表旨在展示typing.Literal
提供的机会范围,并增强对这个有趣且有价值工具的理解。
示例 1:仅一个值
如前所述,当变量只接受单一值时,可以使用字面量类型。虽然这乍一看可能不符合直觉,文档提供了相关示例:
def validate_simple(data: Any) -> Literal[True]:
...
这个函数旨在进行数据验证,并始终返回 True
。换句话说,如果验证失败,函数会引发错误;否则,它会返回 True
。
理论上,如下所示的 bool
类型的返回值类型签名,对于静态检查器来说是可以接受的:
def validate_simple(data: Any) -> bool:
...
然而,该函数从未返回 False
,使得这个类型提示具有误导性和不准确性。使用 bool
表示函数根据情况可以返回两个布尔值中的任意一个。当函数始终只返回其中一个值而从不返回另一个时,使用 bool
是误导性的。
这正是字面量类型发挥作用的地方。它不仅满足静态检查器的要求,还为用户提供了有价值的信息。
示例 2:需要静态类型
当运行时类型检查不需要时,静态类型通常提供最有效的解决方案。因此,如果你需要一个接受一个或多个特定值的类型,并且你的主要目标是通知静态检查器,创建相应的字面量类型是一个极好的方法。
示例 3:多个字符串
此用例包含了一系列字符串,例如模式、产品或颜色。以下是一些示例:
Colors = Literal["white", "black", "grey"]
Grey = Literal["grey", "gray", "shades of grey", "shades of gray"]
Mode = Literal["read", "write", "append"]
如你所见,此用例中的字面量类型可以包含两个或更多的字符串。重要的是,使用 Literal
不允许我们建立个别值之间的关系。例如,我们可以创建以下字面量类型:
Days = Literal[
"Monday", "Tuesday", "Wednesday",
"Thursday", "Friday", "Saturday", "Sunday"
]
值的提供顺序是否重要?在 Python 3.9.1 之前,是的:
在 Python 3.9.1 之前,字面量类型中的值的顺序是重要的。图片由作者提供
但自那以后就不再重要了:
从 Python 3.9.1 开始,字面量类型中值的顺序不再重要。图片由作者提供
因此,重要的是可能的选择,而不是它们之间的关系。如果利用值的顺序是关键,考虑使用其他类型,而不是字面量类型。一个解决方案是利用枚举类型,使用 enum.Enum
类;我们将很快在专门的文章中深入探讨这个概念。
谨慎提醒:Python 3.11 及更新版本引入了typing.LiteralString
。这是一个不同的工具,因为与typing.Literal
不同,它作为一种类型存在,而不是创建类型的工具。在本文中,我们探讨了字面量类型的创建,我不希望引入与这个略有不同但相关的工具的混淆。如果你有兴趣了解更多,访问文章末尾的附录。不过,让我们现在将这个话题搁置。关键是,typing.LiteralString
不是typing.Literal
的字符串替代品。
typing.LiteralString
不是typing.Literal
的字符串替代品。
示例 4:相同类型的多个值
这个示例扩展了前一个示例,涵盖了更广泛的数据类型。就像我们为字符串使用字面量类型一样,我们也可以将它们应用于大多数其他数据类型。这里是一些示例:
Literal[1, 5, 22] # integers
Literal["1", "5", "22"] # strings
如上所述,你可以使用int
、bool
、str
或bytes
的字面量值、枚举值和None
。
示例 5:组合各种类型的值
这代表了字面量类型的最通用形式。你可以组合任何类型的对象,它将正常工作。这有些类似于使用typing.Union
类型,但与典型的Union
使用情况不同,我们是在组合对象而不是类型。
注意区别:一个常见的 Union 使用案例可能如下所示:
Union[int, str]
而一个组合了int
和str
类型对象的字面量类型可能如下:
Tens = Literal[10, "10", "ten"]
这里是一些其他示例:
Positives = Literal[True, 1, "true", "yes"]
Negatives = Literal[False, 0, "false", "no"]
YesOrNo = Literal[Positives, Negatives]
你可以创建以下类型:Literal[True, False, None]
。它类似于这里描述的OptionalBool
类型。
## An OptionalBool Type for Python: None, False or True
使用 OptionalBool 而不是 Optional[bool]。
上述文章中描述的OptionalBool
类型比基于Literal
的对应类型要复杂得多,后者既易于使用和理解,又具有显著较差的功能。
上述代码块中的三个例子也很有趣。它们显示了你可以创建两个(或更多)字面量类型的组合。这里,YesOrNo
是一个将两个其他字面量类型,即Positives
和Negatives
组合在一起的字面量类型:
在 Python 3.9.1 及更高版本中连接两个字面量类型。作者想象
但请记住,这在 Python 3.9.1 之前的版本中不会以相同的方式工作(我们之前讨论了类型定义中字面量的顺序):
在 Python 3.9.1 之前连接两个字面量类型。作者想象
示例 6:运行时 membership 检查
在前面的例子中,我们专注于字面量类型的静态应用。然而,这并不排除它们在运行时的使用,即使这偏离了 Python 类型提示的原意。在这里,我将演示当需要时,你可以对字面量类型进行运行时成员检查。换句话说,你可以验证一个给定的值是否属于字面量类型的可能选择集合。
坦白说,我认为这一单一能力使 typing.Literal
成为一个更强大的工具。虽然它偏离了字面量类型的传统用法(静态代码检查),但这并不是一种黑客行为。这是类型模块的一个合法功能:typing.get_args()
。
一个例子将最好地说明这个概念。首先,让我们定义一个字面量类型:
from typing import Any, get_args, Literal, Optional
Tens = Literal[10, "10", "ten"]
Tens
类型涵盖了数字 10
的各种表示形式。现在,让我们定义一个函数来验证一个对象是否具有 Tens
类型:
def is_ten(obj: Any) -> Optional[Tens]:
if obj in get_args(Tens):
return obj
return None
关于这个函数的几点说明:
-
它接受任何对象,并返回
Optional[Tens]
,这表明如果obj
是Tens
的有效成员,函数将返回它;否则,将返回None
。这就是为什么使用typing.Optional
(参见 这篇文章)。 -
使用
typing.get_args()
函数进行检查。对于字面量类型,它返回所有可能的值。 -
在这里情况变得有趣。从动态的角度来看,函数的最后一行(
return None
)是多余的,因为缺少的None
返回值会被隐式解释为None
返回值。然而,mypy
不接受 隐式 None 返回值,如下图所示。
Mypy 不接受隐式的 None 返回值。截图来自 Visual Studio Code。图片由作者提供
根据官方文档中的[mypy](https://mypy.readthedocs.io/en/stable/kinds_of_types.html#disabling-strict-optional-checking)
,你可以使用[--no-strict-optional](https://mypy.readthedocs.io/en/stable/command_line.html#cmdoption-mypy-no-strict-optional)
命令行选项来禁用严格的None
检查。如果你打算使用这个选项,请三思。我更倾向于明确声明某种类型是否接受None
。禁用严格检查意味着任何类型都假定接受None
,这可能导致意外行为,使代码更难以理解和维护。虽然我不是非常喜欢非常详细的类型提示,但在我看来,使用[--no-strict-optional](https://mypy.readthedocs.io/en/stable/command_line.html#cmdoption-mypy-no-strict-optional)
标志是一种过于简化的做法,因为None
是一个非常重要的哨兵值,不应如此轻忽。
如果你确实需要在特定情况下禁用严格检查,请记住,当你这样做而其他人没有时,他们可能会在代码中遇到许多静态错误。在整个代码库中保持一致的类型检查设置是一个好的实践。
字面量与枚举
在阅读前一部分时,你是否注意到一些字面量类型与枚举类型相似?确实,它们确实有一些相似之处,但字面量类型缺乏枚举固有的自然值顺序。
比较这两种类型定义:
from typing import Literal
from enum import Enum
ColorsL = Literal["white", "black", "grey"]
class ColorsE(Enum):
WHITE = "white"
BLACK = "black"
GREY = "grey"
如果你主要注意到的是语法差异,要知道你也可以使用静态工厂方法来定义枚举类型:
ColorsE2 = Enum("ColorsE2", ["WHITE", "BLACK", "GREY"])
ColorsE3 = Enum("ColorsE3", "WHITE BLACK GREY")
因此,定义语法并不是字面量类型和枚举类型之间的关键区别。首先,字面量类型是具有少量动态功能的静态类型,而枚举类型则提供了静态和动态能力,使其更加多功能。如果你需要的功能超出了字面量类型的范围,枚举类型可能是更好的选择。
本文不会深入探讨 Python 枚举的复杂性。然而,以下表格比较了这两种工具。在继续之前,请分析表格并观察typing.literal
提供了enum.Enum
的一部分功能。
enum.Enum
与typing.Literal
的比较。图片由作者提供
尽管字面量类型在简洁性、简短性和可读性方面表现优异。虽然 Python 枚举类型也很简单和可读,但字面量类型提供了更高水平的清晰性和简洁性。
结论
本文的核心信息是 typing.Literal
和字面量类型是强大的工具,提供的功能超出了最初的假设。它们的简单性掩盖了它们的深度和多功能性。正如我在文章开头提到的,我曾经低估了这个工具的价值。然而,今天我认识到它——以及一般的字面量类型——是增强 Python 代码简洁性同时保持静态正确性的强大而简单的机制。
实际上,使用其他类型提示来表达与字面量类型相同的概念可能会导致混淆,即使静态检查器没有报错。当你只需要静态类型供静态检查器检查时,typing.Literal
应该是你的首选。它的使用方法很简单,不需要过多的代码:只需类型定义,这通常需要一行或多行,具体取决于类型中包含的字面量数量。
对于需要更多高级动态功能的场景,枚举可能是更好的选择。它们通过防止无效值分配,在运行时提供了额外的安全层。另一方面,字面量类型并没有提供这种固有的保护,尽管可以像上述 is_ten()
函数演示的那样实现。然而,这种保护需要在每次用户提供该类型的值时应用。
本质上,记住字面量类型和 typing.Literal
。将它们融入你的 Python 代码中,以实现简洁和可读性。我认为在 Python 中,typing.Literal
实现了最高的实用性与复杂性的比率之一,使其既非常有用又极其简单。
附录 1
typing.LiteralString
Python 3.11 及更高版本引入了 typing.LiteralString
类型。尽管其名称如此,但它并不是 typing.Literal
在字符串方面的直接替代品。为了避免不必要的混淆,我们在这里不深入探讨此类型。相反,我们简要概述一下此类型的基本方面。
与用作创建字面量类型机制的 typing.Literal
不同,typing.LiteralString
本身就是一个类型。它可以用来指定变量应持有一个字面量字符串,如下例所示:
from typing import LiteralString
def foo(s: LiteralString) -> None
...
请注意文档中的说明:
任何字符串字面量都与
*LiteralString*
兼容,另一个*LiteralString*
也是如此。然而,单独标记为*str*
的对象则不兼容。
而且
*LiteralString*
对于敏感 API 很有用,在这些 API 中,任意用户生成的字符串可能会产生问题。例如,上述生成类型检查器错误的两个情况可能会受到 SQL 注入攻击的威胁。
这个简要概述应该足以满足我们当前的讨论。如果你有兴趣进一步探索此类型,请参阅 PEP 675,该 PEP 介绍了这个字面量类型。
附录 2
使用可迭代对象定义字面量类型
警告:本节展示了一个静态无法工作的技巧。因此,如果你的唯一目标是创建静态类型,请不要使用这个技巧。这更多的是一个有趣的信息,而非生产代码中的内容。
如果你不熟悉typing.Literal
,Literal[]
可能类似于索引,而Literal[1, 2, 3]
可能类似于列表。因此,你可能会被诱导使用列表推导式,如下所示:
>>> OneToTen = Literal[i for i in range(1, 11)]
File "<stdin>", line 1
OneToTen = Literal[i for i in range(1, 11)]
^^^
SyntaxError: invalid syntax
错误消息表明这不是有效的语法。这是因为typing.Literal
不应该用作列表推导式。相反,它用于指定类型接受的特定值。
但看看这里:
>>> OneToTen = Literal[[i for i in range(1, 11)]]
没有错误?那么,我们没问题,对吧?
不,我们不是。看看OneToTen
是什么:
>>> OneToTen
typing.Literal[[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]]
>>> get_args(OneToTen)
([1, 2, 3, 4, 5, 6, 7, 8, 9, 10],)
如你所见,这个定义有效,但并不是我们想要的方式。OneToTen
是一个字面量类型,只有一个值:一个从 1 到 10 的整数列表。列表不仅不是一个可接受的字面量类型,这也不是我们期望的!
但别担心,我们还没完成。还有一个技巧可以帮助我们实现预期结果。我们可以通过两种方式访问字面量类型的可能值。一种方法是我们已经看到的get_args()
函数。另一种方法是使用类型的.__args__
属性:
>>> get_args(OneToTen)
([1, 2, 3, 4, 5, 6, 7, 8, 9, 10],)
>>> OneToTen.__args__
([1, 2, 3, 4, 5, 6, 7, 8, 9, 10],)
>>> get_args(OneToTen) == OneToTen.__args__
True
虽然get_args()
允许我们获取字面量类型的值,但我们可以利用.__args__
属性来更新类型。看看:
>>> OneToTen.__args__ = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
>>> OneToTen
typing.Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
哈!这就是我之前提到的技巧。我们可以称之为.__args__
技巧。
上面我使用了一个列表,但你使用什么类型的可迭代对象并不重要:
>>> OneToTen == Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
True
>>> OneToTen.__args__ = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
>>> OneToTen == Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
True
>>> OneToTen.__args__ = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
>>> OneToTen == Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
True
我将一个列表字面量赋值给了OneToTen.__args__
,但你可以用其他方式实现,比如使用列表推导式或另一种推导式:
>>> OneToTen.__args__ = [i for i in range(1, 11)]
>>> OneToTen == Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
True
>>> OneToTen.__args__ = list(range(1, 11))
>>> OneToTen == Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
True
>>> OneToTen.__args__ = {i for i in range(1, 11)}
>>> OneToTen == Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
True
然而,你确实需要小心,因为Literal
并不总是表现得可预测。例如,它在range()
中像上面那样有效,但在生成器表达式中则不行:
>>> OneToTen.__args__ = range(1, 11)
>>> OneToTen == Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
True
>>> OneToTen.__args__ = (i for i in range(1, 11))
>>> OneToTen == Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
False
>>> OneToTen.__args__
<generator object <genexpr> at 0x7f...>
实际上,在使用Literal
进行生成器表达式实验时,我发现它确实有几次有效……我不知道为什么:通常它不这样工作,所以在我尝试的二十多次中,只有效了 2 或 3 次。这让我担心,因为我讨厌编程语言表现出不可预测的行为——即使是在技巧中。
难以相信这一点?看看这张来自 Python 3.11 的截图:
使用生成器表达式时typing.Literal.__args__
的不可预测行为。截图来自 Python 3.11。图片由作者提供
仅供参考,之前没有使用A
,但使用过OneToTen
——不过,这应该不影响结果。此外,下次我尝试这个时,换了个新名称B
,结果也没有成功:
typing.Literal.__args__
与生成器表达式的行为不同于之前。截图来自 Python 3.11。图像由作者提供
因此,除非你准备好接受 Python 的不可预测行为,否则在这个问题解决之前,不要将 typing.Literal
与生成器表达式一起使用。不过没什么好担心的,因为生成器表达式通常用于克服内存问题——创建字面量类型似乎不会导致这样的问题。因此,你可以将其转化为一个列表并使用,而不是用生成器创建字面量类型。
如本节开头所述,你应该避免使用 .__args__
hack。它会动态工作,但 mypy
不会接受它。了解这一点是好的,因为它扩展了你对 typing
类型提示的知识,但这不是你应该在生产代码中使用的东西。
感谢阅读。如果你喜欢这篇文章,你也可能喜欢我写的其他文章;你可以在这里查看。如果你想加入 Medium,请使用下面我的推荐链接:
[## 使用我的推荐链接加入 Medium - Marcin Kozak
作为 Medium 会员,你的一部分会员费用将会分配给你阅读的作者,并且你可以完全访问每一个故事……
medium.com](https://medium.com/@nyggus/membership?source=post_page-----03c60ce42750--------------------------------)