Skip to content

Python 入门教程

INFO

Gemini 2.5 Pro 基于 Python 3.14 官方文档教程生成,请对内容进行甄别。

1. 课前甜点

如果你经常使用电脑处理工作,那么你很可能在某个时刻想过:“这个重复性的任务,能不能让电脑自动完成?” 无论这个任务是批量查找并替换上百个文本文件里的内容,还是依据复杂的规则来重命名、整理一大堆照片文件,编程都可以帮你实现。或许,你还有更大的想法,比如编写一个迷你的数据库,开发一个有窗口和按钮的桌面应用,甚至制作一个简单的游戏。

对于这些需求,Python 可能是你最好的答案。

1.1 为什么选择 Python?

你可能会想,电脑上不是已经有可以实现自动化的工具了吗?比如 Unix 的 shell 脚本或者 Windows 的批处理文件。它们确实很强大,在移动文件、处理简单文本方面是专家,但如果你想做一个带有图形用户界面 (GUI) 的应用,或者开发一个小游戏,它们就显得力不从心了。

那么,使用 C++ 或 Java 这样的“工业级”语言呢?当然可以,但它们通常更复杂,开发周期也更长。仅仅是编写一个最简单的“Hello, World!”程序,从编写代码、到编译、链接,再到最终运行,整个过程对于新手来说可能就已经充满了挑战。如果你只是想快速完成一个任务,用它们就像“杀鸡用牛刀”。

Python 则在这两者之间找到了一个完美的平衡点。它足够简单,让你能够快速上手、迅速完成工作;同时它又足够强大,是一门真正的、功能完备的编程语言。它在 Windows, macOS 和 Unix 等所有主流操作系统上都能很好地运行。

1.2 Python 是一门什么样的语言?

1.2.1 解释型语言:告别繁琐的编译

官方教程里提到 Python 是一门“解释型语言”,这让它“不需要编译和链接”。对于新手来说,这可能是最关键也最友好的一个特性。那么,什么是编译和解释呢?

我们可以用一个比喻来理解:

  • 编译型语言 (例如 C, C++):这就像是把一本中文小说 完整地 翻译成英文版。翻译家(编译器)需要一次性把整本书从头到尾看完,理解上下文,然后翻译成一本完整的英文书(可执行文件)。这个过程比较耗时,如果在翻译过程中发现原文有一处语病,可能需要重新翻译整个章节甚至全书。但优点是,一旦翻译完成,英国读者(计算机)拿到英文版就能非常快地直接阅读。

  • 解释型语言 (例如 Python):这更像是在进行一场同声传译。你说一句中文(写一行 Python 代码),翻译官(解释器)就立刻把它翻译成英文给听众(计算机)。这个过程是逐句进行的。优点是交流非常高效,你可以立即得到反馈,如果说错了一句话,马上更正再说下一句就行了。这极大地节省了开发时间,让你能够快速地试验和修改你的代码。

Python 的解释器还提供了一个“交互式”环境。你可以打开它,像使用计算器一样,输入一行代码,它就立刻返回结果。这种即时反馈的特性对于学习和测试语言功能非常有帮助。

1.2.2 非常高级的语言:内置强大的工具

Python 被称为“非常高级的语言”,这并不是说它有多么难学,恰恰相反,它意味着 Python 帮你处理了许多底层的、繁琐的细节。

其中一个体现就是它内置了强大的数据结构,比如“灵活的数组与字典”。你可以这样理解它们:

  • 数组 (在 Python 中更常被称为“列表 list”):就像一个购物清单,你可以按顺序在上面添加、删除商品,或者查看第几个商品是什么。它是一个有序的集合。

  • 字典 (dictionary):就像一本真正的字典或者电话簿。每个词条(键)都对应着它的解释(值)。你可以通过一个人的名字(键)快速查到他的电话号码(值),而不需要从头到尾翻一遍。它是一个通过名称(键)来索引的集合。

这些高级的数据类型让你仅用一行代码就能表达复杂的操作,而如果使用 C 这样的语言,你可能需要编写十几行代码才能实现同样的功能。

1.3 Python 的优势在哪里?

1.3.1 简洁易读的语法

Python 的程序通常比功能相同的 C++ 或 Java 程序要短得多。这主要得益于它的一些设计哲学:

  • 使用缩进分组代码:这是 Python 最具特色的地方之一。在许多其他语言中,代码块(比如一个 if 判断的范围)是由大括号 {} 来包裹的。而在 Python 中,代码块是通过缩进(通常是 4 个空格)来定义的。

    例如,一段表示“如果天气好,就出门”的逻辑,在其他语言里可能是这样:

    if (weather == "good") {
        go_outside();
        have_fun();
    }

    而在 Python 里,是这样的:

    python
    if weather == "good":
        go_outside()
        have_fun()

    这种强制的缩进使得代码天生就具有清晰的结构和极高的可读性。

  • 无需声明变量类型:在很多语言里,你在使用一个变量(可以理解为一个用来存放数据的盒子)之前,必须先告诉计算机:“我要一个名叫 box 的盒子,它只能用来装数字。” 而在 Python 中,你直接把数字放进去就行了,它会自动知道这是一个装数字的盒子。

1.3.2 庞大的“标准库”与模块化

Python 支持把程序拆分成一个个“模块”,就像乐高积木一样。你可以搭建一个复杂的项目,也可以把你写好的一个模块用在另一个完全不同的项目里,实现代码的复用。

更棒的是,Python 自带了一个庞大而全面的“标准库”。你可以把它想象成你买乐高时,盒子里已经附带了各种轮子、窗户、门等基础组件。这个标准库里包含了用于处理 I/O (文件读写)、系统调用 (与操作系统交互)、套接字 (网络通信),甚至是图形用户界面 (例如 Tk) 的模块。这意味着,很多常见的功能,你根本不需要自己从零开始写,直接使用标准库里的模块就能轻松实现。

1.3.3 可扩展性

如果你追求极致的性能,或者需要调用一个已有的 C/C++ 库,Python 也完全能够胜任。它允许你用 C 或 C++ 编写性能关键的部分,然后在 Python 程序中调用它们,实现“胶水语言”的功能,将不同语言编写的部件黏合在一起。

1.4 关于 Python 的名字

顺便一提,Python 这个名字的来源并非“蟒蛇”,而是源于 BBC 的一个喜剧团体“Monty Python's Flying Circus” (蒙提·派森的飞行马戏团)。在 Python 的圈子里,这是一个众所周知的趣闻,所以如果你在文档或教程中看到一些奇怪的幽默,不要感到意外。

现在,你可能已经对 Python 产生了浓厚的兴趣。学习一门编程语言最好的方式就是亲手去实践它。从下一章开始,我们将会介绍如何使用 Python 解释器。虽然开始的步骤可能有些枯燥,但这是你亲手体验后续所有示例和概念的必要准备。

让我们一起开始这段激动人心的旅程吧。

2. 使用 Python 的解释器

2.1. 唤出解释器

“Python 解释器” 听起来可能有些复杂,但你可以把它简单地理解为一个能听懂 Python 语言的程序。我们需要先启动这个程序,然后才能在里面输入 Python 指令。启动它的地方,通常是你的计算机的 “命令行” 或 “终端” (Terminal)。

在不同的操作系统上,启动它的方式略有不同:

  • 在 Unix-like 系统 (例如 Linux, macOS) 上 你打开 “终端” (Terminal) 程序后,可以尝试输入以下命令并按回车:

    bash
    python3.14

    如果系统提示找不到命令,不用担心。因为安装位置不同,或者为了区分系统上可能存在的旧版 Python 2,可执行文件的名字可能是 python3。所以你也可以试试:

    bash
    python3

    官方教程中提到的 /usr/local/bin 是一个在 Unix 系统中存放程序的常见目录。“将这个目录加入你的 shell 搜索路径” 意思就是告诉你的终端,当我想运行一个程序时,也去这个文件夹里找一找。通常情况下,Python 安装程序会自动帮你处理好这些设置。

  • 在 Windows 系统上 打开 “命令提示符” (Command Prompt) 或者 “PowerShell”。如果你是通过 Microsoft Store 安装的 Python,可以直接使用 python3.14python3 命令。

    更推荐的方式是使用 py.exe 启动器,你只需要输入:

    bash
    py

    这个启动器是专门为 Windows 设计的,能更好地管理你电脑上可能安装的多个 Python 版本。

当你成功启动解释器后,你会看到一些版本和版权信息,然后是一个主提示符 >>>,表示它已经准备好接收你的第一条 Python 指令了。

要退出这个解释器环境,你可以:

  • 在 Unix-like 系统上,按下 Control-D
  • 在 Windows 系统上,按下 Control-Z 然后按回车。
  • 或者,输入 quit() 命令然后按回车,这个方法在所有系统上都通用。

解释器的行编辑功能

现代的 Python 解释器非常友好。你可以像在文本编辑器里一样,使用左右方向键移动光标,使用退格键删除字符。更方便的是,它通常还支持 “历史记录” 功能。不信你可以在输入一条指令后,按一下键盘的 上箭头 ,刚才输入的指令是不是又出现了?这在你需要重复执行或修改上一条命令时非常有用。这个功能依赖于一个叫做 GNU Readline 的库,绝大多数非 Windows 系统都默认支持它。

运行 Python 代码的几种方式

除了我们刚刚体验的交互式输入指令,还有其他几种方式来运行 Python 代码:

  1. 执行脚本文件:这是最常见的方式。你可以把所有的 Python 代码写在一个以 .py 结尾的文件里 (例如 my_script.py),然后在命令行中通过 python3 my_script.py 来执行它。解释器会一次性读取并运行文件里的所有代码。

  2. python -c command:这个选项允许你在命令行直接执行一小段 Python 代码字符串,而无需创建 .py 文件。例如:

    bash
    python3 -c "print('Hello, Python!')"

    因为代码字符串里可能包含空格等特殊字符,所以最好用引号把它包起来。

  3. python -m module:这个选项用来运行 Python 库中的模块。比如,Python 内置了一个简单的 web 服务器,你可以通过以下命令在当前目录下快速启动它,这在临时分享文件时非常方便:

    bash
    python3 -m http.server
  4. python -i script.py:有时你可能希望在运行完一个脚本文件后,能停留在交互模式下,检查脚本中变量的值。-i 选项就能满足这个需求。它会先执行 script.py,然后显示 >>> 提示符,此时你可以访问脚本中已经创建的任何变量和函数。

2.1.1. 传入参数

我们的程序经常需要处理来自外部的数据。一个常见的场景就是,在命令行运行脚本时,给它提供一些额外的信息,这些信息被称为“命令行参数”。

Python 解释器会将这些参数收集起来,放进一个名叫 sys.argv 的列表里。要访问它,你首先需要导入 sys 模块。sys.argv 是一个字符串列表 (list),其中:

  • 第一个元素 sys.argv[0] 永远是脚本自己的名字 (或者描述其来源的字符串)。
  • 从第二个元素 sys.argv[1] 开始,依次是你在脚本名后传入的各个参数。

让我们通过一个例子来理解它。创建一个名为 greeter.py 的文件,内容如下:

python
import sys

print("所有传入的参数是:", sys.argv)

# 检查是否传入了至少一个名字
if len(sys.argv) > 1:
    # sys.argv[0] 是脚本名,所以我们从索引 1 开始获取真正的参数
    name = sys.argv[1]
    print(f"你好, {name}!")
else:
    print("你好, 陌生人! 你可以尝试在脚本名后提供一个名字。")

现在,在你的命令行里尝试用不同方式运行它:

bash
# 不提供任何参数
$ python3 greeter.py
所有传入的参数是: ['greeter.py']
你好, 陌生人! 你可以尝试在脚本名后提供一个名字。

# 提供一个参数 "Alice"
$ python3 greeter.py Alice
所有传入的参数是: ['greeter.py', 'Alice']
你好, Alice!

# 提供多个参数
$ python3 greeter.py Bob Charlie
所有传入的参数是: ['greeter.py', 'Bob', 'Charlie']
你好, Bob!

正如你所见,sys.argv 忠实地记录了命令行传入的所有信息,并以空格为分隔符将它们拆分成了列表中的字符串。

2.1.2. 交互模式

当你在终端里直接运行 python3 命令,没有跟任何 .py 文件名时,解释器就进入了“交互模式”。这是学习和试验 Python 功能的绝佳场所。

在这种模式下,你会看到两种不同的提示符:

  • 主提示符 >>>:这表示解释器已经准备就绪,等待你输入一条新的、完整的 Python 指令。
  • 次要提示符 ...:当你输入的指令没有写完时,它就会出现。最常见的情况是当你开始一个多行代码块时,比如一个 if 语句,一个 for 循环,或者定义一个函数。它在提醒你:“我知道你还没说完,请继续输入这个代码块的内容。”

下面是一个官方教程中的例子,它清晰地展示了这两种提示符:

python
>>> the_world_is_flat = True
>>> if the_world_is_flat:
...     print("Be careful not to fall off!")
...
Be careful not to fall off!
>>>

我们来分析一下这个过程:

  1. 第一行 >>> the_world_is_flat = True 是一条完整的赋值语句,解释器执行后,立刻回到 >>> 准备接收新指令。
  2. 第二行 >>> if the_world_is_flat: 是一个 if 语句的开始,解释器知道它后面必须跟一个缩进的代码块,所以下一行它给出了次要提示符 ...
  3. 第三行 ... print("Be careful not to fall off!")if 语句块的内容。因为一个 if 语句块里可能还有更多代码,所以解释器在下一行依然显示 ...
  4. 第四行 ... 我们直接按了回车,表示这个代码块结束了。解释器于是执行整个 if 语句,打印出结果,然后回到主提示符 >>>,等待下一条全新的指令。

2.2. 解释器的运行环境

2.2.1. 源文件的字符编码

计算机的本质是处理数字,它并不直接认识我们人类语言中的文字,比如汉字 或者带音标的字母 é。为了让计算机能够存储和显示这些字符,我们需要一张“密码本”,这张密码本规定了每个字符对应哪个数字。这张“密码本”就是 字符编码

过去,存在着许多不同的编码标准,比如 GBK 主要用于简体中文,Big5 用于繁体中文,cp1252 用于西欧语言。这导致了一个问题:如果一个用 GBK 编码编写的文件,在没有安装相应“密码本”的电脑上打开,就会显示成一堆乱码。

为了解决这个问题,UTF-8 编码诞生了。它是一套统一的、涵盖了世界上几乎所有字符的编码方案。

默认情况下,Python 3 假定你所有的 .py 源文件都是用 UTF-8 编码保存的。 这是一个非常明智的默认设置。它意味着你可以放心地在你的代码里写中文,无论是作为字符串,还是作为注释,甚至用中文来做变量名 (尽管为了代码的可移植性和通用性,通常不推荐这样做)。

python
# 这是一行中文注释
名字 = "小明"
print(f"你好, {名字}")

只要你的文本编辑器 (例如 VS Code, Sublime Text, PyCharm) 也将这个文件保存为 UTF-8 格式 (现在绝大多数编辑器都默认如此),这段代码就能完美运行。

如果我必须使用其他编码呢?

在极少数情况下,比如你正在维护一个使用 GBK 编码的旧项目,你需要明确地告诉 Python 解释器用指定的“密码本”来读取源文件。你可以在文件的 第一行 添加一个特殊的注释来声明编码,格式如下:

python
# -*- coding: encoding -*-

其中 encoding 是你实际使用的编码名称。例如,要声明文件是 GBK 编码,你应该这样写:

python
# -*- coding: gbk -*-

# 这里是你的 Python 代码...
print("这段代码是用 GBK 编码保存的。")

“shebang” 行的例外

在 Linux 或 macOS 系统中,你可能会在一些 Python 脚本的开头看到这样一行:

bash
#!/usr/bin/env python3

这一行被称为 "shebang",它的作用是告诉操作系统,当直接执行这个文件时,应该用 python3 这个程序来解释它。

如果你的脚本里有这一行,那么编码声明就必须写在 第二行

python
#!/usr/bin/env python3
# -*- coding: cp1252 -*-

# 这里是你的 Python 代码...

总而言之,对于新手来说,最好的做法是:始终使用你的文本编辑器将代码文件保存为 UTF-8 编码,并且通常情况下,你不需要在文件里添加任何编码声明行。

3. Python 速览

在接下来的例子中,我们将直接在 Python 的交互式解释器里输入代码。为了让你能清晰地分辨哪些是我们需要输入的内容,哪些是计算机返回的输出,教程会遵循一个简单的约定:

  • >>>... 开头的行,是我们 输入 的代码。
  • 没有任何提示符的行,是解释器给我们的 输出

当你自己练习时,只需要输入提示符 >>> 后面的内容即可。

另外,你会发现代码中经常出现以井号 # 开头的部分。这在 Python 中被称为 注释 (comment)。注释是写给我们自己看的笔记,用来解释代码是做什么的。Python 解释器会完全忽略它们。注释可以单独占一行,也可以跟在代码的后面,但不能出现在字符串的引号内部。

python
# 这是一条单独的注释
spam = 1  # 这条注释跟在代码后面
text = "# 这不是注释,因为它在引号里。"

在学习时,你可以省略这些注释,它们不影响代码的运行。

3.1. Python 用作计算器

让我们从最简单的功能开始。启动你的 Python 解释器,它立刻就能变身为一个功能强大的计算器。

3.1.1. 数字

你可以直接输入数学表达式,解释器会计算并返回结果。基本的运算符 + (加)、- (减)、* (乘)、/ (除) 和数学课上学到的一样。你也可以用圆括号 () 来控制运算的优先级。

python
>>> 2 + 2
4
>>> 50 - 5 * 6
20
>>> (50 - 5 * 6) / 4
5.0
>>> 8 / 5  # 普通除法总是返回一个带小数的数
1.6

你会注意到,数字有两种基本类型:不带小数点的整数 (例如 2, 20),在 Python 中被称为 int;和带小数点的数 (例如 5.0, 1.6),被称为 float 或浮点数。

一个非常重要的细节是:在 Python 3 中,除法运算符 / 总是 返回一个浮点数,即使两个数可以整除。

如果你想做传统意义上的整除,丢掉小数部分,你需要使用 // 运算符,这被称为 向下取整除法 (floor division)。而如果你只关心余数,可以使用 % 运算符,称为 取模 (modulo)。

python
>>> 17 / 3  # 普通除法
5.666666666666667
>>>
>>> 17 // 3  # 向下取整除法,小数部分被舍弃了
5
>>> 17 % 3   # 取模运算,得到的是余数
2
>>> 5 * 3 + 2  # 验证一下:商 * 除数 + 余数 = 被除数
17

要计算乘方,可以使用 ** 运算符。

python
>>> 5 ** 2  # 5 的平方
25
>>> 2 ** 7  # 2 的 7 次方
128

我们可以用等号 = 把一个计算结果赋给一个 变量。变量就像一个贴了标签的盒子,你可以把数据存进去,方便以后使用。赋值操作本身不会产生任何输出。

python
>>> width = 20
>>> height = 5 * 9
>>> width * height
900

如果你试图使用一个从未被赋值过的变量,Python 会报错,因为它不知道这个“盒子”里装的是什么。

python
>>> n  # 尝试使用一个未定义的变量
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'n' is not defined

当整数和浮点数混合运算时,Python 会自动把整数转换成浮点数,以保证精度。

python
>>> 4 * 3.75 - 1
14.0

在交互模式下,有一个很方便的特殊变量 _ (一个下划线),它会自动保存 上一次 解释器输出的结果。这让连续计算变得非常简单。

python
>>> tax = 12.5 / 100
>>> price = 100.50
>>> price * tax
12.5625
>>> price + _  # _ 的值是 12.5625
113.0625
>>> round(_, 2) # round() 是一个内置函数,用于四舍五入
113.06

注意:最好只读取 _ 变量的值,不要手动给它赋值。否则你会创建一个新的同名变量,覆盖掉这个特殊功能。

除了 intfloat,Python 还支持处理更复杂的数字,比如复数 (用 jJ 表示虚部,例如 3+5j),但对于入门来说,掌握整数和浮点数就足够了。

3.1.2. 文本

除了数字,Python 还能处理文本。在编程中,我们称之为 字符串 (string),用 str 类型表示。字符串可以用单引号 '' 或双引号 "" 包裹起来,效果是完全一样的。

python
>>> 'spam eggs'  # 单引号
'spam eggs'
>>> "Paris rabbit got your back :)! Yay!"  # 双引号
'Paris rabbit got your back :)! Yay!'
>>> '1975'  # 引号里的数字也被当作文本,而不是数字
'1975'

使用两种引号的好处在于,可以方便地在字符串中包含另一种引号,而无需特殊处理。

python
>>> "doesn't"  # 字符串里有单引号,所以用双引号包裹
"doesn't"
>>> '"Yes," they said.' # 字符串里有双引号,所以用单引号包裹
'"Yes," they said.'

如果不得不在同一种引号内包含它自己,你需要用反斜杠 \ 对它进行 转义 (escape),告诉 Python 这个引号是字符串内容的一部分,而不是字符串的结束标记。

python
>>> 'doesn\'t'
"doesn't"
>>> "\"Yes,\" they said."
'"Yes," they said.'

在 Python 解释器里直接输入一个字符串变量,你会看到它以“原始”的形式被输出,带着引号和转义字符。而使用 print() 函数,则会以更符合人类阅读习惯的方式输出,它会解释特殊字符,比如 \n 会被解释成一个换行符。

python
>>> s = 'First line.\nSecond line.'  # \n 是一个换行符
>>> s  # 直接输出变量 s
'First line.\\nSecond line.'
>>> print(s) # 使用 print() 函数输出
First line.
Second line.

如果你不希望 \ 被解释成特殊字符,可以在字符串的第一个引号前加上 r,创建一个 原始字符串 (raw string)。这在处理 Windows 文件路径时特别有用。

python
>>> print('C:\some\name')  # 这里的 \n 被解释成了换行
C:\some
ame
>>> print(r'C:\some\name') # r 让整个字符串按原样输出
C:\some\name

如果想创建包含多行文本的字符串,可以使用三引号 """..."""'''...'''

python
>>> print("""
... Usage: thingy [OPTIONS]
...      -h                        Display this usage message
...      -H hostname               Hostname to connect to
... """)
Usage: thingy [OPTIONS]
     -h                        Display this usage message
     -H hostname               Hostname to connect to

字符串之间可以用 + 号进行拼接,用 * 号进行重复。

python
>>> 3 * 'un' + 'ium'
'unununium'

一个有趣的小特性是,如果两个字符串字面值 (直接写在代码里的字符串) 挨在一起,Python 会自动把它们合并。这在书写很长的字符串时,可以帮助你把它们分成几行来写,增加可读性。

python
>>> 'Py' 'thon'
'Python'
>>> text = ('Put several strings within parentheses '
...         'to have them joined together.')
>>> text
'Put several strings within parentheses to have them joined together.'

注意:这个自动合并功能只对字面值有效,不能用于变量和表达式。如果要拼接变量,必须使用 + 号。

python
>>> prefix = 'Py'
>>> prefix + 'thon'
'Python'

字符串是 序列 (sequence),这意味着它的每个字符都有一个固定的位置。我们可以通过 索引 (index) 来获取单个字符。非常重要的一点是:索引从 0 开始!

python
>>> word = 'Python'
>>> word[0]  # 第一个字符 (索引为 0)
'P'
>>> word[5]  # 第六个字符 (索引为 5)
'n'

索引也可以是负数,表示从右往左数,-1 代表最后一个字符。

python
>>> word[-1] # 最后一个字符
'n'
>>> word[-2] # 倒数第二个字符
'o'

除了获取单个字符,我们还可以通过 切片 (slicing) 来获取一个子字符串。切片的语法是 [start:end],它提取从 start 索引开始,直到 end 索引 之前 的所有字符。这被称为“包头不包尾”。

python
>>> word[0:2]  # 从索引 0 (包含) 到索引 2 (不包含)
'Py'
>>> word[2:5]  # 从索引 2 (包含) 到索引 5 (不包含)
'tho'

切片时,startend 索引可以省略。如果省略 start,则默认为 0 (从头开始);如果省略 end,则默认为字符串的结尾。

python
>>> word[:2]   # 从开头到索引 2 (不包含)
'Py'
>>> word[4:]   # 从索引 4 (包含) 到结尾
'on'
>>> word[-2:]  # 从倒数第二个字符 (包含) 到结尾
'on'

Python 的字符串是 不可变的 (immutable)。这意味着一旦一个字符串被创建,你就不能修改它的任何部分。试图给某个索引位置赋值会引发错误。

python
>>> word[0] = 'J'
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'str' object does not support item assignment

如果你需要一个修改过的字符串,正确的方法是利用切片和拼接来 创建一个新的字符串

python
>>> 'J' + word[1:]
'Jython'
>>> word[:2] + 'py'
'Pypy'

要获取一个字符串的长度,可以使用内置的 len() 函数。

python
>>> s = 'supercalifragilisticexpialidocious'
>>> len(s)
34

3.1.3. 列表

列表 (list) 是 Python 中最常用的复合数据类型之一。它是一个值的集合,这些值用逗号隔开,并包裹在方括号 [] 中。列表中的元素 (条目) 可以是不同的数据类型,但通常我们用它来存放同一种类型的数据。

python
>>> squares = [1, 4, 9, 16, 25]
>>> squares
[1, 4, 9, 16, 25]

和字符串一样,列表也支持索引和切片。对列表进行索引会返回单个元素,而切片则会返回一个 新的列表

python
>>> squares[0]   # 索引返回单个元素
1
>>> squares[-1]
25
>>> squares[-3:] # 切片返回一个新列表
[9, 16, 25]

列表也支持用 + 号进行拼接。

python
>>> squares + [36, 49, 64, 81, 100]
[1, 4, 9, 16, 25, 36, 49, 64, 81, 100]

与字符串最大的不同在于,列表是 可变的 (mutable)。这意味着你可以随时修改它的内容。

python
>>> cubes = [1, 8, 27, 65, 125]  # 4 的立方应该是 64,这里写错了
>>> cubes[3] = 64  # 通过索引直接修改错误的值
>>> cubes
[1, 8, 27, 64, 125]

你还可以使用 append() 方法 (method) 在列表的末尾添加新的元素。(我们稍后会详细学习什么是方法)

python
>>> cubes.append(216)  # 添加 6 的立方
>>> cubes.append(7 ** 3) # 添加 7 的立方
>>> cubes
[1, 8, 27, 64, 125, 216, 343]

你也可以对列表的切片进行赋值,这甚至可以改变列表的长度,或者清空整个列表。

python
>>> letters = ['a', 'b', 'c', 'd', 'e', 'f', 'g']
>>> # 替换从索引 2 到 4 的元素
>>> letters[2:5] = ['C', 'D', 'E']
>>> letters
['a', 'b', 'C', 'D', 'E', 'f', 'g']
>>> # 删除它们
>>> letters[2:5] = []
>>> letters
['a', 'b', 'f', 'g']
>>> # 清空整个列表
>>> letters[:] = []
>>> letters
[]

len() 函数同样适用于列表。

python
>>> letters = ['a', 'b', 'c', 'd']
>>> len(letters)
4

列表还可以 嵌套,也就是一个列表的元素可以是另一个列表。

python
>>> a = ['a', 'b', 'c']
>>> n = [1, 2, 3]
>>> x = [a, n]
>>> x
[['a', 'b', 'c'], [1, 2, 3]]
>>> x[0]
['a', 'b', 'c']
>>> x[0][1] # 获取 x 中第 0 个元素的第 1 个元素
'b'

3.2. 走向编程的第一步

当然,Python 的能力远不止于加减乘除。我们可以用它来编写程序,完成更复杂的任务。例如,下面这段代码可以生成著名的 斐波那契数列 (Fibonacci sequence) 的前几个数字。

python
>>> # 斐波那契数列:
... # 每一项都是前两项的和
... a, b = 0, 1
>>> while a < 10:
...     print(a)
...     a, b = b, a+b
...
0
1
1
2
3
5
8

这个小例子引入了几个非常核心的编程概念:

  1. 多重赋值: 第一行 a, b = 0, 1 同时给两个变量赋了值。最后一行 a, b = b, a+b 再次使用了它。这里有个精妙之处:在赋值发生 之前,等号右边的所有表达式 (ba+b) 会被先计算出来。所以这是用旧的 ab 的值来计算出新的 ab 的值,完美地实现了数列的递推。

  2. while 循环: while 语句会不断地执行它下方的代码块,只要它的条件 (a < 10) 保持为真。在 Python 中,任何非零的数字和任何非空的序列 (如字符串或列表) 都被认为是 "真 (True)",而 0 和空序列被认为是 "假 (False)"。

  3. 缩进: 你会发现 while 循环下方的两行代码 (print 和赋值语句) 前面都有一些空格。这就是 缩进。在 Python 中,缩进不是为了美观,而是语法的一部分。它用来告诉 Python 哪些代码是属于同一个代码块的 (比如,哪些代码是属于 while 循环的)。同一个代码块的所有行必须保持完全相同的缩进量。

  4. print() 函数: 我们已经见过 print() 了。在这里,它被用来在每次循环时打印出变量 a 的当前值。默认情况下,print() 在输出内容后会自动换行。我们可以通过 end 参数来改变这个行为。

    例如,如果我们想让所有数字打印在同一行,并用逗号隔开,可以这样做:

    python
    >>> a, b = 0, 1
    >>> while a < 1000:
    ...     print(a, end=',')
    ...     a, b = b, a+b
    ...
    0,1,1,2,3,5,8,13,21,34,55,89,144,233,377,610,987,

4. 更多控制流工具

除了上一章我们学习过的 while 循环,Python 还提供了其他一些控制程序执行流程的工具。

4.1. if 语句

在编程中,我们最常做的事情之一就是“做决定”。if 语句就是用来帮助程序做决定的。它的基本思想是:“如果 (if) 某个条件成立,就做这件事;否则 (else),就做另一件事。”

让我们来看一个例子。下面的代码会请求用户输入一个整数,然后根据这个数字的大小进行不同的判断:

python
>>> x = int(input("Please enter an integer: "))
Please enter an integer: 42
>>> if x < 0:
...     x = 0
...     print('Negative changed to zero')
... elif x == 0:
...     print('Zero')
... elif x == 1:
...     print('Single')
... else:
...     print('More')
...
More

这段代码引入了几个新知识点,我们来分解一下:

  1. input() 函数: 这个函数会向用户显示一个提示 (括号里的字符串),然后等待用户输入内容并按回车。需要注意的是,input() 函数接收到的任何内容,一律被当作文本字符串
  2. int() 函数: 因为 input() 返回的是字符串,我们不能直接拿它和数字 0 进行比较。int() 函数的作用就是把一个字符串 (或者其他类型的数字) 转换为整数。所以 int(input(...)) 的意思就是“获取用户的输入,并把它转换成一个整数”。

现在我们来看 if 语句的结构:

  • if: 这是第一个判断。如果 x < 0 这个条件为 真 (True),那么它下方缩进的代码块就会被执行,然后整个 if 语句就结束了。
  • elif: 这是 "else if" 的缩写。如果前面的 if 条件为 假 (False),程序就会接着检查第一个 elif 的条件 (x == 0)。如果这个条件为真,就执行它下方的代码块,然后结束。如果还是假,就继续检查下一个 elif。你可以有任意多个 elif 分支。
  • else: 这是最后的“备用选项”。如果前面的所有 ifelif 条件都为假,那么 else 下方的代码块就会被执行。else 是可选的。

整个 if...elif...else 结构就像一个多级的筛选过程,但最终 只会有一个 代码块被执行。

4.2. for 语句

for 语句在 Python 中用于 遍历 (iterate) 一个序列 (比如列表或字符串) 中的每一个元素。它的工作方式与其他一些编程语言 (如 C 或 Pascal) 有所不同,它更加直观。你可以把它想象成“依次取出盒子里的每一个物品,并对它做点什么”。

例如,我们有一个单词列表,想依次打印出每个单词和它的长度:

python
>>> # 创建一个单词列表
>>> words = ['cat', 'window', 'defenestrate']
>>> for w in words:
...     print(w, len(w))
...
cat 3
window 6
defenestrate 12

这个循环的执行过程是:

  1. words 列表中取出第一个元素 'cat',把它赋值给变量 w,然后执行循环体 print(w, len(w))
  2. 接着取出第二个元素 'window',把它赋值给 w,再次执行循环体。
  3. 最后取出第三个元素 'defenestrate',赋值给 w,执行循环体。
  4. 当列表中的所有元素都被遍历过后,循环结束。

一个重要的警告:不要在循环内部修改你正在遍历的集合

在遍历一个列表或字典的同时,直接修改它 (比如删除元素) 是一件非常危险的事情,很容易导致意想不到的错误。这就像你一边数着队伍里的人数,一边有人在队伍里插队或离开,你很容易就会数错。

更安全、更清晰的做法是遍历集合的 副本,或者创建一个 新的集合 来存放结果。

python
# 一个包含用户及其状态的字典
users = {'Hans': 'active', 'Éléonore': 'inactive', '景太郎': 'active'}

# 策略一:遍历字典的副本,从原始字典中删除不活跃用户
# .copy() 创建了一个副本,这样修改 users 就不会影响循环过程
for user, status in users.copy().items():
    if status == 'inactive':
        del users[user]

# 策略二:创建一个新的空字典,只把活跃用户添加进去
active_users = {}
for user, status in users.items():
    if status == 'active':
        active_users[user] = status

对于新手来说,强烈推荐使用第二种策略 (创建一个新集合),因为它通常逻辑更清晰,更不容易出错。

4.3. range() 函数

如果你需要循环一个固定的次数,或者需要按数字顺序进行迭代,内置的 range() 函数就派上用场了。它能生成一个等差数列。

最简单的形式是 range(stop),它会生成从 0 开始,到 stop - 1 结束的整数序列。

python
>>> for i in range(5):
...     print(i)
...
0
1
2
3
4

注意range(5) 生成的序列不包括 5 本身。这是一个在 Python 中非常常见的“包头不包尾”的原则。

range() 也可以接受更多的参数:

  • range(start, stop): 生成从 startstop - 1 的序列。
  • range(start, stop, step): 生成从 startstop - 1,但每次递增 step 的序列。
python
>>> list(range(5, 10))
[5, 6, 7, 8, 9]

>>> list(range(0, 10, 3))
[0, 3, 6, 9]

>>> list(range(-10, -100, -30))
[-10, -40, -70]

(这里我们用 list() 函数把 range() 对象转换成了列表,以便能直观地看到它包含的所有数字。)

如果你想遍历一个列表并同时需要知道每个元素的索引,可以组合使用 range()len()

python
>>> a = ['Mary', 'had', 'a', 'little', 'lamb']
>>> for i in range(len(a)):
...     print(i, a[i])
...
0 Mary
1 had
2 a
3 little
4 lamb

range() 到底是什么?

当你直接打印一个 range 对象时,你可能得不到预期的结果:

python
>>> range(10)
range(0, 10)

这是因为 range() 返回的不是一个列表,而是一个特殊的“可迭代”对象。你可以把它想象成一个“数字生成配方”,而不是一个装满了所有数字的实际容器。它只在你需要的时候 (比如 for 循环向它要下一个数字时) 才生成那个数字。这样做的好处是极大地节省了内存,特别是当你需要一个非常大的数字序列时 (比如 range(1000000))。

4.4. break 和 continue 语句

在循环内部,有时我们需要更精细地控制循环的流程。breakcontinue 就是为此而生的。

  • break 语句: 它的作用是 立即终止 当前所在的循环。一旦执行到 break,程序会跳出整个循环,继续执行循环之后的代码。

    下面的例子用来寻找一个数的最小因子。一旦找到了一个因子 (n % x == 0),就没有必要再继续找下去了,所以用 break 提前跳出内层循环。

    python
    >>> for n in range(2, 10):
    ...     for x in range(2, n):
    ...         if n % x == 0:
    ...             print(f"{n} equals {x} * {n//x}")
    ...             break # 找到了,跳出内层循环
    ...
    4 equals 2 * 2
    6 equals 2 * 3
    8 equals 2 * 4
    9 equals 3 * 3
  • continue 语句: 它的作用是 跳过当前这次循环的剩余部分,直接开始下一次循环。

    下面的例子遍历数字 2 到 9。如果是偶数,就打印一条信息,然后用 continue 跳过后面的 print 语句,直接进入下一个数字的判断;如果是奇数,则会执行后面的 print 语句。

    python
    >>> for num in range(2, 10):
    ...     if num % 2 == 0:
    ...         print(f"Found an even number {num}")
    ...         continue
    ...     print(f"Found an odd number {num}")
    ...
    Found an even number 2
    Found an odd number 3
    Found an even number 4
    Found an odd number 5
    ...

4.5. 循环的 else 子句

Python 的 forwhile 循环有一个非常独特 (且容易让新手困惑) 的特性:它们可以带一个 else 子句。

这个 else 子句的执行规则是:当循环正常执行完毕 (即没有被 break 语句中断) 时,else 子句就会被执行。

这个特性在“搜索”场景中特别有用。你可以用循环来寻找某个东西,如果找到了就 break,如果循环结束了都没找到 (也就是 break 从未被执行),那么 else 里的代码就会执行,告诉你“没找到”。

让我们用一个寻找素数的例子来理解它。下面的代码中,else 子句属于 内层的 for 循环

python
>>> for n in range(2, 10):
...     for x in range(2, n):
...         if n % x == 0:
...             print(n, 'equals', x, '*', n//x)
...             break # 找到了因子,不是素数,中断内层循环
...     else:
...         # 如果内层循环正常结束 (从未 break),说明没找到任何因子
...         print(n, 'is a prime number')
...
2 is a prime number
3 is a prime number
4 equals 2 * 2
5 is a prime number
6 equals 2 * 3
7 is a prime number
8 equals 2 * 4
9 equals 3 * 3

请仔细观察这个例子:

  • n3 时,内层 for x in range(2, 3) 循环没有找到任何能整除 3 的数,循环正常结束,所以 else 子句被执行,打印 "3 is a prime number"。
  • n4 时,内层循环在 x2 时发现 4 % 2 == 0,于是打印 "4 equals 2 * 2",然后执行 break。因为循环是被 break 中断的,所以 else 子句 不会 被执行。

4.6. pass 语句

pass 语句是一个“空操作”语句。它什么也不做。

那么为什么需要一个什么也不做的语句呢?因为 Python 的语法规定,在某些地方 (比如 if, while, def 的代码块里) 必须至少有一条语句。当你构思程序结构,暂时还没想好具体代码怎么写时,可以用 pass 来做占位符,以保证程序语法正确。

python
>>> while True:
...     pass  # 等待用户按 Ctrl+C 来中断程序
...

>>> class MyEmptyClass:
...     pass # 一个最简单的类定义
...

>>> def initlog(*args):
...     pass   # 提醒自己:这个函数以后要回来实现!
...

4.7. match 语句

match 语句 (在 Python 3.10 中引入) 是一种强大的控制流工具,称为“结构化模式匹配”。你可以把它看作是 if...elif...else 的一个更高级、更结构化的版本,特别适合于根据一个值的结构来做判断。

最简单的形式是匹配字面值,类似于其他语言的 switch...case

python
def http_error(status):
    match status:
        case 400:
            return "Bad request"
        case 404:
            return "Not found"
        case 418:
            return "I'm a teapot"
        case _: # _ 是一个通配符,能匹配任何东西,相当于 else
            return "Something's wrong with the internet"

match 语句的强大之处在于它能匹配模式并从中提取值:

python
# 假设 point 是一个 (x, y) 坐标元组
match point:
    case (0, 0):
        print("Origin")
    case (0, y): # 匹配任何在 Y 轴上的点,并把 y 坐标赋值给变量 y
        print(f"Y={y}")
    case (x, 0): # 匹配任何在 X 轴上的点
        print(f"X={x}")
    case (x, y): # 匹配任何其他点
        print(f"X={x}, Y={y}")
    case _:
        raise ValueError("Not a point")

match 语句还有很多高级用法,比如匹配对象属性、使用“守卫”子句 (if 条件) 等。对于初学者来说,了解它的基本用途即可。

4.8. 定义函数

当我们发现一段代码需要被反复使用时,最好的方法就是把它定义成一个 函数。函数就像一个可重复使用的代码块,你可以给它一个名字,并在需要时“调用”它。

我们使用 def 关键字来定义函数。

python
>>> def fib(n):  # 定义一个名为 fib 的函数,它接受一个参数 n
...     """Print a Fibonacci series up to n.""" # 这是函数的文档字符串 (docstring)
...     a, b = 0, 1
...     while a < n:
...         print(a, end=' ')
...         a, b = b, a+b
...     print()
...
>>> # 现在我们可以调用这个函数了
>>> fib(2000)
0 1 1 2 3 5 8 13 21 34 55 89 144 233 377 610 987 1597

文档字符串 (Docstrings): 函数定义下的第一个字符串字面值就是它的文档字符串。这是一个非常好的编程习惯,用它来解释函数是做什么的、它的参数是什么等等。你可以通过 函数名.__doc__ 来访问它。

函数的返回值

上面的 fib 函数只是打印了一些数字,它本身没有 返回 (return) 任何值。在 Python 中,如果一个函数没有显式地使用 return 语句返回值,它会默认返回一个特殊的值:None

python
>>> print(fib(0))

None

如果我们想让函数返回一个结果供后续代码使用,就需要 return 语句。下面是 fib 函数的另一个版本,它不打印,而是返回一个包含斐波那契数列的列表。

python
>>> def fib2(n):
...     """Return a list containing the Fibonacci series up to n."""
...     result = []
...     a, b = 0, 1
...     while a < n:
...         result.append(a) # .append() 是列表的一个方法,用于在末尾添加元素
...         a, b = b, a+b
...     return result
...
>>> f100 = fib2(100) # 调用函数,并把返回的列表赋值给 f100
>>> f100
[0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89]

4.9. 函数定义详解

Python 的函数定义非常灵活,支持多种定义和调用方式。

4.9.1. 默认值参数

你可以为函数的参数提供一个默认值。这样,在调用函数时,如果这个参数没有被提供,它就会自动使用这个默认值。

python
def ask_ok(prompt, retries=4, reminder='Please try again!'):
    # ... 函数体 ...

这个函数可以这样调用:

  • ask_ok('Do you really want to quit?') (retries 和 reminder 使用默认值)
  • ask_ok('OK to overwrite the file?', 2) (reminder 使用默认值)
  • ask_ok('OK to overwrite the file?', 2, 'Come on, only yes or no!') (所有参数都提供)

重要警告: 函数的默认值只在函数 定义 时被计算一次。如果默认值是一个可变对象 (比如列表或字典),这可能会导致意想不到的结果。

python
def f(a, L=[]): # 错误的示范!
    L.append(a)
    return L

print(f(1)) # 输出 [1]
print(f(2)) # 输出 [1, 2],因为它修改的是同一个列表!
print(f(3)) # 输出 [1, 2, 3]

正确的做法是使用 None 作为默认值,然后在函数内部检查并创建新的列表:

python
def f(a, L=None): # 正确的做法
    if L is None:
        L = []
    L.append(a)
    return L

4.9.2. 关键字参数

你也可以通过 关键字=值 的形式来调用函数。这种方式下,参数的顺序就不重要了。

python
def parrot(voltage, state='a stiff', action='voom'):
    # ... 函数体 ...

parrot(voltage=1000)
parrot(action='VOOOOOM', voltage=1000000) # 顺序不重要
parrot('a thousand', state='pushing up the daisies') # 位置参数和关键字参数混用

规则:在函数调用中,所有 位置参数 (不带名字的) 必须出现在所有 关键字参数 (带名字的) 之前。

4.9.3. 特殊参数 (/*)

在函数定义中,你可以使用 /* 来强制规定参数的传递方式。

  • 仅限位置参数 (Positional-Only): 写在 / 之前 的所有参数,都只能通过位置来传递,不能使用关键字。
  • 仅限关键字参数 (Keyword-Only): 写在 * 之后 的所有参数,都只能通过关键字来传递。
python
def combined_example(pos_only, /, standard, *, kwd_only):
    print(pos_only, standard, kwd_only)

# 正确调用:
combined_example(1, 2, kwd_only=3)
combined_example(1, standard=2, kwd_only=3)

# 错误调用:
# combined_example(pos_only=1, standard=2, kwd_only=3) # pos_only 不能用关键字
# combined_example(1, 2, 3) # kwd_only 必须用关键字

对于初学者,了解即可,在编写自己的函数时可以暂时不使用这种高级特性。

4.9.4. 任意实参列表

如果希望函数能接受任意数量的参数,可以在参数名前加上 ***

  • *args: 这个参数会收集所有多余的 位置参数,并把它们打包成一个 元组 (tuple)
  • **kwargs: 这个参数会收集所有多余的 关键字参数,并把它们打包成一个 字典 (dictionary)
python
def cheeseshop(kind, *arguments, **keywords):
    print("-- Do you have any", kind, "?")
    for arg in arguments:
        print(arg)
    print("-" * 40)
    for kw, val in keywords.items():
        print(kw, ":", val)

cheeseshop("Limburger", "It's very runny, sir.",
           "It's really very, VERY runny, sir.",
           shopkeeper="Michael Palin",
           client="John Cleese")

4.9.5. 解包实参列表

这和上面的操作正好相反。如果你有一个列表或元组,想把它里面的元素作为独立的位置参数传给函数,可以在它前面加一个 *

python
>>> args = [3, 6]
>>> list(range(*args)) # 相当于 list(range(3, 6))
[3, 4, 5]

同样,如果你有一个字典,想把它作为关键字参数传递,可以在它前面加 **

python
>>> d = {"voltage": "four million", "state": "bleedin' demised"}
>>> parrot(**d) # 相当于 parrot(voltage="four million", state="bleedin' demised")

4.9.6. Lambda 表达式

lambda 关键字可以用来创建小型的、匿名的、单行的函数。它常被用在需要一个函数对象作为参数的地方。

它的语法是 lambda arguments: expression

python
# 一个普通函数
def make_incrementor(n):
    return lambda x: x + n

>>> f = make_incrementor(42) # f 现在是一个 "lambda x: x + 42" 的函数
>>> f(0)
42
>>> f(1)
43

一个常见的用途是在排序时提供一个自定义的排序规则:

python
>>> pairs = [(1, 'one'), (2, 'two'), (3, 'three'), (4, 'four')]
>>> # 按照每个元组的第二个元素 (字符串) 来排序
>>> pairs.sort(key=lambda pair: pair[1])
>>> pairs
[(4, 'four'), (1, 'one'), (3, 'three'), (2, 'two')]

4.9.7. 文档字符串

再次强调,为你的函数编写清晰的文档字符串是一个极其重要的好习惯。它应该简洁地描述函数的功能、参数和返回值。

4.9.8. 函数注解

你可以在函数定义中为参数和返回值添加“类型提示”,这被称为函数注解。

python
def f(ham: str, eggs: str = 'eggs') -> str:
    # ...

ham: str 表示我们期望 ham 是一个字符串,-> str 表示我们期望这个函数返回一个字符串。

对于 Python 解释器来说,这些注解没有任何作用,代码的运行方式不会有任何改变。它们主要是给开发者和第三方工具 (如代码检查器) 看的,可以提高代码的可读性和可维护性。

4.10. 小插曲:编码风格

现在你的代码越来越长,是时候关注一下 编码风格 了。写出格式清晰、易于阅读的代码和写出能运行的代码同样重要。

Python 社区广泛遵循 PEP 8 风格指南。以下是其中最核心的几点:

  • 使用 4 个空格作为缩进,不要使用 Tab 键。
  • 每行代码最长不要超过 79 个字符。
  • 使用空行来分隔函数、类,以及函数内部逻辑相关的代码块。
  • 注释应该独占一行。
  • 在运算符前后和逗号后使用空格,但在括号内侧不要加空格。例如:a = f(1, 2) + g(3, 4)
  • UpperCamelCase (大驼峰命名法) 来命名类,用 lowercase_with_underscores (带下划线的小写命名法) 来命名函数和变量。

遵守统一的编码风格,会让你的代码更专业,也更容易被他人理解和维护。

5. 数据结构

本章我们将深入探讨一些已经接触过的数据类型,并介绍一些新的。

5.1. 列表详解

列表是 Python 中功能最丰富的数据结构之一。它提供了许多内置的 方法 (method) 来方便地操作其内容。方法可以看作是专属于某个对象的函数,通过 对象.方法名() 的形式来调用。

以下是列表对象最常用的一些方法:

  • list.append(x) 在列表的 末尾 添加一个元素 x

  • list.extend(iterable) 将一个 可迭代对象 (例如另一个列表) 中的所有元素都添加到列表的末尾。

    python
    >>> a = [1, 2]
    >>> b = [3, 4]
    >>> a.append(b)
    >>> a
    [1, 2, [3, 4]] # append 把整个列表 b 当作一个元素添加了进去
    >>>
    >>> a = [1, 2]
    >>> a.extend(b)
    >>> a
    [1, 2, 3, 4] # extend 把列表 b 的元素拆开添加了进去
  • list.insert(i, x) 在索引为 i 的位置 插入 一个元素 x。例如,a.insert(0, x) 会在列表的最前面插入元素。

  • list.remove(x) 从列表中移除 第一个 值为 x 的元素。如果列表中不存在 x,程序会报错 (ValueError)。

  • list.pop([i]) 移除并 返回 列表中指定索引 i 处的元素。如果 i 没有被指定,它会移除并返回 最后一个 元素。这使得列表可以很方便地被当作“堆栈”来使用。

  • list.clear() 清空列表中的所有元素。

  • list.index(x[, start[, end]]) 返回列表中第一个值为 x 的元素的索引。如果 x 不存在,会报错 (ValueError)。可以提供可选的 startend 参数,把它想象成在一个切片 list[start:end] 中进行搜索。

  • list.count(x) 返回元素 x 在列表中出现的次数。

  • list.sort(*, key=None, reverse=False)就地 (in-place) 对列表中的元素进行排序。这意味着它会直接修改原列表,而不是返回一个新列表。keyreverse 是用于自定义排序规则的高级参数。

  • list.reverse()就地 翻转列表中的元素。

  • list.copy() 返回列表的一个 浅拷贝 (shallow copy)。这等同于使用切片 a[:]

让我们来看一个综合的例子:

python
>>> fruits = ['orange', 'apple', 'pear', 'banana', 'kiwi', 'apple', 'banana']
>>> fruits.count('apple')
2
>>> fruits.index('banana') # 查找第一个 'banana' 的位置
3
>>> fruits.index('banana', 4)  # 从索引 4 开始,查找下一个 'banana' 的位置
6
>>> fruits.reverse() # 原地翻转列表
>>> fruits
['banana', 'apple', 'kiwi', 'banana', 'pear', 'apple', 'orange']
>>> fruits.sort() # 原地排序列表
>>> fruits
['apple', 'apple', 'banana', 'banana', 'kiwi', 'orange', 'pear']
>>> fruits.pop() # 弹出并返回最后一个元素
'pear'

一个重要的设计原则:那些直接修改列表 (可变数据结构) 的方法,如 sort(), reverse(), append() 等,它们的返回值都是 None。它们改变对象本身,但不返回新的对象。

5.1.1. 用列表实现堆栈

堆栈 (stack) 是一种遵循“后进先出” (Last-In, First-Out, LIFO) 原则的数据结构。你可以把它想象成一叠盘子:你最后放上去的盘子,会是第一个被拿走的。

列表的 append()pop() (不带索引) 方法完美地模拟了堆栈的行为:append() 就像把盘子放到最上面 (入栈),pop() 就像从最上面拿走一个盘子 (出栈)。

python
>>> stack = [3, 4, 5]
>>> stack.append(6) # 6 入栈
>>> stack.append(7) # 7 入栈
>>> stack
[3, 4, 5, 6, 7]
>>> stack.pop() # 7 出栈
7
>>> stack
[3, 4, 5, 6]
>>> stack.pop() # 6 出栈
6
>>> stack
[3, 4]

5.1.2. 用列表实现队列

队列 (queue) 则遵循“先进先出” (First-In, First-Out, FIFO) 原则,就像排队买票一样,最先来的人最先买到票。

虽然可以用列表来模拟队列 (append() 入队,pop(0) 出队),但这样做 效率极低。因为每次从列表头部移除一个元素 (pop(0)),后面的所有元素都需要向前移动一位,当列表很长时,这会非常耗时。

在 Python 中,实现队列的正确、高效的方式是使用 collections.deque 对象,它是一个为快速从两端添加和删除元素而设计的双端队列。

python
>>> from collections import deque
>>> queue = deque(["Eric", "John", "Michael"])
>>> queue.append("Terry")   # Terry 入队
>>> queue.append("Graham")  # Graham 入队
>>> queue.popleft()         # 'Eric' (第一个入队的) 出队
'Eric'
>>> queue.popleft()         # 'John' (第二个入队的) 出队
'John'
>>> queue
deque(['Michael', 'Terry', 'Graham'])

5.1.3. 列表推导式

列表推导式 (List Comprehensions) 提供了一种更简洁、更具可读性的方式来创建列表。它通常比使用 for 循环和 append() 的方式更受欢迎。

假设我们想创建一个包含 0 到 9 的平方数的列表。传统的 for 循环方式是这样的:

python
>>> squares = []
>>> for x in range(10):
...     squares.append(x**2)
...
>>> squares
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

而使用列表推导式,只需要一行代码:

python
>>> squares = [x**2 for x in range(10)]
>>> squares
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

列表推导式的基本结构是 [expression for item in iterable]。它还可以包含 if 子句来过滤元素。

python
# 组合两个列表里不相等的元素
>>> [(x, y) for x in [1,2,3] for y in [3,1,4] if x != y]
[(1, 3), (1, 4), (2, 3), (2, 1), (2, 4), (3, 1), (3, 4)]

# 过滤掉列表中的负数
>>> vec = [-4, -2, 0, 2, 4]
>>> [x for x in vec if x >= 0]
[0, 2, 4]

# 对所有元素应用一个函数
>>> [abs(x) for x in vec]
[4, 2, 0, 2, 4]

5.1.4. 嵌套的列表推导式

列表推导式可以嵌套,用于处理嵌套的列表,比如矩阵。例如,我们可以用它来转置一个矩阵 (行列互换)。

python
>>> matrix = [
...     [1, 2, 3, 4],
...     [5, 6, 7, 8],
...     [9, 10, 11, 12],
... ]

# 使用嵌套列表推导式进行转置
>>> [[row[i] for row in matrix] for i in range(4)]
[[1, 5, 9], [2, 6, 10], [3, 7, 11], [4, 8, 12]]

虽然这很强大,但在实际应用中,对于矩阵转置这类常见操作,使用内置函数通常是更好的选择。zip() 函数配合 * 操作符可以优雅地完成这个任务:

python
>>> list(zip(*matrix))
[(1, 5, 9), (2, 6, 10), (3, 7, 11), (4, 8, 12)]

5.2. del 语句

我们之前学习过 list.pop()list.remove() 来删除列表元素。del 语句提供了另一种方式,它通过 索引 来删除元素。与 pop() 不同,del 不返回任何值。

del 语句还可以删除一个切片,甚至整个列表。

python
>>> a = [-1, 1, 66.25, 333, 333, 1234.5]
>>> del a[0] # 删除第一个元素
>>> a
[1, 66.25, 333, 333, 1234.5]
>>> del a[2:4] # 删除一个切片
>>> a
[1, 66.25, 1234.5]
>>> del a[:] # 清空整个列表
>>> a
[]

del 也可以用来删除整个变量。

python
>>> del a

执行后,变量 a 就不再存在了。

5.3. 元组和序列

元组 (tuple) 是另一种序列类型,与列表非常相似。最大的区别在于,元组是 不可变的 (immutable),而列表是可变的。元组由逗号分隔的值组成,通常用圆括号括起来。

python
>>> t = 12345, 54321, 'hello!'
>>> t[0]
12345
>>> t
(12345, 54321, 'hello!')

# 元组是不可变的,不能给它的元素赋值
>>> t[0] = 88888
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'tuple' object does not support item assignment

虽然元组本身不可变,但它可以包含可变的对象,比如列表。

创建只有一个元素的元组时,需要特别注意:必须在元素后面加一个逗号。

python
>>> singleton = 'hello',    # 注意这个逗号!
>>> len(singleton)
1
>>> singleton
('hello',)

将多个值用逗号分隔放在一起,会自动创建一个元组,这个过程被称为 元组打包。反向操作,将一个序列 (如元组或列表) 的元素赋给对应数量的变量,被称为 序列解包

python
>>> t = 12345, 54321, 'hello!'  # 元组打包
>>> x, y, z = t                   # 序列解包
>>> print(x)
12345

5.4. 集合

集合 (set) 是一个 无序不包含重复元素 的容器。它非常适合用来进行成员资格测试和消除重复项。集合还支持强大的数学运算,如并集、交集、差集等。

可以用花括号 {} 或者 set() 函数来创建集合。特别注意:要创建一个空集合,必须 使用 set(),因为 {} 创建的是一个空字典。

python
>>> basket = {'apple', 'orange', 'apple', 'pear', 'orange', 'banana'}
>>> print(basket) # 重复的元素被自动去除了
{'pear', 'apple', 'orange', 'banana'}

>>> 'orange' in basket # 成员测试非常快
True

集合运算的例子:

python
>>> a = set('abracadabra')
>>> b = set('alacazam')
>>> a
{'r', 'b', 'd', 'c', 'a'}
>>> a - b          # 差集: 在 a 中但不在 b 中的字母
{'r', 'b', 'd'}
>>> a | b          # 并集: 在 a 或 b 中的所有字母
{'l', 'r', 'b', 'm', 'd', 'z', 'c', 'a'}
>>> a & b          # 交集: 同时在 a 和 b 中的字母
{'c', 'a'}
>>> a ^ b          # 对称差集: 在 a 或 b 中,但不同时在 a 和 b 中的字母
{'l', 'r', 'b', 'm', 'd', 'z'}

集合也支持推导式:

python
>>> {x for x in 'abracadabra' if x not in 'abc'}
{'r', 'd'}

5.5. 字典

字典 (dictionary) 是 Python 的核心数据结构之一。它是一个 键-值 (key-value) 对 的集合。与列表通过数字索引不同,字典通过 来索引。键必须是 不可变 类型,比如字符串、数字或元组。

字典用花括号 {} 创建,里面包含逗号分隔的 key: value 对。

python
>>> tel = {'jack': 4098, 'sape': 4139}
>>> tel['guido'] = 4127 # 添加一个新的键值对
>>> tel
{'jack': 4098, 'sape': 4139, 'guido': 4127}
>>> tel['jack'] # 通过键获取值
4098
>>> del tel['sape'] # 删除一个键值对
>>> 'guido' in tel # 检查键是否存在
True

如果你试图访问一个不存在的键,会引发 KeyError。使用 get() 方法可以避免这个问题,它在键不存在时会返回 None 或你指定的默认值。

list(d) 会返回一个包含字典所有键的列表。

字典也可以通过 dict() 构造函数创建,或通过字典推导式创建。

python
# 字典推导式
>>> {x: x**2 for x in (2, 4, 6)}
{2: 4, 4: 16, 6: 36}

# 使用关键字参数创建
>>> dict(sape=4139, guido=4127, jack=4098)
{'sape': 4139, 'guido': 4127, 'jack': 4098}

5.6. 循环的技巧

在处理数据结构时,有一些技巧可以让你的循环更优雅、更高效。

  • 遍历字典时,使用 .items() 方法可以同时获取键和值。

    python
    >>> knights = {'gallahad': 'the pure', 'robin': 'the brave'}
    >>> for k, v in knights.items():
    ...     print(k, v)
    ...
    gallahad the pure
    robin the brave
  • 遍历序列时,使用 enumerate() 函数可以同时获取索引和值。

    python
    >>> for i, v in enumerate(['tic', 'tac', 'toe']):
    ...     print(i, v)
    ...
    0 tic
    1 tac
    2 toe
  • 要同时遍历两个或多个序列,使用 zip() 函数。它会将序列中的元素一一配对,直到最短的序列结束。

    python
    >>> questions = ['name', 'quest', 'favorite color']
    >>> answers = ['lancelot', 'the holy grail', 'blue']
    >>> for q, a in zip(questions, answers):
    ...     print(f'What is your {q}?  It is {a}.')
    ...
  • 要反向遍历一个序列,先正常指定序列,然后调用 reversed() 函数。

    python
    >>> for i in reversed(range(1, 10, 2)):
    ...     print(i)
    ...
    9
    7
    ...
  • 要按排序后的顺序遍历序列,而不修改原序列,使用 sorted() 函数。

    python
    >>> basket = ['apple', 'orange', 'apple', 'pear']
    >>> for f in sorted(basket):
    ...     print(f)
    ...
    apple
    apple
    orange
    pear

    结合 set() 可以很方便地遍历序列中的唯一元素并排序。

    python
    >>> for f in sorted(set(basket)):
    ...     print(f)
    ...
    apple
    orange
    pear

5.7. 深入条件控制

ifwhile 语句中的条件可以比简单的比较更复杂。

  • 成员测试运算符 innot in 用于判断一个值是否存在于一个容器 (如列表、元组、集合、字典的键) 中。

  • 同一性运算符 isis not 用于判断两个变量是否指向 同一个对象。这与 == 不同,== 判断的是两个对象的值是否相等。

  • 比较可以 链式 进行,例如 a < b == c

  • 布尔运算符 andor短路 运算符。它们从左到右求值,一旦结果可以确定,就会停止求值。例如,在 A and B 中,如果 AFalse,那么 B 就不会被求值。

  • 可以将布尔表达式的结果赋值给一个变量。

    python
    >>> string1, string2, string3 = '', 'Trondheim', 'Hammer Dance'
    >>> non_null = string1 or string2 or string3
    >>> non_null # or 返回第一个为真的值
    'Trondheim'

5.8. 序列和其他类型的比较

序列类型的对象 (如列表、元组、字符串) 之间可以进行比较。比较采用 字典序 (lexicographical order):

  1. 首先比较第一个元素。
  2. 如果它们不同,比较就结束了。
  3. 如果它们相同,就比较第二个元素,以此类推。
  4. 如果一个序列是另一个序列的初始子序列,那么较短的序列被认为是较小的。
python
(1, 2, 3)              < (1, 2, 4)
'ABC' < 'C' < 'Pascal' < 'Python'
(1, 2)                 < (1, 2, -1)

比较不同类型的对象通常会引发 TypeError,除非这些类型之间定义了明确的比较规则 (比如整数和浮点数)。

6. 模块

当你在 Python 解释器的交互模式下编程时,你所创建的所有函数和变量都会在你退出解释器时丢失。因此,当你需要编写一个更长的、需要被保存的程序时,更好的方式是将代码写入一个文件,这被称为 脚本

随着程序越来越复杂,你可能会想把它拆分成多个文件,以便于管理和维护。这样做还有一个巨大的好处:如果你在多个程序中都需要使用同一个函数,你不需要在每个程序里都复制粘贴一遍它的定义。

为了支持这种需求,Python 提供了一种将代码定义组织起来的方式,这个方式就是 模块 (Module)

一个模块就是一个包含了 Python 定义和语句的文件。它的文件名就是模块名,加上 .py 的后缀。在一个模块内部,可以通过一个名为 __name__ 的全局变量来获取该模块的名称 (一个字符串)。

让我们来动手创建一个模块。打开你的文本编辑器,创建一个名为 fibo.py 的文件,并输入以下内容:

python
# 斐波那契数列模块

def fib(n):
    """打印小于 n 的斐波那契数列。"""
    a, b = 0, 1
    while a < n:
        print(a, end=' ')
        a, b = b, a+b
    print()

def fib2(n):
    """返回一个包含小于 n 的斐波那契数列的列表。"""
    result = []
    a, b = 0, 1
    while a < n:
        result.append(a)
        a, b = b, a+b
    return result

现在,保存这个文件。然后启动 Python 解释器,确保你在 fibo.py 文件所在的目录中。接下来,用 import 命令来使用这个模块:

python
>>> import fibo

这个命令做了什么?它并没有把 fibo 模块里的 fibfib2 函数直接加载到你当前的环境中。相反,它创建了一个名为 fibo命名空间 (namespace)。你可以把这个命名空间想象成一个专属的“容器”或“工具箱”,fibo.py 文件里定义的所有函数和变量都被装在了这个容器里。

要使用容器里的工具,你需要通过 模块名.函数名 的方式来访问它们:

python
>>> fibo.fib(1000)
0 1 1 2 3 5 8 13 21 34 55 89 144 233 377 610 987
>>> fibo.fib2(100)
[0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89]
>>> fibo.__name__
'fibo'

如果你觉得某个函数特别常用,不想每次都输入 fibo. 前缀,可以把它赋值给一个本地变量:

python
>>> fib = fibo.fib
>>> fib(500)
0 1 1 2 3 5 8 13 21 34 55 89 144 233 377

6.1. 模块详解

一个模块文件里可以包含可执行的语句,就像我们之前写的 if 语句和循环一样。这些语句用于初始化模块,它们只在模块 第一次import 时执行一次。

6.1.1. 模块的命名空间

每个模块都有自己独立的 命名空间 (在官方文档里也叫“私有符号表”)。这正是模块最有用的特性之一:它避免了命名冲突。在一个模块里定义的变量,不会与另一个模块里同名的变量,或者你的主程序里同名的变量发生冲突。例如,你在你自己的脚本里可以有一个名为 fib 的变量,它和 fibo 模块里的 fibo.fib 函数是完全不同的两样东西。

6.1.2. 导入模块的不同方式

除了 import fibo,还有其他几种导入模块的方式:

  • from ... import ...

    如果你只想从模块中导入一两个特定的函数或变量,可以直接把它们导入到当前的命名空间中。

    python
    >>> from fibo import fib, fib2
    >>> fib(500) # 无需使用 fibo. 前缀
    0 1 1 2 3 5 8 13 21 34 55 89 144 233 377

    这样做的好处是代码更简洁,但坏处是可能会与你当前代码中已有的同名函数或变量产生冲突。

  • from ... import *

    这会把一个模块中所有不以下划线 _ 开头的名字都导入到当前的命名空间。

    python
    >>> from fibo import *
    >>> fib(500)
    0 1 1 2 3 5 8 13 21 34 55 89 144 233 377

    强烈建议不要在你的代码中常规地使用这种方式。 它会向你的环境里引入大量你可能并不了解的名称,如果恰好有重名,它会悄无声息地覆盖掉你之前定义过的变量或函数,这会让代码变得难以阅读和调试。

  • import ... as ...

    这允许你给导入的模块起一个别名。这在模块名很长,或者为了遵循普遍的编程习惯时非常有用。

    python
    >>> import fibo as fib
    >>> fib.fib(500)
    0 1 1 2 3 5 8 13 21 34 55 89 144 233 377

    你也可以给从模块中导入的特定函数起别名:

    python
    >>> from fibo import fib as fibonacci
    >>> fibonacci(500)
    0 1 1 2 3 5 8 13 21 34 55 89 144 233 377

    关于模块重载的说明:为了效率,Python 在一次会话中只会加载一个模块一次。如果你在导入后修改了 fibo.py 的源文件,再次 import fibo 并不会加载你修改后的版本。你必须重启解释器,或者使用 importlib.reload() 函数来重新加载模块。

6.1.3. 以脚本方式执行模块

一个 Python 的 .py 文件有两种用途:它可以作为一个模块被其他程序 导入,也可以作为一个脚本被 直接运行

当你通过命令行运行它时: python fibo.py <arguments>

Python 解释器会执行文件里的代码,就像导入它一样,但有一个关键区别:它会把特殊的 __name__ 变量的值设置为 "__main__"

利用这个特性,我们可以在模块的末尾添加一段代码,使得它既能被导入,也能作为脚本独立运行。

修改 fibo.py,在末尾添加以下代码:

python
if __name__ == "__main__":
    import sys
    fib(int(sys.argv[1]))

这段代码的意思是:“只有当这个文件是作为主程序直接运行时,才执行我下面的代码”。

  • 作为脚本运行:

    bash
    $ python fibo.py 50
    0 1 1 2 3 5 8 13 21 34

    在这种情况下,__name__ 的值是 "__main__",所以 if 语句块内的代码被执行了。

  • 作为模块导入:

    python
    >>> import fibo
    >>>

    在这种情况下,fibo.py 内部的 __name__ 变量的值是 "fibo",所以 if 语句块内的代码 不会 被执行。

这种 if __name__ == "__main__" 的结构是 Python 的一个常用惯例,常用于为模块提供一个简单的命令行接口,或者用于模块的单元测试。

6.1.4. 模块搜索路径

当你写 import spam 时,Python 是如何找到 spam.py 这个文件的呢?解释器会按照一个明确的顺序去搜索:

  1. 内置模块:首先,检查 spam是不是一个内置模块。
  2. sys.path 列表:如果不是内置模块,解释器就会去 sys.path 这个变量里包含的目录列表中寻找 spam.py 文件。

sys.path 是一个包含了多个目录路径的字符串列表。它的初始值来自于以下几个地方:

  • 你正在运行的脚本所在的目录 (或者,如果你没有指定文件,就是当前工作目录)。
  • 环境变量 PYTHONPATH (这是一个高级用法)。
  • Python 安装时的默认目录 (标准库等所在地)。

你可以导入 sys 模块来查看或修改 sys.path

6.1.5. “已编译的” Python 文件

为了提高模块的加载速度,Python 实现了一个缓存机制。你可能已经注意到,在你的代码目录中,会自动生成一个名为 __pycache__ 的文件夹。

当你第一次导入一个模块 (例如 spam.py) 时,Python 会把它编译成一种更高效的中间形式——字节码,并把结果保存为一个 .pyc 文件 (例如 spam.cpython-314.pyc) 存放在 __pycache__ 目录中。

下一次你的程序再导入 spam 模块时,如果 Python 发现 spam.py 的修改时间没有比 spam.pyc 更新,它就会直接加载这个已编译的 .pyc 文件,从而跳过了编译步骤,加快了程序的启动速度。

这个过程是 完全自动的。你不需要关心 .pyc 文件,甚至可以安全地删除 __pycache__ 文件夹,Python 会在下次需要时重新生成它。

6.2. 标准模块

Python 的一个巨大优势是它自带了一个庞大而功能齐全的 标准库 (Standard Library),通常被称为“电池内置”(batteries included)。这个库包含了大量预先写好的模块,可以用来处理各种常见的任务,比如文件 I/O, 系统操作, 网络通信, 数学计算等等。

其中一个特别重要的模块是 sys,它内嵌在每一个 Python 解释器中。通过 sys 模块,你可以访问和修改解释器的状态,例如我们之前看到的 sys.path

还有一个有趣的例子是,在交互模式下,你可以修改主提示符 (>>>) 和次要提示符 (...):

python
>>> import sys
>>> sys.ps1
'>>> '
>>> sys.ps1 = 'C> '
C> print('Yuck!')
Yuck!
C>

6.3. dir() 函数

内置函数 dir() 是一个非常有用的工具,它可以用来查看一个模块 (或任何其他对象) 定义了哪些名称。它返回一个排好序的字符串列表。

python
>>> import fibo, sys
>>> dir(fibo)
['__name__', 'fib', 'fib2']

这告诉你 fibo 模块里有 __name__fibfib2 这三个名称。

如果不带参数调用 dir(),它会列出你当前命名空间中定义的所有名称:

python
>>> a = [1, 2, 3]
>>> import fibo
>>> fib = fibo.fib
>>> dir()
['__builtins__', '__name__', 'a', 'fib', 'fibo', 'sys']

6.4. 包

当你的项目越来越大,模块文件越来越多时,你可能需要更高一级的组织方式。包 (Package) 就是用来组织模块的。

一个包就是一个包含了其他模块的 文件夹。通过使用“带点号的模块名”,我们可以清晰地组织和访问一个复杂库的结构。例如,模块名 A.B 指的是名为 A 的包里的一个名为 B 的子模块。

要让 Python 把一个文件夹当作一个包,这个文件夹里 必须 包含一个名为 __init__.py 的文件。这个文件可以是一个空文件,它的存在本身就是告诉 Python:“这是一个包”。

假设我们正在创建一个处理声音的包,目录结构可能如下:

sound/
      __init__.py
      formats/
              __init__.py
              wavread.py
              wavwrite.py
      effects/
              __init__.py
              echo.py
              surround.py
      filters/
              __init__.py
              equalizer.py

在这个结构中,sound, formats, effects, filters 都是包。

6.4.1. 从包中导入模块

我们可以用带点号的路径来导入包里的模块:

python
import sound.effects.echo

调用时需要使用完整的路径:

python
sound.effects.echo.echofilter(...)

或者,使用 from 语句来导入子模块:

python
from sound.effects import echo

调用时路径会变短:

python
echo.echofilter(...)

甚至可以直接导入所需的函数:

python
from sound.effects.echo import echofilter

调用时就更直接了:

python
echofilter(...)

6.4.2. 从包中导入 *

from sound.effects import * 这个语句的行为是由包的作者控制的。默认情况下,它不会导入包里的所有子模块。

如果包的作者希望 import * 能够导入特定的模块,他们需要在包的 __init__.py 文件里定义一个名为 __all__ 的列表,这个列表里包含了应该被导入的模块名的字符串。

例如,在 sound/effects/__init__.py 中可以这样写:

python
__all__ = ["echo", "surround", "reverse"]

这样,当用户执行 from sound.effects import * 时,只有 echo, surround, 和 reverse 这三个子模块会被导入。

6.4.3. 相对导入

当你在一个复杂的包内部编写代码时 (例如,在 sound.filters.vocoder 模块里),你可能需要引用同包内的其他模块 (例如 sound.effects.echo)。

你可以使用 绝对导入from sound.effects import echo

也可以使用 相对导入,它使用点号 . 来表示相对位置:

  • 一个点 . 表示当前包。
  • 两个点 .. 表示上级包。

例如,在 sound.effects.surround 模块中:

python
from . import echo # 从当前包 (effects) 导入 echo 模块
from .. import formats # 从上级包 (sound) 导入 formats 子包
from ..filters import equalizer # 从上级包的 filters 子包中导入 equalizer 模块

注意:相对导入只能在包内部的模块之间使用。你不能在一个作为主程序直接运行的脚本中使用相对导入。

7. 输入与输出

一个程序很少是完全封闭运行的,它总是需要与外部世界进行交互:要么向用户展示信息,要么将处理结果保存起来供日后使用。本章将介绍几种常见的输入输出方式。

7.1. 更复杂的输出格式

到目前为止,我们已经学习了两种输出值的方式:直接在解释器里输入表达式让它回显结果,以及使用 print() 函数。但有时,我们需要对输出的格式有更精细的控制。Python 提供了几种方法来实现这一点。

7.1.1. 格式化字符串字面值 (f-strings)

f-strings 是目前最推荐、最现代、也最简洁的字符串格式化方法。它的使用非常简单:只需在字符串的第一个引号前加上一个 fF,然后在字符串内部,你就可以用花括号 {} 把变量名或任何有效的 Python 表达式包裹起来,它们的值会自动被嵌入到字符串中。

python
>>> year = 2024
>>> event = 'Olympics'
>>> f'Results of the {year} {event}'
'Results of the 2024 Olympics'

>>> import math
>>> f'The value of pi is approximately {math.pi}.'
'The value of pi is approximately 3.141592653589793.'

f-strings 的强大之处在于,你可以在花括号内表达式的后面加上一个冒号 :,并在冒号后提供 格式说明符,来精确控制输出的格式。

  • 控制小数位数

    python
    >>> import math
    >>> print(f'The value of pi is approximately {math.pi:.3f}.') # .3f 表示格式化为保留 3 位小数的浮点数
    The value of pi is approximately 3.142.
  • 控制字段宽度和对齐: 这在制作整齐的表格时非常有用。在冒号后提供一个整数,可以指定该字段的最小宽度。

    python
    >>> table = {'Sjoerd': 4127, 'Jack': 4098, 'Dcab': 7678}
    >>> for name, phone in table.items():
    ...     # :10 表示 name 字段占 10 个字符宽度,默认左对齐
    ...     # :10d 表示 phone 字段占 10 个字符宽度,并作为十进制数右对齐
    ...     print(f'{name:10} ==> {phone:10d}')
    ...
    Sjoerd     ==>       4127
    Jack       ==>       4098
    Dcab       ==>       7678
  • 转换标志: 你可以在表达式后使用 !r, !s!a,来强制调用 repr(), str()ascii() 函数。

    python
    >>> animals = 'eels'
    >>> print(f'My hovercraft is full of {animals!r}.') # 使用 !r 会得到带引号的表示形式
    My hovercraft is full of 'eels'.
  • 自描述表达式 (=): 这是一个方便调试的小技巧。在表达式后加上 =,它会自动输出表达式本身、一个等号和表达式的值。

    python
    >>> bugs = 'roaches'
    >>> count = 13
    >>> print(f'Debugging {bugs=} {count=}')
    Debugging bugs='roaches' count=13

7.1.2. str.format() 方法

这是 f-strings 出现之前,推荐的字符串格式化方法。它同样使用花括号 {} 作为占位符,但你需要通过调用字符串的 .format() 方法来传入要替换的值。

  • 按顺序填充

    python
    >>> print('We are the {} who say "{}!"'.format('knights', 'Ni'))
    We are the knights who say "Ni!"
  • 按索引填充

    python
    >>> print('{1} and {0}'.format('spam', 'eggs'))
    eggs and spam
  • 按名称填充

    python
    >>> print('This {food} is {adjective}.'.format(
    ...       food='spam', adjective='absolutely horrible'))
    This spam is absolutely horrible.
  • 通过 ** 解包字典: 这是一种非常方便的技巧,可以将一个字典的所有键值对作为关键字参数传给 .format()

    python
    >>> table = {'Sjoerd': 4127, 'Jack': 4098, 'Dcab': 8637678}
    >>> print('Jack: {Jack:d}; Sjoerd: {Sjoerd:d}; Dcab: {Dcab:d}'.format(**table))
    Jack: 4098; Sjoerd: 4127; Dcab: 8637678

str.format() 也支持与 f-strings 类似的格式说明符 (冒号 : 后的部分)。

7.1.3. str()repr()

如果你只是想快速查看一个变量的值,并不需要复杂的格式,str()repr() 这两个内置函数很有用。

  • str():返回一个对象的“用户友好”的字符串表示形式,追求 可读性
  • repr():返回一个对象的“官方”字符串表示形式,追求 明确性,其结果通常是有效的 Python 代码,可以用来重新创建该对象。
python
>>> s = 'Hello, world.'
>>> str(s)
'Hello, world.'
>>> repr(s)
"'Hello, world.'" # repr() 会加上引号

>>> import datetime
>>> today = datetime.datetime.now()
>>> str(today)
'2025-10-21 14:22:00.123456'
>>> repr(today)
'datetime.datetime(2025, 10, 21, 14, 22, 0, 123456)'

7.2. 读写文件

程序经常需要将数据永久保存到文件中,或者从文件中读取数据。

7.2.1. 打开文件

要读写文件,你首先需要用 open() 函数来打开它,这会返回一个 文件对象

open(filename, mode, encoding=None)

  • filename: 文件的路径和名称。
  • mode: 一个字符串,表示你打算如何操作这个文件。常用的模式有:
    • 'r': 读取 (默认模式)。
    • 'w': 写入 (如果文件已存在,其内容会被 清空)。
    • 'a': 追加 (在文件末尾添加内容)。
    • 'r+': 读写模式。
  • encoding: 文件的编码格式。强烈建议在处理文本文件时总是明确指定 encoding="utf-8"

7.2.2. with 语句

处理文件时,一个最佳实践是使用 with 语句。它的巨大优势在于,无论代码块内部是否发生错误,它都能 自动且安全地关闭文件

python
>>> with open('workfile.txt', 'w', encoding="utf-8") as f:
...     f.write("This is a test.\n")
...
>>> f.closed # 退出 with 代码块后,文件 f 已经被自动关闭了
True

如果你不使用 with 语句,那么你必须手动调用 f.close() 来关闭文件,否则可能会导致数据没有被完整写入。

7.2.3. 文件对象的方法

一旦你有了一个文件对象 (比如上面例子中的 f),就可以使用它的方法来读写。

  • f.read(size): 读取文件内容。如果不提供 size,它会一次性读取整个文件。小心! 如果文件非常大,这可能会耗尽你的内存。如果提供了 size,它会读取指定数量的字符 (文本模式) 或字节 (二进制模式)。

  • f.readline(): 读取文件中的一行。行末的换行符 \n 会被保留。

  • 遍历文件对象: 这是读取文件逐行内容最常用、最高效的方式。

    python
    # 假设 'poem.txt' 已经存在
    with open('poem.txt', 'r', encoding="utf-8") as f:
        for line in f:
            print(line, end='') # end='' 是为了防止 print 自己再加一个换行符
  • f.readlines(): 读取所有行并以列表的形式返回。同样,对于大文件要小心使用。

  • f.write(string): 将 string 的内容写入文件。它返回实际写入的字符数。注意write() 不会自动添加换行符,你需要自己添加 \n

    python
    >>> with open('workfile.txt', 'a', encoding="utf-8") as f:
    ...     value = ('the answer', 42)
    ...     s = str(value) # 写入前必须确保内容是字符串
    ...     f.write(s)
    ...

7.2.4. 使用 json 保存结构化数据

当你想保存的数据不只是简单的字符串或数字,而是像列表、字典这样的结构化数据时,手动处理会变得非常复杂。

这时,JSON (JavaScript Object Notation) 格式和 Python 的 json 模块就派上用场了。JSON 是一种轻量级的数据交换格式,易于人阅读和编写,也易于机器解析和生成。

  • 序列化 (Serialization):将 Python 对象 (如列表、字典) 转换为 JSON 格式的字符串。这个过程也叫编码 (encoding) 或转储 (dumping)。

  • 反序列化 (Deserialization):将 JSON 格式的字符串转换回 Python 对象。这个过程也叫解码 (decoding) 或加载 (loading)。

json 模块提供了非常简单易用的函数:

  • json.dumps(obj): 将 Python 对象 obj 序列化 成 JSON 字符串。(s 代表 string)
  • json.dump(obj, f): 将 Python 对象 obj 序列化后 写入 到文件对象 f 中。
python
>>> import json
>>> data = {'name': 'Alice', 'age': 30, 'is_student': False, 'courses': ['Math', 'Physics']}
>>>
>>> # 序列化为字符串
>>> json_string = json.dumps(data, indent=4) # indent=4 可以让输出格式更美观
>>> print(json_string)
{
    "name": "Alice",
    "age": 30,
    "is_student": false,
    "courses": [
        "Math",
        "Physics"
    ]
}
>>>
>>> # 序列化到文件
>>> with open('data.json', 'w', encoding='utf-8') as f:
...     json.dump(data, f, indent=4)
...
  • json.loads(s): 将 JSON 字符串 s 反序列化 回 Python 对象。
  • json.load(f): 从文件对象 f 中读取 JSON 数据并反序列化。
python
>>> # 从字符串反序列化
>>> restored_data = json.loads(json_string)
>>> restored_data['name']
'Alice'
>>>
>>> # 从文件反序列化
>>> with open('data.json', 'r', encoding='utf-8') as f:
...     data_from_file = json.load(f)
...
>>> data_from_file['courses']
['Math', 'Physics']

使用 json 模块是保存和交换结构化数据的标准、可靠的方式。

9. 类

在之前的学习中,我们已经接触了 Python 中的许多数据类型,比如数字 (integers, floats),字符串 (strings),列表 (lists) 和字典 (dictionaries)。类 (Class) 是一种强大的工具,它允许我们创造属于自己的、全新的数据类型。

你可以把类想象成一张“蓝图”或者一个“模具”。比如,建筑师会先设计一张房子的蓝图,这张蓝图详细描述了房子应该有什么样的特征(比如有多少个房间、几扇窗户)和功能(比如可以开门、可以开灯)。有了这张蓝图,我们就可以根据它建造出许多栋具体、真实存在的房子。

在 Python 中,类就是这张“蓝图”。它把相关的数据(称为“属性”,Attribute)和操作这些数据的函数(称为“方法”,Method)巧妙地捆绑在一起。当我们定义了一个类,我们就定义了一种新的对象 类型。而根据这个类创建出来的每一个具体的对象,我们称之为该类的一个 实例 (Instance)。每一栋根据蓝图盖好的房子就是一个实例,它们各自有自己的状态(例如,A 房子的灯是开着的,B 房子的灯是关着的),但它们都共享蓝图上定义的通用功能。

与其他编程语言相比,Python 在设计类的时候,力求用最少的新语法和新概念来实现强大的功能。它支持“面向对象编程” (Object-Oriented Programming, OOP) 的所有核心特性:

  • 继承 (Inheritance):一个类(子类)可以继承另一个类(父类)的全部功能,并在此基础上进行扩展或修改,就像是升级版的蓝图。一个子类甚至可以同时继承多个父类。
  • 方法覆盖 (Overriding):子类可以重新定义从父类继承过来的某个方法,以实现自己特定的行为。
  • 方法调用 (Method Calling):子类的方法可以方便地调用父类中同名的方法,以便在父类功能的基础上进行扩展。

对象可以包含任意数量和类型的数据。就像模块一样,类也是动态的:它们在程序运行时被创建,并且在创建之后仍然可以被修改。

从其他语言(如 C++)转过来的开发者可能会关心一些技术细节。在 Python 中,一个类的所有成员(包括数据和方法)默认都是 public (公开的),意味着可以从任何地方访问它们。所有的方法在本质上都是 virtual (虚拟的),这意味着当子类覆盖父类的方法时,程序总会正确地调用到子类的版本。

另外,与 C++ 和 Modula-3 不同的是,Python 的内置类型(如 listdict)本身也是类,你可以继承它们并进行扩展。同时,Python 中常见的运算符(如 +, -, [])也可以被重新定义,让你自己创建的类的实例能够像内置类型一样自然地进行运算。

在本章的学习中,我们会逐步揭开类的神秘面纱。

9.1 名称和对象

在深入类之前,我们必须先回顾一个 Python 中至关重要的概念:名称 (name) 和对象 (object) 之间的关系。

在 Python 中,我们操作的一切几乎都是对象。一个数字 123 是一个对象,一个字符串 'hello' 是一个对象,一个列表 [1, 2, 3] 也是一个对象。这些对象真实地存在于计算机的内存中。而我们通常使用的变量名,比如 x = 123 中的 x,它本身并不是对象,它只是一个“名称”或者说“标签”,这个标签被“贴”在了 123 这个对象上。

一个对象可以被多个名称所指代。这种情况在其他语言里被称为“别名” (aliasing)。

python
a = [1, 2, 3]
b = a

在这个例子中,我们首先创建了一个列表对象 [1, 2, 3],并让名称 a 指向它。然后,b = a 这条语句并没有复制这个列表,而是创建了一个新的名称 b,并让它也指向了 同一个 列表对象。ab 就像是同一个人的两个不同昵称。

当你处理不可变 (immutable) 的对象,比如数字、字符串或元组时,这个概念通常不会引起困惑。但当你处理可变 (mutable) 的对象,如列表或字典时,“别名”现象可能会带来一些意想不到的结果。

python
a = [1, 2, 3]
b = a

# 我们通过名称 b 来修改这个列表
b.append(4)

# 打印 a,看看会发生什么
print(a)  # 输出: [1, 2, 3, 4]

因为 ab 指向的是同一个列表对象,所以通过 b 对列表所做的任何修改,都会通过 a 反映出来。

这种机制并非缺陷,反而在很多时候非常有用。它类似于其他语言中的“指针”,但更安全、更易用。例如,当你把一个很大的列表传递给一个函数时,Python 只需要传递这个对象的“地址”(或者说引用),而不需要完整地复制一遍列表,这使得函数调用非常高效。同时,如果函数内部修改了这个列表,调用者在函数外部也能够看到这个修改。

9.2 Python 作用域和命名空间

在编写类之前,理解 Python 如何组织和查找名称是至关重要的。类定义本身就是一种巧妙地运用命名空间 (namespace) 和作用域 (scope) 的技巧。

9.2.1 什么是命名空间?

命名空间 (namespace) 是一个从名称到对象的映射。你可以把它想象成一本字典,其中的键 (key) 是名称(比如变量名 x),值 (value) 是这个名称所指向的对象(比如数字 123)。

Python 中有很多不同的命名空间,例如:

  • 内置命名空间:包含了所有 Python 内置的函数和异常,比如 len() 函数, print() 函数, StopIteration 异常等。这个命名空间在 Python 解释器启动时被创建,并且会一直存在。
  • 全局命名空间:每个模块 (.py 文件) 都有自己的全局命名空间。它记录了你在模块顶层定义的所有变量、函数和类。
  • 局部命名空间:每当一个函数被调用时,就会为这次调用创建一个新的局部命名空间。它记录了函数内部定义的变量和参数。当函数执行完毕后,这个局部命名空间通常就会被销毁。

一个非常重要的原则是:不同命名空间中的名称是完全独立的。两个不同的模块 module1module2 都可以定义一个名为 my_function 的函数,它们之间不会产生任何冲突。当你需要使用时,可以通过 module1.my_function()module2.my_function() 来明确指定你想要的是哪一个。

这种使用点号 (.) 来访问的名称,我们称之为对象的 属性 (attribute)。表达式 z.real 中,real 是对象 z 的一个属性。同样地,modname.funcname 中,modname 是一个模块对象,而 funcname 是它的一个属性。

9.2.2 什么是作用域?

一个 作用域 (scope) 是代码中的一个文本区域,在这个区域内,你可以直接访问某个命名空间里的名称,而不需要添加任何前缀。

虽然作用域是静态定义的(在你写代码时就确定了),但 Python 在运行时是动态地使用它们的。在程序执行的任何时刻,都存在一个嵌套的作用域层次结构。当你的代码使用一个名称时(比如 spam),Python 会按照一个固定的顺序来搜索这个名称,这个顺序通常被称为 LEGB 规则

  1. L (Local):最内层的作用域,首先被搜索。这通常是当前函数内部的局部命名空间。
  2. E (Enclosing function locals):外层闭包函数的作用域。如果一个函数嵌套在另一个函数内部,Python 会从内到外逐层搜索这些嵌套函数的局部命名空间。
  3. G (Global):当前模块的全局命名空间。
  4. B (Built-in):最外层的作用域,最后被搜索。这是包含了所有内置名称的命名空间。

如果一个名称在所有这些作用域中都找不到,Python 就会抛出一个 NameError 异常。

通常情况下,对一个名称进行赋值 (=) 会在最内层的作用域(局部作用域)中创建或绑定这个名称。例如,在函数内部 x = 10 会创建一个新的局部变量 x

如果你想在函数内部修改一个全局变量,你需要使用 global 关键字来告诉 Python:“我接下来要操作的这个 spam 是全局命名空间里的那个,不是新的局部变量。”

同样地,如果你在一个嵌套函数中,想修改外层函数的变量(非全局变量),你需要使用 nonlocal 关键字。

9.2.3 作用域和命名空间示例

让我们通过一个例子来直观地理解这些规则。

python
def scope_test():
    def do_local():
        spam = "local spam"

    def do_nonlocal():
        nonlocal spam
        spam = "nonlocal spam"

    def do_global():
        global spam
        spam = "global spam"

    spam = "test spam"
    do_local()
    print("After local assignment:", spam)
    do_nonlocal()
    print("After nonlocal assignment:", spam)
    do_global()
    print("After global assignment:", spam)

# 在模块顶层(全局作用域)调用函数
scope_test()
print("In global scope:", spam)

这段代码的输出结果是:

After local assignment: test spam
After nonlocal assignment: nonlocal spam
After global assignment: nonlocal spam
In global scope: global spam

让我们一步步分析:

  1. spam = "test spam":在 scope_test 函数的局部作用域中,创建了一个变量 spam,它的值是 "test spam"
  2. do_local() 被调用。在 do_local 内部,spam = "local spam" 创建了一个 新的、属于 do_local 的局部变量 spam。它与 scope_test 中的 spam 毫无关系。当 do_local 执行完毕,这个局部变量就被销毁了。
  3. 因此,第一个 print 语句输出的 spam 仍然是 scope_test 作用域中的 "test spam"
  4. do_nonlocal() 被调用。nonlocal spam 告诉 Python,接下来操作的 spam 是外层函数 scope_test 的那个 spam。所以 spam = "nonlocal spam" 修改了 scope_testspam
  5. 因此,第二个 print 语句输出 "nonlocal spam"
  6. do_global() 被调用。global spam 告诉 Python,接下来要操作的是 模块全局作用域spamspam = "global spam" 这条语句在全局命名空间中创建(或者修改)了一个名为 spam 的变量。这 不会 影响到 scope_test 作用域中的 spam
  7. 因此,第三个 print 语句输出的 spam 仍然是 scope_test 作用域中的那个,它现在的值是 "nonlocal spam"
  8. scope_test() 函数执行完毕。最后一条 print 语句是在全局作用域中执行的,它打印的是被 do_global 修改过的全局变量 spam,所以输出是 "global spam"

这个例子清晰地展示了默认赋值、nonlocal 赋值和 global 赋值是如何在不同的命名空间中工作的。

9.3 初探类

类为我们引入了一些新的语法、三种新的对象类型和一些新的语义。

9.3.1 类定义语法

定义一个类的最简单形式如下:

python
class ClassName:
    <statement-1>
    .
    .
    .
    <statement-N>

和函数定义(def 语句)一样,class 语句必须先被执行,这个类才会被创建并生效。你可以把一个类定义放在 if 语句的一个分支里,或者放在一个函数内部。

在实践中,类定义内部的语句通常都是函数定义(也就是我们之后要讲的“方法”),但也可以是其他的 Python 语句。

当 Python 执行到一个 class 定义时,它会创建一个新的命名空间,并将其作为当前的局部作用域。因此,在类定义内部的所有赋值操作(比如定义一个变量或一个函数)都会绑定到这个新的命名空间里。

当 Python 正常执行完类定义的所有语句后,一个 类对象 就被创建了。这个类对象本质上是对刚才创建的那个命名空间的包装。然后,Python 会把这个新创建的类对象绑定到 class 关键字后面给出的名称上(在这个例子中是 ClassName)。

9.3.2 类对象

一旦我们有了一个类对象 (Class Object),就可以对它进行两种主要的操作:属性引用和实例化。

属性引用 使用和其他对象一样的标准语法:obj.name。只要是在类被创建时,存在于其命名空间中的名称,都可以作为有效的属性名。

让我们看一个例子:

python
class MyClass:
    """A simple example class"""
    i = 12345

    def f(self):
        return 'hello world'

在这个例子中,MyClass.iMyClass.f 都是有效的属性引用。MyClass.i 会返回整数 12345MyClass.f 会返回一个函数对象。我们甚至可以给类属性赋值,比如 MyClass.i = 54321

__doc__ 也是一个有效的属性,它会返回类的文档字符串:"A simple example class"

实例化 (Instantiation) 看起来就像函数调用。你可以把类对象看作是一个特殊的“函数”,调用它会返回这个类的一个新的实例。

python
x = MyClass()

这行代码创建了 MyClass 的一个新 实例 (instance),并将这个实例对象赋值给了局部变量 x

这个实例化操作会创建一个空的对象。但很多时候,我们希望实例在被创建时就具有一个特定的初始状态。为了实现这一点,类可以定义一个特殊的方法,名为 __init__()

python
class MyClass:
    def __init__(self):
        self.data = []

当一个类定义了 __init__() 方法时,对这个类的实例化操作会自动调用 __init__()。因此,x = MyClass() 这行代码实际上会执行以下两步:

  1. 创建一个 MyClass 的新实例。
  2. 将这个新创建的实例作为第一个参数(我们约定俗成地称之为 self),自动调用 __init__(self) 方法。

__init__() 方法也可以接受更多的参数,以实现更灵活的初始化。在这种情况下,你在实例化时提供的参数会被传递给 __init__()

python
class Complex:
    def __init__(self, realpart, imagpart):
        self.r = realpart
        self.i = imagpart

x = Complex(3.0, -4.5)
print(x.r, x.i)  # 输出: 3.0 -4.5

在这里,Complex(3.0, -4.5) 会自动调用 __init__(instance, 3.0, -4.5),其中 instance 就是新创建的 Complex 对象。

9.3.3 实例对象

现在我们创建了一个实例对象 x,能用它做什么呢?实例对象唯一能理解的操作就是属性引用。有两种有效的属性名称:数据属性和方法。

数据属性 (Data Attributes) 不需要预先声明。它们就像局部变量一样,在第一次被赋值时“诞生”。

python
x = MyClass()
x.counter = 1
while x.counter < 10:
    x.counter = x.counter * 2
print(x.counter)  # 输出 16
del x.counter

在这里,我们给实例 x 添加了一个名为 counter 的数据属性。

方法 (Methods) 是“属于”一个对象的函数。一个实例对象有哪些有效的方法,取决于它的类。按照定义,一个类中所有是函数对象的属性,都对应着其实例的方法。

在我们的 MyClass 例子中,x.f 是一个有效的方法引用,因为 MyClass.f 是一个函数。而 x.i 不是方法,因为 MyClass.i 不是一个函数。

但是,x.fMyClass.f 并不是同一个东西。MyClass.f 是一个函数对象 (function object),而 x.f 是一个 方法对象 (method object)

9.3.4 方法对象

通常,我们获取一个方法后会立即调用它:

python
x = MyClass()
print(x.f())  # 输出: 'hello world'

不过,方法对象也可以被存储起来,稍后再调用:

python
xf = x.f
while True:
    print(xf())

这段代码会不停地打印 'hello world'

当一个方法被调用时,到底发生了什么?你可能已经注意到了,我们定义 f(self) 时明明有一个参数 self,但在调用 x.f() 时却没有提供任何参数。这个 self 参数去哪了?

这正是方法的特殊之处:实例对象本身会自动作为第一个参数传递给函数

调用 x.f() 实际上完全等同于调用 MyClass.f(x)

总的来说,当引用一个实例的非数据属性时(比如 x.f),Python 会去查找这个实例的类 (MyClass)。如果找到了一个同名的函数 (f),Python 会将实例对象 (x) 和这个函数对象 (MyClass.f) 打包成一个方法对象。当我们调用这个方法对象并提供参数列表时(比如 x.f(arg1, arg2)),Python 实际上会用实例对象作为第一个参数,构造一个新的参数列表(x, arg1, arg2),然后用这个新的参数列表去调用原始的函数对象 (MyClass.f(x, arg1, arg2))。

9.3.5 类和实例变量

我们可以在类中定义两种变量:类变量和实例变量。

  • 实例变量 (Instance Variables) 是每个实例独有的数据。它们通常在 __init__() 方法中,通过 self.variable_name = value 的方式来定义。
  • 类变量 (Class Variables) 是由一个类的所有实例共享的属性和方法。它们直接在 class 代码块下定义。
python
class Dog:

    kind = 'canine'         # 类变量,被所有实例共享

    def __init__(self, name):
        self.name = name    # 实例变量,每个实例独有

d = Dog('Fido')
e = Dog('Buddy')

print(d.kind)  # 'canine'
print(e.kind)  # 'canine'

print(d.name)  # 'Fido'
print(e.name)  # 'Buddy'

在这里,kind 对于所有的狗来说都是 'canine',所以它是一个类变量。而每只狗都有自己独特的名字 name,所以它是一个实例变量。

共享数据在使用可变对象(如列表和字典)时需要特别小心。如果把一个可变对象作为类变量,可能会导致意想不到的结果。

python
class Dog:

    tricks = []             # 错误地使用了类变量

    def __init__(self, name):
        self.name = name

    def add_trick(self, trick):
        self.tricks.append(trick)

d = Dog('Fido')
e = Dog('Buddy')
d.add_trick('roll over')
e.add_trick('play dead')

print(d.tricks)  # 意外地输出了 ['roll over', 'play dead']

因为 tricks 是一个类变量,所有的 Dog 实例都共享同一个列表。当 d 添加一个技能时,这个技能被添加到了那个唯一的共享列表里;当 e 添加技能时,也是如此。

正确的做法是为每个实例创建一个独立的 tricks 列表,即将它作为实例变量:

python
class Dog:
    def __init__(self, name):
        self.name = name
        self.tricks = []    # 为每个实例创建一个新的空列表

    def add_trick(self, trick):
        self.tricks.append(trick)

d = Dog('Fido')
e = Dog('Buddy')
d.add_trick('roll over')
e.add_trick('play dead')

print(d.tricks) # ['roll over']
print(e.tricks) # ['play dead']

现在,每只狗都有了自己专属的技能列表,问题得到了解决。

9.4 补充说明

如果实例和一个类中同时存在同名的属性,那么在通过实例访问该属性时,会优先查找实例的属性

python
class Warehouse:
   purpose = 'storage'
   region = 'west'

w1 = Warehouse()
print(w1.purpose, w1.region) # storage west

w2 = Warehouse()
w2.region = 'east' # 这会为实例 w2 创建一个名为 region 的实例属性
print(w2.purpose, w2.region) # storage east

w1.region 访问的是类属性,而 w2.region 访问的是它自己独有的实例属性,该实例属性“遮蔽”了同名的类属性。

方法的第一个参数通常被命名为 self。这仅仅是一个约定,self 这个词在 Python 中没有任何特殊含义。但是,这是一个非常强烈的约定,不遵守它会让你代码的可读性变得很差,所以请务必遵守。

类中的方法可以通过 self 来调用同一个实例的其他方法或访问其实例属性。

python
class Bag:
    def __init__(self):
        self.data = []

    def add(self, x):
        self.data.append(x)

    def addtwice(self, x):
        self.add(x)
        self.add(x)

在这里,addtwice 方法通过 self.add(x) 调用了同一个实例的 add 方法。

9.5 继承

当然,如果一门语言的“类”特性不支持继承,那它就名不副实了。继承的语法非常直观:

python
class DerivedClassName(BaseClassName):
    <statement-1>
    .
    .
    .
    <statement-N>

这里的 BaseClassName 被称为基类或父类,DerivedClassName 被称为派生类或子类。子类会“继承”父类的所有属性和方法。

当解析一个属性引用时,如果这个属性在子类中找不到,Python 就会去它的父类中查找。如果父类也继承自其他类,这个查找链会一直递归下去。

子类可以 覆盖 (override) 其父类的方法。也就是说,子类可以定义一个与父类同名的方法,以实现不同的行为。

python
class Parent:
    def greet(self):
        print("Hello from Parent")

class Child(Parent):
    def greet(self):
        print("Hello from Child")

p = Parent()
c = Child()
p.greet() # Hello from Parent
c.greet() # Hello from Child

由于 Child 类中定义了自己的 greet 方法,所以调用 c.greet() 时,执行的是子类的版本。

有时候,子类的方法可能只是想在父类方法的基础上做一些扩展,而不是完全替代它。你可以通过 BaseClassName.methodname(self, arguments) 的方式显式地调用父类的方法。

python
class Child(Parent):
    def greet(self):
        Parent.greet(self) # 显式调用父类的方法
        print("Child part of the greeting")

c = Child()
c.greet()
# 输出:
# Hello from Parent
# Child part of the greeting

(注:现代 Python 中更推荐使用 super().methodname(arguments) 来调用父类方法,这在处理多重继承时更为健壮。)

Python 提供了两个内置函数来帮助我们处理继承关系:

  • isinstance(obj, int):检查一个对象 obj 是否是 int 类或其任何子类的实例。
  • issubclass(bool, int):检查一个类 bool 是否是 int 类的子类。

9.5.1 多重继承

Python 支持一个类同时从多个基类继承。语法如下:

python
class DerivedClassName(Base1, Base2, Base3):
    <statement-1>
    .
    .
    .
    <statement-N>

当查找一个属性时,Python 会按照一个特定的顺序来搜索这些父类。这个顺序被称为方法解析顺序 (Method Resolution Order, MRO)。简单来说,它会先深度优先、从左到右地搜索。例如,先在 Base1 及其所有父类中搜索,然后再去 Base2 及其父类中搜索,以此类推。

真实的情况要更复杂一些,因为 Python 的 MRO 算法非常智能,它能妥善处理复杂的继承结构(比如“菱形继承”问题),确保每个父类只被搜索一次,并保持一个合理且一致的顺序。

9.6 私有变量

在 Python 中,并没有真正意义上的“私有” (private) 变量,即那种只能在对象内部访问的变量。Python 的哲学是“我们都是成年人”,它依赖于约定而不是强制。

一个普遍的约定是:以单个下划线 _ 开头的名称(例如 _spam)应该被视为非公开的 API,是实现细节,外部代码不应该直接依赖它。

然而,为了避免子类中的命名与父类中的命名发生意外冲突,Python 提供了一种有限的支持机制,称为 名称改写 (name mangling)。任何形式为 __spam(至少两个前导下划线,最多一个末尾下划线)的标识符,在类的定义中,都会被文本替换为 _ClassName__spam,其中 ClassName 是当前的类名(去掉了前导下划线)。

python
class Mapping:
    def __init__(self, iterable):
        self.items_list = []
        self.__update(iterable) # 这里会被改写为调用 self._Mapping__update

    def update(self, iterable):
        for item in iterable:
            self.items_list.append(item)

    __update = update   # 创建 update() 的一个私有副本

class MappingSubclass(Mapping):
    def update(self, keys, values):
        # 提供了一个新的 update 实现
        # 但它不会破坏父类 __init__() 的行为
        for item in zip(keys, values):
            self.items_list.append(item)

在这个例子中,Mapping 类的 __init__ 方法中调用的 __update 被改写成了 _Mapping__update。即使子类 MappingSubclass 也定义了一个名为 __update 的属性,它会被改写为 _MappingSubclass__update,因此不会与父类的实现发生冲突。

需要强调的是,这只是一种避免命名冲突的机制,而不是严格的安全限制。如果你知道了改写后的名字,你依然可以在外部访问它,例如 instance._Mapping__update

9.7 杂项说明

有时,你可能需要一个简单的数据结构来捆绑一些命名的字段,类似于 Pascal 的 "record" 或 C 的 "struct"。在现代 Python 中,实现这一目标的最佳方式是使用 dataclasses 模块。

python
from dataclasses import dataclass

@dataclass
class Employee:
    name: str
    dept: str
    salary: int

john = Employee('john', 'computer lab', 1000)
print(john.dept)    # computer lab
print(john.salary)  # 1000

@dataclass 装饰器会自动为你生成 __init__(), __repr__() 等特殊方法,让代码更加简洁。

9.8 迭代器

你可能已经注意到,Python 中大多数的容器对象(如列表、元组、字典、字符串)都可以用 for 循环来遍历。

python
for element in [1, 2, 3]:
    print(element)
for char in "123":
    print(char)

这种统一的访问方式背后,是 迭代器协议 (iterator protocol) 在起作用。当 for 语句开始工作时,它会在容器对象上调用 iter() 内置函数。这个函数会返回一个迭代器对象 (iterator object)。

迭代器对象的核心是定义了 __next__() 方法。for 循环每循环一次,就会调用一次迭代器的 __next__() 方法来获取下一个元素。当容器中没有更多元素时,__next__() 方法会引发一个 StopIteration 异常,for 循环捕捉到这个异常后,就会知道迭代结束并正常退出。

我们可以手动模拟这个过程:

python
s = 'abc'
it = iter(s) # 获取字符串的迭代器
print(next(it)) # 'a'
print(next(it)) # 'b'
print(next(it)) # 'c'
# print(next(it)) # 这会引发 StopIteration 异常

理解了这个机制后,我们就可以让我们自己的类支持迭代。只需要定义两个方法:

  • __iter__():这个方法应该返回一个迭代器对象。
  • __next__():这个方法应该返回下一个元素,或者在没有元素时引发 StopIteration

如果你的类本身就打算作为迭代器,那么 __iter__() 方法可以简单地返回 self

python
class Reverse:
    """一个反向遍历序列的迭代器"""
    def __init__(self, data):
        self.data = data
        self.index = len(data)

    def __iter__(self):
        return self

    def __next__(self):
        if self.index == 0:
            raise StopIteration
        self.index = self.index - 1
        return self.data[self.index]

rev = Reverse('spam')
for char in rev:
    print(char)
# 输出:
# m
# a
# p
# s

9.9 生成器

生成器 (Generator) 是一种用于创建迭代器的、非常简单且强大的工具。它的写法像一个普通的函数,但是它使用 yield 关键字来返回数据,而不是 return

每当 for 循环(或者 next() 函数)向生成器请求下一个值时,生成器函数会从它上次离开的地方继续执行,直到遇到下一个 yield 语句。yield 会“产出”一个值并暂停函数的执行,同时保存所有的局部变量和执行状态。下次再请求时,它会从暂停的地方无缝恢复。

我们可以用生成器非常轻松地重写上面的 Reverse 例子:

python
def reverse(data):
    for index in range(len(data)-1, -1, -1):
        yield data[index]

for char in reverse('golf'):
    print(char)
# 输出:
# f
# l
# o
# g

这个 reverse 函数就是一个生成器函数。调用它会返回一个生成器对象,它本身就是一个迭代器。可以看到,生成器的代码比实现一个完整的迭代器类要紧凑得多。它自动处理了 __iter__(), __next__() 的创建和 StopIteration 异常的抛出。

9.10 生成器表达式

对于一些简单的生成器,我们可以用一种更简洁的语法来创建它们,这种语法被称为 生成器表达式 (Generator Expression)。它看起来很像列表推导式,但用的是圆括号 () 而不是方括号 []

列表推导式会立即在内存中创建一个完整的列表:

python
# 这会创建一个包含 100 万个元素的完整列表,占用大量内存
my_list = [i*i for i in range(1000000)]

而生成器表达式则会创建一个生成器对象。它不会立即计算所有的值,而是在你请求下一个值的时候才计算,这极大地节省了内存。

python
# 这只会创建一个生成器对象,几乎不占用内存
my_generator = (i*i for i in range(1000000))

生成器表达式非常适合用在那些期望接收一个迭代器作为参数的函数中,比如 sum(), max(), min() 等。

python
# 计算 0 到 9 的平方和
total = sum(i*i for i in range(10))
print(total) # 285

xvec = [10, 20, 30]
yvec = [7, 5, 3]
# 计算点积
dot_product = sum(x*y for x,y in zip(xvec, yvec))
print(dot_product) # 260

在这些例子中,我们甚至不需要将生成器表达式赋值给一个变量,而是直接将它作为参数传递给了 sum() 函数。这种写法既高效又优雅。

Reunited - Toby Fox
00:0000:00