「哈佛大学CS50P」Python编程入门教程
Python 编程入门教程
前言
本教程旨在为编程初学者提供一条清晰、系统的 Python 学习路径,其内容结构与核心概念源自哈佛大学 CS50 的 Python 编程入门课程。无论您是编程新手,从未接触过任何编程语言,还是希望在 CS50 课程之前、之中或之后深化 Python 技能,本教程都将为您打下坚实的基础。我们将从基础语法出发,逐步深入到更高级的概念和实践,最终目标是让您掌握利用 Python 解决实际问题的能力,并为您未来的学习和项目开发储备必要的知识与技能。
第0讲:编程基础:函数与变量
本章将介绍编程的基本构件:函数(Functions)与变量(Variables)。函数是封装特定任务的代码块,它允许我们将复杂问题分解为若干可管理的小单元,并通过组合这些单元来构建大型解决方案。变量则用于存储和引用程序运行过程中所需的数据。理解并熟练运用函数和变量,是编写任何有效代码的第一步。
第1讲:逻辑控制:条件语句
程序通常需要根据不同的情况执行不同的操作。本章将探讨条件语句(Conditionals),即在代码中表达逻辑判断的能力。我们将学习如何使用 if
、elif
和 else
等关键字,根据特定条件的真(True)或假(False)来决定程序的执行路径,从而实现更智能、更灵活的程序行为。
第2讲:重复执行:循环结构
许多编程任务涉及重复执行某项操作。本章将介绍循环(Loops)的概念,学习如何在代码中高效地实现重复性任务。我们将重点掌握 for
循环和 while
循环,了解它们的适用场景和使用方法,以处理序列数据或执行需要重复特定次数的操作。
第3讲:健壮性编程:异常处理
在程序开发与运行过程中,错误(Errors)或异常(Exceptions)的发生是不可避免的。本章将引入异常处理(Exception Handling)机制。我们将学习如何预见潜在的运行时错误,并使用 try-except
等结构来“捕获”这些异常,进行妥善处理,从而编写出更具防御性、不易崩溃的健壮程序,提升用户体验。
第4讲:代码复用:库与模块
为了避免重复发明轮子,高效利用现有成果至关重要。本章将介绍库(Libraries)的概念,即他人(或过去的自己)编写的可重用代码集合。我们将学习如何导入和使用 Python 内置库以及第三方库,借助这些强大的工具来扩展程序功能,加速开发进程。
第5讲:质量保证:单元测试
确保代码的正确性和稳定性是软件开发的核心环节。本章将介绍单元测试(Unit Tests)的概念与实践。您将学习如何编写专门的代码来测试您自己编写的功能代码。这不仅能验证当前代码的正确性,更能保障在未来代码修改或迭代时,原有功能不被破坏,是工业界广泛采用的最佳实践。
第6章:数据持久化:文件输入/输出
程序运行期间的数据通常存储在内存中,断电即消失。为了实现数据的持久化存储,本章将讲解文件输入/输出(File I/O)。我们将学习如何读取文件内容,以及如何将程序中的数据写入文件,实现数据在磁盘上的长期保存和后续访问。
第7讲:文本处理利器:正则表达式
处理和验证文本数据是常见的编程需求。本章将介绍正则表达式(Regular Expressions),一种强大的文本模式匹配工具。我们将学习如何定义模式(Patterns)来验证用户输入的格式是否符合预期,或从大量文本数据中精确地提取所需信息。
第8讲:高级范式:面向对象编程
随着程序规模和复杂度的增加,良好的代码组织结构变得尤为重要。本章将重点介绍面向对象编程(Object-Oriented Programming, OOP)这一核心编程范式,学习如何通过类(Classes)和对象(Objects)在代码中模拟现实世界的实体及其关系。同时,我们也会回顾并对比在本教程中贯穿使用的过程式编程(Procedural Programming,通过函数逐步解决问题)以及提及函数式编程(Functional Programming)等其他编程思想。
第9讲:总结与补充
在本教程的最后阶段,我们将回顾所学的核心概念、工具和技术。您将拥有一个包含函数、变量、控制流、异常处理、库、测试、文件操作、正则表达式和面向对象基础知识的综合工具箱。这些知识和技能将为您奠定坚实的基础,使您能够充满信心地开启自己的编程项目,或继续深入学习更专业的计算机科学领域,无论您的兴趣是在技术、艺术、人文、社科还是自然科学。
结语
本教程旨在为您开启 Python 编程世界的大门。通过系统学习和实践,您将掌握一种强大的工具,用以表达思想、解决问题、创造价值。祝您学习顺利,享受编程的乐趣!
第0讲:函数与变量
引言
欢迎学习 Python 编程。本教程基于哈佛大学 CS50P《Python 编程导论》第 0 讲的内容,旨在为初学者介绍 Python 编程的核心基础概念:函数(Functions)与变量(Variables)。无论您是否具备编程背景,本教程都将引导您从基础开始,逐步进入 Python 的世界。
I. 编程入门与环境设置
1.1 课程简介与目标
本教程聚焦于 Python 语言的基础知识,助您迈出编程的第一步。我们将从编写经典的 "hello, world" 程序开始,熟悉基本的编程工具与流程。
1.2 编写并运行您的第一个 Python 程序
所有 Python 程序代码都存储在文本文件中,通常以 .py
作为文件扩展名,例如 hello.py
。
要在屏幕上显示文本,我们可以使用 Python 内置的 print()
函数。函数可以理解为执行特定动作的指令。
# hello.py
print("hello, world")
上述代码使用了 print()
函数,将字符串 "hello, world"
作为参数传递给它,其效果是在屏幕上输出这段文字。
1.3 编程工具:文本编辑器与 IDE
编写代码本质上是编辑文本。理论上任何文本编辑器均可使用,但专业的文本编辑器或集成开发环境(IDE),如 Visual Studio Code (VS Code),能提供语法高亮、代码补全、错误提示等功能,极大地提升编程效率和体验。强烈建议使用此类工具。请注意,不应使用如 Microsoft Word 这样的富文本编辑器编写代码,因为它们会添加额外的格式信息,导致代码无法运行。
1.4 代码的执行:命令行与 Python 解释器
编写好的 Python 代码需要通过 Python 解释器(Interpreter)来执行。解释器是一个程序,它读取您的 .py
文件,逐行解析代码,并将其翻译成计算机能够理解和执行的指令。
通常,我们通过命令行界面(CLI)或称为终端(Terminal)的工具来与解释器交互。在终端中,切换到包含您代码文件的目录,然后运行以下命令:
python hello.py
其中,python
是启动解释器的命令,hello.py
是您要执行的文件名。执行后,您将在终端看到 hello, world
的输出。
II. 函数、参数与变量
2.1 函数(Function):执行动作
如前所述,函数是执行特定任务的代码块,如同语言中的动词。print()
就是一个用于输出的内置函数。
2.2 参数(Argument):函数的输入
函数通常需要接收信息才能工作,这些信息被称为参数。参数在调用函数时放在括号内。例如,传递给 print()
的 "hello, world"
就是一个参数,它告诉 print()
函数具体要输出什么内容。
2.3 副作用(Side Effect):函数的影响
函数执行时可能产生一些可观察的效果,称为副作用。print()
函数的副作用就是在屏幕上显示了文本。
2.4 返回值(Return Value):函数的结果
除了产生副作用,许多函数还会返回一个结果(值)给调用它的代码。例如,Python 内置的 input()
函数用于获取用户的键盘输入。它会暂停程序,等待用户输入并按回车,然后将用户输入的内容作为字符串(文本)返回。
# 获取用户姓名
user_name = input("What's your name? ")
print(user_name) # 打印用户输入的名字
2.5 变量(Variable):存储数据
为了存储和后续使用数据(如 input()
的返回值),我们需要变量。变量可以看作是存储数据的带标签的容器。
使用赋值运算符 =
可以将一个值存入变量。赋值操作是从右向左执行的:先计算 =
右侧表达式的值,然后将该值存储到 =
左侧的变量中。
name = input("What's your name? ") # 将 input() 的返回值赋给变量 name
变量名应具有描述性,以便理解其存储内容的含义。使用变量时,直接写变量名即可访问其存储的值。注意区分 print(name)
(打印变量 name
的值)和 print("name")
(打印字符串 "name" 本身)。
III. 代码注释与调试基础
3.1 注释(Comment):代码的说明
在 Python 中,以 #
号开头的部分是注释。注释是写给人类阅读的笔记,用于解释代码的意图、功能或逻辑。Python 解释器会完全忽略注释。良好的注释能极大提高代码的可读性和可维护性。
# 这是一个单行注释,解释下一行代码的功能
name = input("What's your name? ") # 也可以在代码行末添加注释
3.2 伪代码(Pseudocode):规划思路
在编写复杂代码之前,使用伪代码是一种有效的规划方法。伪代码是用自然语言(如中文或英文)描述程序执行步骤和逻辑的非正式方式,通常写在注释中。它帮助我们在不关心具体语法细节的情况下,梳理程序的整体结构。
# 伪代码示例
# 1. 询问用户的名字
# 2. 获取用户输入并存储
# 3. 向用户问好
3.3 Bug 与调试(Debugging):处理错误
程序中的错误被称为 Bug。编写代码时难免会遇到 Bug。调试(Debugging) 就是发现并修复这些 Bug 的过程。
常见的错误类型之一是语法错误(SyntaxError),通常是由于代码不符合 Python 的语法规则造成的,例如括号或引号未正确闭合。解释器在尝试运行代码时会报告此类错误,并通常会指出错误发生的位置。
# 错误的示例 (缺少右括号)
# print("hello, world"
# SyntaxError: unexpected EOF while parsing (或类似错误信息)
遇到错误是编程学习过程中的常态,通过阅读错误信息、检查代码和逐步调试来解决问题是程序员必备的技能。
IV. 字符串 (String) 处理
4.1 字符串(str)类型简介
在 Python 中,文本数据被称为字符串,其类型表示为 str
。字符串需要用单引号 ('...'
) 或双引号 ("..."
) 包裹起来。两者通常可以互换使用,但建议在项目中保持一致性。
message1 = '这是一个单引号字符串'
message2 = "这是一个双引号字符串"
4.2 基本字符串操作
拼接(Concatenation): 使用
+
运算符可以将两个或多个字符串连接起来。greeting = "Hello, " name = "Alice" full_greeting = greeting + name # 结果是 "Hello, Alice" print(full_greeting)
f-Strings (格式化字符串字面量): 这是现代 Python 中推荐的、功能强大且易读的字符串格式化方法。在字符串前加上字母
f
或F
,然后在字符串内部使用花括号{}
来嵌入变量或表达式的值。name = "Bob" age = 30 message = f"My name is {name} and I am {age} years old." print(message) # 输出: My name is Bob and I am 30 years old.
4.3 常用字符串方法(String Methods)
字符串是对象,它们自带一些用于处理自身数据的内置函数,称为方法。通过点号 (.
) 来调用字符串的方法。
.strip()
: 移除字符串开头和结尾的空白字符(包括空格、制表符\t
、换行符\n
)。.capitalize()
: 将字符串的第一个字符转换为大写,其余所有字符转换为小写。.title()
: 将字符串中每个单词的首字母转换为大写,其余字母转换为小写。.split(separator)
: 根据指定的分隔符separator
将字符串拆分成多个子字符串,并返回一个包含这些子字符串的列表(List)(列表是 Python 的另一种数据结构,后续会学习)。如果未指定分隔符,默认按空白字符拆分。
raw_input = " loW qUaliTy dAta \n"
cleaned_data = raw_input.strip() # "loW qUaliTy dAta"
capitalized_data = cleaned_data.capitalize() # "Low quality data"
title_cased_data = cleaned_data.title() # "Low Quality Data"
print(title_cased_data)
full_name = "Alice Wonderland"
name_parts = full_name.split(" ") # 按空格拆分,结果是 ['Alice', 'Wonderland']
print(name_parts)
4.4 特殊字符与转义序列
要在字符串中表示一些特殊字符(如换行符或引号本身),需要使用转义序列,即一个反斜杠 \
后面跟着一个或多个字符。
\n
: 换行符。\t
: 制表符。\\
: 表示一个反斜杠本身。\"
: 在双引号字符串中表示一个双引号。\'
: 在单引号字符串中表示一个单引号。
print("第一行\n第二行")
print("他说:\"你好!\"")
print('文件路径:C:\\Users\\Public')
# 也可以通过内外使用不同引号来避免转义引号
print('他说:"你好!"')
print("It's a beautiful day.")
4.5 方法链(Method Chaining)
可以连续调用多个字符串方法,前一个方法的返回值将作为后一个方法的调用对象。
user_input = " DavId mALAn "
formatted_name = user_input.strip().title() # 先去除首尾空格,再标题化
print(formatted_name) # 输出: David Malan
V. 数值类型与运算
Python 支持多种数值类型,最常用的是整数和浮点数。
5.1 整数(int)与浮点数(float)
- 整数 (
int
): 不包含小数点的数字,可以是正数、负数或零。例如:10
,-5
,0
。 - 浮点数 (
float
): 包含小数点的数字。例如:3.14
,-0.5
,2.71828
。
5.2 算术运算符
Python 支持标准的算术运算:
+
: 加法-
: 减法*
: 乘法/
: 除法(结果总是浮点数)%
: 取模(求余数)**
: 幂运算(例如2 ** 3
结果是 8)//
: 整除(除法结果向下取整到最接近的整数)
5.3 类型转换
input()
函数总是返回字符串 (str
) 类型。如果需要对用户输入的数字进行数学运算,必须先将其转换为数值类型(int
或 float
)。
int(value)
: 尝试将value
转换为整数。float(value)
: 尝试将value
转换为浮点数。
如果 value
的内容无法被解析为相应的数值类型(例如 int("cat")
),程序会抛出 ValueError
错误。
x_str = input("Enter first number (x): ")
y_str = input("Enter second number (y): ")
x = int(x_str) # 将字符串转换为整数
y = int(y_str) # 将字符串转换为整数
sum_result = x + y
print(f"The sum of {x} and {y} is {sum_result}")
5.4 数值格式化
round(number, ndigits)
函数: 对数字number
进行四舍五入。- 如果提供
ndigits
参数,则四舍五入到指定的小数位数。 - 如果省略
ndigits
,则四舍五入到最接近的整数。
pi = 3.14159 print(round(pi)) # 输出: 3 print(round(pi, 2)) # 输出: 3.14
- 如果提供
f-String 格式化: f-string 提供了强大的数值格式化功能,通过在花括号内变量名后添加冒号
:
和格式说明符实现。- 添加千位分隔符:
f"{large_number:,}"
(例如1,000,000
) - 控制小数位数:
f"{float_number:.2f}"
(例如3.14
)
price = 12345.6789 print(f"Price: ${price:,.2f}") # 输出: Price: $12,345.68
- 添加千位分隔符:
5.5 Python 交互模式(REPL)
在终端中直接运行 python
命令(不带文件名)会进入 Python 的交互模式(Read-Eval-Print Loop, REPL)。在这里,您可以输入一行 Python 代码并立即看到执行结果,非常适合快速测试代码片段或探索语言特性。提示符通常是 >>>
。
>>> 1 + 1
2
>>> name = "CS50"
>>> print(f"Hello, {name}")
Hello, CS50
>>> exit() # 输入 exit() 或按 Ctrl+D (Unix/macOS) / Ctrl+Z Enter (Windows) 退出
VI. 自定义函数与作用域
除了使用 Python 内置的函数,我们还可以创建自己的函数来封装可重用的代码块。
6.1 定义函数 (def
)
使用 def
关键字来定义一个新函数。基本语法如下:
def function_name(parameter1, parameter2, ...):
"""
(可选) 函数文档字符串 (Docstring),描述函数功能。
"""
# 函数体 (需要缩进)
statement1
statement2
# ...
return value # (可选) 返回值
function_name
: 您为函数选择的名字。parameter1, parameter2, ...
: 函数期望接收的输入参数(形参)。可以没有参数。- 函数体内的所有代码行必须相对于
def
行进行缩进(通常是 4 个空格)。 return value
: (可选)使用return
语句将结果返回给调用者。若无return
或return
后无值,函数默认返回None
。
6.2 调用函数
定义函数后,通过函数名加上括号 ()
来调用(执行)它。如果函数需要参数,需在括号内提供相应的值(实参)。
def greet(person_name):
print(f"Hello, {person_name}!")
# 调用函数
greet("World") # 输出: Hello, World!
greet("Python") # 输出: Hello, Python!
6.3 参数与默认值
可以在函数定义时为参数指定默认值。如果在调用函数时没有为该参数提供值,则会使用默认值。
def power(base, exponent=2): # exponent 默认值为 2
return base ** exponent
print(power(3)) # 调用时未提供 exponent,使用默认值 2。输出: 9
print(power(3, 3)) # 调用时提供了 exponent=3。输出: 27
6.4 return
语句:返回值
return
语句用于从函数中退出,并将一个值(return
后面的表达式的结果)返回给函数调用处。
def calculate_square(number):
result = number * number
return result
num = 5
square_of_num = calculate_square(num) # square_of_num 将接收到 25
print(f"The square of {num} is {square_of_num}")
6.5 main
函数模式:组织代码
一个常见的 Python 编程约定是将程序的主要执行逻辑放入一个名为 main
的函数中。然后在脚本的末尾调用 main()
函数来启动整个程序。这样做的好处是:
- 结构清晰: 主要逻辑集中在一处。
- 代码重用:
main
函数本身也可以被其他模块导入和调用(虽然在此阶段不常用)。 - 函数定义顺序: 允许您在
main
函数之后定义其需要调用的辅助函数,因为函数定义在 Python 中需要在使用前被解释器读取到,但main
的调用通常在文件底部,此时所有函数都已被定义。
def main():
# 程序的主要逻辑
name = get_user_name()
display_greeting(name)
def get_user_name():
return input("What is your name? ")
def display_greeting(name):
print(f"Hello, {name}")
# 在脚本末尾调用 main 函数启动程序
main()
6.6 作用域(Scope):变量的可见范围
作用域指的是一个变量能够被访问或可见的代码区域。
- 局部作用域 (Local Scope): 在函数内部定义的变量(包括参数)具有局部作用域。它们通常只能在该函数内部被访问。函数执行结束后,这些局部变量就不再存在。
- 全局作用域 (Global Scope): 在所有函数之外定义的变量具有全局作用域,理论上可以在程序的任何地方访问(但不推荐过度使用全局变量)。
不同函数内部可以有同名的局部变量,它们互不影响。要在函数间共享数据,通常通过参数传递(将数据传入函数)和返回值(从函数传出数据)来实现。试图在函数内部访问另一个函数定义的局部变量会导致 NameError
。
def func1():
x = 10 # x 是 func1 的局部变量
print(f"Inside func1, x = {x}")
def func2():
y = 20 # y 是 func2 的局部变量
# print(x) # 这里会产生 NameError,因为 x 在 func2 的作用域内不可见
print(f"Inside func2, y = {y}")
func1()
func2()
# print(x) # 这里也会产生 NameError,因为 x 是 func1 的局部变量
VII. 代码风格与最佳实践
编写代码不仅仅是为了让计算机执行,也是为了让人类(包括未来的自己)阅读和维护。良好的代码风格至关重要。
- 可读性 (Readability): 使用有意义的变量名和函数名。添加必要的注释。保持代码结构清晰。
- 简洁性 (Simplicity): 避免不必要的复杂性。在清晰的前提下,力求代码简洁明了。
- 一致性 (Consistency): 在整个项目中遵循统一的命名约定、缩进风格(推荐使用 4 个空格)和代码布局。
- 权衡 (Trade-offs): 编程中常常需要在不同目标(如简洁性、可读性、效率)之间进行权衡。没有绝对的“最佳”方案,但选择应基于合理的理由。理解不同实现方式的优劣是提升编程能力的一部分。
总结
在本教程中,我们学习了 Python 编程的基础知识,包括:
- 使用
print()
输出和input()
获取输入。 - 变量的定义、赋值和使用。
str
(字符串)、int
(整数) 和float
(浮点数) 三种基本数据类型及其操作。- 字符串处理方法,如
.strip()
,.title()
等,以及强大的 f-string 格式化。 - 算术运算和数值类型转换。
- 使用
def
定义自己的函数,理解参数、默认值和return
语句。 main
函数模式用于组织代码。- 作用域的概念以及它如何影响变量的可见性。
- 代码注释、伪代码、调试的基本概念以及代码风格的重要性。
这些是构建更复杂 Python 程序的基础。随着学习的深入,您将掌握更多工具和技巧来解决更有趣的问题。
第1讲:条件控制流
引言:让程序拥有决策能力
在编程世界中,程序不仅仅是按顺序执行一系列指令。更强大的程序需要能够根据不同的情况做出不同的响应,就像我们在生活中需要根据天气选择衣物,或者根据红绿灯决定是否过马路一样。这种让程序根据特定条件执行不同代码路径的能力,被称为条件控制流。Python 提供了多种强大的工具来实现这种决策逻辑,本教程将引导您逐步掌握这些工具,从最基础的比较到更结构化的模式匹配。我们的目标是编写出不仅能正确工作,而且清晰、高效、易于理解的代码。
一、 万事之基:比较与布尔逻辑
决策的核心在于“提问”并得到“是”或“否”的答案。在编程中,这通常意味着比较两个值的大小、相等性等。Python 提供了一套直观的比较运算符来完成这项工作:
>
:大于>=
:大于或等于<
:小于<=
:小于或等于==
:等于(注意: 这是两个等号,用于比较两个值是否相等)!=
:不等于
特别需要强调的是 =
和 ==
的区别。单个等号 =
是赋值运算符,用于将右侧的值赋给左侧的变量(例如 x = 5
)。而双等号 ==
才是比较运算符,用于判断左右两侧的值是否相等,其结果是一个布尔值 (Boolean)。
布尔值是逻辑判断的基础,只有两种可能:True
(真)和 False
(假)。所有比较运算的结果都是布尔值。例如,5 > 3
的结果是 True
,而 10 == 20
的结果是 False
。这些布尔值是后续条件语句做出决策的依据。
二、 第一个岔路口:if
语句
最基本的条件控制结构是 if
语句。它允许我们指定一段代码,只有当某个条件为 True
时才执行。
其基本语法结构如下:
# 假设我们有两个变量
x = 10
y = 5
# 使用 if 语句进行判断
if x > y:
# 下面的代码块只有在 x > y 为 True 时执行
# 注意:代码块必须相对于 if 语句进行缩进(通常是4个空格)
print("x 的确大于 y")
print("这条语句总会执行,因为它在 if 代码块之外")
这里的关键点:
- 使用关键字
if
开始。 - 紧跟一个结果为
True
或False
的条件表达式(布尔表达式),例如x > y
。 - 条件表达式后面必须跟一个冒号
:
。 - 冒号之后的下一行或多行代码,如果希望它们在条件满足时执行,就必须缩进。Python 通过缩进来识别代码块的范围,这是其语法的一个重要特点。
如果 if
后面的条件表达式结果为 False
,那么缩进的代码块将被完全跳过。
三、 处理多种可能:elif
与 else
通常,我们需要处理不止一种情况。例如,比较两个数 x
和 y
,有三种可能:x < y
、x > y
或 x == y
。如果我们使用多个独立的 if
语句来检查:
# 低效的方式:使用多个独立的 if
x = 7
y = 7
if x < y:
print("x 小于 y")
if x > y:
print("x 大于 y")
if x == y:
print("x 等于 y")
这种写法虽然逻辑上可能覆盖所有情况,但效率低下且不够优雅。即使第一个 if
条件满足(例如 x < y
),程序仍然会继续检查后面的 if x > y
和 if x == y
,这是不必要的计算。
为了处理这种互斥的多分支情况,Python 提供了 elif
(else if) 和 else
关键字。
elif
:当前面的if
或elif
条件不满足(为False
)时,检查elif
后面的条件。如果满足,则执行其对应的代码块,并跳过整个if-elif-else
结构中余下的部分。else
:当前面所有的if
和elif
条件都不满足时,执行else
对应的代码块。else
后面不跟条件,它是一个“包罗万象”的默认选项。
让我们用 if-elif-else
结构来重写上面的比较示例:
# 更优的方式:使用 if-elif-else
x = int(input("请输入 x 的值: "))
y = int(input("请输入 y 的值: "))
if x < y:
print("x 小于 y")
elif x > y:
print("x 大于 y")
else:
# 如果既不小于也不大于,那么必然是等于
print("x 等于 y")
print("比较完成。")
在这个结构中,一旦某个条件(if
或 elif
)被满足,其代码块执行后,整个结构就结束了,后续的 elif
和 else
都不会再被检查或执行。这保证了只有一个代码块会被执行,既符合逻辑(因为这三种情况是互斥的),也提高了效率。else
的使用确保了即使 x < y
和 x > y
都不成立,程序也有一个明确的处理路径(即 x == y
的情况)。
四、 组合条件:逻辑运算符 and
与 or
有时,我们的决策需要基于多个条件的组合。Python 提供了逻辑运算符 and
和 or
来实现这一点。
and
:当且仅当and
两侧的条件都为True
时,整个表达式的结果才为True
。否则为False
。or
:只要or
两侧的条件至少有一个为True
,整个表达式的结果就为True
。只有当两侧都为False
时,结果才为False
。
例如,在评定学生成绩等级时,我们可能需要检查分数是否在一个特定的区间内:
# grade.py 示例:使用 and 定义分数区间
score = int(input("请输入学生分数 (0-100): "))
if score >= 90 and score <= 100:
print("等级:A")
elif score >= 80 and score < 90: # 注意边界条件
print("等级:B")
elif score >= 70 and score < 80:
print("等级:C")
elif score >= 60 and score < 70:
print("等级:D")
else: # 涵盖 score < 60 以及可能的无效输入(如果未做额外检查)
print("等级:F")
在这个例子中,score >= 90 and score <= 100
确保了只有当分数同时满足大于等于90和小于等于100两个条件时,等级才为A。
or
运算符则适用于满足任何一个条件即可的情况。例如,判断一个数 x
是否不等于 y
,可以用 x < y or x > y
来表达(虽然直接用 x != y
更简洁)。
五、 精炼逻辑:优化与链式比较
在编写条件语句时,我们不仅要追求正确性,还要考虑代码的简洁性和效率。
利用 if-elif
的顺序性进行优化:
在上面的 grade.py
例子中,当我们检查 elif score >= 80
时,其实我们已经知道 score < 90
了,因为如果 score >= 90
,程序就已经在第一个 if
分支退出了。因此,我们可以简化 elif
中的条件,省去对上限的检查:
# grade.py 优化版本:利用 elif 的顺序性
score = int(input("请输入学生分数 (0-100): "))
if score >= 90: # 隐含了 <= 100 (假设输入有效)
print("等级:A")
elif score >= 80: # 能到这里,说明 score < 90
print("等级:B")
elif score >= 70: # 能到这里,说明 score < 80
print("等级:C")
elif score >= 60: # 能到这里,说明 score < 70
print("等级:D")
else: # 能到这里,说明 score < 60
print("等级:F")
这个版本逻辑上等价,但更简洁。
链式比较 (Chaining Comparison Operators):
Python 允许将比较运算符链接起来,使区间的表达更加自然和易读。例如,score >= 90 and score <= 100
可以直接写成:
# 使用链式比较
score = 95
if 90 <= score <= 100:
print("分数在 90 到 100 之间")
这与数学上的写法非常相似,提高了代码的可读性。
六、 余数的神奇作用:模运算符 %
与奇偶判断
模运算符 %
计算两个数相除后的余数。例如,10 % 3
的结果是 1
(因为 10 除以 3 等于 3 余 1)。
这个运算符在很多场景下非常有用,一个经典的例子就是判断一个整数是奇数还是偶数。一个数如果是偶数,那么它除以 2 的余数必然是 0;如果是奇数,余数则是 1。
# parity.py 示例:使用模运算符判断奇偶性
number = int(input("请输入一个整数: "))
if number % 2 == 0:
print(f"{number} 是偶数")
else:
print(f"{number} 是奇数")
七、 封装决策:布尔函数与 Pythonic 表达
我们可以将决策逻辑封装在函数中,使其可以被复用。特别是,我们可以创建返回布尔值 (True
或 False
) 的函数,这种函数通常用于回答“是/否”类型的问题。
# 定义一个判断偶数的函数
def is_even(n):
"""判断一个整数 n 是否为偶数"""
if n % 2 == 0:
return True # 如果余数为 0,返回 True
else:
return False # 否则返回 False
# 在主程序逻辑中使用该函数
num = 6
if is_even(num):
print(f"{num} 是偶数 (通过函数判断)")
else:
print(f"{num} 是奇数 (通过函数判断)")
函数 is_even
的返回值可以直接用在 if
语句的条件中。
追求 Pythonic 风格:
Python 社区推崇简洁、易读、高效的代码风格,有时被称为 "Pythonic"。对于上面 is_even
函数的实现,有更 Pythonic 的写法:
使用条件表达式 (Conditional Expression): Python 提供了一种紧凑的
if-else
表达式语法:value_if_true if condition else value_if_false
。def is_even_pythonic_v1(n): """使用条件表达式判断偶数""" return True if n % 2 == 0 else False
这行代码读起来就像:“如果 n 除以 2 余数为 0,则返回 True,否则返回 False”。
直接返回布尔表达式的结果: 更进一步,我们注意到表达式
n % 2 == 0
本身的结果就是True
或False
。我们完全可以直接返回这个结果,无需额外的if-else
结构。def is_even_pythonic_v2(n): """最 Pythonic 的方式:直接返回布尔表达式结果""" return n % 2 == 0
这是判断奇偶性最简洁、最受推崇的 Pythonic 写法。它清晰地表达了“一个数是偶数当且仅当它除以2的余数为0”。
虽然简洁通常是好事,但最终选择哪种写法取决于可读性和团队约定。对于初学者,明确的 if/else return
可能更容易理解。
八、 结构化分支:match
语句
Python 3.10 引入了 match
语句,它提供了一种更结构化的方式来处理基于特定值的多分支选择,类似于其他语言中的 switch
语句,但功能更为强大(支持更复杂的模式匹配,此处仅介绍基础用法)。
match
语句将一个“主题”(subject,通常是一个变量)与一系列 case
模式进行比较。
# house.py 示例:使用 match 语句根据名字分配学院
name = input("What's your name? ")
match name:
case "Harry" | "Hermione" | "Ron": # 使用 | 匹配多个值
print("Gryffindor")
case "Draco":
print("Slytherin")
# case "Luna" | "Cho": # 可以添加更多 case
# print("Ravenclaw")
case _: # 下划线 _ 作为通配符,匹配任何未被前面 case 捕获的情况
print("Who? (Perhaps a Hufflepuff, or needs sorting!)")
match
语句的特点:
- 使用
match
关键字后跟要匹配的主题和冒号:
。 - 下面是一系列
case
块,每个case
后跟一个或多个模式 (pattern) 和冒号:
。 - 如果主题与某个
case
的模式匹配成功,则执行该case
下缩进的代码块,然后整个match
语句结束(不需要像某些语言那样写break
)。 - 可以使用竖线
|
在一个case
中指定多个可选的模式(逻辑或)。 - 单个下划线
_
是一个特殊的通配符模式,它总能匹配成功,通常放在最后作为默认情况(类似于else
)。
对于基于特定值的多路分支,match
语句通常比冗长的 if-elif-else
链更清晰、更易于维护。
九、 结语
条件控制流是编程的核心组成部分,它赋予了程序根据输入、状态或计算结果动态调整行为的能力。通过掌握 Python 的 if
, elif
, else
结构,结合比较运算符和逻辑运算符,您可以构建出复杂的决策逻辑。了解并运用诸如链式比较、模运算、布尔函数以及 Pythonic 的表达方式,能让您的代码更加高效和优雅。而 match
语句则为处理结构化分支提供了现代化的选择。熟练运用这些工具,将是您编写更智能、更强大 Python 程序的关键一步。
第2讲:循环结构
引言
欢迎学习 Python 编程。本教程聚焦于 Python 及众多编程语言中的一个基础且强大的概念——循环(Loops)。循环结构允许我们重复执行特定的代码块,从而高效地处理重复性任务,避免冗余代码,并实现更复杂的逻辑。本教程将详细阐述为何需要循环,并深入探讨 Python 中两种主要的循环类型:while
循环和 for
循环,以及它们相关的概念和应用。
一、 循环的必要性:告别重复
在编程实践中,我们常常需要多次执行相同的或相似的操作。例如,假设我们需要让程序输出三次 "meow"。一种最直观的方式是连续编写三次 print("meow")
语句:
print("meow")
print("meow")
print("meow")
这段代码确实能够实现目标。然而,这种方法存在明显的缺陷:
- 冗余性 (Redundancy): 代码包含了大量的重复。如果需要输出五十次或五百次 "meow",简单地复制粘贴将导致代码冗长、难以维护。
- 维护困难 (Maintenance Difficulty): 若需修改重复的操作(例如,将 "meow" 改为 "woof"),则必须在所有重复出现的地方进行修改,这既繁琐又容易出错。
优秀的程序设计追求简洁、高效和易于维护。重复编写相同的代码显然不符合这些原则。因此,我们需要一种机制来指示计算机“重复执行这个操作 N 次”,这就是循环结构的核心价值所在。
二、 while
循环:基于条件的重复
while
循环是 Python 提供的一种基本的循环结构。它允许我们指定一个条件(一个布尔表达式,其结果为 True
或 False
),只要该条件持续为 True
,循环体内的代码就会被反复执行。
2.1 基本语法与工作原理
while
循环的基本语法如下:
while <条件表达式>:
# 循环体:当条件表达式为 True 时执行的代码
# (通常包含更新循环条件的语句)
解释器在执行 while
循环时:
- 首先评估
<条件表达式>
。 - 如果结果为
True
,则执行整个缩进的循环体
代码块。 - 执行完循环体后,返回第 1 步,重新评估
<条件表达式>
。 - 如果结果为
False
,则跳过循环体,继续执行循环结构之后的代码。
2.2 使用 while
循环实现计数
让我们重新审视打印三次 "meow" 的需求。我们可以使用 while
循环和计数器变量来实现:
示例 1:递减计数
i = 3 # 初始化计数器
while i != 0: # 条件:当 i 不等于 0 时
print("meow")
i = i - 1 # 更新计数器,使其递减
在这个例子中:
- 变量
i
初始化为 3。 - 循环条件是
i != 0
。 - 循环体内,首先打印 "meow"。
- 然后,将
i
的值减 1。 - 这个过程重复进行,直到
i
变为 0,此时条件i != 0
为False
,循环终止。
重要提示:避免无限循环
如果在循环体内忘记了更新与循环条件相关的变量(例如,忘记 i = i - 1
),条件可能永远为 True
,导致程序陷入无限循环 (Infinite Loop)。程序会持续执行循环体,可能导致资源耗尽或失去响应。如果意外触发无限循环,在大多数终端环境中,可以通过按下 Ctrl+C
来强制中断程序。
示例 2:递增计数(从 1 开始)
我们也可以从 1 开始向上计数:
i = 1
while i <= 3: # 条件:当 i 小于或等于 3 时
print("meow")
i = i + 1 # 更新计数器,使其递增
示例 3:递增计数(从 0 开始,编程惯例)
在计算机科学中,从 0 开始计数是一种常见的惯例。这在处理索引等场景时尤其方便。
i = 0
while i < 3: # 条件:当 i 小于 3 时 (即 0, 1, 2)
print("meow")
i = i + 1
这种从 0 开始计数,并使用 <
(小于)而非 <=
(小于等于)作为边界条件的模式,是许多程序员偏好的风格。
2.3 语法糖:+=
运算符
对于 i = i + 1
这种常见的自身增量操作,Python 提供了更简洁的写法:i += 1
。类似地,i = i - 1
可以写为 i -= 1
。这被称为增强赋值运算符 (Augmented Assignment Operators),它们提高了代码的简洁性。
i = 0
while i < 3:
print("meow")
i += 1 # 使用 += 语法糖
三、 for
循环:遍历序列
for
循环是 Python 中另一种常用的循环结构,它特别适用于遍历 (Iterate) 一个序列(如列表、字符串、范围等)中的每个元素。
3.1 引入列表(List)
在深入 for
循环之前,我们先介绍 Python 的一种重要数据类型:列表 (List)。列表是一个有序的值的集合,可以包含不同类型的元素。列表使用方括号 []
定义,元素之间用逗号 ,
分隔。
例如,一个包含三个整数的列表:[0, 1, 2]
一个包含三个字符串的列表:["Hermione", "Harry", "Ron"]
3.2 使用 for
循环遍历列表
for
循环可以直接遍历列表中的每个元素:
for i in [0, 1, 2]:
print("meow")
在这个例子中:
for i in [0, 1, 2]
表示:“对于列表[0, 1, 2]
中的每一个元素...”- 在循环的第一次迭代中,变量
i
被自动赋值为列表的第一个元素0
。 - 在第二次迭代中,
i
被赋值为1
。 - 在第三次迭代中,
i
被赋值为2
。 - 对于
i
的每个值,循环体print("meow")
都执行一次。 - 列表遍历完毕后,循环结束。
这种 for
循环通常比 while
循环更简洁,因为它自动处理了元素的迭代和循环的终止,无需手动管理计数器和条件判断。
3.3 使用 range()
函数生成数字序列
直接在代码中写出如 [0, 1, 2]
这样的列表,对于需要大量重复次数的情况(如一百万次)是不可行的。Python 提供了 range()
函数来解决这个问题。
range(n)
会生成一个从 0 开始,到 n-1
结束的整数序列。
for i in range(3): # range(3) 生成序列 0, 1, 2
print("meow")
使用 range()
的优势在于其可扩展性 (Scalability)。如果需要重复一百万次,只需将 range(3)
改为 range(1000000)
即可,无需手动列出所有数字。
3.4 下划线 (_
) 惯例:忽略循环变量
在某些循环中,我们只关心循环执行的次数,而不关心循环变量本身的值(例如,在上面的 meow
示例中,变量 i
或 _
的值 0, 1, 2 并未在 print
语句中使用)。在这种情况下,Python 程序员通常使用单个下划线 _
作为循环变量名。这是一种惯例 (Convention),向阅读代码的人表明该变量是有意未被使用的。
for _ in range(3): # 使用 _ 表示不关心循环变量的值
print("meow")
这不会改变程序的行为,但提高了代码的可读性。
3.5 Pythonic 技巧:字符串乘法
Python 提供了一种非常简洁的方式来重复字符串:使用乘法运算符 *
。
print("meow" * 3) # 输出 "meowmeowmeow"
为了让每次 "meow" 独占一行,我们可以结合换行符 \n
:
print("meow\n" * 3)
这会输出:
meow
meow
meow
<-- 这里会有一个额外的空行
默认情况下,print
函数会在输出末尾添加一个换行符。如果我们想精确控制输出,避免最后的额外换行,可以使用 end
参数:
print("meow\n" * 3, end="") # end="" 指定行尾不添加任何字符
这种方法非常 Pythonic(符合 Python 风格),但对于初学者可能不如 for
循环直观。在追求简洁性和可读性之间需要权衡。
四、 循环与用户输入验证
循环结构在处理用户输入,特别是需要验证输入有效性时非常有用。例如,我们希望用户输入一个正整数。如果用户输入了无效的值(如负数或零),我们应该反复提示用户重新输入,直到获得有效值为止。
使用简单的 if
判断无法处理用户连续输入无效值的情况。这时,while
循环是理想的解决方案。一个常见的模式是使用 while True
创建一个看似无限的循环,然后在循环内部检查条件,一旦满足条件就使用 break
语句跳出循环。
while True: # 创建一个无限循环
n = int(input("请输入一个正整数 n: ")) # 获取用户输入并转换为整数
if n > 0: # 检查条件:n 是否大于 0
break # 如果条件满足,跳出 while 循环
# 循环结束后,变量 n 必定是一个正整数
print(f"您输入的正整数是: {n}")
# 可以接着使用 n,例如,打印 n 次 "meow"
for _ in range(n):
print("meow")
这个模式确保了程序只有在获得有效输入后才会继续执行后续逻辑。break
语句用于立即终止其所在的最近一层循环。
五、 函数抽象与循环
随着程序功能的增加,将代码组织成函数是一种重要的实践,这被称为抽象 (Abstraction)。我们可以将循环逻辑封装在函数中,使其更易于理解、重用和维护。
例如,我们可以将获取正整数和打印 "meow" 的逻辑分别封装到函数中:
def main():
"""程序主函数"""
number = get_positive_integer("请输入喵叫次数 n: ")
meow(number)
def get_positive_integer(prompt):
"""反复提示用户,直到获取一个正整数"""
while True:
try:
n = int(input(prompt))
if n > 0:
return n # 返回有效的正整数,同时结束函数和循环
except ValueError:
print("无效输入,请输入一个整数。") # 处理非整数输入
def meow(n):
"""打印 n 次 'meow'"""
for _ in range(n):
print("meow")
# 调用主函数开始执行程序
main()
在这个重构后的版本中:
get_positive_integer
函数封装了输入验证的while
循环。当获取到有效值时,使用return
语句不仅可以返回值,也会立即结束该函数的执行(从而跳出循环)。meow
函数封装了打印 "meow" 的for
循环。main
函数负责协调调用这两个辅助函数,使主逻辑更清晰。
函数允许我们将复杂问题分解为更小、更易管理的部分。
六、 列表(List)详解与迭代
列表是 Python 中极其灵活和常用的数据结构。
6.1 创建与访问元素(零索引)
如前所述,列表用方括号 []
创建。要访问列表中的特定元素,我们使用索引 (Index)。Python 列表是零索引 (Zero-indexed) 的,意味着第一个元素的索引是 0,第二个是 1,依此类推。
students = ["Hermione", "Harry", "Ron"]
print(students[0]) # 输出: Hermione (第一个元素,索引为 0)
print(students[1]) # 输出: Harry (第二个元素,索引为 1)
print(students[2]) # 输出: Ron (第三个元素,索引为 2)
试图访问超出范围的索引(例如 students[3]
)会导致 IndexError
。
6.2 使用 for
循环遍历列表元素
遍历列表最 Pythonic 的方式是直接迭代其元素:
students = ["Hermione", "Harry", "Ron"]
for student in students: # student 变量依次取 "Hermione", "Harry", "Ron"
print(student)
这种方式简洁明了,直接关注列表中的内容。
6.3 使用 range()
和 len()
遍历列表索引
有时,我们不仅需要列表元素的值,还需要它在列表中的索引位置。这时,可以结合 len()
函数和 range()
来遍历索引。len(list)
函数返回列表的长度(元素的数量)。
students = ["Hermione", "Harry", "Ron"]
list_length = len(students) # list_length 的值为 3
# range(list_length) 即 range(3),生成序列 0, 1, 2
for i in range(list_length):
# i 依次取 0, 1, 2
print(f"排名 {i + 1}: {students[i]}") # 使用索引 i 访问元素
输出:
排名 1: Hermione
排名 2: Harry
排名 3: Ron
注意我们在输出排名时使用了 i + 1
,因为我们通常从 1 开始计数排名,而索引是从 0 开始的。
七、 字典(Dictionary)详解
字典(dict
)是 Python 中另一种强大的内置数据结构。与列表存储有序的元素序列不同,字典存储的是键值对 (Key-Value Pairs) 的集合。每个键(Key)都是唯一的,并与一个值(Value)相关联。这类似于现实世界中的字典,其中每个单词(键)都有对应的释义(值)。
7.1 概念与创建
字典使用花括号 {}
创建,键和值之间用冒号 :
分隔,键值对之间用逗号 ,
分隔。键通常是字符串或数字等不可变类型。
示例:存储学生的学院信息
一种不推荐的方式是使用平行列表:
# 不推荐:平行列表维护困难
students_list = ["Hermione", "Harry", "Ron", "Draco"]
houses_list = ["Gryffindor", "Gryffindor", "Gryffindor", "Slytherin"]
# 需要假设 students_list[i] 对应 houses_list[i]
这种方式难以维护,如果列表顺序变动或需要添加更多信息(如守护神),会变得非常混乱。
推荐的方式:使用字典
# 使用字典,键是学生姓名,值是学院
students_houses = {
"Hermione": "Gryffindor",
"Harry": "Gryffindor",
"Ron": "Gryffindor",
"Draco": "Slytherin"
}
# 为了可读性,长字典可以格式化为多行
students_houses = {
"Hermione": "Gryffindor",
"Harry": "Gryffindor",
"Ron": "Gryffindor",
"Draco": "Slytherin",
}
7.2 访问字典值
要访问字典中的值,我们使用方括号 []
并提供相应的键:
print(students_houses["Hermione"]) # 输出: Gryffindor
print(students_houses["Draco"]) # 输出: Slytherin
如果尝试访问一个不存在的键,会导致 KeyError
。
7.3 使用 for
循环遍历字典
当使用 for
循环直接遍历字典时,默认遍历的是字典的键 (Keys):
for student in students_houses: # student 依次取 "Hermione", "Harry", "Ron", "Draco"
print(student)
如果需要同时访问键和对应的值,可以在循环体内使用键来查找值:
for student in students_houses:
house = students_houses[student] # 使用键 student 查找对应的值 house
print(f"{student} 在 {house} 学院")
或者使用 print
的多参数特性(默认以空格分隔):
for student in students_houses:
print(student, students_houses[student], sep=", ") # 使用 sep 指定分隔符
八、 复合数据结构:字典列表
我们可以组合使用列表和字典来表示更复杂的数据结构。例如,如果我们想存储每个学生的姓名、学院和守护神信息,可以使用字典列表 (List of Dictionaries)。列表中的每个元素都是一个字典,代表一个学生的所有信息。
8.1 结构与应用
students_details = [
{"name": "Hermione", "house": "Gryffindor", "patronus": "Otter"},
{"name": "Harry", "house": "Gryffindor", "patronus": "Stag"},
{"name": "Ron", "house": "Gryffindor", "patronus": "Jack Russell terrier"},
{"name": "Draco", "house": "Slytherin", "patronus": None} # 使用 None 表示值的缺失
]
在这个结构中:
students_details
是一个列表。- 列表中的每个元素(例如
students_details[0]
)是一个字典。 - 每个字典包含该学生的详细信息,键是信息的类别("name", "house", "patronus"),值是具体信息。
8.2 None
类型
注意到 Draco 的守护神值为 None
。None
是 Python 中的一个特殊类型,表示值的缺失或空值。它不同于空字符串 ""
或数字 0,它明确表示“没有值”。
8.3 遍历与访问嵌套数据
遍历字典列表时,外层循环遍历列表中的每个字典,内层通过键访问字典中的具体值:
for student_dict in students_details:
# student_dict 依次是列表中的每个字典
name = student_dict["name"]
house = student_dict["house"]
patronus = student_dict["patronus"]
# 处理 None 值,避免直接打印 "None"
if patronus is None:
patronus_info = "无守护神"
else:
patronus_info = f"守护神是 {patronus}"
print(f"{name} 来自 {house} 学院, {patronus_info}.")
九、 嵌套循环
一个循环结构可以包含在另一个循环结构内部,这称为嵌套循环 (Nested Loops)。嵌套循环常用于处理二维结构,如网格、矩阵或打印图形。
9.1 概念与应用
假设我们要打印一个 的由 #
组成的方块:
###
###
###
我们可以将这个问题分解为:
- 需要打印 3 行。
- 在每一行中,需要打印 3 个
#
。
这自然地导向了嵌套循环的结构:外层循环控制行数,内层循环控制每行中的列数(或元素)。
9.2 示例:打印正方形
方法一:使用嵌套循环
def print_square(size):
"""使用嵌套循环打印一个 size x size 的 '#' 方块"""
# 外层循环:控制行 (i 从 0 到 size-1)
for i in range(size):
# 内层循环:控制当前行的列 (j 从 0 到 size-1)
for j in range(size):
# 打印 '#',但不换行 (end="")
print("#", end="")
# 内层循环结束后(即一行打印完毕),打印一个换行符
print()
print_square(3)
- 外层循环 (
for i
) 执行size
次,每次代表新的一行。 - 内层循环 (
for j
) 在外层循环的每次迭代中都执行size
次,负责打印当前行的所有#
。 print("#", end="")
确保#
打印在同一行。- 内层循环结束后,
print()
用于换到下一行。
方法二:结合字符串乘法简化
对于打印重复字符的行,可以利用字符串乘法简化内层循环:
def print_square_simplified(size):
"""使用单层循环和字符串乘法打印方块"""
for i in range(size):
# 每行直接打印 size 个 '#'
print("#" * size)
print_square_simplified(3)
这种方法更简洁,但理解嵌套循环的概念对于解决更复杂的问题仍然重要。
方法三:结合函数抽象
我们还可以将打印一行的逻辑抽象成一个函数,然后在打印正方形的函数中调用它:
def print_row(width):
"""打印包含 width 个 '#' 的一行"""
print("#" * width)
def print_square_abstracted(size):
"""调用 print_row 函数打印方块"""
for i in range(size):
print_row(size) # 调用函数打印一行
print_square_abstracted(3)
这展示了如何通过函数抽象将复杂问题分解,即使底层实现(如 print_row
)可能很简单。
十、 总结
本教程深入探讨了 Python 中的循环结构,包括 while
循环和 for
循环。我们学习了:
- 循环的基本原理和必要性,以避免代码重复。
while
循环基于条件的重复执行机制,以及如何使用计数器和break
语句控制循环。for
循环遍历序列(列表、range
对象、字典键等)的便捷性。- 列表和字典这两种重要的数据结构及其访问和遍历方式。
None
类型用于表示值的缺失。- 嵌套循环用于处理二维结构。
- 结合函数抽象来组织和简化循环逻辑。
掌握循环是 Python 编程的基础。结合之前学习的条件语句、函数和变量,您现在拥有了解决更广泛、更有趣问题的核心工具集。在后续的学习中,我们将继续探索更高级的概念,并学习如何调试和优化包含循环的代码。
第3讲:异常处理
引言:程序中的意外
在编写程序时,我们期望代码能够按照预设的逻辑顺利执行。然而,现实世界的复杂性,尤其是与外部环境(如用户输入)的交互,往往会引入各种意外情况,导致程序中断或产生非预期结果。这些在程序执行期间发生的、干扰正常指令流的事件,在Python中被称为异常 (Exceptions)。理解并妥善处理异常,是编写健壮、可靠程序的关键一步。本教程将引导您了解Python中的错误类型以及如何使用异常处理机制来应对它们。
区分错误类型:语法与运行时
在深入异常处理之前,需要区分两种基本的错误类型:
语法错误 (SyntaxError):这类错误发生在代码被Python解释器解析的阶段,意味着代码违反了Python语言的语法规则。例如,遗漏了括号、引号未闭合或使用了错误的关键字。语法错误会阻止程序启动,必须在运行前修正。
# 这是一个语法错误的例子,引号未闭合 # print("Hello, world # >>> SyntaxError: unterminated string literal (detected at line 2)
由于语法错误在程序运行前就会被检测到,它们不属于我们通常使用
try...except
机制处理的“异常”。修复语法错误是程序员的基本职责。运行时错误 (Runtime Error / Exception):这类错误发生在程序已经开始执行之后。代码的语法是正确的,但在执行过程中,由于某些操作无法完成或遇到了非法情况,程序抛出了异常。
常见的运行时错误包括:
值错误 (ValueError):当传递给操作或函数的参数类型正确,但其值不适用时引发。一个典型的例子是尝试将非数字内容的字符串转换为整数。
# 尝试将文本转换为整数,引发 ValueError try: user_input = "cat" number = int(user_input) print(f"The number is: {number}") except ValueError as e: print(f"运行时错误发生: {e}") # 输出: 运行时错误发生: invalid literal for int() with base 10: 'cat'
名称错误 (NameError):当尝试访问一个尚未被定义的变量(或函数名)时引发。
# 尝试访问未定义的变量,引发 NameError try: print(non_existent_variable) except NameError as e: print(f"运行时错误发生: {e}") # 输出: 运行时错误发生: name 'non_existent_variable' is not defined
运行时错误是可以通过Python的异常处理机制来捕获和管理的。
异常处理基础:try
与 except
Python 提供 try...except
语句块来处理可能引发异常的代码。其基本结构如下:
try:
# --- 可能引发异常的代码块 ---
# 将可能出错的操作放在这里
potentially_risky_operation()
except SpecificExceptionType:
# --- 异常处理代码块 ---
# 如果 try 块中发生了 SpecificExceptionType 类型的异常,
# 则执行这里的代码进行处理或恢复。
handle_the_exception()
核心思想:解释器尝试 (try
) 执行 try
子句中的代码。如果执行过程中没有发生任何异常,则跳过 except
子句,try
语句执行完毕。如果在 try
子句执行过程中发生了异常,解释器会查找是否有 except
子句能够处理该类型的异常。如果找到匹配的 except
子句,则 try
子句剩余的部分被跳过,然后执行匹配的 except
子句中的代码。
最佳实践:指定异常类型
虽然可以直接使用 except:
来捕获所有类型的异常,但这通常被认为是不良实践。因为它会隐藏所有未预料到的错误,使得调试变得困难。推荐的做法是明确指定你期望捕获并能够处理的异常类型。
# 改进后的 ValueError 处理示例
user_input = input("请输入一个数字: ")
try:
number = int(user_input)
print(f"您输入的数字是: {number}")
# 明确捕获 ValueError
except ValueError:
print(f"输入无效:'{user_input}' 不是一个有效的整数。")
完善处理流程:else
与 pass
try...except
结构还可以包含 else
和 finally
子句(finally
本讲未深入,但它用于无论是否发生异常都需要执行的清理代码)。
else
子句:else
块中的代码仅在try
块没有引发任何异常时执行。这对于放置那些依赖于try
块成功执行的代码非常有用,可以避免将不必要的操作放在try
块内,减少了try
块的范围。# 使用 else 子句处理成功转换后的逻辑 user_input = input("请输入一个数字: ") try: number = int(user_input) except ValueError: print(f"输入无效:'{user_input}' 不是一个有效的整数。") else: # 只有当 try 块成功执行(即 int() 转换未引发 ValueError)时, # 才执行这里的代码。 print(f"成功转换! 您输入的数字是: {number}") # 这样做也避免了在 except 发生后,number 未定义却被访问的 NameError
pass
语句:有时,我们可能想要捕获一个异常,但并不想执行任何特定的处理逻辑,仅仅是阻止异常传播导致程序终止,或者简单地忽略它,让程序继续执行(例如在循环中)。在这种情况下,可以在except
块中使用pass
语句。# 在循环中,若输入无效则忽略错误,继续提示 while True: user_input = input("请输入一个数字 (或输入 'quit' 退出): ") if user_input.lower() == 'quit': break try: number = int(user_input) print(f"有效输入: {number}") # 可以在这里添加更多处理逻辑,或跳出循环 break # 假设获取到有效数字后就退出 except ValueError: # 输入无效,不打印错误信息,仅默默地继续循环提示 pass
实战:构建健壮的用户输入函数
现在,我们将综合运用所学知识,创建一个函数,其目标是从用户那里安全地获取一个整数输入,即使在用户输入无效内容时也能持续提示,直到获得有效输入为止。
初始尝试(存在问题)
# 版本 1: 简单的输入与转换,容易因无效输入崩溃
# user_input = input("请输入一个整数: ")
# x = int(user_input) # 如果输入 "cat", 这里会抛出 ValueError
# print(f"x is {x}")
引入 try...except
# 版本 2: 使用 try-except 处理单次输入的错误
# try:
# user_input = input("请输入一个整数: ")
# x = int(user_input)
# print(f"x is {x}")
# except ValueError:
# print("输入不是有效的整数。")
# 这个版本只处理一次输入,不够健壮。
结合循环实现反复提示 为了让程序在用户输入无效时能够重新提示,我们需要将输入和处理逻辑放入一个循环中。当且仅当成功获取并转换整数后,才退出循环。
# 版本 3: 使用 while 循环和 try-except 实现健壮输入
def get_integer_input_v1():
"""反复提示用户,直到输入一个有效的整数为止。"""
while True:
user_input = input("请输入一个整数: ")
try:
number = int(user_input)
# 成功转换,可以退出循环了
break
except ValueError:
# 输入无效,打印提示信息,循环将继续
print("输入无效,请输入一个整数。")
# 循环结束后,number 变量中存储了有效的整数
return number
# 使用函数
# user_number = get_integer_input_v1()
# print(f"您最终输入的整数是: {user_number}")
在这个版本中,while True
创建了一个无限循环。在循环内部,我们尝试获取并转换输入。如果 int()
成功,break
语句会终止循环。如果 int()
抛出 ValueError
,except
块会捕获它,打印提示信息,然后循环自然进入下一次迭代,再次提示用户。
抽象与重用:定义函数与参数化
上面的 get_integer_input_v1
函数已经比较健壮,但我们可以通过函数封装和参数化使其更加通用和优雅。
封装逻辑
将获取整数的逻辑封装在一个明确的函数 get_int
中,使得主程序逻辑更清晰。
# 版本 4: 将逻辑封装进函数,并在成功时直接 return
def get_int():
"""更简洁的版本,成功时直接从函数返回。"""
while True:
user_input = input("请输入一个整数: ")
try:
# 尝试转换,如果成功,直接返回值,函数执行结束
return int(user_input)
except ValueError:
# 转换失败,使用 pass 静默处理,继续循环
# 或者可以选择打印提示信息:
# print("输入无效,请输入一个整数。")
pass # 这里选择静默处理,仅重新提示
def main_v1():
"""主程序逻辑"""
age = get_int()
print(f"您的年龄是: {age}")
# 调用主函数
# main_v1()
在这个版本中,一旦 int()
转换成功,return
语句不仅返回了整数值,也同时终止了函数的执行(从而退出了循环)。如果发生 ValueError
,except
块可以选择打印消息或使用 pass
静默处理,然后循环继续。
参数化提示信息
当前的 get_int
函数使用了硬编码的提示信息 "请输入一个整数: "
。为了让这个函数更具通用性,可以将其作为参数传入。
# 版本 5: 参数化提示信息,提高函数复用性
def get_int_prompt(prompt_message):
"""获取整数,并使用指定的提示信息。"""
while True:
user_input = input(prompt_message)
try:
return int(user_input)
except ValueError:
# 可以在这里决定是否对所有调用都保持静默处理
pass # 保持静默
def main_v2():
"""主程序逻辑,使用带参数的 get_int 函数"""
age = get_int_prompt("请输入您的年龄: ")
print(f"您的年龄是: {age}")
quantity = get_int_prompt("请输入商品数量: ")
print(f"商品数量为: {quantity}")
# 调用主函数
main_v2()
现在,get_int_prompt
函数可以用于获取任何需要整数输入的场景,只需提供相应的提示信息即可。
结语:编程哲学与实践
Python 的异常处理机制体现了一种被称为 EAFP (Easier to Ask for Forgiveness than Permission) 的编程哲学。相比于 LBYL (Look Before You Leap) —— 即在执行操作前进行大量检查(例如,先用 .isdigit()
或 isnumeric()
检查字符串是否像数字,再调用 int()
),EAFP 倾向于直接尝试执行操作,并准备好处理可能发生的异常。在很多Python场景下,EAFP 被认为更简洁、更高效,尤其是当预期操作成功的概率较高时。
错误和异常是编程过程中不可避免的一部分。掌握 try...except...else
结构,学会捕获和处理特定的异常,并将相关逻辑封装到函数中,是提升代码健壮性和可维护性的重要技能。随着您编写更多Python代码,您将遇到更多类型的异常,但处理它们的基本原则和技巧将保持一致。
第4讲:库、模块与生态系统
引言:代码复用的基石——库与模块
在软件开发的实践中,我们常常发现某些功能或逻辑需要在不同的项目或程序的多个部分重复使用。为了避免冗余的代码编写和维护困难,现代编程语言普遍提供了代码复用的机制。在 Python 中,这一机制的核心便是 库 (Library) 与 模块 (Module)。
一个 模块 本质上是一个包含了 Python 定义和语句的文件(通常是 .py
文件)。我们可以将一组相关的函数、类或变量组织在一个模块中。而 库 则是一个更宽泛的概念,它可以是单个模块,也可以是包含多个模块和子包的集合(即 包 Package)。使用库和模块的主要目的是 封装功能、组织代码 并最终实现 代码复用。Python 本身自带了丰富的 标准库 (Standard Library),提供了大量开箱即用的功能,同时,Python 庞大的 第三方库生态系统 更是其强大生产力的重要体现。
本教程将引导您学习如何在 Python 中有效地利用库和模块,从使用标准库到安装第三方库,乃至创建您自己的可复用模块。
使用 Python 标准库
Python 安装时便附带了许多有用的标准库模块。要使用一个模块,我们首先需要将其“导入”到当前的程序环境中。
随机事件模拟:random
模块
random
模块提供了生成伪随机数和执行随机选择的功能。例如,模拟抛硬币或从一组选项中随机挑选。
要使用 random
模块,我们首先使用 import
关键字导入它:
# 导入 random 模块
import random
# --- 示例 1: random.choice() ---
# 从序列中随机选择一个元素。序列可以是列表、元组等。
# 模拟抛硬币,硬币有 "正面" 和 "反面"
possible_outcomes = ["正面", "反面"]
coin_flip_result = random.choice(possible_outcomes)
print(f"抛硬币结果: {coin_flip_result}")
# --- 示例 2: random.randint() ---
# 生成一个指定范围内的随机整数(包含两个端点)。
# 生成一个 1 到 10 之间的随机整数
random_number = random.randint(1, 10)
print(f"生成的随机整数 (1-10): {random_number}")
# --- 示例 3: random.shuffle() ---
# 将一个可变序列(如列表)中的元素原地随机打乱顺序。
# 注意:此函数不返回值,直接修改原列表。
cards = ["J", "Q", "K", "A"]
print(f"原始牌序: {cards}")
random.shuffle(cards)
print(f"打乱后的牌序: {cards}")
在上述代码中,调用模块内函数时,我们使用了 模块名.函数名
的语法,如 random.choice()
。这明确了函数来源,有助于代码的可读性,并避免了潜在的命名冲突。
选择性导入:from ... import ...
有时,我们可能只需要模块中的某一个或几个特定函数,或者希望在代码中直接使用函数名而不是 模块名.函数名
的形式。这时可以使用 from ... import ...
语法。
# 从 random 模块只导入 choice 函数
from random import choice
# 现在可以直接使用 choice 函数名
coin_flip_result_v2 = choice(["正面", "反面"])
print(f"再次抛硬币结果: {coin_flip_result_v2}")
# 也可以导入多个指定的函数
from random import randint, shuffle
another_random_number = randint(1, 100)
another_deck = ["2", "3", "4", "5"]
shuffle(another_deck)
print(f"另一个随机数 (1-100): {another_random_number}")
print(f"另一副打乱的牌: {another_deck}")
使用 from ... import ...
的优点是代码可能更简洁,但缺点是如果导入的函数名与当前作用域中的其他变量或函数名相同,则会发生 命名冲突。此外,它可能降低代码的可读性,因为读者需要查找 import
语句才能确定函数的来源。因此,建议谨慎使用,尤其是在大型项目中或导入多个名称时。避免使用 from random import *
,这种方式会导入模块所有公开的名称,极易造成命名空间污染。
基础统计计算:statistics
模块
Python 标准库还包含用于数学统计的 statistics
模块。例如,计算一组数据的平均值。
# 导入 statistics 模块
import statistics
# 假设有考试成绩列表
scores = [100, 90, 85, 95, 88]
# 计算平均分
average_score = statistics.mean(scores)
print(f"平均分: {average_score}")
# 还可以计算中位数等
# median_score = statistics.median(scores)
# print(f"中位数: {median_score}")
与运行环境交互:命令行参数
程序运行时,除了通过 input()
函数与用户交互获取输入外,还可以在启动程序时通过 命令行参数 (Command-Line Arguments) 传递信息。这对于自动化脚本或需要配置启动选项的程序非常有用。
系统交互接口:sys
模块与 sys.argv
Python 的 sys
模块提供了访问由解释器使用或维护的变量以及与解释器强烈交互的函数。其中,sys.argv
是一个包含命令行参数的列表 (list)。
sys.argv[0]
永远是程序脚本本身的名称。sys.argv[1]
是传递给脚本的第一个参数。sys.argv[2]
是第二个参数,以此类推。
让我们编写一个简单的程序,打印出问候语,名字从命令行参数获取。
# name_greeter.py
import sys
# 检查是否提供了至少一个名字作为参数
# 需要的参数总数是 2 (脚本名 + 名字)
if len(sys.argv) < 2:
print("错误:请提供一个名字作为命令行参数。")
print("用法: python name_greeter.py <名字>")
sys.exit(1) # 使用非零状态码表示错误退出
# 获取第一个命令行参数作为名字
# sys.argv[0] 是 'name_greeter.py'
name = sys.argv[1]
print(f"你好, {name}!")
在命令行中运行此脚本:
python name_greeter.py David
输出将是:
你好, David!
如果未提供参数运行 python name_greeter.py
,则会触发 if
条件,打印错误消息并退出。
处理潜在错误:IndexError
与参数数量检查
直接访问 sys.argv[1]
或更高索引存在风险:如果用户没有提供足够的参数,程序会因 IndexError: list index out of range
而崩溃。
处理此问题的最佳实践是在访问参数前检查 len(sys.argv)
是否满足预期数量。
# name_greeter_robust.py
import sys
# 期望正好一个名字参数(总共 2 个 argv 元素)
if len(sys.argv) != 2:
if len(sys.argv) < 2:
print("错误: 参数过少。")
else: # len(sys.argv) > 2
print("错误: 参数过多。")
print("用法: python name_greeter_robust.py <名字>")
sys.exit(1) # 退出程序
# 此时可以安全地访问 sys.argv[1]
name = sys.argv[1]
print(f"你好, {name}!")
另一种处理方式是使用 try...except IndexError
块,但这通常不如预先检查 len()
清晰,后者能提供更具体的错误反馈(过少还是过多)。
优雅地退出:sys.exit()
sys.exit()
函数用于终止 Python 程序的执行。它可以接受一个可选的参数,通常是一个整数状态码(0 表示成功,非零表示错误)或一个字符串。如果传递字符串,该字符串将被打印到标准错误流 (stderr),然后程序以状态码 1 退出。这在验证输入无效后提前结束程序非常有用,可以避免将错误处理逻辑嵌套在主程序流程中,使代码结构更清晰。
处理多个参数与列表切片
如果程序需要接受多个命令行参数,例如多个名字,我们可以使用循环遍历 sys.argv
。但通常我们只关心用户输入的参数,而非脚本名本身。这时,列表切片 (List Slicing) 就派上用场了。
列表切片的语法是 list[start:end]
,它返回一个新的列表,包含从 start
索引到 end-1
索引的元素。
sys.argv[1:]
表示从索引 1 开始(即第一个用户参数)直到列表末尾的所有元素。
# greet_multiple.py
import sys
# 至少需要一个名字参数
if len(sys.argv) < 2:
sys.exit("错误:请至少提供一个名字。用法: python greet_multiple.py <名字1> [名字2] ...")
# 使用切片获取所有用户提供的名字
names = sys.argv[1:]
print("依次问候:")
for name in names:
# 如果名字包含空格,用户需在命令行用引号括起来,如 "David Malan"
print(f" 你好, {name}!")
运行 python greet_multiple.py Alice Bob "Charlie Brown"
将会问候这三个人。
扩展能力:第三方库与包管理
Python 标准库虽功能强大,但无法涵盖所有需求。Python 的真正威力在于其庞大且活跃的 第三方库生态系统。开发者们创建了无数高质量的库(或称为 包 Package),用于数据科学、Web 开发、机器学习、图像处理等各种领域。
发现与获取:PyPI 与 pip
PyPI (Python Package Index) 是官方的第三方 Python 软件包存储库 (pypi.org),是查找和获取这些库的主要平台。
pip
是 Python 的 包安装器 (Package Installer for Python),通常随 Python 一同安装。它允许我们从 PyPI 轻松地下载和安装第三方包到我们的开发环境中。
安装一个包的基本命令是:
pip install <package_name>
网络请求示例:requests
库
requests
库是 Python 中用于发送 HTTP 请求(与网站和 API 交互)的事实标准库,以其简洁易用的 API 而闻名。
首先,需要安装它:
pip install requests
安装后,我们就可以在代码中导入并使用它了。
与 Web API 交互:获取并处理 JSON 数据
API (Application Programming Interface) 是软件系统之间交互的接口。许多 Web 服务通过 HTTP API 提供数据。这些 API 通常以 JSON (JavaScript Object Notation) 格式返回数据。JSON 是一种轻量级的数据交换格式,结构清晰,易于人类阅读和机器解析,其结构与 Python 的字典和列表非常相似。
假设我们要查询 Apple iTunes API 来搜索特定艺术家的歌曲。
# itunes_search.py
import requests
import sys
import json # 导入内置的 json 模块
# 检查命令行参数
if len(sys.argv) != 2:
sys.exit("用法: python itunes_search.py <艺术家名称>")
artist_name = sys.argv[1]
# 构建 API 请求 URL (限制返回 5 首歌曲)
api_url = f"https://itunes.apple.com/search?entity=song&limit=5&term={artist_name}"
try:
# 发送 GET 请求
response = requests.get(api_url)
# 检查请求是否成功 (例如,状态码 200 OK)
response.raise_for_status() # 如果状态码不是 2xx,会抛出异常
# 解析 JSON 响应为 Python 对象 (通常是字典)
data = response.json()
# (可选) 使用 json.dumps 美化打印整个响应,便于调试和理解结构
# print(json.dumps(data, indent=2))
# 提取并打印歌曲名称
print(f"\n找到关于 '{artist_name}' 的歌曲:")
# 'results' 键对应的值是一个列表,列表中每个元素是代表一首歌的字典
for result in data.get('results', []): # 使用 .get 提供默认空列表以防 'results' 不存在
track_name = result.get('trackName', '未知曲目') # .get 防止 'trackName' 不存在
print(f"- {track_name}")
except requests.RequestException as e:
sys.exit(f"网络请求错误: {e}")
except json.JSONDecodeError:
sys.exit("错误:无法解析服务器返回的 JSON 数据。")
except KeyError:
sys.exit("错误:服务器返回的数据格式不符合预期。")
运行 python itunes_search.py Weezer
将会查询并列出 Weezer 的前 5 首歌曲名称。
这里我们还使用了内置的 json
模块。json.dumps()
函数可以将 Python 对象(如字典)转换回 JSON 格式的字符串,indent=2
参数使其输出带有缩进,非常适合查看复杂 API 响应的结构。
构建自己的工具箱:创建自定义模块
随着项目复杂度的增加,或者当你发现某些自编函数具有通用性时,将它们组织成自己的模块(库)就变得非常有价值。这不仅能让当前项目结构更清晰,也方便未来在其他项目中复用这些代码。
创建与组织
创建一个自定义模块非常简单:
- 创建一个新的
.py
文件,例如myutils.py
。 - 在该文件中定义你的函数、类或变量。
# myutils.py
"""
这是一个包含一些实用工具函数的自定义模块。
"""
def format_greeting(name):
"""返回一个格式化的问候语字符串。"""
return f"你好,{name}!欢迎!"
def calculate_area(length, width):
"""计算矩形的面积。"""
if length < 0 or width < 0:
raise ValueError("长度和宽度必须是非负数。")
return length * width
PI = 3.14159 # 模块级别的常量
导入与使用
在同一目录下的另一个 Python 文件(例如 main_app.py
)中,你可以像导入标准库或第三方库一样导入你的自定义模块:
# main_app.py
# 导入整个自定义模块
import myutils
# 调用模块中的函数
message = myutils.format_greeting("Alice")
print(message)
rect_area = myutils.calculate_area(10, 5)
print(f"矩形面积: {rect_area}")
# 访问模块中的变量
print(f"模块中的 PI 值: {myutils.PI}")
# --- 或者,只导入特定内容 ---
from myutils import format_greeting as greet # 可以使用 as 重命名
message_bob = greet("Bob")
print(message_bob)
模块的独立运行与 if __name__ == "__main__":
有时,我们希望模块文件本身也能被直接运行,例如执行一些测试代码或作为一个独立的脚本工具。然而,如果在模块文件中直接编写了执行代码(如函数调用),那么当其他文件 import
这个模块时,这些代码也会被执行,这通常不是我们想要的行为。
为了解决这个问题,Python 提供了一个特殊的内置变量 __name__
。
- 当一个
.py
文件被直接通过python <filename>.py
运行时,其__name__
变量的值会被设置为字符串"__main__"
。 - 当一个
.py
文件被其他文件通过import
语句导入时,其__name__
变量的值会被设置为该模块的名称(即文件名,不含.py
后缀)。
利用这个特性,我们可以将只希望在模块被直接运行时才执行的代码放入一个 if __name__ == "__main__":
块中。这是一种 Python 的标准实践,用于区分模块作为库被导入时和作为主程序运行时执行的代码。
# myutils.py (添加了 __main__ 保护块)
# ... (之前的函数定义和 PI 定义) ...
def _test_functions():
"""模块内部的测试函数 (通常以下划线开头表示内部使用)"""
print("--- 开始测试 myutils 模块 ---")
test_name = "测试用户"
greeting = format_greeting(test_name)
print(f"测试问候语: {greeting}")
try:
area = calculate_area(4, 6)
print(f"测试面积计算 (4x6): {area}")
calculate_area(-1, 5) # 这将引发 ValueError
except ValueError as e:
print(f"捕获到预期的错误: {e}")
print("--- 结束测试 myutils 模块 ---")
# 这个代码块只在直接运行 myutils.py 时执行
if __name__ == "__main__":
_test_functions()
现在,如果运行 python myutils.py
,测试代码 _test_functions()
将被执行。但如果其他文件 import myutils
,这个 if
块内的代码则不会执行,模块仅仅是提供了定义的函数和变量供导入方使用。
结语
掌握 Python 中库和模块的使用是提升编程能力和效率的关键一步。通过利用标准库、第三方库以及构建自己的可复用模块,您可以站在巨人的肩膀上,更专注于解决核心问题,编写出更健壮、更易于维护的代码。Python 丰富的生态系统等待着您去探索和利用。
第5讲:单元测试-使用 pytest 提高代码质量
1. 引言:为何需要测试代码?
在软件开发中,编写功能代码只是第一步。如何确保我们编写的代码能够按照预期正确、稳定地运行,尤其是在代码不断迭代、功能日趋复杂或多人协作的情况下?传统的手动测试方法,即反复运行程序并输入各种值来观察结果,虽然直观,但存在明显弊端:
- 效率低下:对于复杂的程序,手动测试耗时耗力。
- 容易遗漏:难以覆盖所有的执行路径和边界情况(Corner Cases)。
- 一致性差:每次手动测试难以保证操作完全一致。
- 回归困难:当代码修改后,需要重新进行大量手动测试以确保未引入新错误(回归测试),这非常繁琐。
为了克服这些局限性,自动化测试应运而生。通过编写额外的代码来自动验证功能代码的正确性,我们可以系统、高效且一致地进行测试。单元测试(Unit Testing)是自动化测试的一种重要形式,它专注于验证程序中最小的可测试单元——通常是单个函数或方法——的行为是否符合预期。
本教程将引导您了解Python中进行单元测试的基本概念和实践方法,重点介绍业界广泛使用的测试框架 pytest
。
2. 基础测试概念与原生方法
在引入专业测试框架之前,我们先了解一些基本的测试思路。
2.1 准备待测试代码
假设我们有一个简单的计算器模块 calculator.py
,包含一个计算平方的函数:
# calculator.py
def square(n):
"""计算一个数的平方"""
return n * n
def main():
x = int(input("What's x? "))
print("x squared is", square(x))
if __name__ == "__main__":
main()
注意 if __name__ == "__main__":
的用法。这确保了当此文件被其他模块(如测试脚本)导入时,main()
函数不会自动执行,使得我们可以安全地导入并测试其中的 square
函数。
2.2 使用 assert
断言
Python 内置了 assert
关键字,可用于进行简单的断言。assert
后面跟一个布尔表达式,如果表达式为 True
,则程序继续执行;如果为 False
,则会抛出 AssertionError
异常,通常表示测试失败。
我们可以创建一个测试文件 test_calculator_simple.py
来使用 assert
:
# test_calculator_simple.py
from calculator import square
def test_square_positive():
assert square(2) == 4
assert square(3) == 9
def test_square_negative():
assert square(-2) == 4
assert square(-3) == 9
def test_square_zero():
assert square(0) == 0
# 注意:这种方式下,我们需要手动运行此脚本,并观察是否有AssertionError抛出
# 如果想捕捉错误并打印更友好的信息,需要使用 try...except AssertionError,
# 这会使测试代码变得冗长。
局限性:虽然 assert
简单直接,但纯粹依赖它进行测试有不足之处:
- 需要手动运行测试脚本。
- 默认的
AssertionError
信息不够详细,定位问题可能需要更多分析。 - 若要提供更友好的失败报告或进行更复杂的测试设置/清理,需要编写大量样板代码(如
try...except
结构、测试执行逻辑等)。
正是为了解决这些问题,专业的测试框架应运而生。
3. 使用 pytest
进行高效单元测试
pytest
是一个成熟、功能强大且易于使用的Python第三方测试框架。它通过约定优于配置的原则和丰富的插件生态,极大地简化了测试的编写、组织和执行。
3.1 安装 pytest
可以通过 pip 安装 pytest
:
pip install pytest
3.2 编写 pytest
测试用例
pytest
遵循一些简单的约定来自动发现测试:
- 测试文件通常命名为
test_*.py
或*_test.py
。 - 测试文件内的测试函数以
test_
开头。 - 测试类以
Test
开头(本教程主要关注函数式测试)。
使用 pytest
重写之前的测试 test_calculator.py
:
# test_calculator.py (适用于 pytest)
from calculator import square
def test_positive():
"""测试正数的平方"""
assert square(2) == 4
assert square(3) == 9
def test_negative():
"""测试负数的平方"""
assert square(-2) == 4
assert square(-3) == 9
def test_zero():
"""测试零的平方"""
assert square(0) == 0
关键改进:
- 无需编写
main
函数或if __name__ == "__main__":
来执行测试。 - 无需手动处理
AssertionError
,pytest
会自动捕捉并生成报告。 - 测试函数可以按照逻辑分组(如正数、负数、零),提高了组织性。
3.3 执行测试与解读结果
在包含 test_calculator.py
和 calculator.py
的目录下,打开终端并运行:
pytest
或者指定测试文件:
pytest test_calculator.py
pytest
会自动发现并执行 test_calculator.py
文件中所有以 test_
开头的函数。
- 测试通过:如果所有断言都成功,
pytest
会输出类似.
(点号)的标记,表示一个测试函数通过,并最终报告通过的测试数量。 - 测试失败:如果任何
assert
语句失败,pytest
会:输出
F
标记表示失败。提供详细的失败报告,清晰地指出哪个测试函数、哪行断言失败,并显示失败时的变量值和比较差异,极大地帮助定位问题。例如,如果
square
函数错误地实现为n + n
,测试test_positive
时对assert square(3) == 9
的报告会类似:E AssertionError: assert 6 == 9 E + where 6 = square(3)
这清楚地表明
square(3)
返回了 6,而预期是 9。
3.4 结构化测试:拆分测试函数
将不同场景或类型的测试放在独立的测试函数中(如上例的 test_positive
, test_negative
, test_zero
)是一个好习惯。这样做的好处是:
- 独立执行:每个
test_
函数都被独立运行。 - 全面反馈:即使一个测试函数失败,其他测试函数仍会继续执行。最终报告会显示所有测试函数的结果,让您了解哪些方面的功能正常,哪些存在问题。
4. 测试预期的异常
有时,函数的正确行为是在接收到无效输入时抛出特定的异常。例如,我们的 square
函数期望接收数字,如果传入字符串,应该抛出 TypeError
。我们需要测试这种情况是否按预期发生。
pytest
提供了 pytest.raises()
上下文管理器来优雅地处理这类测试:
# test_calculator.py (续)
import pytest # 需要导入 pytest
from calculator import square
# ... (之前的测试函数) ...
def test_string_input_raises_type_error():
"""测试传入字符串时是否抛出TypeError"""
with pytest.raises(TypeError):
square("cat")
这段代码断言:在 with
块内执行 square("cat")
时,必须抛出一个 TypeError
异常。如果确实抛出了 TypeError
,测试通过;如果没有抛出任何异常,或者抛出了其他类型的异常,测试失败。
5. 处理副作用与设计可测试的代码
单元测试最适合测试那些具有明确输入和输出(返回值)的纯函数。如果函数的主要作用是产生副作用(Side Effects),例如直接打印到控制台、修改全局变量或操作文件系统,那么测试会变得困难。
考虑一个简单的问候程序 hello.py
的早期版本:
# hello_v1.py (不易测试)
def hello(to="world"):
print("hello,", to) # 直接打印,产生副作用
def main():
name = input("What's your name? ")
hello(name)
if __name__ == "__main__":
main()
我们很难用 assert
直接验证 hello("David")
是否在屏幕上打印了 "hello, David"。
提高可测试性的设计原则:分离逻辑与副作用
一个更好的设计是将核心逻辑(生成问候语)与副作用(打印)分离:
# hello.py (可测试版本)
def hello(to="world"):
"""生成问候语字符串"""
return f"hello, {to}" # 返回字符串,无副作用
def main():
name = input("What's your name? ")
message = hello(name) # 获取逻辑结果
print(message) # 单独处理副作用
if __name__ == "__main__":
main()
现在,hello
函数变成了一个纯函数,非常容易测试:
# test_hello.py
from hello import hello
def test_hello_default():
"""测试默认问候语"""
assert hello() == "hello, world"
def test_hello_argument():
"""测试带参数的问候语"""
assert hello("David") == "hello, David"
核心思想:尽量让你的函数返回值而不是直接执行IO操作或修改状态。将这些副作用操作推迟到调用该函数的地方处理。这使得核心逻辑单元易于隔离和测试。
6. 组织大型测试套件
当项目变大,测试用例增多时,将所有测试都放在一个文件里可能变得混乱。pytest
支持将测试组织在目录中:
- 创建一个专门存放测试文件的目录,通常命名为
tests/
或test/
。 - 将你的
test_*.py
文件放入这个目录。 - 在该测试目录下创建一个空的
__init__.py
文件。这个文件(即使是空的)的存在告诉 Python 该目录是一个包(package),这有助于pytest
的发现机制以及可能的模块导入。
项目结构示例:
your_project/
├── calculator.py
├── hello.py
└── tests/
├── __init__.py (空文件)
├── test_calculator.py
└── test_hello.py
现在,你可以在项目根目录 your_project/
下运行 pytest
,它会自动发现并执行 tests/
目录下的所有测试:
cd your_project/
pytest
或者指定测试目录:
pytest tests/
7. 结论
单元测试是现代软件开发不可或缺的一环。它有助于:
- 提高代码质量:及早发现并修复错误。
- 增强开发信心:放心修改和重构代码,因为有测试保障其行为。
- 改善代码设计:编写可测试的代码往往促使开发者设计出更模块化、低耦合的函数。
- 促进团队协作:测试用例是代码行为的活文档,并确保团队成员的修改不会破坏现有功能。
通过使用像 pytest
这样的测试框架,我们可以高效、系统地实践单元测试,从而构建更可靠、更易于维护的 Python 应用程序。养成编写单元测试的习惯,将为您的编程实践带来长远的益处。
第6讲: 文件 I/O 基础
引言
在编程过程中,我们经常需要处理数据。程序运行时,数据通常存储在内存(如变量、列表)中,这种存储是临时的——当程序执行结束,内存中的数据便会丢失。为了实现数据的持久化存储,即让数据在程序关闭后依然存在,我们需要学习如何与文件系统交互。文件 I/O(Input/Output,输入/输出)正是实现这一目标的关键技术,它允许我们的程序从文件中读取数据,并将数据写入文件以供后续使用。
本教程将系统介绍 Python 中进行文件 I/O 的基本概念、常用操作以及处理不同文件格式(特别是 CSV 文件)的最佳实践,并简要涉及二进制文件的处理。
一、 基本文本文件操作
1.1 写入文本文件
将数据写入文件是文件操作的基础。Python 提供了内建的 open()
函数来打开文件,并返回一个文件句柄 (file handle),通过这个句柄可以对文件进行读写操作。
打开模式:
open()
函数的第二个参数指定了文件的打开模式。对于写入操作,常用的模式有两种:
'w'
(Write): 写入模式。如果文件已存在,此模式会清空文件内容后开始写入;如果文件不存在,则会创建新文件。'a'
(Append): 追加模式。在此模式下写入内容会被添加到文件的末尾;如果文件不存在,同样会创建新文件。
写入方法:
获取文件句柄后,可以使用其 write()
方法将字符串写入文件。
关闭文件:
完成文件操作后,必须调用文件句柄的 close()
方法来确保所有缓冲数据被写入磁盘,并释放操作系统资源。
示例:
# 使用 'w' 模式写入,会覆盖旧内容或创建新文件
file_handle = open("greeting.txt", "w")
file_handle.write("Hello, Python!")
file_handle.close() # 必须关闭
# 使用 'a' 模式追加
file_handle = open("greeting.txt", "a")
file_handle.write("\nWelcome to File I/O.") # 追加内容
file_handle.close() # 必须关闭
注意换行符: write()
方法本身不会自动添加换行符 (\n
)。如果希望每条数据占据一行,需要在写入的字符串末尾显式添加 \n
。
names = ["Alice", "Bob", "Charlie"]
file_handle = open("names.txt", "w")
for name in names:
file_handle.write(name + "\n") # 手动添加换行符
file_handle.close()
1.2 使用 with
语句管理文件 (推荐)
手动调用 close()
容易遗忘,且如果在 write()
和 close()
之间发生错误,文件可能不会被正确关闭。Python 提供了 with
语句来自动管理文件资源。当代码块执行完毕(无论正常结束还是发生异常),with
语句都会确保文件被正确关闭。
names = ["David", "Emma"]
# 使用 'a' 模式和 with 语句追加
with open("names.txt", "a") as file: # 将文件句柄赋值给变量 file
for name in names:
file.write(f"{name}\n") # 使用 f-string 更方便地添加换行符
# 在此,文件已自动关闭,无需调用 file.close()
with
语句是处理文件(以及其他需要显式释放资源的场景)的推荐方式,因为它更安全、更简洁。
二、 读取文本文件
从文件中读取数据同样重要。open()
函数在不指定模式或使用 'r'
(Read) 模式时,会以读取模式打开文件。
2.1 读取方法
readlines()
: 一次性读取文件的所有行,并返回一个包含所有行的列表 (list)。列表中的每个元素都是文件中的一行字符串,且通常包含行尾的换行符\n
。lines_list = [] with open("names.txt", "r") as file: lines_list = file.readlines() # lines_list 可能像这样: ['Alice\n', 'Bob\n', 'Charlie\n', 'David\n', 'Emma\n'] print(lines_list)
对于非常大的文件,一次性读入内存可能导致性能问题或内存耗尽。
直接迭代文件句柄 (推荐): 这是更常用且内存效率更高的方式,尤其适用于大文件。可以直接在
for
循环中迭代文件句柄,每次迭代会读取文件的一行。with open("names.txt", "r") as file: for line in file: # 每次循环,line 变量会持有文件的一行内容 (带 \n) print(f"Read line: {line}")
2.2 处理行尾换行符
从文件中读取的行通常包含末尾的换行符 \n
。如果直接使用 print()
函数输出这些行,由于 print()
默认也会添加一个换行符,会导致输出中出现多余的空行。
解决方法:
print(line, end="")
: 在print()
函数中设置end
参数为空字符串,阻止print()
添加额外的换行。line.rstrip()
(推荐): 使用字符串的rstrip()
方法移除字符串末尾的空白字符(包括空格、制表符和换行符)。这通常是更好的选择,因为它处理的是数据本身,使后续处理更干净。with open("names.txt", "r") as file: for line in file: processed_line = line.rstrip() # 移除行尾空白 print(f"Processed line: {processed_line}")
三、 读取、处理与排序数据
一个常见的编程模式是:从文件中读取数据,将其存储在内存中的数据结构(如列表)中,对数据进行处理(例如排序),然后输出或进一步使用处理后的结果。
示例:读取名字文件,按字母顺序排序后打印
names_in_memory = []
with open("names.txt", "r") as file:
for line in file:
names_in_memory.append(line.rstrip()) # 读取并存储处理后的名字
# 文件读取完毕后,对内存中的列表进行排序
sorted_names = sorted(names_in_memory)
# 处理排序后的结果
print("\nSorted Names:")
for name in sorted_names:
print(f"Hello, {name}")
# 按字母降序排序
print("\nReverse Sorted Names:")
for name in sorted(names_in_memory, reverse=True):
print(f"Hello, {name}")
这里使用了 Python 的 sorted()
函数,它接受一个可迭代对象(如列表)并返回一个新的已排序列表。通过 reverse=True
参数可以实现降序排序。
四、 处理结构化数据:CSV 文件
当需要存储包含多个相关字段的数据记录时(例如,学生的姓名、学院、家乡),简单的每行一个值的文本文件就显得不足。CSV (Comma-Separated Values,逗号分隔值) 是一种广泛用于存储表格数据的纯文本格式。
- 结构: 每行代表一条记录,记录内的字段由逗号分隔。
- 优点: 格式简单,易于人类阅读和程序处理,兼容性好(可被 Excel、数据库等多种软件导入导出)。
4.1 手动解析 CSV 的挑战
虽然可以通过读取行、使用 split(',')
方法来手动解析 CSV,但这存在局限性:
# 假设 students.csv 内容为:
# name,house
# Harry,Gryffindor
# Draco,"Slytherin"
# 手动解析示例 (存在问题)
with open("students.csv", "r") as file:
for line in file:
fields = line.rstrip().split(',')
# 如果某字段值本身包含逗号 (如 "Number Four, Privet Drive"),
# split(',') 会错误地将其分割,导致字段数量不匹配或数据错误。
# 此外,还需要手动处理可能存在的引号。
if len(fields) == 2: # 简单的检查可能不足
print(f"Name: {fields[0]}, House: {fields[1]}")
主要问题在于,如果数据字段本身包含逗号,简单的 split(',')
会导致解析错误。CSV 标准允许使用引号来包围包含特殊字符(如逗号、引号、换行符)的字段,手动处理这些规则会非常复杂且容易出错。
4.2 使用 csv
模块 (推荐)
Python 的标准库 csv
模块提供了专门用于处理 CSV 文件的工具,能够自动、正确地处理逗号、引号、换行符等复杂情况,是处理 CSV 文件的最佳实践。
导入模块: import csv
读取 CSV:
csv.reader
: 将 CSV 文件视为行的列表。它返回一个 reader 对象,迭代该对象时,每一行被解析为一个字符串列表。import csv students_data = [] with open("students.csv", "r", newline='') as file: # newline='' 推荐用于 csv reader = csv.reader(file) header = next(reader) # 读取并跳过标题行 (如果存在) print(f"CSV Header: {header}") for row in reader: # row 是一个列表, e.g., ['Harry', 'Gryffindor'] if len(row) == 2: # 确保行包含预期数量的字段 students_data.append({"name": row[0], "house": row[1]}) print(students_data)
注意:
csv.reader
不会自动处理标题行,通常需要手动读取(如用next(reader)
)并跳过。代码依赖于列的固定顺序。csv.DictReader
(更推荐): 将 CSV 文件视为字典的列表。它假定文件的第一行是标题行,并使用这些标题作为键。迭代DictReader
对象时,每一行被解析为一个字典,其中键是列标题,值是对应的数据。import csv students_data = [] with open("students.csv", "r", newline='') as file: reader = csv.DictReader(file) # 自动使用第一行作为键 # fieldnames 属性包含标题: reader.fieldnames for row in reader: # row 是一个字典, e.g., {'name': 'Harry', 'house': 'Gryffindor'} students_data.append(row) # 可以直接追加字典 print(students_data) # 可以通过键访问数据,不依赖列顺序 for student in students_data: print(f"{student['name']} is in {student['house']}")
DictReader
的主要优势在于代码不依赖于 CSV 文件中列的物理顺序,只要标题行存在且正确,代码就能通过列名(键)访问数据,更加健壮和可读。
写入 CSV:
csv.writer
: 用于将数据(通常是列表或元组的列表)写入 CSV 文件。writerow(list_of_values)
: 写入单行,接收一个包含该行各字段值的列表。
import csv header = ['name', 'home'] data = [ {'name': 'Ron', 'home': 'The Burrow'}, {'name': 'Harry', 'home': 'Number Four, Privet Drive'} # 包含逗号的数据 ] with open("output_students.csv", "w", newline='') as file: writer = csv.writer(file) writer.writerow(header) # 写入标题行 for student in data: # 需要按标题顺序提取值放入列表 writer.writerow([student['name'], student['home']])
csv.DictWriter
(更推荐): 用于将字典列表写入 CSV 文件。- 创建时需要指定
fieldnames
参数,这是一个包含所有列标题(按期望写入顺序)的列表。 writeheader()
: (可选)写入标题行。writerow(dictionary)
: 写入单行,接收一个包含该行数据的字典。字典的键应与fieldnames
对应。
import csv fieldnames = ['name', 'home'] # 定义列标题和顺序 data = [ {'name': 'Ron', 'home': 'The Burrow'}, {'name': 'Harry', 'home': 'Number Four, Privet Drive'} ] with open("output_students_dict.csv", "w", newline='') as file: writer = csv.DictWriter(file, fieldnames=fieldnames) writer.writeheader() # 写入标题行 writer.writerows(data) # writerows 可以一次写入多个字典行 # 或者逐行写入: # for student in data: # writer.writerow(student)
DictWriter
同样不依赖于传入字典中键值对的顺序,只要字典包含fieldnames
中指定的键即可。csv
模块在写入时会自动处理需要加引号的字段。- 创建时需要指定
五、 排序复杂数据结构
当数据被读取并存储为字典列表后(例如使用 DictReader
),我们可能需要根据字典中某个键的值来对整个列表进行排序。sorted()
函数的 key
参数在这里非常有用。key
参数接受一个函数,这个函数会被应用于列表中的每个元素(每个字典),sorted()
则根据这个函数返回的值进行排序。
5.1 使用 lambda
函数作为 key
通常,用于 key
的函数很简单,只用于提取某个值。在这种情况下,定义一个完整的命名函数显得冗余。lambda
函数允许我们创建匿名的、单行的函数,非常适合用作 key
。
# students_data 是一个字典列表, e.g., [{'name': 'Ron', 'home': 'The Burrow'}, ...]
# 按 'name' 字段排序
sorted_by_name = sorted(students_data, key=lambda student: student['name'])
# 按 'home' 字段排序
sorted_by_home = sorted(students_data, key=lambda student: student['home'])
print("\nSorted by Name:")
for student in sorted_by_name:
print(student)
print("\nSorted by Home:")
for student in sorted_by_home:
print(student)
lambda student: student['name']
定义了一个匿名函数,它接受一个参数 student
(代表列表中的一个字典),并返回该字典中 name
键对应的值。sorted()
使用这些返回的名字来进行字母排序。
六、 处理二进制文件简介
到目前为止,我们主要关注的是文本文件(TXT, CSV)。但文件也可以是二进制 (binary) 格式,它们存储的不是可直接阅读的字符,而是原始的字节数据(0 和 1)。图像(JPEG, PNG, GIF)、音频(MP3, WAV)、视频(MP4)等都是二进制文件。
处理二进制文件通常需要特定的库来理解其内部结构。Python 拥有丰富的库生态系统来支持各种二进制格式。
示例:使用 Pillow
库处理图像
Pillow
(PIL Fork) 是 Python 中用于图像处理的流行库。它可以打开、操作和保存多种图像格式。
# 需要先安装 Pillow: pip install Pillow
from PIL import Image
import sys
# 假设从命令行获取图像文件名列表 (sys.argv[1:])
image_files = ["costume1.gif", "costume2.gif"] # 示例文件名
images_to_animate = []
try:
for filename in image_files:
img = Image.open(filename)
images_to_animate.append(img)
if len(images_to_animate) >= 1:
# 创建动画 GIF (至少需要一个图像才能保存)
images_to_animate[0].save(
"animated_cat.gif",
save_all=True, # 必须设置此项来保存多帧
append_images=images_to_animate[1:], # 要附加的后续帧 (如果存在)
duration=200, # 每帧持续时间 (毫秒)
loop=0 # 循环次数 (0 表示无限循环)
)
print("Animated GIF created successfully!")
else:
print("No images found to create animation.")
except FileNotFoundError as e:
print(f"Error: Image file not found - {e}")
except Exception as e:
print(f"An error occurred: {e}")
这个例子展示了如何使用 Pillow
打开多个静态 GIF 文件,并将它们组合成一个动画 GIF 文件。这说明了 Python 通过库可以操作非文本的二进制数据。
总结
文件 I/O 是 Python 编程中不可或缺的一部分,它使得程序能够与外部世界持久地交换数据。本教程涵盖了:
- 使用
open()
和with
语句进行安全的文件打开、读取和写入操作。 - 处理文本文件中的换行符。
- 使用
csv
模块高效、健壮地读写结构化的 CSV 数据。 - 利用
sorted()
和lambda
对包含字典的列表进行排序。 - 认识到 Python 处理二进制文件的能力(以图像为例)。
掌握这些文件操作技术,将极大增强你编写能够处理真实世界数据、实现持久化存储的 Python 程序的能力。
第7讲: 正则表达式
引言
在 Python 编程中,处理字符串是一项常见的任务。虽然 Python 内建的字符串方法(如 find()
, replace()
, split()
)可以处理许多基本场景,但当我们需要识别和操作复杂的文本模式时,它们往往显得力不从心。此时,正则表达式(Regular Expressions,简称 Regex)便成为了一个强大而必要的工具。
正则表达式是一种微型语言,用于定义文本的搜索模式。通过它,我们可以精确地描述想要查找、验证、提取或替换的字符串结构。Python 通过内置的 re
模块提供了对正则表达式的全面支持。本教程将基于 CS50P 第七讲的内容,系统介绍 Python 中正则表达式的核心概念和常用操作。
传统字符串方法的局限性:一个实例
让我们考虑一个常见需求:验证用户输入的电子邮件地址是否格式有效。若尝试使用基础字符串方法,可能会经历如下过程:
- 基础检查:首先检查是否包含
@
符号。但这过于宽松,@
或a@b
都会通过。 - 增加检查:加入对
.
的检查,如if "@" in email and "." in email:
。但这同样不精确,@.
也能通过,且无法保证.
在域名部分。 - 结构化处理:使用
email.split('@')
分割用户名和域名,再检查域名部分是否包含.
或以特定后缀(如.edu
)结尾。代码开始变得复杂:username, domain = email.split('@'); if username and domain.endswith(".edu"): ...
- 处理变体:如果需要支持子域名(如
cs50.harvard.edu
)、处理可选空格、或验证更复杂的规则,代码将迅速膨胀,逻辑变得混乱且极易出错。
这个例子清晰地表明,对于具有一定结构和变异性的文本模式,仅依赖基础字符串方法会导致代码冗长、脆弱且难以维护。正则表达式正是为了解决这类问题而设计的。
Python re
模块入门
要在 Python 中使用正则表达式,首先需要导入 re
模块:
import re
re
模块提供了多个函数,其中最常用的是 re.search()
,用于在字符串中查找模式的第一次出现:
re.search(pattern, string, flags=0)
pattern
: 一个字符串,表示要搜索的正则表达式模式。string
: 需要在其中搜索模式的目标字符串。flags
(可选): 一个或多个标志,用于修改匹配行为(例如,re.IGNORECASE
用于忽略大小写)。
如果 re.search()
找到匹配项,它会返回一个匹配对象 (Match Object);如果未找到匹配项,则返回 None
。因此,通常在使用其结果前检查返回值是否为真:
email = "malan@harvard.edu"
match = re.search(r".+@.+\.edu", email) # 使用简单的 regex 模式
if match:
print("模式匹配成功")
else:
print("模式未匹配")
注意: 模式字符串前的 r
表示这是一个原始字符串 (Raw String)。在正则表达式中,反斜杠 \
有特殊含义(用于转义或表示特殊序列)。使用原始字符串可以防止 Python 解释器本身对反斜杠进行转义,确保正则表达式引擎接收到正确的模式。强烈建议在定义正则表达式模式时始终使用原始字符串。
正则表达式核心语法
正则表达式由普通字符(如 a
, b
, 1
, @
)和具有特殊含义的元字符 (Metacharacters) 组成。
1. 基本元字符与量词
.
(点): 匹配除换行符\n
之外的任何单个字符。*
(星号): 匹配其前面的字符或分组零次或多次。例如,a*
匹配 "" (空字符串), "a", "aa", "aaa" 等。+
(加号): 匹配其前面的字符或分组一次或多次。例如,a+
匹配 "a", "aa", "aaa" 等,但不匹配空字符串。?
(问号): 匹配其前面的字符或分组零次或一次。常用于表示可选部分。例如,https?
匹配 "http" 或 "https"。{m}
: 精确匹配其前面的字符或分组m
次。例如,\d{3}
匹配恰好三个数字。{m,n}
: 匹配其前面的字符或分组至少m
次,至多n
次。例如,\d{2,4}
匹配两到四个数字。{m,}
表示至少m
次,{,n}
表示至多n
次。
2. 锚点 (Anchors)
锚点用于指定匹配发生的位置,它们本身不匹配任何字符。
^
(脱字符): 匹配字符串的开头。如果设置了re.MULTILINE
标志,也会匹配每一行的开头。$
(美元符号): 匹配字符串的结尾,或者在字符串结尾的换行符\n
之前。如果设置了re.MULTILINE
标志,也会匹配每一行的结尾。
结合使用 ^
和 $
可以强制模式匹配整个字符串。例如,^abc$
只匹配字符串 "abc"。
3. 字符集 (Character Sets)
使用方括号 [...]
定义一个字符集,匹配方括号内所包含的任意一个字符。
[abc]
: 匹配 "a", "b", 或 "c" 中的任意一个字符。[a-z]
: 匹配任何一个小写字母(从 "a" 到 "z" 的范围)。[A-Z]
: 匹配任何一个大写字母。[0-9]
: 匹配任何一个数字。- 组合使用:
[a-zA-Z0-9_]
匹配任何字母、数字或下划线。 - 特殊字符:在字符集内部,大多数元字符(如
.
、*
)失去特殊含义,按字面匹配。但^
,-
,]
需要注意:^
如果出现在首位,表示排除型字符集 (Negated Character Set),匹配 不在 集合内的任意字符。例如[^0-9]
匹配任何非数字字符。-
用于表示范围,如a-z
。若要匹配字面连字符-
,需将其放在首位、末位或紧邻范围之后,如[-a-z]
或[a-z-]
。]
若要匹配字面右方括号,需将其放在首位或用反斜杠转义,如[]a]
或[a\]]
。
4. 预定义字符类 (Predefined Character Classes)
为方便起见,正则表达式提供了一些常用字符集的简写形式:
\d
: 匹配任何数字。等价于[0-9]
。\D
: 匹配任何非数字字符。等价于[^0-9]
。\s
: 匹配任何空白字符(包括空格\t
、换行符\n
、回车符\r
等)。\S
: 匹配任何非空白字符。\w
: 匹配任何“单词”字符 (word character),包括字母、数字和下划线_
。等价于[a-zA-Z0-9_]
(在 ASCII 模式下)。\W
: 匹配任何非单词字符。
5. 转义 (Escaping)
如果需要匹配元字符本身(如 .
, *
, +
, ?
, ^
, $
, (
, )
, [
, ]
, {
, }
, \
),必须在其前面加上反斜杠 \
进行转义。例如,要匹配字面上的点号,应使用 \.
。
分组与捕获
括号 ()
在正则表达式中有两个主要作用:分组和捕获。
1. 分组
- 将模式的一部分视为一个整体单元。例如,对整个分组应用量词:
(abc)+
匹配 "abc", "abcabc" 等。 - 用于定义“或”逻辑 (Alternation) 与竖线
|
结合:(cat|dog)
匹配 "cat" 或 "dog"。
2. 捕获 (Capturing)
- 默认情况下,括号
()
会“捕获”其内部模式所匹配到的文本。 re.search()
返回的匹配对象match
提供了访问这些捕获内容的方法:match.group(n)
: 返回第n
个捕获组匹配的文本。注意:组号从 1 开始计数!match.group(0)
或match.group()
: 返回整个正则表达式匹配的文本。match.groups()
: 返回一个元组,包含所有捕获组(从 1 到 n)匹配的文本。
示例:
text = "Name: Doe, John"
match = re.search(r"^Name: (\w+),\s*(\w+)$", text)
if match:
last_name = match.group(1) # 'Doe' (第一个括号捕获的内容)
first_name = match.group(2) # 'John' (第二个括号捕获的内容)
full_match = match.group(0) # 'Name: Doe, John' (整个模式匹配的内容)
all_groups = match.groups() # ('Doe', 'John')
print(f"First: {first_name}, Last: {last_name}")
3. 非捕获组 (Non-capturing Groups)
有时我们只需要分组功能(例如应用量词或 |
),但并不想捕获该部分内容,也不希望它干扰后续捕获组的编号。这时可以使用非捕获组 (?:...)
:
# 模式包含一个非捕获组 (?:https?://) 和一个捕获组 ([^/]+)
url = "http://example.com/path"
match = re.search(r"^(?:https?://)([^/]+)", url)
if match:
# match.group(1) 仍然是第一个捕获组的内容,即 'example.com'
# 非捕获组 (?:https?://) 不计入组号
print(match.group(1))
这在处理复杂的 URL 或需要使部分模式可选但又不关心其具体内容时非常有用。
修改行为:标志位 (Flags)
可以在 re
函数中通过 flags
参数传递一个或多个标志来调整匹配行为。常用标志包括:
re.IGNORECASE
或re.I
: 进行不区分大小写的匹配。re.MULTILINE
或re.M
: 使^
和$
能够匹配每一行的开头和结尾(而不仅仅是整个字符串的开头和结尾)。re.DOTALL
或re.S
: 使元字符.
能够匹配包括换行符\n
在内的所有字符。
text = "Hello World"
match_case_sensitive = re.search(r"hello", text) # 返回 None
match_ignore_case = re.search(r"hello", text, re.IGNORECASE) # 返回 Match 对象
正则表达式的实际应用
现在,我们将结合之前讨论的例子,展示如何应用正则表达式解决实际问题。
1. 数据验证 (Email Revisited)
使用正则表达式可以更健壮地验证邮箱格式。一个相对简化的模式可能如下:
# 一个改进的邮箱验证模式 (仍非完美,真实世界更复杂)
pattern = r"^\w+@(?:\w+\.)?\w+\.(?:com|edu|org)$" # 简化示例
email = "malan@cs50.harvard.edu"
if re.search(pattern, email, re.IGNORECASE):
print("Email format appears valid.")
else:
print("Invalid email format.")
这个模式使用了 \w+
匹配用户名和域名部分,(?:\w+\.)?
使子域名可选(使用非捕获组),\.
匹配字面点号,并用 (?:com|edu|org)
限制了顶级域名。注意,即使这样也远非完美,真实的邮箱验证 regex 非常复杂,通常建议在生产环境中使用经过充分测试的库。
2. 数据清理与格式化 (format.py
Name Example)
将 "姓, 名" 格式统一为 "名 姓":
name_input = "Malan, David"
# 使用捕获组提取姓和名
match = re.search(r"^(.+?),\s*(.+?)$", name_input.strip()) # 使用非贪婪匹配 `+?` 可能更安全
if match:
last_name = match.group(1)
first_name = match.group(2)
formatted_name = f"{first_name} {last_name}"
print(formatted_name) # 输出: David Malan
else:
formatted_name = name_input.strip() # 若格式不匹配,则保持原样
print(formatted_name)
这里,^(.+?),
捕获逗号前的姓,\s*(.+?)$
捕获逗号后(可能带前导空格)的名。re.search
结合捕获组提供了比 split
更灵活、更精确的解析能力。
3. 数据提取 (twitter.py
URL Example)
从 Twitter URL 中提取用户名,同时处理多种 URL 变体:
url = "https://www.twitter.com/davidjmalan"
# 模式使用非捕获组处理可选协议和 www,用捕获组提取用户名
pattern = r"^(?:https?://)?(?:www\.)?twitter\.com/([a-zA-Z0-9_]+)$"
match = re.search(pattern, url.strip(), re.IGNORECASE)
if match:
username = match.group(1) # 用户名是第一个(也是唯一一个)捕获组
print(f"Username: {username}") # 输出: davidjmalan
else:
print("Could not extract username from URL.")
这个模式优雅地处理了 http/https
和 www.
的可选性,并精确捕获了符合 Twitter 用户名规则 ([a-zA-Z0-9_]+
) 的部分。
替代方案:re.sub()
re.sub(pattern, replacement, string, count=0, flags=0)
函数用于查找 pattern
匹配的所有子串,并用 replacement
替换它们。
url = "https://twitter.com/davidjmalan"
# 替换掉 URL 前缀,留下用户名
# 注意:这个 re.sub 模式也需要像 re.search 一样处理变体才健壮
pattern_prefix = r"^(?:https?://)?(?:www\.)?twitter\.com/"
username = re.sub(pattern_prefix, "", url.strip(), flags=re.IGNORECASE)
print(f"Username (using sub): {username}")
虽然 re.sub
可以用于简单的移除前缀,但 re.search
结合捕获组通常在需要精确提取特定部分时更为合适和安全,因为它能验证整体结构。
Python 3.8+ 新特性:海象运算符 (:=
)
在 Python 3.8 及以后版本中,可以使用海象运算符简化“先匹配再判断”的常见模式:
pattern = r"..." # 定义你的模式
string_to_search = "..."
# 传统方式
match = re.search(pattern, string_to_search)
if match:
# 使用 match 对象
print(match.group(1))
# 使用海象运算符
if match := re.search(pattern, string_to_search):
# 直接使用 match 对象
print(match.group(1))
这使得代码更加紧凑。
其他 re
模块函数
除了 re.search
和 re.sub
,re
模块还提供其他有用的函数:
re.match(pattern, string, flags=0)
: 只从字符串的 开头 尝试匹配模式(相当于模式自带^
)。re.fullmatch(pattern, string, flags=0)
: 尝试让整个字符串完全匹配模式(相当于模式自带^
和$
)。re.split(pattern, string, maxsplit=0, flags=0)
: 使用正则表达式pattern
作为分隔符来分割string
。re.findall(pattern, string, flags=0)
: 查找string
中所有与pattern
不重叠的匹配项,并以列表形式返回。如果模式中有捕获组,则返回包含各组内容的元组列表。
编写正则表达式的最佳实践
- 逐步构建与测试: 不要试图一次性写出完美的复杂模式。从简单的部分开始,逐步添加功能,并在每一步都进行测试,验证其行为是否符合预期。
- 明确性与可读性: 虽然正则表达式力求简洁,但过度复杂的模式会变得难以理解和维护。适当使用非捕获组
(?:...)
来组织逻辑,添加注释解释复杂部分,或者考虑将过于复杂的逻辑拆分到 Python 代码中处理。 - 考虑边界情况: 思考各种可能的有效和无效输入,包括空字符串、包含特殊字符的字符串、不同格式变体等,确保你的模式能够正确处理它们。
- 使用原始字符串: 始终使用
r"..."
定义正则表达式模式,避免反斜杠转义问题。
结论
正则表达式是 Python 中进行高级字符串处理和模式匹配的基石。通过掌握其核心语法和 re
模块的功能,您可以编写出更强大、更灵活、更健壮的代码来应对各种文本处理挑战,无论是数据验证、清理、提取还是转换。虽然初学时语法可能显得有些晦涩,但通过实践和逐步构建的方式,您将能够熟练运用这一强大的工具。
第8讲:面向对象编程
一、 引言
面向对象编程(Object-Oriented Programming, OOP)是现代软件开发中广泛采用的一种核心编程范式。与我们此前主要接触的过程式(procedural)或函数式(functional)编程方法不同,OOP 提供了一套强大的思想和工具,用以组织和管理日益增长的程序复杂性。
本教程旨在系统性地介绍 Python 语言中的面向对象编程概念及其应用。我们将从一个简单的问题出发——如何有效地表示和操作现实世界(或虚拟世界)中的实体信息(例如学生数据),逐步揭示为何需要超越内置数据结构(如元组、列表、字典)并创建自定义数据类型。通过本教程的学习,您将掌握 OOP 的基石——类(Class)与对象(Object)的概念,学会如何定义类、初始化对象状态、赋予对象行为,并利用封装、继承、运算符重载等高级特性来构建结构清晰、可维护性强、易于扩展的 Python 程序。
二、 探索数据组织方式:从简单结构到类的需求
在深入 OOP 之前,我们首先回顾几种组织相关数据的方式,并分析其优劣,从而引出对自定义类型的需求。
2.1 初始尝试:过程式脚本与函数化
设想一个简单场景:获取学生的姓名(name)和所属学院(house)。最初,我们可能编写一个简单的过程式脚本:
# 过程式示例 (简化版)
name = input("请输入姓名: ")
house = input("请输入学院: ")
print(f"{name} 来自 {house}")
为了提高代码的组织性和可复用性,我们自然会将其重构为函数:
# 函数化示例 (简化版)
def get_name():
return input("请输入姓名: ")
def get_house():
return input("请输入学院: ")
def main():
name = get_name()
house = get_house()
print(f"{name} 来自 {house}")
if __name__ == "__main__":
main()
进一步地,我们可以将获取学生相关信息的功能整合到单一函数 get_student
中。然而,此时我们面临一个问题:一个函数通常返回单个值,但我们需要同时返回姓名和学院两个信息。
2.2 使用元组(Tuple)组织数据
Python 允许函数返回多个值,其底层机制是自动将这些值打包成一个元组 (Tuple)。
def get_student_tuple():
name = input("请输入姓名: ")
house = input("请输入学院: ")
return name, house # 隐式创建并返回元组 (name, house)
student_info = get_student_tuple()
# 通过索引访问: student_info[0] 为姓名, student_info[1] 为学院
print(f"{student_info[0]} 来自 {student_info[1]}")
元组特性:
- 有序集合:元素按插入顺序排列。
- 不可变性 (Immutable):一旦创建,元组的内容无法被修改。尝试修改(如
student_info[1] = "新学院"
)将引发TypeError
。 - 访问方式:通过数字索引(从 0 开始)访问。
不可变性使得元组在希望数据集合创建后保持不变的场景下非常有用,有助于防止意外修改,提高代码的健壮性(一种“防御性编程”策略)。然而,其缺点在于通过数字索引访问不够直观,且无法满足需要修改数据的场景。
2.3 使用列表(List)组织数据
若数据需要在创建后被修改(例如,修正录入错误),列表 (List) 是一个合适的选择。
def get_student_list():
name = input("请输入姓名: ")
house = input("请输入学院: ")
return [name, house] # 返回列表
student_info = get_student_list()
# 列表是可变的,可以修改
student_info[1] = "拉文克劳" # 假设需要修正学院
print(f"{student_info[0]} 来自 {student_info[1]}")
列表特性:
- 有序集合。
- 可变性 (Mutable):可以自由添加、删除或修改元素。
列表解决了可变性的需求,但仍保留了通过数字索引访问不够语义化的问题。
2.4 使用字典(Dictionary)组织数据
字典 (Dictionary / dict) 使用键值对 (key-value pairs) 来存储数据,提供了更佳的语义清晰度。
def get_student_dict():
name = input("请输入姓名: ")
house = input("请输入学院: ")
return {"name": name, "house": house} # 返回字典
student_info = get_student_dict()
# 通过键访问,更具可读性
print(f"{student_info['name']} 来自 {student_info['house']}")
# 字典也是可变的
student_info["house"] = "赫奇帕奇"
字典特性:
- 键值对存储:每个元素由一个唯一的键和对应的值组成。
- 无序性 (Python 3.7+ 保持插入顺序):在较新版本的 Python 中,字典会记住元素的插入顺序。
- 可变性 (Mutable)。
- 访问方式:通过键(通常是字符串)访问对应的值。
字典通过键名提高了代码的可读性,避免了记忆索引位置的麻烦。然而,无论是元组、列表还是字典,它们都是通用的数据结构。对于“学生”这样一个具体的概念,如果能有一种专门为其设计的、名为 Student
的数据类型,无疑将使代码更加符合面向对象的思想,也更易于管理和扩展。这正是引入类 (Class) 的动机。
三、 核心概念:类 (Class) 与对象 (Object)
面向对象编程的核心在于类 (Class) 和对象 (Object)。
类 (Class):可以理解为创建对象的蓝图或模板。它定义了一类事物共有的属性 (Attributes)(特征、数据)和方法 (Methods)(行为、功能)。在 Python 中,我们使用
class
关键字来定义一个类。类名通常采用驼峰式命名法(首字母大写),例如Student
。对象 (Object):是类的一个具体实例 (Instance)。根据类的定义创建出来的实体。例如,具体的某个学生 "Harry" 就是
Student
类的一个对象。创建对象的过程称为实例化 (Instantiation)。属性 (Attributes) / 实例变量 (Instance Variables):与特定对象相关联的数据。它们存储了对象的状态。例如,一个
Student
对象可能有name
和house
属性。在 Python 中,通常通过点号 (.
) 来访问和设置对象的属性(如harry.name = "Harry"
)。
# 定义一个简单的 Student 类
class Student:
pass # pass 是一个占位符,表示类体为空
# 创建(实例化)Student 对象
harry = Student()
# 为对象设置属性
harry.name = "Harry"
harry.house = "Gryffindor"
# 访问对象的属性
print(f"{harry.name} 来自 {harry.house}")
ron = Student()
ron.name = "Ron"
ron.house = "Gryffindor"
print(f"{ron.name} 来自 {ron.house}")
四、 类的关键组成部分
一个功能完善的类通常包含以下关键部分:
4.1 初始化方法 (__init__
) 与实例变量
为了确保对象在创建时就具有必要的初始状态,我们使用一个特殊的方法:__init__
(称为 dunder init,dunder 是 double underscore 的缩写)。
__init__
方法:- 它是一个初始化方法(常被类比为其他语言中的构造函数)。当使用
ClassName(...)
创建对象时,Python 会自动调用该类的__init__
方法。 - 其主要作用是初始化新创建对象的状态,即设置实例变量的初始值。
- 语法:
def __init__(self, parameter1, parameter2, ...):
- 它是一个初始化方法(常被类比为其他语言中的构造函数)。当使用
self
参数:__init__
以及所有实例方法的第一个参数约定俗成地命名为self
。self
代表当前正在操作的对象实例本身。当调用一个对象的方法时(如harry.__init__(...)
,虽然通常是隐式调用),Python 会自动将该对象(harry
)传递给self
参数。- 在方法内部,我们使用
self.attribute_name
来访问或设置属于该对象的实例变量。
- 实例变量 (Instance Variables):
- 定义在
__init__
方法内部(或其他实例方法内),通过self
绑定的变量。 - 它们存储了每个对象独特的状态信息。例如,
harry
对象的name
是 "Harry",而ron
对象的name
是 "Ron"。
- 定义在
class Student:
def __init__(self, name, house): # name 和 house 是从外部传入的参数
print("正在初始化学生对象...")
# 使用 self 将传入的 name 和 house 值赋给实例变量 self.name 和 self.house
self.name = name
self.house = house
# 创建对象时,将参数传递给 __init__ (self 由 Python 自动传入)
harry = Student("Harry", "Gryffindor")
ron = Student("Ron", "Gryffindor")
# 现在可以直接访问通过 __init__ 设置的属性
print(f"{harry.name} 来自 {harry.house}") # 输出: Harry 来自 Gryffindor
print(f"{ron.name} 来自 {ron.house}") # 输出: Ron 来自 Gryffindor
4.2 实例方法 (Instance Methods)
实例方法是定义在类内部、用于操作对象实例状态或执行与对象相关的行为的函数。
- 定义:与普通函数定义类似,但必须在类定义内部,且第一个参数必须是
self
。 - 调用:通过对象实例调用,使用点号 (
.
),例如object.method_name(arguments)
。Python 会自动将object
作为self
传入。
4.2.1 特殊方法 __str__
__str__
是一个非常有用的特殊实例方法(dunder method)。
- 作用:定义当对象需要被转换为用户友好的字符串表示时(例如,使用
print(object)
或str(object)
),应该返回什么内容。 - 返回值:必须返回一个字符串。
- 如果类没有定义
__str__
方法,print(object)
会输出一个默认的、通常不太易读的表示形式(如<__main__.Student object at 0x...>
)。
class Student:
def __init__(self, name, house):
self.name = name
self.house = house
# 定义 __str__ 方法
def __str__(self):
return f"{self.name} 来自 {self.house}"
harry = Student("Harry", "Gryffindor")
print(harry) # 会自动调用 harry.__str__(),输出: Harry 来自 Gryffindor
4.2.2 自定义实例方法
除了特殊方法,我们可以定义任意数量的自定义实例方法来封装对象的行为。
class Student:
def __init__(self, name, house, patronus=None): # 添加可选的守护神属性
self.name = name
self.house = house
self.patronus = patronus
def __str__(self):
return f"{self.name} 来自 {self.house}"
# 自定义方法:施放守护神咒语
def cast_patronus_charm(self):
if self.patronus:
print(f"呼神护卫!({self.patronus}) ✨")
# 可以在此根据守护神返回不同的表情符号或描述
else:
print(f"{self.name} 尚未掌握守护神咒。")
harry = Student("Harry", "Gryffindor", "雄鹿")
harry.cast_patronus_charm() # 输出: 呼神护卫!(雄鹿) ✨
draco = Student("Draco", "Slytherin")
draco.cast_patronus_charm() # 输出: Draco 尚未掌握守护神咒。
4.3 封装与数据验证:属性 (Properties)
直接访问实例变量(如 harry.house = "一个无效的学院"
)可能破坏数据的完整性,绕过我们在 __init__
中可能设置的验证逻辑。封装 (Encapsulation) 是 OOP 的核心原则之一,旨在将数据(属性)和操作数据的方法(方法)捆绑在一起,并控制对数据的直接访问。Python 通过属性 (Properties) 机制提供了实现封装和数据验证的优雅方式。
属性看起来像普通的实例变量,但其读取(get)、设置(set)甚至删除(delete)操作实际上是由我们定义的特定方法(getter, setter, deleter)来处理的。
- 问题:我们需要在设置属性值时(无论是在初始化时还是之后修改时)强制执行验证规则。
- 解决方案:使用
@property
装饰器定义 getter 方法,使用@<property_name>.setter
装饰器定义 setter 方法。 - 内部变量约定:为了避免属性方法名与实际存储数据的变量名冲突,通常将内部存储变量命名为以下划线开头(例如
_name
,_house
)。这是一种约定,提示开发者该变量是内部实现细节,不应直接从外部访问(尽管 Python 并不强制阻止)。
class Student:
def __init__(self, name, house):
# 注意:这里调用 self.name 和 self.house 会触发 setter 进行验证
self.name = name
self.house = house
def __str__(self):
return f"{self.name} 来自 {self.house}"
# Getter for 'name'
@property
def name(self):
return self._name # 返回内部变量
# Setter for 'name'
@name.setter
def name(self, name):
if not name: # 验证:名字不能为空
raise ValueError("姓名缺失")
self._name = name # 验证通过,设置内部变量
# Getter for 'house'
@property
def house(self):
return self._house
# Setter for 'house'
@house.setter
def house(self, house):
valid_houses = ["Gryffindor", "Hufflepuff", "Ravenclaw", "Slytherin"]
if house not in valid_houses: # 验证:学院必须有效
raise ValueError("无效的学院")
self._house = house
# --- 使用 ---
try:
harry = Student("Harry", "Gryffindor")
print(harry)
# 尝试修改属性,同样会触发 setter 验证
harry.house = "赫奇帕奇"
print(harry)
# 尝试设置无效值,会引发 ValueError
# student_invalid = Student("无效学生", "未知学院")
# harry.name = ""
except ValueError as e:
print(f"错误: {e}")
通过属性,我们将验证逻辑集中在了 setter 方法中,确保了无论何时设置属性值,数据都必须符合预设规则,增强了类的健壮性。
五、 内置类型亦是类
一个重要的启示是:我们日常使用的 Python 内置数据类型,如 int
, str
, list
, dict
, float
等,其本质都是类。当我们创建一个整数、字符串或列表时,实际上是在实例化对应的类,得到一个对象。
例如:
x = 50
创建了一个int
类的对象。s = "hello"
创建了一个str
类的对象。my_list = [1, 2, 3]
创建了一个list
类的对象。
我们早已在不自觉中使用了这些内置类对象的方法,例如:
s.lower()
调用str
对象的lower
实例方法。my_list.append(4)
调用list
对象的append
实例方法。
可以使用 type()
函数来验证这一点:
print(type(50)) # 输出: <class 'int'>
print(type("hello")) # 输出: <class 'str'>
print(type([])) # 输出: <class 'list'>
print(type({})) # 输出: <class 'dict'>
理解这一点有助于深化对 Python 工作方式的认识:我们一直在与对象及其方法打交道。
六、 高级类特性
除了基础概念,Python 的 OOP 还支持一些高级特性,进一步增强了其表达能力和应用范围。
6.1 类方法 (@classmethod
) 与类变量
有时,某些数据或功能与类本身相关,而不是与类的某个特定实例相关。
- 类变量 (Class Variable):
- 定义在类体中,但在任何方法之外。
- 该类的所有实例共享同一个类变量。修改类变量会影响所有实例(除非实例有同名的实例变量覆盖了它)。
- 类方法 (Class Method):
- 使用
@classmethod
装饰器定义。 - 其第一个参数约定为
cls
,代表类本身(而非self
代表的实例)。 - 可以通过
cls
访问类变量或调用其他类方法。 - 通常通过类名直接调用(如
ClassName.class_method()
),也可以通过实例调用。 - 适用场景:
- 工厂方法 (Factory Methods):用于创建类的实例,其创建逻辑比较复杂或有多种方式。例如,定义一个
Student.get_from_input()
类方法,封装从用户输入获取信息并创建Student
实例的过程。这解决了必须先有实例才能调用方法来创建实例的“鸡生蛋”问题。 - 操作或访问类级别的状态(类变量)。
- 当方法逻辑与类相关,但不需要访问具体实例的状态时。
- 工厂方法 (Factory Methods):用于创建类的实例,其创建逻辑比较复杂或有多种方式。例如,定义一个
- 使用
import random
class Hat:
# 类变量,所有 Hat 实例(如果创建的话)共享
houses = ["Gryffindor", "Hufflepuff", "Ravenclaw", "Slytherin"]
@classmethod
def sort(cls, name): # 第一个参数是 cls (类本身)
# 通过 cls 访问类变量 houses
chosen_house = random.choice(cls.houses)
print(f"{name} 被分到了 {chosen_house}")
# 直接通过类名调用类方法,无需创建 Hat 实例
Hat.sort("Harry")
Hat.sort("Draco")
# --- 应用于 Student 类的工厂方法 ---
class Student:
# ... (之前的 __init__, __str__, properties) ...
@classmethod
def get(cls): # 工厂方法:从用户输入创建 Student 实例
name = input("请输入姓名: ")
house = input("请输入学院: ")
# 调用 cls(...) 相当于调用 Student(...) 来创建实例
# 这会自动触发 __init__ 方法,并通过 setter 进行验证
try:
return cls(name, house)
except ValueError as e:
print(f"创建学生失败: {e}")
return None # 或者重新提示输入
def main():
student = Student.get() # 使用类方法获取学生实例
if student:
print(student)
if __name__ == "__main__":
main()
类方法和类变量为组织与类本身相关的逻辑和数据提供了有效的途径。
6.2 继承 (Inheritance)
继承是 OOP 的另一核心支柱,它允许一个类(称为子类或派生类)继承另一个类(称为父类、基类或超类)的属性和方法。
- 目的:
- 代码复用:子类可以重用父类中已定义的功能,避免重复编写。
- 建立层次关系 (Is-A Relationship):表达类之间的归属关系,例如,
Student
is aWizard
。
- 语法:
class ChildClass(ParentClass): ...
- 子类的行为:
- 子类自动拥有父类所有非私有的属性和方法。
- 子类可以添加自己特有的属性和方法。
- 子类可以重写 (Override) 父类的方法,提供特定于子类的实现。
super()
函数:- 在子类中,可以使用
super()
来调用父类的方法,这在子类的__init__
中尤为常见,用于调用父类的__init__
来完成共享属性的初始化。
- 在子类中,可以使用
# 父类
class Wizard:
def __init__(self, name):
if not name:
raise ValueError("姓名缺失")
self.name = name
def introduce(self):
print(f"我是一名巫师,名叫 {self.name}")
# 子类 Student 继承自 Wizard
class Student(Wizard): # 继承 Wizard
def __init__(self, name, house):
# 调用父类的 __init__ 来初始化 name
super().__init__(name)
# 添加 Student 特有的属性
if house not in ["Gryffindor", "Hufflepuff", "Ravenclaw", "Slytherin"]:
raise ValueError("无效的学院")
self.house = house
# 重写父类的 introduce 方法
def introduce(self):
print(f"我是一名来自 {self.house} 学院的学生,名叫 {self.name}")
# 子类 Professor 继承自 Wizard
class Professor(Wizard):
def __init__(self, name, subject):
super().__init__(name)
self.subject = subject
# Professor 特有的方法
def teach(self):
print(f"{self.name} 教授正在教 {self.subject}")
# --- 使用 ---
try:
harry = Student("Harry", "Gryffindor")
snape = Professor("Severus", "魔药学")
harry.introduce() # 调用 Student 重写的 introduce 方法
snape.introduce() # 调用 Wizard 继承来的 introduce 方法
snape.teach() # 调用 Professor 特有的 teach 方法
except ValueError as e:
print(f"错误: {e}")
# Python 内置的 Exception 体系是继承的经典例子
# ValueError 继承自 Exception,Exception 继承自 BaseException
继承是构建可扩展、结构化类体系的重要机制。
6.3 运算符重载 (Operator Overloading)
Python 允许我们重新定义标准运算符(如 +
, -
, *
, /
, <
, >
, ==
等)在自定义类对象上的行为。
- 目的:让自定义类的对象能够像内置类型一样自然地使用运算符,提高代码的可读性和表达力。
- 实现:通过在类中定义特定的特殊方法(dunder methods)来实现。例如:
__add__(self, other)
对应+
运算符 (self + other
)__sub__(self, other)
对应-
运算符 (self - other
)__mul__(self, other)
对应*
运算符__lt__(self, other)
对应<
运算符__eq__(self, other)
对应==
运算符- (
__str__
也是一种特殊方法,重载了对象到字符串的转换行为)
self
和other
:在二元运算符(如+
)的特殊方法中,self
通常代表运算符左侧的对象,other
代表右侧的对象。
# 示例:古灵阁金库 Vault 类,支持加法运算合并金库
class Vault:
def __init__(self, galleons=0, sickles=0, knuts=0):
self.galleons = galleons
self.sickles = sickles
self.knuts = knuts
def __str__(self):
return f"{self.galleons} 加隆, {self.sickles} 西可, {self.knuts} 纳特"
# 重载加法运算符 (+)
def __add__(self, other):
# 检查 other 是否也是 Vault 类型,以确保操作有意义
if not isinstance(other, Vault):
return NotImplemented # 表示此操作不支持
# 计算合并后的货币数量
total_galleons = self.galleons + other.galleons
total_sickles = self.sickles + other.sickles
total_knuts = self.knuts + other.knuts
# 返回一个新的 Vault 对象,包含合并后的总额
return Vault(total_galleons, total_sickles, total_knuts)
# --- 使用 ---
potter_vault = Vault(100, 50, 25)
weasley_vault = Vault(25, 50, 100)
print("波特金库:", potter_vault)
print("韦斯莱金库:", weasley_vault)
# 使用重载的 + 运算符合并金库
total_vault = potter_vault + weasley_vault
print("合并后金库:", total_vault) # 输出: 合并后金库: 125 加隆, 100 西可, 125 纳特
# 我们早已体验过运算符重载:
# 1 + 2 # 整数加法
# "hello" + " " + "world" # 字符串拼接
# [1, 2] + [3, 4] # 列表合并
运算符重载使得对自定义对象的操作更加直观和符合习惯。
七、 总结
面向对象编程(OOP)为 Python 开发者提供了一套强大的工具和思想,用以构建结构清晰、可维护、可扩展的应用程序。通过本教程的学习,我们掌握了:
- 类与对象是 OOP 的基础,类是蓝图,对象是实例。
- 使用
__init__
初始化对象状态(实例变量)。 - 通过实例方法(包括特殊方法如
__str__
)为对象添加行为。 - 利用属性 (Properties) 实现封装和数据验证,保护对象状态。
- Python 的内置类型本身就是类,我们一直在使用 OOP。
- 类方法和类变量处理与类本身相关的逻辑和数据。
- 继承实现了代码复用和类层次结构的构建。
- 运算符重载让自定义对象的操作更加自然。
虽然元组、列表、字典等数据结构在特定场景下依然重要,但面向对象编程通过类提供了一种更为强大的方式来建模现实世界或抽象概念中的实体及其关系。随着程序规模的增长,熟练运用 OOP 原则将极大地提升代码质量和开发效率。
第9讲:补充内容(Et Cetera)
引言
在我们掌握了 Python 编程的基础构建块——如函数、变量、控制流、数据结构以及面向对象的基本概念之后,是时候拓宽视野,探索 Python 语言中那些虽非核心主干、却能显著提升代码质量、效率与表达力的诸多补充特性了。本篇旨在引领读者深入这些“补充内容”(Et Cetera),它们如同工具箱中的精密仪器,能帮助我们应对更复杂的编程挑战,编写出更优雅、更健壮的 Python 代码。我们将涉及数据处理的新方式、作用域管理的细节、代码规范的实践、命令行交互的简化、函数式编程的元素以及内存优化的策略等。学习这些内容不仅能丰富我们的技能集,更能深化对 Python 设计哲学的理解,并激发持续学习与探索的动力。
一、 集合 (set
):数据的唯一性保证
在处理数据时,我们常常遇到需要去除重复元素并仅保留唯一项的场景。Python 内置的 set
类型为此提供了简洁高效的解决方案。集合是一种无序的数据结构,其核心特性在于它自动保证元素的唯一性,这与数学中集合的概念一致。
场景:统计独特的学院
假设我们有一个包含学生信息的列表,每个学生信息是一个字典,记录了姓名和所属学院。我们的目标是获取所有学生所属学院的唯一列表。
# 学生数据示例
students = [
{"name": "Hermione", "house": "Gryffindor"},
{"name": "Harry", "house": "Gryffindor"},
{"name": "Ron", "house": "Gryffindor"},
{"name": "Draco", "house": "Slytherin"},
{"name": "Padma", "house": "Ravenclaw"}
]
传统列表方法:手动去重
一种直接的思路是使用列表来收集学院名称,并在添加前检查该学院是否已存在。
# 使用列表收集学院,并手动检查重复
houses_list = []
for student in students:
if student["house"] not in houses_list:
houses_list.append(student["house"])
# 为了便于查看,排序后打印
print(sorted(houses_list))
# 输出: ['Gryffindor', 'Ravenclaw', 'Slytherin']
这种方法虽然可行,但需要显式地编写去重逻辑 (if student["house"] not in houses_list
)。当数据量增大时,in
操作符在列表上的查找效率相对较低。
集合方法:自动去重
利用 set
可以极大地简化这个过程。我们只需将所有学院名称添加到集合中,集合的特性会自动处理重复项。
# 使用集合收集学院,利用其自动去重的特性
houses_set = set()
for student in students:
houses_set.add(student["house"]) # 使用 add() 方法添加元素
# 集合是无序的,如果需要有序输出,可以转换为列表后排序
print(sorted(list(houses_set)))
# 输出: ['Gryffindor', 'Ravenclaw', 'Slytherin']
代码变得更加简洁,并且 set
的添加和成员检查操作通常具有更好的平均时间复杂度。创建空集合使用 set()
,添加元素使用 .add()
方法。检查元素是否存在于集合中,仍然使用 in
关键字,例如 if "Gryffindor" in houses_set:
。
二、 全局作用域与 global
关键字:管理跨函数状态
Python 的变量拥有作用域(Scope),决定了变量的可访问范围。通常,在函数内部定义的变量是局部变量(Local),仅在该函数内有效。而在所有函数外部(通常在模块顶层)定义的变量则具有全局作用域(Global,严格来说是模块作用域),理论上可在模块内任何地方被访问。
读取与修改的差异
函数内部可以直接读取全局变量的值,但修改全局变量则需要特别注意。
balance = 0 # 全局变量
def deposit(n):
# 尝试直接修改全局变量 - 这会导致错误!
# balance += n # UnboundLocalError: local variable 'balance' referenced before assignment
print("尝试存款(错误的方式)")
def check_balance():
# 读取全局变量是允许的
print(f"当前余额(读取): {balance}")
check_balance()
# deposit(100) # 如果取消注释,会引发 UnboundLocalError
尝试在 deposit
函数中执行 balance += n
会引发 UnboundLocalError
。这是因为 Python 在函数内部遇到赋值操作(包括 +=
这样的增强赋值)时,默认会将其视为创建一个新的局部变量 balance
,而此时这个局部的 balance
尚未被赋予初始值就被用于右侧的运算,故而出错。
解决方案一:global
关键字
要明确告知 Python 我们意图在函数内部修改的是全局作用域中的 balance
变量,需要使用 global
关键字进行声明。
balance = 0 # 全局变量
def deposit_correct(n):
global balance # 声明 balance 是全局变量
balance += n
print(f"成功存款 {n},新余额: {balance}")
deposit_correct(100)
check_balance()
# 输出:
# 成功存款 100,新余额: 100
# 当前余额(读取): 100
通过 global balance
,deposit_correct
函数内的 balance
就指向了全局变量,可以成功修改。
解决方案二(更佳):面向对象封装
虽然 global
关键字解决了修改问题,但过度使用全局变量会使代码耦合度增高,状态难以追踪和管理,通常被认为是不良的编程实践。对于需要共享状态和相关操作的场景,更好的方式是使用面向对象编程(OOP)进行封装。
class Account:
def __init__(self):
"""初始化账户,余额为0"""
self._balance = 0 # 使用下划线前缀约定表示内部变量
@property
def balance(self):
"""提供一个只读的余额属性"""
return self._balance
def deposit(self, amount):
"""存款"""
if amount > 0:
self._balance += amount
print(f"成功存款 {amount},新余额: {self._balance}")
else:
print("存款金额必须为正")
def withdraw(self, amount):
"""取款"""
if amount > 0 and amount <= self._balance:
self._balance -= amount
print(f"成功取款 {amount},新余额: {self._balance}")
elif amount <= 0:
print("取款金额必须为正")
else:
print("余额不足")
# 使用 Account 类
my_account = Account()
print(f"初始余额: {my_account.balance}")
my_account.deposit(100)
my_account.withdraw(30)
print(f"最终余额: {my_account.balance}")
# 输出:
# 初始余额: 0
# 成功存款 100,新余额: 100
# 成功取款 30,新余额: 70
# 最终余额: 70
在这个 OOP 版本中,余额 _balance
作为实例变量被封装在 Account
对象内部,通过对象的方法(deposit
, withdraw
)来安全地修改。这种方式代码结构更清晰,数据和操作紧密关联,易于维护和扩展,避免了对全局状态的直接依赖。因此,推荐优先考虑 OOP 或函数参数传递的方式来管理状态,谨慎使用 global
。
三、 常量:以约定表达不变性
在编程中,常量是指那些一旦被赋值后,其值就不应再改变的标识符。使用常量可以提高代码的可读性和可维护性,例如用 MAX_CONNECTIONS
代替硬编码的数字 10
。
Python 语言本身并不提供强制性的常量机制(即无法阻止对标记为常量的变量进行重新赋值)。但 Python 社区遵循一个广泛接受的约定:使用全大写字母和下划线组成的变量名来表示常量。
# 模块级常量约定
PI = 3.14159
MAX_BUFFER_SIZE = 1024
def calculate_area(radius):
# 使用常量 PI
return PI * radius * radius
# 类级常量约定
class Circle:
DEFAULT_COLOR = "red"
def __init__(self, radius, color=None):
self.radius = radius
# 使用类常量作为默认值
self.color = color if color is not None else Circle.DEFAULT_COLOR
def get_area(self):
# 可以在方法内部访问类常量
# print(Circle.PI) # 假设 Circle 类也定义了 PI
return calculate_area(self.radius) # 调用使用模块级常量的函数
print(f"半径为 5 的圆面积: {calculate_area(5)}")
c = Circle(10)
print(f"默认颜色的圆: 半径 {c.radius}, 颜色 {c.color}")
# 输出:
# 半径为 5 的圆面积: 78.53975
# 默认颜色的圆: 半径 10, 颜色 red
虽然 Python 解释器不会阻止你写 PI = 3.14
这样的代码,但遵循全大写命名约定是对其他开发者(以及未来的自己)的一个清晰信号:这个值被设计为不应改变。这有助于减少因意外修改配置值或关键参数而导致的错误。
四、 类型提示与 mypy
:静态分析的助力
Python 是一种动态类型语言,变量的类型在运行时确定。这带来了灵活性,但也可能隐藏类型相关的错误,这些错误直到运行时才暴露出来。为了弥补这一点,Python 引入了类型提示(Type Hints)机制。
类型提示允许开发者在代码中为变量、函数参数和返回值添加标注,明确期望的数据类型。
# 变量类型提示
count: int = 0
name: str = "Alice"
is_active: bool = True
scores: list[int] = [90, 85, 92] # 提示列表元素类型
user_data: dict[str, str] = {"id": "123", "email": "alice@example.com"} # 提示字典键值类型
# 函数参数与返回值类型提示
def greet(user_name: str) -> str:
"""生成问候语"""
return f"Hello, {user_name}!"
def process_data(data: list[int]) -> None: # None 表示函数不返回任何有意义的值
"""处理整数列表(例如打印)"""
print("Processing scores:")
for score in data:
print(score)
# 使用带类型提示的代码
count += 1
message = greet(name)
process_data(scores)
print(message)
# 输出:
# Processing scores:
# 90
# 85
# 92
# Hello, Alice!
重要提示: Python 解释器本身通常不在运行时强制执行类型提示。name = 123
这样的代码即使 name
被提示为 str
,默认情况下也不会在运行时报错。类型提示的主要目的是:
- 提高代码可读性与可维护性:清晰地表明代码的预期输入和输出。
- 辅助开发工具:IDE 可以利用类型提示提供更好的自动补全和错误检查。
- 支持静态类型检查:这是类型提示最强大的用途之一。
mypy
:静态类型检查器
mypy
是一个流行的第三方工具,它可以读取 Python 代码中的类型提示,并在运行代码之前进行静态分析,检查是否存在类型不匹配等潜在错误。
假设我们有以下代码 (type_error_example.py
):
def add_numbers(a: int, b: int) -> int:
return a + b
result = add_numbers(5, "3") # 错误:第二个参数是 str,期望是 int
print(result)
直接运行 python type_error_example.py
会在运行时抛出 TypeError
。但如果我们先使用 mypy
:
pip install mypy # 如果尚未安装
mypy type_error_example.py
mypy
会输出类似以下的错误信息:
type_error_example.py:4: error: Argument 2 to "add_numbers" has incompatible type "str"; expected "int"
Found 1 error in 1 file (checked 1 source file)
mypy
在代码运行前就发现了类型错误,帮助开发者及早修复问题,提高了代码的健壮性。在大型项目或团队协作中,使用类型提示和 mypy
等工具是一种推荐的最佳实践。
五、 文档字符串 (Docstrings
):代码的内置说明书
良好的文档是软件开发的关键部分。Python 提供了一种内置的标准方式来为模块、类、函数和方法编写文档,称为文档字符串(Docstrings)。
文档字符串是出现在定义(def
, class
等)之后、作为其内部第一个语句的多行字符串(通常使用三引号 """
或 '''
)。
def calculate_average(numbers: list[float]) -> float:
"""计算一个数字列表的平均值。
如果列表为空,则行为未定义(可能引发 ZeroDivisionError)。
Args:
numbers (list[float]): 需要计算平均值的数字列表。
Returns:
float: 列表中数字的平均值。
Raises:
TypeError: 如果输入不是列表或列表包含非数字项。
ZeroDivisionError: 如果输入列表为空。
"""
if not isinstance(numbers, list):
raise TypeError("输入必须是一个列表")
if not numbers:
# 可以选择返回 0、NaN 或引发异常,这里选择后者
raise ZeroDivisionError("不能计算空列表的平均值")
total = sum(numbers)
return total / len(numbers)
# 如何查看文档字符串?
# 1. 使用 help() 函数
help(calculate_average)
# 2. 访问 __doc__ 属性
print(calculate_average.__doc__)
运行 help(calculate_average)
或打印 calculate_average.__doc__
都会显示我们编写的文档字符串。
文档字符串的约定
虽然内容可以自由编写,但遵循一定的格式约定(如 Google 风格、NumPy 风格或 reStructuredText 风格)能让文档更规范、易读,并能被自动化工具(如 Sphinx)解析以生成项目文档网站。上面示例中使用了类似 reStructuredText 的风格:
- 第一行是简要总结。
- 空一行后是更详细的描述。
- 使用
Args:
(或:param:
) 描述参数,包括名称、类型和说明。 - 使用
Returns:
(或:return:
,:rtype:
) 描述返回值及其类型。 - 使用
Raises:
(或:raises:
) 描述可能引发的异常及其条件。
编写清晰、准确的文档字符串是提高代码可维护性和协作效率的重要习惯。
六、 argparse
:优雅地处理命令行参数
许多 Python 程序需要从命令行接收参数来配置其行为。直接解析 sys.argv
列表虽然可行,但很快就会变得繁琐且容易出错,尤其当需要支持选项(如 -f
)、带值的参数(如 --output file.txt
)、类型转换、默认值以及生成帮助信息时。
Python 的标准库 argparse
模块正是为了解决这个问题而设计的,它提供了一种强大而灵活的方式来定义和解析命令行参数。
示例:一个简单的加法命令行工具
import argparse
def main():
# 1. 创建 ArgumentParser 对象
parser = argparse.ArgumentParser(description="一个简单的命令行加法器")
# 2. 添加需要解析的参数
# 位置参数 (必需)
parser.add_argument("num1", type=float, help="第一个加数")
parser.add_argument("num2", type=float, help="第二个加数")
# 可选参数/标志 (带默认值)
parser.add_argument("-v", "--verbose", action="store_true", # action="store_true" 表示该参数出现时值为 True
help="增加输出的详细程度")
# 3. 解析命令行传入的参数
args = parser.parse_args() # argparse 会自动处理 sys.argv
# 4. 使用解析后的参数
result = args.num1 + args.num2
if args.verbose:
print(f"正在计算 {args.num1} + {args.num2} ...")
print(f"结果是: {result}")
if __name__ == "__main__":
main()
使用示例:
保存为 adder.py
后,在命令行中运行:
# 查看帮助信息 (自动生成)
python adder.py -h
# usage: adder.py [-h] [-v] num1 num2
#
# 一个简单的命令行加法器
#
# positional arguments:
# num1 第一个加数
# num2 第二个加数
#
# options:
# -h, --help show this help message and exit
# -v, --verbose
# 增加输出的详细程度
# 基本用法
python adder.py 10 25.5
# 结果是: 35.5
# 使用详细模式
python adder.py -v 3 7
# 正在计算 3.0 + 7.0 ...
# 结果是: 10.0
# 缺少参数或类型错误 (自动处理)
python adder.py 10
# usage: adder.py [-h] [-v] num1 num2
# adder.py: error: the following arguments are required: num2
python adder.py 10 ten
# usage: adder.py [-h] [-v] num1 num2
# adder.py: error: argument num2: invalid float value: 'ten'
argparse
自动处理了:
- 参数的定义(位置参数、可选参数/标志)。
- 参数的帮助信息的生成(
-h
或--help
)。 - 参数值的类型转换(
type=float
)。 - 参数的存在性检查和错误处理。
- 布尔标志的解析(
action="store_true"
)。
使用 argparse
可以让你的命令行工具更加用户友好和健壮。
七、 解包 (*
, **
):灵活传递参数
解包(Unpacking)是 Python 中一种强大的语法特性,允许我们将可迭代对象(如列表、元组)或映射对象(如字典)的元素“解开”,作为独立的项使用,特别是在函数调用时非常有用。
1. 序列解包 (*
) 用于位置参数
当你有一个列表或元组,想将其中的每个元素作为独立的位置参数传递给函数时,可以使用 *
操作符。
def describe_pet(animal_type, pet_name, age):
print(f"我有一只 {animal_type}。")
print(f"它的名字叫 {pet_name},今年 {age} 岁了。")
pet_info_list = ["狗", "旺财", 3]
pet_info_tuple = ("猫", "咪咪", 2)
# 不使用解包,需要手动索引
# describe_pet(pet_info_list[0], pet_info_list[1], pet_info_list[2])
# 使用 * 解包列表
describe_pet(*pet_info_list)
# 输出:
# 我有一只 狗。
# 它的名字叫 旺财,今年 3 岁了。
# 使用 * 解包元组
describe_pet(*pet_info_tuple)
# 输出:
# 我有一只 猫。
# 它的名字叫 咪咪,今年 2 岁了。
*pet_info_list
相当于 describe_pet("狗", "旺财", 3)
。这避免了繁琐的索引操作,使代码更简洁。
**2. 字典解包 (**
) 用于关键字参数**
当你有一个字典,想将其中的键值对作为关键字参数(key=value
的形式)传递给函数时,可以使用 **
操作符。字典的键必须与函数的参数名相匹配。
def build_profile(first, last, **user_info):
"""创建一个用户资料字典"""
profile = {'first_name': first, 'last_name': last}
for key, value in user_info.items():
profile[key] = value
return profile
basic_info = {"first": "Albert", "last": "Einstein"}
additional_info = {"location": "Princeton", "field": "Physics"}
# 不使用解包,需要手动传入关键字参数
# profile1 = build_profile(first=basic_info["first"], last=basic_info["last"],
# location=additional_info["location"], field=additional_info["field"])
# 使用 ** 解包字典
# 注意:build_profile 接受 first 和 last 作为位置/关键字参数,其余用 **user_info 收集
# 我们需要将 basic_info 和 additional_info 合并或分开传递
# 方法一:分开传递 (假设函数签名匹配)
def describe_person(first, last, location, field):
print(f"{first} {last} 在 {location} 研究 {field}")
person_data = {"first": "Marie", "last": "Curie", "location": "Paris", "field": "Chemistry"}
describe_person(**person_data)
# 输出: Marie Curie 在 Paris 研究 Chemistry
# 方法二:结合使用 * 和 ** (不适用于此例,仅作演示)
# def func(a, b, *args, **kwargs): ...
# items = [1, 2, 3, 4]
# config = {'x': 10, 'y': 20}
# func(0, *items, **config) # 传递 a=0, b=1, args=(2,3,4), kwargs={'x':10, 'y':20}
# 对于 build_profile 示例:
profile2 = build_profile(**basic_info, **additional_info) # 使用 ** 多次解包合并
print(profile2)
# 输出: {'first_name': 'Albert', 'last_name': 'Einstein', 'location': 'Princeton', 'field': 'Physics'}
**person_data
相当于 describe_person(first="Marie", last="Curie", location="Paris", field="Chemistry")
。字典解包对于需要动态构建函数调用的场景特别有用。
八、 可变参数 (*args
, **kwargs
):定义灵活的函数签名
有时,我们希望定义的函数能够接受任意数量的参数,而不是固定数量。Python 提供了 *args
和 **kwargs
语法来实现这一点。
1. *args
:收集任意数量的位置参数
在函数定义中,使用 *
后跟一个参数名(按惯例是 args
,但可以是任何合法名称),可以将所有调用时传入的多余的、未被命名的位置参数收集到一个元组(tuple)中。
def make_pizza(size, *toppings):
"""概述要制作的比萨"""
print(f"\n制作一个 {size} 寸的比萨,配料如下:")
if toppings: # toppings 是一个元组
for topping in toppings:
print(f"- {topping}")
else:
print("- 芝士")
make_pizza(12, "蘑菇")
make_pizza(16, "意大利辣肠", "青椒", "洋葱")
make_pizza(9) # toppings 会是空元组 ()
# 输出:
# 制作一个 12 寸的比萨,配料如下:
# - 蘑菇
#
# 制作一个 16 寸的比萨,配料如下:
# - 意大利辣肠
# - 青椒
# - 洋葱
#
# 制作一个 9 寸的比萨,配料如下:
# - 芝士
*toppings
捕获了在 size
参数之后传入的所有位置参数。
**2. **kwargs
:收集任意数量的关键字参数**
在函数定义中,使用 **
后跟一个参数名(按惯例是 kwargs
,即 keyword arguments),可以将所有调用时传入的、未被函数签名中显式定义的关键字参数收集到一个字典(dict)中。
# 继续使用之前的 build_profile 函数
def build_profile(first, last, **user_info):
"""创建一个用户资料字典"""
profile = {'first_name': first, 'last_name': last}
# user_info 是一个字典,包含所有额外的关键字参数
for key, value in user_info.items():
profile[key] = value
return profile
user_profile = build_profile('albert', 'einstein',
location='princeton',
field='physics',
born=1879)
print(user_profile)
# 输出: {'first_name': 'albert', 'last_name': 'einstein', 'location': 'princeton', 'field': 'physics', 'born': 1879}
location='princeton'
, field='physics'
, born=1879
这些关键字参数被收集到了 user_info
字典中。
3. 结合使用
你可以同时使用 *args
和 **kwargs
来创建能接受几乎任何形式参数的函数。它们必须按特定顺序出现在函数签名中:先是普通参数,然后是 *args
,最后是 **kwargs
。
def highly_flexible_function(required_arg, *args, **kwargs):
print(f"必需参数: {required_arg}")
print(f"其他位置参数 (*args): {args}")
print(f"其他关键字参数 (**kwargs): {kwargs}")
highly_flexible_function("必需值", 1, 2, 3, key1="value1", key2="value2")
# 输出:
# 必需参数: 必需值
# 其他位置参数 (*args): (1, 2, 3)
# 其他关键字参数 (**kwargs): {'key1': 'value1', 'key2': 'value2'}
内置的 print
函数就是一个广泛使用可变参数的例子,它可以接受任意数量的对象进行打印,以及 sep
, end
等关键字参数。
九、 函数式编程元素:map
与 filter
Python 虽然是多范式语言,但也借鉴了一些函数式编程(Functional Programming)的概念,提供了如 map
和 filter
这样的内置函数,允许以声明式的方式处理序列数据。
1. map(function, iterable)
:对每个元素应用函数
map
函数接收一个函数和一个可迭代对象(如列表)作为参数,它会将这个函数应用于可迭代对象的每一个元素,并返回一个迭代器(map object),该迭代器包含了所有应用函数后的结果。通常需要用 list()
或其他方式将其转换为具体的数据结构。
numbers = [1, 2, 3, 4, 5]
# 使用 map 将列表中的每个数字平方
squared_iterator = map(lambda x: x * x, numbers)
squared_list = list(squared_iterator) # 将迭代器转换为列表
print(squared_list)
# 输出: [1, 4, 9, 16, 25]
# 使用 map 将字符串列表转换为大写
words = ["apple", "banana", "cherry"]
uppercase_words = list(map(str.upper, words))
print(uppercase_words)
# 输出: ['APPLE', 'BANANA', 'CHERRY']
这里使用了 lambda
匿名函数和 str.upper
方法作为 map
的第一个参数。map
本身不做计算,只定义了映射关系,计算在迭代时发生。
2. filter(function, iterable)
:筛选符合条件的元素
filter
函数也接收一个函数和一个可迭代对象。它会将这个函数(该函数必须返回布尔值 True
或 False
)应用于可迭代对象的每个元素,并返回一个迭代器(filter object),该迭代器仅包含那些使函数返回 True
的元素。
numbers = [-2, -1, 0, 1, 2, 3, 4, 5]
# 使用 filter 筛选出列表中的正数
positive_numbers_iterator = filter(lambda x: x > 0, numbers)
positive_numbers_list = list(positive_numbers_iterator)
print(positive_numbers_list)
# 输出: [1, 2, 3, 4, 5]
# 使用 filter 筛选出长度大于 5 的单词
words = ["apple", "banana", "cherry", "date", "elderberry"]
long_words = list(filter(lambda word: len(word) > 5, words))
print(long_words)
# 输出: ['banana', 'cherry', 'elderberry']
filter
提供了一种声明式的筛选方式,代码意图清晰。与 map
类似,filter
返回的是迭代器,需要转换才能看到所有结果。
十、 列表推导式:Pythonic 的列表构建方式
列表推导式(List Comprehensions)提供了一种非常简洁和富有表现力的方式来创建列表。它通常比使用 for
循环加 append
或 map
/filter
更紧凑,被认为是更 "Pythonic" 的风格。
基本语法: [expression for item in iterable]
带条件语法: [expression for item in iterable if condition]
# 示例 1: 创建数字平方列表 (等同于 map 示例)
squares = [x * x for x in range(1, 6)]
print(squares)
# 输出: [1, 4, 9, 16, 25]
# 示例 2: 将字符串列表转为大写 (等同于 map 示例)
words = ["apple", "banana", "cherry"]
uppercase_words_comp = [word.upper() for word in words]
print(uppercase_words_comp)
# 输出: ['APPLE', 'BANANA', 'CHERRY']
# 示例 3: 筛选出正数 (等同于 filter 示例)
numbers = [-2, -1, 0, 1, 2, 3, 4, 5]
positive_numbers_comp = [x for x in numbers if x > 0]
print(positive_numbers_comp)
# 输出: [1, 2, 3, 4, 5]
# 示例 4: 组合 expression 和 condition - 获取偶数的平方
even_squares = [x * x for x in range(1, 11) if x % 2 == 0]
print(even_squares)
# 输出: [4, 16, 36, 64, 100]
# 示例 5: 从字典列表创建名字列表 (带条件)
students = [
{"name": "Hermione", "house": "Gryffindor"},
{"name": "Harry", "house": "Gryffindor"},
{"name": "Draco", "house": "Slytherin"}
]
gryffindor_names = [student["name"] for student in students if student["house"] == "Gryffindor"]
print(gryffindor_names)
# 输出: ['Hermione', 'Harry']
列表推导式将循环、条件判断和元素转换/构造紧密地结合在一行代码中,提高了代码密度和可读性(一旦熟悉了其结构)。
十一、 字典推导式:简洁创建字典
与列表推导式类似,字典推导式(Dictionary Comprehensions)提供了一种简洁的方式来创建字典。
语法: {key_expression: value_expression for item in iterable if condition}
# 示例 1: 创建数字及其平方的字典
number_squares = {x: x * x for x in range(1, 6)}
print(number_squares)
# 输出: {1: 1, 2: 4, 3: 9, 4: 16, 5: 25}
# 示例 2: 从列表中创建 姓名: 学院 的字典
students_list = ["Hermione", "Harry", "Ron"]
# 假设他们都来自 Gryffindor
student_house_dict = {student: "Gryffindor" for student in students_list}
print(student_house_dict)
# 输出: {'Hermione': 'Gryffindor', 'Harry': 'Gryffindor', 'Ron': 'Gryffindor'}
# 示例 3: 翻转字典的键值对 (假设值是唯一的)
original_dict = {'a': 1, 'b': 2, 'c': 3}
flipped_dict = {value: key for key, value in original_dict.items()}
print(flipped_dict)
# 输出: {1: 'a', 2: 'b', 3: 'c'}
# 示例 4: 带条件 - 创建只包含偶数的数字平方字典
even_number_squares = {x: x * x for x in range(1, 11) if x % 2 == 0}
print(even_number_squares)
# 输出: {2: 4, 4: 16, 6: 36, 8: 64, 10: 100}
字典推导式同样提高了代码的紧凑性和表达力。
十二、 enumerate
:迭代时获取索引与值
在遍历一个序列(如列表、元组)时,有时我们不仅需要元素本身,还需要它在序列中的索引(位置)。一种常见但不那么 Pythonic 的方法是使用 range(len(sequence))
结合索引访问。
fruits = ["apple", "banana", "cherry"]
# 方法一:使用 range(len(...))
print("使用 range(len(...)) 获取索引:")
for i in range(len(fruits)):
print(f"索引 {i}: {fruits[i]}")
# 方法二:手动维护计数器 (更不推荐)
# index = 0
# for fruit in fruits:
# print(f"索引 {index}: {fruit}")
# index += 1
Python 提供了内置函数 enumerate
来更优雅地解决这个问题。enumerate(iterable, start=0)
返回一个迭代器,每次迭代产生一个包含 (索引, 值)
的元组。start
参数允许指定索引起始值(默认为 0)。
fruits = ["apple", "banana", "cherry"]
# 使用 enumerate 获取索引和值
print("\n使用 enumerate 获取索引:")
for index, fruit in enumerate(fruits):
print(f"索引 {index}: {fruit}")
# 使用 enumerate 并指定起始索引为 1
print("\n使用 enumerate (start=1):")
for index, fruit in enumerate(fruits, start=1):
print(f"排名 {index}: {fruit}")
# 输出:
# 使用 range(len(...)) 获取索引:
# 索引 0: apple
# 索引 1: banana
# 索引 2: cherry
#
# 使用 enumerate 获取索引:
# 索引 0: apple
# 索引 1: banana
# 索引 2: cherry
#
# 使用 enumerate (start=1):
# 排名 1: apple
# 排名 2: banana
# 排名 3: cherry
使用 enumerate
的代码更简洁、易读,且不易出错(避免了手动管理索引可能引入的错误)。
十三、 生成器与 yield
:高效处理大数据流
当一个函数需要产生大量数据(例如,一个包含数百万项的列表)时,如果一次性将所有数据构建在内存中,可能会消耗巨大的内存资源,甚至导致程序因 MemoryError
而崩溃,或者性能急剧下降。
生成器(Generators) 是 Python 中解决此类问题的关键机制。生成器是一种特殊的函数,它不会一次性计算并返回所有结果,而是返回一个迭代器(Iterator)。这个迭代器可以在每次被请求时产生(yield) 一个值,从而实现按需生成数据。
yield
关键字
在函数内部使用 yield
关键字来返回值,而不是 return
。当函数执行到 yield
语句时,它会:
- 返回
yield
后面的值给调用者。 - 暂停 函数的执行,并保存当前的局部状态(包括变量值、指令指针等)。
- 当迭代器下一次被请求值时(例如在
for
循环的下一次迭代中),函数会从上次暂停的地方恢复执行,直到遇到下一个yield
或函数结束。
示例:数羊催眠(内存优化版)
假设我们需要一个函数来生成 N 行“数羊”的字符串(第 0 行 0 只羊,第 1 行 1 只羊,... 第 N-1 行 N-1 只羊)。
内存密集型方法(使用列表):
def count_sheep_list(n):
"""生成包含 n 行羊字符串的列表 (可能消耗大量内存)"""
flock = []
sheep_char = "🐑"
for i in range(n):
flock.append(sheep_char * i)
return flock
# 如果 n 非常大 (如 1,000,000),这可能会耗尽内存
# sheep_lines = count_sheep_list(1000000)
# for line in sheep_lines:
# print(line)
生成器方法(使用 yield
):
def count_sheep_generator(n):
"""生成 n 行羊字符串的生成器 (内存高效)"""
sheep_char = "🐑"
for i in range(n):
yield sheep_char * i # 每次循环产生一行
# 使用生成器,即使 n 非常大,内存占用也很小
print("\n使用生成器数羊:")
# n_sheep = 10 # 为了演示,使用较小值
n_sheep = 1000000 # 即使是百万,也能正常工作(虽然打印会很慢)
sheep_iterator = count_sheep_generator(n_sheep)
# 我们可以迭代这个生成器
count = 0
for line in sheep_iterator:
# print(line) # 打印大量内容会非常慢,这里注释掉
count += 1
if count >= 5 and n_sheep > 10: # 仅打印前几行用于演示
if count == 5: print("...")
continue
elif n_sheep <= 10:
print(line)
print(f"成功生成了 {count} 行羊(即使 n={n_sheep} 很大)")
# 输出 (如果 n_sheep = 5):
# 使用生成器数羊:
#
# 🐑
# 🐑🐑
# 🐑🐑🐑
# 🐑🐑🐑🐑
# 成功生成了 5 行羊(即使 n=5 很大)
# 输出 (如果 n_sheep = 1000000):
# 使用生成器数羊:
#
# 🐑
# 🐑🐑
# 🐑🐑🐑
# 🐑🐑🐑🐑
# ...
# 成功生成了 1000000 行羊(即使 n=1000000 很大)
count_sheep_generator
函数返回的是一个迭代器。当 for
循环向它请求下一个值时,函数内部的循环才执行一次,yield
一行字符串,然后暂停。这使得我们可以处理理论上无限大的数据序列,而内存占用只与单次生成的值的大小相关。生成器在处理文件、网络流、大型数据集等方面非常有用。
十四、 结语:持续学习与实践
本讲所介绍的 set
、global
的权衡、常量约定、类型提示、argparse
、解包、可变参数、函数式工具、推导式、enumerate
以及生成器等,都是 Python 工具箱中非常有价值的补充。它们或许不像基础语法那样无处不在,但在合适的场景下,能让我们的代码更加简洁、高效、健壮和富有表现力。
编程学习是一个持续精进的过程。掌握基础之后,不断探索和学习语言提供的更高级特性、库和编程范式,是提升能力的关键。更重要的是,将所学知识应用于解决实际问题——无论是个人项目、工作任务还是开源贡献——这才是将知识内化为技能的最佳途径。
希望这些“补充内容”能为你未来的 Python 编程之旅提供更多助力。继续探索,不断实践,享受编程带来的创造乐趣!