这是很早之前学 Python 记的一些东西,经过我重新整理发布在个人网站上。
我本科正式学习的是 C 和 C++ 语言,所以学 Python 的时候有点走马观花,会用就行,不够扎实。本文我将更关注 Python 基础知识中自己不太熟悉或未理解到位的部分,关注编程语言的逻辑,完善自己的知识体系。
我不打算像网上的教程那样举很多生动形象的例子,也不打算拆成一大串系列文章。此笔记是较抽象的高度总结性的文章,旨在把事情言简意赅地说明白,定义清楚,再加上一些对难点的深层次的理解,就够了;不适合当作顺序阅读的从入门到精通的教程,内容组织方式不是按照学习顺序或难易顺序,更适合当作概念手册。
本笔记内容仅限原生的 Python,参考较多的资料是廖雪峰的 Python 教程。其他 Python 的库在另外的笔记中(Numpy、Pandas 学习笔记,《动手学深度学习》读书笔记系列);也不再介绍安装、配置、界面等杂事(在 Conda 学习笔记 等笔记中会涉及);具体代码的细节我放在自己整理的速查手册上。
目录
一、杂七杂八的事情
运行 Python 程序
Python 程序就是一个扩展名为 py 的文本文件,里面存放的是编程人员写的遵守 Python 语法的代码。不同的 IDE 都有一些图形界面可以运行 Python 程序,例如通常有一个“运行”按钮。无论是什么形式,所有运行 Python 程序的方式本质上都是执行了命令行中的如下命令(关于 Linux 命令的知识见我的 Linux 学习笔记):
1
??/??/python ??/??/prog.py
前者指定了 Python 解释器,后者即要运行的 Python 程序。解释器本身就是一个程序,它可以作为命令直接执行,而要运行的 Python 程序相当于这个命令的参数。
一般 Python 安装时会加入系统的环境变量,只需要:
1 python ??/??/prog.py
解释器的类型
解释器除了安装 Python 时自带的(称为 CPython,因为是用 C 语言开发的),还有很多其他功能更高级的,如 IPython、Python、PyPy、IronPython 等。
我们需要了解 IPython,因为是 Jupyter 公司开发的,常用的 Jupyter Notebook 用的就是自家的解释器。可以从提示符上区分:CPython 的提示符是 >>>
,IPython 的提示符是 In [序号]:
。
IPython 特色功能是 Tab 自动补全命令、历史记录等,还额外扩展了一些 Python 语法:
!{Shell命令}
:可以通过 Python 执行 Shell 命令;{变量}?
:查看变量的相关信息;{函数}?
,{函数}??
:查看函数的文档、源代码;_{序号}
,_i{序号}
:查看第 {序号} 次输出结果、语句内容;- 常用的魔法函数(IPython 预定义好的一些命令,以
%
开头)%lsmagic
:打印可用的魔法函数列表;%time
:测试一句代码(跟在它后面)运行时间;%matplotlib inline
:要求 Matplotlib 绘图模式是内嵌(inline)模式,即将绘图直接显示在当前命令行中而不是单独的窗口。
在命令行传参
Python 可以在解释器的执行命令中向程序里传参,在一些场景下非常方便。允许可以传入无限个参数,以空格隔开:
1
??/??/python ??/??/prog.py arg1 arg2 ...
在程序内这些参数通过 sys.argv
来接收(需要 import sys 模块)。它是一个字符串列表,存放了解释器的执行命令的所有参数。以上为例,sys.argv
的内容为 ['??/??/prog.py','arg1','arg2',...]
。
Python 内置的 argparse 模块提供了更高级的功能,请参考我 Python 命令行解析参数的笔记和 argparse 模块的文档。
变量命名规范
编程语言一般有一些约定俗成的习惯,如变量命名规范,下针对 Python 具体情况总结:
- 模块名/包名/文件名:单词全小写,加下划线;
- 类名:单词首字母大写,不分隔(驼峰命名法);
- 变量名
- 普通变量(包括实例):单词全小写,加下划线;
- 全局变量(当作常量):单词全大写,加下划线;
- 函数名/方法名:单词全小写,加下划线。
除此之外,还有一些其他 Python 规定的命名法,用以表示特殊含义(如私有、保护、特殊变量或方法),见第五、六两章。
二、基本数据类型与数据结构
文档:https://docspython.org/3/library/stdtypes.html
以下是我整理的 Python 的内置类型(包括了各种基本数据类型与数据结构),它们是逻辑上基本的类型,还有一些零散的内置类型是为其他功能服务的,虽然也是内置类型,但不方便划归到主要的分类逻辑,在后面穿插讲解。
应当强调一件事情:Python 中任何数据类型都是类,任何变量都是对象(类的实例)。Python 定义了一个基类 object
,所有内置类型都是它的子类(是在 Python 安装目录中的源码 py 文件中定义的)。连函数这种东西也是一种类,见“函数也是类!”一节。
Python 是动态语言,数据结构非常灵活,这些容器里的元素可以是任何数据类型。例如列表里的元素可以是列表,从而形成列表的嵌套。
C 语言里经常学的隐式转换在 Python 也有,规则麻烦,没有必要了解,老老实实地统一数据类型按规矩来,必要时用强制类型转换。强制类型转换函数就是 类型名()
,它们是 Python 的内置函数。
除此之外,Python 还有个空类型,用 None
表示。
在上面的列表中,类型间的层级关系表示相应类的继承关系,也就意味着子类继承了父类的操作,又自己定义了一些操作。明白这种关系很有必要,也方便分类记忆操作。
另外,Python 变量类型分可变(mutable)和不可变(immutable),前者可以修改数据结构里面的值,后者只能看作一个整体。在上图中,红色是不可变类型:数值类型、元组、字符串等,蓝色是可变类型:列表、字典、集合等。这个概念非常重要,是理解 Python 变量机制的关键。
Python 变量赋值机制
在介绍具体数据类型前,先强调 Python 变量的赋值机制,对下文深入理解非常重要。先说结论:Python 所有变量都是一段内存空间的标签,或称引用。
以一个简单的赋值语句为例:A = B
,深究它其实是非常麻烦的事。先看 C 语言:
- 设 B 是常量,第一次对 A 赋值是初始化,系统为 A 分配一块内存空间存放 B;
- A 和这一块存储空间是绑定死的,之后的赋值都是修改这块空间;
- 设 B 是变量,赋值操作也是把 B 的内容取出来,复制到 A 的这块空间。注:C 语言不存在大小不合适放不进来的问题,因为 A、B 的类型都声明过了,如果不同会报错(不考虑强制类型转换)。
再看 Python 语言:
- 设 B 是常量,第一次对 A 赋值是初始化,和 C 语言一样,系统为 A 分配一块内存空间存放 B;
- 但 A 不是绑死在这块空间的,之后遇到新的赋值
A = B'
(B’ 不等于 B) 时,会为 B’ 分配一块新的空间,然后将 A 绑定到这块空间; - 设 B 是变量,赋值操作是将 A 绑定到 B 绑定的那块空间。
Python 内置函数
id()
可以查看变量,与 C 语言 取地址&
运算符类似,以上内容可以用此函数验证。
这种灵活的绑定与换绑就是标签的逻辑。可以把常量想象成客观存在的物体,如桌子、凳子、电脑;变量就是一堆贴纸,赋值操作就是拿着贴纸贴来贴去。
标签机制也解释了为什么 Python 语言是动态的,即一个变量不需要声明其类型,是在被赋值后才确定类型的。因为它就是个标签啊!
因此,Python 中的 del 语句仅仅是删除了变量与数据的绑定,而不是真正释放了内存。Python 是高级语言,垃圾自动回收,不需要显式地销毁对象。
可变类型内部机制
这里要涉及一下可变类型与不可变类型的区别。它们都是变量,如果把变量看作整体的话,上面的“标签”机制都是适用的。但可变类型还可以修改里面的值,例如考虑列表修改元素 A[i] = B
,这时“标签”机制还适用吗?
可变类型就是一撮不可变类型和可变类型的集合,如此递归下去,即可变类型就是一撮不可变类型的集合。它存放里面的每个不可变类型都是以变量的形式,所以就是一个盛放标签的容器。这个机制对区分可变与不可变至关重要,也对理解 Python 函数传参方式至关重要。
以列表和元组的区别举例,A1 = (1,2,3,4,5) 和 A2 = [1,2,3,4,5] 的区别在于,前者是不可分割的一个整体,后者相当于一个盛了 5 个变量 a,b,c,d,e 的桶,它们分别是贴在整数 1,2,3,4,5 上的标签。因此,上面的回答是肯定的,比如 A2[0] = 0 就是把贴在 1 身上的标签 a 撕下来贴到 0 身上。
例:Python 复制可变变量的坑
方便性自然会带来一些麻烦。Python 这种灵活的变量赋值机制也会带来麻烦,例如这里要介绍的在复制可变变量时的坑。这也是为什么要了解这些机制的原因,因为如果还按照 C 语言的理解方式的话,写代码会遇到不了解就永远想不通的 bug!
以列表为例,假设想把变量 B(是个列表)复制一份给变量 A,并要求这两份完全独立。可以想到很多方式:
A = B
:这个在 C 语言都知道是错的(不过是因为 A、B 是数组的第一个元素地址)。 Python 中如果这样的话,会把 A 这个标签贴到 B 贴的地方,之后它俩是联动的,一个动另一个也跟着动,因为指的是一个东西;A = B[:]
或copy()
方法:切片切全部。这个语句原理是:虽然 B 是变量名,但是B[:]
就是具体的一个列表了。但是问题在于,如果列表里面的元素有可变类型的话,复制这个元素就属于第一种A = B
的方式了。copy()
方法实现的原理是一样的;- 写循环按元素复制:这个可以确保没问题,但是非常麻烦!我之前碰到 bug 后就直接这样做了,有点蠢;
A = copy.deepcopy(B)
:终极杀招。调用copy
库中的deepcopy
函数,会递归地(深度优先)复制列表的每一个角落。
上面第 2 条称为浅复制(顾名思义是只真正复制了列表的最外面一层),第 4 条称为深复制。可以看到,在 Python 这些复制上的区别就是列表变量的“标签”机制引入的麻烦。
以下介绍 Python 各类型,分成几组来讲。
数值类型
从逻辑上讲,编程语言基本的数值类型简化成整数、浮点数、复数就够了。Python 和这种简单的逻辑是一致的,也是只有这些基本的数值类型,不需要像 C 语言等底层语言那样还要区分整数的位、符号、浮点数的精度等(例如:long、int32、float64),也不用考虑溢出等问题,按照其逻辑意义放心使用就行,非常省心。
数值类型的操作一般通过运算符定义。常用的运算包括:
- 四则运算:加、减、乘、除、余数、幂……
- 比较运算:大于、小于、不等于……
- 布尔数:与、或、非……
- 整数的按位运算
- 复数:取实部、虚部、共轭……
- 增强赋值:二元运算符 +
=
这些运算都是学编程/数学最基本的东西,不可能忘记。可能有些符号上的不同,现查就可以了,不必花工夫记忆。
序列类型
序列类型包括基本的列表和元组,还有一些数据类型是它们的子类:
- 字符串(string):只能存字符的元组;
- 数字范围(range):只能存等差整数列的元组,一般用于 for 循环;
- 字节串(bytes):只能存 ASCII 字符的元组;
- 可变字节串(bytearray):只能存 ASCII 字符的列表。 这才是这些数据类型的本质,并不是单独的、全新的类型。这样也就理解了诸如为什么字符串的操作和元组类似的问题,因为它就是个元组。
非数值类型(包括序列类型和下面的集合、字典等)通常由 Python 内置函数或该类方法定义,也有些常用操作重载到符号。
可变和不可变类型共有的是一些访问操作,例如切片、取最值、拼接(注意拼接这类操作会返回新的对象而不是原地操作);可变类型特有的是修改操作,例如添加、删除、替换。这种方式方便记忆。
集合、字典
我把集合和字典放在一起说,因为它们都模拟了数学概念。这些数学概念本身就是从现实世界中抽象出来的,所以对应的数据结构在编程中也很实用。
集合可以理解成是操作受限的线性表,而且是无序的。受限的操作就是集合的交、并、补等运算。另外,集合也分可变(set)和不可变(frozenset),是两种不一样的数据类型。
之前一直对字典的概念理解不到位,平时也慎用字典。就是字典这个词语太迷惑人了,它就是数学上的映射(或称函数),即给定只不过定义域是有限的集合。当然字典是无序的,因为映射的概念并没有给定义域赋予序关系。字典的操作完全可以拿数学上对映射的操作来对应,例如 list 方法就是取定义域等等。
迭代器、生成器
这部分是高阶内容,需要花大篇幅讲解。
直观概念
迭代器是一个用于遍历,并能记住遍历位置的类。可以将其看作一个售货机,一开始里面装满了商品,每次投币它就会吐一个商品,吐完了机器就会报错(也可以设计永远吐不完的迭代器)。迭代器每次投币后会吐什么东西,什么时候吐完,都是编程人员自己可以设计的。
可能会想,之前介绍的序列类型不是已经实现这种功能了吗?其实 Python 的序列等类型可以直接构造迭代器,但还有很多其他的方式(见“设计迭代器”小节)。通过其他方式设计的迭代器有很多额外的优点:如不需要事先计算好所有要遍历的元素(例:深度学习把一批批数据放在叫 Dataloader 的迭代器中),有的可以实现无限遍历下去(例:自然数),等等。
应当注意,迭代器是一次性的,实例创建了之后,就开始计投币,无法撤销。因为迭代器就是用来遍历的,别无他用,吐完了的迭代器就废了没用了,没法重置。因为这种一次性的特性,Python 设计者干脆设计了一种工厂,专门生产(即创建实例)这些一次性的迭代器,用完了没关系再生产一个就得了。这个工厂就是所谓的可迭代类。
用法
当一个对象 A 属于可迭代类(严格定义见下一小节)时,调用 iter(A)
返回一个迭代器对象(相当于工厂生产了一个一次性的迭代器)。设它为 I,每调用一次 next(I)
,就相当于投一次币,吐出来的商品就是 next()
函数的返回值。多次调用 next(I)
后,可能会抛出异常 StopIteration
,可能永远不会(迭代器的设计者决定)。
一种简便的方式是:用 for 循环。以下 for 循环代码
1
2
for x in A:
...
等价于
1
2
3
4
5
6
7
I = iter(A)
while True:
try:
x = next(A)
...
except StopIteration:
break
这样就不用多次手动调用 next()
函数,还能自动检查是否碰到了 StopIteration
(即是否吐完),防止程序报错。
上面的等价代码就是 Python 中 for 循环的本质,它只是用 while 循环定义的一种简便写法,并不是一种新的循环语句。in 后面必须是可迭代类型。
Python 内置函数
zip()
可以将多个可迭代类的对象打包在一起,构成一个以元组为元素的可迭代对象。此函数常用于 for 循环中有多个变量同时循环:
1 2 for x, y in zip(A, B): ...A、B 长度不一致时,取最短者。
设计迭代器
一、从头自定义
Python 在类中提供了两个特殊方法 __iter__()
, __next__()
。这两个方法定义了 Python 的内置函数 iter()
,next()
作用在此类实例上的效果。这两个方法有特殊要求:
- 必须只能有一个参数
self
; iter()
只能返回 Iterator 对象。 一般的设计模式是:__iter__()
定义一些实例属性,用于存放基础数据,通常返回 self 自己;__next__()
定义向下迭代的规则。 以数列为例,会在前者存放初值,后者存放递推公式。
Python 根据方法的定义情况自动判断:
__iter__()
,__next__()
都定义了的是 Iterator 类;- 只定义了
__iter__()
的是 Iterable 类,即可迭代类。 任意一个类(不需要显式继承 Iterator 等类),只要把这两个方法定义了(称实现迭代器),它就是可以像上小节的 A 那样用了。 按照这个定义,__iter__()
返回 self 自己没有问题,因为 self 定义好了两个方法,本身就是 Iterator 类。
二、从内置类型构造
Python 本身的多数能当数据“容器”的类型,包括:列表(list)、元组(tuple)、字典(dict)、集合(set)、字符串(str),已经定义好了 __iter__()
,但没有定义 __next__()
,所以是可迭代类。
Python 定义好的 __iter__()
都不是返回自己,而是一个新的类的对象(如 list_iterator 类,这个类定义了 __iter__()
和 __next__()
所以是 Iterator 类)。
所以这些 Python 内置类型直接用 iter()
包裹一下就是个能用的迭代器,其迭代方式就是依次访问容器里的第 1,2,… 个元素。
这里只是讨论内置类型的原理而已。实际使用上根本用不上迭代器这个概念,因为平时对其的遍历就是 for 循环,它让语法中连
iter()
函数都不会出现。
三、生成器
Python 另外提供简捷的语法实现迭代器。以下两种方式是生成器(generator),它是 Iterator 的子类。这个类没有什么特殊的,暂时理解为普通的迭代器即可。语法如下:
- 将函数的
return
改为yield
,函数就变成了生成器。这种生成器函数会 yield 很多次(通常把 yield 语句包在循环中),其迭代方式就是依次访问第 1,2,… yield 出的东西。 - 有一种快速构造列表的语法,形式如 ` [f(i) for i in A] `。如果把外面的中括号换成小括号,就是构造生成器(并不是元组),这种方式有人称为列表生成式。
其他 Python 预定义的数据结构
- 枚举(enum):与 C 语言中的枚举类似(Python 3.4 版本新功能);
- 文件(file):Python 中文件也是类,打开的文件都在会创建一个 file 类的实例。此类定义的主要操作有打开、关闭、读取、写入等等。
Python 标准库
collections
中有其他更高级更花哨的数据结构,例如双向列表、有序字典、计数器等等。有需要可使用。
三、控制流
编程语言一共有三种控制流:顺序、条件、循环。每种语句都有我不太熟悉的用法。
- 空语句:
pass
。某些时候为了保持程序结构的完整性,必须填一个语句,但它什么也不干; - del 语句:除了可以删除变量与数据的绑定,还可以删除容器类型的元素,参见速查手册;
- 条件选择语句:在 C 语言中是 switch 语句,但 Python 只能用
if...elif...else
实现。(新版 Python 3.10 新引入了类似 switch 语句的 match 语句,应慎用); - 循环语句后加
else
:这是个语法糖,在循环正常执行完毕(指没有通过 break 跳出)后会自动跳到 else 语句,适用于检验是否循环被 break (不用 else 的实现方法是加 flag)。
四、函数
我之前只是知道最基本的函数定义,并知道 Python 是动态语言:函数非常灵活,不需要规定参数和返回值的类型。本章系统介绍 Python 的函数,还是有很多知识可以学的。
函数语法如下:
1
2
3
4
5
def function_name(arguments):
'''
documentation
'''
return expression
冷知识:函数开头三个引号的注释能当作函数的文档(通过命令查询后显示出来的),而
#
开头的注释起不到这个作用。
变量的命名空间与作用域
Python 变量的命名空间与作用域和 C 语言是差不多的(暂不考虑下文中函数中嵌套函数的情况):
- 内置变量:Python 自带的东西;
- 全局变量:在函数外定义,对当前文件(模块)生效;
- 局部变量:在函数内定义,只对此函数生效。
Python 中没有定义外部变量的机制。一般实现起来是用一个配置文件(模块)统一定义各种常量,在要用到的文件中 import 该模块。
Python 的命名空间规则(和 C 语言一样)类似文件系统命名规则,全局变量和局部变量可以重名。如果想在函数内使用重名的全局变量,应加条声明语句:global 变量名
或 nonlocal 变量名
。
参数传递方式
C 语言中函数有两种参数传递方式,搞不清楚这两种方式,就容易出现写函数时不小心把实参修改了这种情况:
- 值传递:相当于在函数内定义里一个局部变量(形参),初始化为传进来的(实参)值,形参动实参不跟着动
- 引用传递:形参是实参的引用,形参动实参跟着动 C 语言是通过在函数定义处参数前加上特殊符号来区分的。例如加
*
,&
可实现引用传递,不加就是值传递。数组都是引用传递。
Python 的参数传递规则理解起来很简单:传进来参数后,它做的事情就相当于在函数开头加了句 形参 = 实参
。
那么它是什么类型的传参方式呢?实际上是兼而有之,严格意义我们不能说值传递还是引用传递,我们应该说传不可变对象和传可变对象。
现假设形参动了,考虑实参会不会跟着动。根据 Python 变量的赋值规则(“标签”机制),如果是整体地动形参,则只是换绑了形参的标签,不会影响实参,此时是值传递;如果形参是修改可变类型的内部,则只是把里面变量换绑了,而形参作为可变类型包含的变量没有变,此时是引用传递。
现在看来,Python 的参数传递规则是根据变量可变类型自动决定的。如果想自己决定怎么办?把变量类型改一改即可,比如把整数用中括号包裹后再传进去。
参数列表
Python 函数的参数列表最多可以有 5 部分组成:位置参数、默认参数、可变参数、命名关键字参数、关键字参数。规定:
- 每一部分都是可有可无;
- 参数列表必须按照这个顺序写,否则会报语法错误。 传参有两种写法:一种前面带
参数名=
,另一种不带直接传。
1
def func(arg, default_arg=0, *args, arg2, **kw):
以这个 5 部分都包含的函数为例,这 5 个参数分别是:
- 位置参数:放在最前面,以位置来标识参数;
- 默认参数:带了默认值的参数;
- 可变参数:可以包含多个元素,只能写一个;
- 命名关键字参数:必须以前面带
参数名=
的形式传的参数,如上例必须以 - 关键字参数:同上,但参数名不受限制,只能写一个(效果类似可变参数,可以理解为“可变的必须带关键字的参数”)。
有一点应说明,命名关键字参数和普通的位置参数写法是一样的,如果它们之间的默认参数和可变参数都没有,怎么区分呢?Python 额外规定了语法:中间用一个
*
隔开,它不是参数,只起到分隔符的作用。 { :.prompt-info }
传参时 Python 背后解析参数的算法是贪心的、有优先级的,即先处理前面的,吃剩下的再施舍给后面的:
- 先按照位置识别位置参数,所有位置参数必须一一传入,否则报错;
- 再识别默认参数,识别到了就传给它该有的值,识别不到就设为默认值;
- 再识别可变参数
*args
:- 只考虑剩下的不带
参数名=
的(不可以直接传args=
或*args=
),统一传到 args 的元组里; - 还能识别前面带
*
的实参,此实参必须是元组(列表也行),可以直接传入 args 元组,只识别一次;
- 只考虑剩下的不带
- 再识别两种关键字参数。此时剩下的一定都是带
参数名=
的(如果有不带的会报错):- 先检查前几个和命名关键字参数是否一致,不匹配直接报错;
- 剩下的全部识别为关键字参数
**kw
:- 统一传到 kw 的字典里(键对应参数名,值对应参数值);
- 类似地,也能识别前面带
**
的实参,此实参必须是字典,可以直接传入 kw,只识别一次。
如何表示任意函数?因为没有规定参数数量,就要用到两个可变的东西——可变参数、关键字参数:
1
def func(*args, **kw):
args、kw 是习惯写法,用别的名字也可以,只要前面加上了适当的星号。
返回值
Python 函数可以有多个返回值,以逗号隔开。return 语句和外部调用里的返回值个数和顺序应一致,否则会报错。
Python 函数返回多个值时,有的我们不需要,可以用临时的替代符号代替,例如
_
: ```python def func(…): … return A, B _, B = func(…)
函数也是类!
本节涉及很多函数式编程(Functional Programming)的理念,不打算详细讲其原理,因为 Python 本身是面向对象编程(Object-Oriented Programming),只是有一点函数式编程的特性罢了。
之前提到函数也是一种类(定义在 Python 安装目录下的某个源码 py 文件中),也继承自 object 基类,也有通用的属性与方法如__name__
,当然也有很多它特有的。所以函数名也是变量。是变量就可以赋值、当作参数传入、返回值返回等等。
注意,C 语言也可以把函数当参数或返回值传入传出,但原理不一样。人家是地址。
匿名函数
匿名函数就是换了个皮肤的缩写版,与函数是同一个东西。设输入为 x,输出为 f(x),则写成 lambda x: f(x)
。(f(x) 代表 x 的表达式)
为什么放在这里讲匿名函数,因为匿名函数这种简写形式通常就是为了把函数当参数传递的,就不用在外面额外定义了。也就是下面要讲的:
高阶函数是指输入输出中有函数对象的函数。以下讨论高阶函数的用法。
Python 标准库 functools 有很多处理高阶函数的工具。本文不打算讲解这些工具,只讲基本概念。
闭包
函数外面再套一层函数把它包裹起来,叫闭包。任给一个函数
1
2
3
def func(*args, **kw):
# 一些用到 args, kw 的语句
return r
其闭包为
1
2
3
4
5
def closure_func(*args, **kw):
def func():
# 一些用到 args, kw 的语句
return r
return func
将函数写成闭包形式的作用是通过运行闭包 func = closure_func(*args, **kw)
暂时记住函数和它的参数(都打包在了 func
变量里面),但先不运行(一般出于节省计算资源考虑);在要运行的地方直接调用 r = func()
会更方便,不需要传参数。
装饰器
Python 中有在函数前加 @
开头的语法:
1
2
@dec
def func(*args, **kw):
称 dec 是 func 的装饰器。加了装饰器后的 func,在调用之前会先 func = dec(func)
,再执行调用 func(...):
。即先用装饰器用装饰一下原来的函数(这时的 func 已经不再是原来的 func),再使用 func。
这对装饰器函数 dec 的内容有要求,否则在调用 func 时会报错。至少它的输入输出都必须是函数。至于怎么装饰则是用户定义的了。
例:实现在调用函数 func 前打印“正在调用 func 函数” 的日志
1
2
3
4
5
def log(func):
def wrapper(*args, **kw):
print("正在调用" + func.__name__ + "函数")
return func(*args, **kw)
return wrapper
Python 也有一些内置函数(下文会涉及到),就是专门用来当作装饰器的,直接拿来去修饰函数即可。
五、面向对象编程
Python 是面向对象的语言。之前提过在 Python 中,万物皆对象。因此这一部分应该属于最基本、底层的知识。面向对象的基本机制是封装、继承与多态,以下分别介绍。
封装
形式上,类是一段封装了各种变量和函数的代码块。以下是模板:
1
2
3
4
5
6
7
8
9
10
11
class Class(object):
'''
documentation
'''
A = 1
def __init__(self, B):
self.B = B
def method(self):
C = 2
return self.B
类里面出现的变量分为三种:
- 实例属性:类的实例自己的属性,通过
实例.属性
调用,例如 B。不可以写在方法外面,总是以实例.属性
的形态出现,例如必须写self.B
; - 类属性:整个类自身的属性,通过
类.属性
调用。例如 A。写在方法外面,和函数并列; - 普通的局部变量:写在方法里面的其他普通的变量。例如 C。
类里面的函数叫做方法,分几种:
类中出现的函数本质上不是函数(参见“函数也是类!”小节,在外面定义的函数本质是一个类)。而且在 Python 里是先有了类的概念,才定义了函数。所以是先有了方法,才有了外面的函数。
- 实例方法:普通的 def 定义出的都是实例方法,它至少有一个参数,第一个参数是实例本身(习惯起名为
self
),在调用时省略; - 类方法:用装饰器
@classmethod
修饰,至少有一个参数,第一个参数是类本身(习惯起名为cls
),在调用时省略; - 静态方法:用装饰器
@staticmethod
修饰,没有对参数的限制。
在属性或方法的名称上加下划线也会起作用,有的可以起到语法上的作用,有的只是约定俗成的提示:
__init__()
:构造方法,在类创建实例时自动执行,一般在这里统一定义实例属性;__名字__
:有特殊用途的属性或方法,通常另作他用,去完成一些扩展的功能;有很多是在基类 object 定义的;__名字
:私有(private)属性或方法,外部代码无法直接调用,调用会报错;(注意:__名字__
不算私有属性或方法)_名字
:受保护(protected)属性或方法,提示最好不要调用它,但调用并不会报错。
以下列举了一些特殊属性或方法,所有类都可以使用它们:
- 类的信息:
__name__
:类属性,返回类的名称;__class__
:实例属性,返回实例所属的类;__dict__
:作类属性,返回所有类属性和方法;作实例属性,返回所有实例属性;
- 定制类:规定 Python 内置函数在该类上的行为
__len__()
:规定len()
函数;__str__()
,__repr__()
:规定print()
函数(优先使用后者);__call__()
:使该类的实例可以像函数一样被调用(callable),此“函数”与__call__()
方法一致;__iter__()
,__next__()
:规定iter()
,next()
函数(见“迭代器、生成器”一节);__getitem__()
,__setitem__()
,__delitem__()
:使该类的实例可以像列表那样用中括号下标取、赋值、删除元素;__getattr__()
:规定在调用该类没有的属性和方法时的行为;__slots__()
:规定类允许添加的实例属性;
- 定制运算符:类似于
__add__()
,__and__()
这种用运算符名称的特殊方法,可以使该类的实例使用运算符合法,具体行为在这些特殊方法中定义。支持的运算符可以现查。
继承与多态
Python 继承与多态的规则如下:
- 继承的语法是把继承的父类名写在类名后的括号里,不写则默认继承基类 object;
- 子类会拥有父类的所有属性和方法,可看作把父类定义的所有代码复制了过来;
- 子类不仅是子类,也是父类(多态);
- 子类可以定义新的属性或方法;
- 方法重写:子类里写与父类名称相同的属性或方法,这样会覆盖掉;
- 不允许方法重载,即有相同的名称,但是参数列表不相同;(同理也不允许函数重载)
- 允许多重继承,在类名后的括号里以逗号隔开。多重继承使得继承的树形结构变成前馈网络状结构。
Python 所有的类都继承自一个共同的基类 object
(注意是小写),就像森林的所有的根结点有一个共同的父节点一样。这个基类会定义一些共有的、通用的属性或方法,如上文提到的特殊属性与方法。
从以上介绍的内容看, Python 似乎与其他面向对象的语言(如 C++)没啥区别,都是面向对象的那一套理论的实现。实际它和 C++ 的主要区别在于 Python 是动态语言,实例的属性和方法都是可以临时添加或删除的,而 C++ 等语言则必须在类定义时声明。 { :.prompt-info }
super()
函数
考虑一个问题:每个类一般都要写构造方法 __init__()
来定义一些实例属性,但是子类和父类的构造函数是重名的,那岂不是把父类的重写了?想要继承父类__init__()
中定义的实例属性怎么办?
做法:不需要手动把代码复制过来,只需在子类的调用 super().__init__(...)
即可。(注:...
中是父类方法的参数,不需要带 self)
super()
函数原理很复杂,不需要搞明白,只需知道其效果是:向上找到最近的匹配点后面方法名字和参数的父类,然后调用该方法。
获取对象信息的内置函数与方法
下面列举与类有关的接口,有的是 Python 的内置函数,有的是类自带的方法:
type(A)
:返回变量 A 的类型。本质上由于所有变量都是对象,它返回的实际上是对应的类名;isinstance(A, Class)
:判断对象 A 是不是类 Class 的实例;dir(A)
:获取对象 A 的所有属性与方法;help(A)
:查看对象的帮助文档;callable(A)
:判断 A 是不是可调用对象(参见函数和__call__()
方法)。
除此之外,一些 IDE 也在这些事情提供了一些方便,方便程序员看代码:如 Ctrl 点按函数、类或模块直接打开源代码,以及上文提到的 Jupyter Notebook 的 ?
等扩展命令。
六、Python 模块
模块的组织
在 Python 中,一个 py 文件就称之为一个模块。
最简单的方式就是在一个目录下写很多并列的 py 文件,这样就有了很多模块。但很多时候,模块间有逻辑上的层次关系。由于模块就是文件,所以可用文件系统的层级关系(即文件夹)实现,但必须手动规定,Python 不会把所有的子文件夹视作自己人。
手动规定的方式是在目录中添加 __init_.py
。Python 只认里面有 __init__.py
文件的文件夹。
这和文件系统还不太一样,文件系统是分文件(叶子结点)和文件夹(内部结点)的,文件夹只起到组织结构的作用,没有内容。而 Python 模块的内部结点是有内容的,就是写在 __init__.py
这个文件里,所以也是个模块。
模块的层级关系表示方法也是以点表示:父模块名.子模块名
。到了叶子结点后,再往下的点表示的就是模块里的变量、类或函数了。像这样的一整套多层的模块通常称为包(package)。Python 内置的标准库以及 pip 安装的第三方库就是包的形式。例如下图是 PyTorch 包结构:
因此想要把自己的代码做成工具形式,就最好打包成包。(即使可能比较简单,如一个目录下多个并列的 py 文件,也最好向此目录添加一个 __init__.py
文件)
模块的使用
使用模块主要是通过各种含 import 关键字的语句。
这里会有一个疑问,模块不就是 py 文件吗,直接运行不就得了?例如要运行模块 A,A 依赖模块 B,可不可以不在 A 的代码中 import B,而是先运行 B 再运行 A 呢?
一个原因是在终端里执行完一个 py 文件,程序就真正结束了,内存得到释放(VSCode 运行 Python 代码也是在内置的终端里跑的)。而有些环境配备了更强大的功能,例如:
- Spyder:每跑完一个 py 文件后,它的工作区会记录下此程序的所有变量,相当于跑完程序没有去释放内存。释放内存需要手动去 clear 工作区。
- Jupyter Notebook:直接把一个个代码块(严格来说不是模块,因为一整个 ipynb 格式的笔记本才是一个文件)当作进入 IPython 提示符后执行的一块块表达式,虽然逻辑不太一样,但也有起到那样的效果。
即使可以用更高级的环境解决,也涉及很多方便性的因素:
- 有很多模块时,并不想去跑去逐个运行一遍,只想运行一下主程序;
- 标准库、第三方库都放在隐藏位置,找出来也很麻烦。
但这些都不是本质的。最重要的事情是:模块也是一种封装机制,它把各种函数、类和变量采用类似于 class 的方式封装起来(调用时前加模块名.
以区分),而不是把各种变量混在一起,只当作按照不同顺序执行的代码块。这体现在各种 import 语句的效果上,见下:
import Package
:模块当作一个大“类”被定义出来,调用模块中的类或函数也和类差不多,都是加点:模块名.类或函数名
。注意:- 可以一次 import 多个,以逗号隔开
- 可以为模块取别名,在后面加
as 别名
from Package import ...
:(沿用上文说法)若 Package 是内部结点模块,可 import 子模块;若是叶子结点模块,则可 import 模块里的变量、类或函数。
实际上,
from Package import *
就起到了“直接运行”的效果。但实际上这个语句并不常用,可见平时大家都是在利用模块的封装功能的。
在使用时,Python 的寻找顺序为:先从当前目录下找,再去 Python 的安装目录里找,都找不到的话就报错 ModuleError。
模块文件模版
一个标准的模块的模版如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/usr/bin/env python3
# -*- coding: encoding -*-
' documentation '
__author__ = '...'
import ...
def ...:
class ...:
if __name__ == '__main__':
...
标准注释
最开始几行的格式化的注释是标准注释,并不是单纯的文本,解释器其实是可以识别注释中的某些格式、提取出一些配置信息的。
第一行由井号和叹号组合的叫 Shebang(或 Hashbang),是类 Unix 操作系统的特有的东西。在代码文件中出现这一行,操作系统会将 #!
后的东西作为解释器来执行。(这一行平时是不加的,因为通常解释器都由 IDE 或 Conda 环境指定好了。)
代码的字符编码也用格式化的注释定义,见第二行。encoding
是编码标准,如 utf-8
,cp1252
等。如果不加这行注释,则表示默认为 utf-8。
Python 作为文本文件,它自己本身是定义过编码的(例:Windows 记事本在保存文件时有选择编码的选项),这是文件本身的属性,定义在整个文件的前 2 个字节。上面注释的定义并不是指这个编码,它规定的是 Python 解释器如何读取解析这个代码文件,通常对输入输出中的符号起作用。
我不打算仔细研究这些东西,直接上懒人包:怕遇到乱码就直接 utf-8,把文本文件和第一行注释都设置好,应该就不会出问题了。
文档信息
Python 模块视第一个字符串为模块的文档,类似于函数的文档。注意它不是写在注释里的,而是字符串。
作者信息由特殊变量 __author__
字符串定义。
类与函数定义区
正文开始一般会定义一些类或函数。在模块中的变量、函数、类也可以加下划线代表一些信息,和类是相似的道理:
__名__
:模块特殊的变量、函数、类;__名
:表示模块私有的,模块外无法直接调用,调用会报错;_名字
:表示模块受保护(protected)的,提示最好不要调用它,但调用不会报错。
运行区
定义完类与函数后,就是运行程序的主要代码,我想不出好名字就叫运行区好了。
这部分代码通常由 if __name__ == '__main__':
包裹。__name__
是模块。只需知道加上这行起到的效果:
- 如果直接运行此模块,则执行其中的语句;
- 如果 import 进别的模块,则运行别的模块时不会执行其中的语句。
此语句不充当程序入口的作用。和 C 语言里的
void main() {...}
意义不同!
考虑以下场景:当一个项目有好多 py 文件时,如果此程序是主程序,那加不加这句都无所谓;如果不是主程序,通常里面都是些定义的类与函数,也没有真正执行的代码,那if __name__ == '__main__':
里面的代码是做什么的?答:测试此模块用。只要运行此模块,就会执行测试代码;而真正运行主程序时不会执行它。
七、输入输出
Python 输入输出有很多方式,这里不讨论由第三方库实现的各种数据的输入输出,只讨论最基本的:
- 标准输入输出:从键盘读取,在 console 打印输出;
- 文件输入输出:读取或写入文件。
文件输入输出和 C 语言形式上类似,比较麻烦,我太不常用就不写了。本章只讲标准输入输出。
input()
与 print()
函数
标准输入输出都是通过几个内置函数实现的。
输入主要靠 input()
函数,效果为:程序在此处暂停,等待用户输入字符串,敲下回车后字符串传入其返回值。input 函数接受一个字符串参数作为提示语。
输入主要靠 print()
函数。print 函数非常灵活,可以接任意类型的任意多个参数,输出结果以空格隔开。除字符串外,Python 为所有的类都规定了一套默认的打印格式,在 __print__()
和__repr__()
方法中定义,可以自行修改。
注意,代码中不能像在提示符下那样变量直接敲回车查看信息。所以直接把
变量
摆在代码里的语句是没有效果的,必须写print(变量)
。
之前的习惯没必要:我喜欢把所有要打印的东西用
str()
函数强制转换成字符串,用加号运算符拼接后再打印。直接用逗号隔开即可。
格式化字符串
格式化字符串是指意义是把动的变量统一放后面,不动的字符堆在前面,看起来更美观。字符串的格式化规则都是在 str 类中 format()
方法定义的。用法:在字符串处要填空的位置以{}
表示,在方法的参数中填入数据。大括号里面可以:
- 什么也不填:顺序传入参数;(即
format()
方法的可变参数,也可以传入一整个*元组
) - 自然数:按照数字标示的参数位置传入;
- 变量名:应传入带
变量名=
的参数;(即format()
方法内的关键字参数,也可以传入一整个**字典
) - 数据格式:以
%
开头,不需记忆,因为与 C 语言的表示方法一致。
这样 print()
函数的使用方法是 print('...{}...{\%f}...'.format(A,B))
。还有一种更简洁的写法:print(f'...{A}...{B:f}...')
,字符串前加 f
表示格式化的字符串。
八、遇到 bug?
本章统一讲解一切有关 bug 和遇到 bug 的时候做的事情。
异常
Python 中的报的错称为异常(exception)。异常本质上是一大类特殊的类,都是一个基类 BaseException
的子类。
常见异常
Python 预定义了各种各样的内置异常,都可在文档中查到:https://docspython.org/3/library/exceptions.html 前面有很多地方涉及到了各种异常。这里我只讨论一些经常遇到的,也是为找 bug 提供经验吧。
先占个坑,以后再写。
自定义异常
Python 的内置异常有时候不够用,可以自定义异常。
做法:只需定义一个继承异常类的子类。最简单的是直接继承异常的基类,什么事也不做。
1
2
class MyError(Exception):
pass
自定义异常应当继承
BaseException
下的Exception
,而不是BaseException
,BaseException
除了Exception
里普通的异常外,主要是KeyboardInterrupt
等系统级别的特殊异常。
异常处理:try 语句
Python 有完善的异常处理机制可供编程人员使用,即 try 语句,可以在代码执行时捕获异常,并采取用户规定的操作。由几部分组成:
try:
后面跟要尝试捕获异常的代码;except EXCEPTION_NAME:
后面跟捕获到名为 EXCEPTION_NAME 的异常时要执行的代码,可以有多个 except 从句;(EXCEPTION_NAME 可以多个用逗号连接,不加它则表示所有异常)else:
后面跟没捕获到任何异常后要执行的代码;finally:
后面的语句是在执行完上面所有程序后一定要执行的代码。 如果触发不在 EXCEPTION_NAME 里的异常或者没有异常,程序不会中断;而触发不在 EXCEPTION_NAME 里的异常,仍然是会报这个异常的错。这两种情况都会触发最后的 finally 从句。
实际上 try 语句定义了四种从句,是比较混乱的,即有的情况可以有很多种表达方式,会有些语法冗余。但是编程语言不是追求逻辑的完备性,而是为了使用方便。所以不必纠结定义混乱的问题。
抛出异常:assert, raise 语句
有时候在代码里需要手动触发异常,自己自定义异常也是为了抛出的。抛出异常有专门的语句,平时 Python 报错抛出的异常在 Python 的源码就是用了这些语句:
raise EXCEPTION_NAME
:抛出名为 EXCEPTION_NAME 的异常;assert EXPRESSION
:EXPRESSION 是一个逻辑表达式,如果为 True 则通过,否则抛出AssertionError
异常。它等价于1 2
if not EXPRESSION: raise AssertionError
assert 语句的用处:
- 预防未知的错误,在写代码时我们脑海中预判了一些情况下可能出现的问题(例如 0除以0),虽然这些情况可能不会发生,但也可以加条
assert 这些情况
在前面以防万一; - 有些机器不满足程序运行的条件,在最前面加条 assert 可以让程序直接报错,而不必等待程序运行后崩溃;
- 调试:见下节。
上下文管理器:with 语句
with 语句一般就是用于异常处理的,放到这里来讲。
with 语句的控制流如下。以下代码的执行顺序为:
1
2
with Class(...) as var:
...
- 创建一个 Class 类的实例;
- 调用该实例的
__enter__()
方法,返回值赋值给as 后面的变量 var; - 执行主体部分的语句;
- 调用该实例的
__exit__()
方法。
可见本质上就是在一段代码前后加上两段代码。但并不是三段代码简单地顺序拼接,它们之间和一个类的实例息息相关。__enter__()
和__exit__()
方法需要自己定义(称实现上下文管理器),也必须遵循一些规则:例如 __exit__()
必须规定三个有关异常的参数 exc_type, exc_val, exc_tb。
可见它适用于对资源进行访问的场合,例如文件操作就是包裹在 with 语句中进行的,file 类就是这里的 Class 类,打开关闭文件都由 file 类定义的上下文管理器控制。自己写的代码根本用不到这东西,只需知道哪些地方最好用 with 包裹,遵守现成的规范即可。
调试
在 Python 中调试代码有以下几种方式:
- print 语句:在合适的位置 print 变量的值;
- assert 语句:用 assert 后接的逻辑表达式来验证自己的想法和程序实际运行是否一致,不一致则报
AssertionError
; - logging 库:专门用于日志的库,其实就是更高级的 print。它可以输出不同样式的信息(DEBUG, INFO, WARNING, ERROR, CRITICAL),也像 assert 一样有开关控制输出哪些信息;不仅可以输出到 console,还能输出到日志文件中;
- pdb 调试器:在代码中
import pdb
,用pdb.set_trace()
语句打断点。 在运行代码时命令加选项-m pdb
,代码运行进入调试模式,提示符变成(Pdb)->
,可在其中输入 pdb 命令控制调试流程:n
:单步执行;l
:是查看当前运行到的代码位置;p 变量
:查看变量;- …
- IDE 里的调试工具:基本都是 pdb 的图形化实现,它不需要在代码中做任何标记,通过按按钮的方式完成打断点、执行,还能即时查看各种变量。
这些调试方法没有优劣之分,各自都有优缺点。平时使用时应视具体情况选用合适的调试方式:
- print 语句可以输出具体信息,assert 则不行。有时候我们需要具体信息,而有时候会看起来太乱;
- assert 语句可以全局开关。在运行代码时加参数
-O
可以关闭所有 assert(相当于把所有 assert 语句删掉)。用 print 语句如果不想调试了必须删除或注释掉; - logging 库更适合耗时较长的大型项目中管理日志,对于小代码实在没有必要;
- 在 IDE 里调试的问题是每次都需要手动打断点,不能长久保存断点信息,也无法输出成文本。更适合临时地查看程序执行的逻辑。 Python 官方教程里也介绍了什么时候用什么工具最好:https://docs.python.org/zh-cn/3/howto/logging.html#logging-basic-tutorial
测试
前面提到在模块的 if __name__ == '__main__':
可以用来做测试。Python 中也有专门的 unittest
模块用来做单元测试,我用不到,就不作介绍了。
附录:其他 Python 标准库与内置函数
Python 标准库与内置函数在上面各章节都有所涉及。其余的暂时列举在这里,只概述其大致用途,使用时现查,不作过多介绍。
官方文档:https://docspython.org/3/library/
- sys 模块:可以查看当前系统或解释器级别的信息,例如上面见到了
sys.argv
; - os 模块:让 Python 能类似于 Shell 命令那样处理文件和目录;
- time, date, datetime 模块:处理时间信息,有专门的时间类表示,也可以整数表示(1970.01.01 后度过的秒数);
time.sleep(secs)
:可以让程序暂停 secs 秒,比较常用;
- math 模块:提供常用数学函数,处理数学计算。cmath 模块用以处理复数;
- re 模块:使用正则表达式处理文本匹配等问题;
- argparse 模块:为命令行向解释器传参提供了更高级的功能(见笔记)。
- json/pickle 模块:都用于保存、加载 Python 变量,前者存的是文本文件(json 格式),后者是二进制文件。文本文件更通用,更易读,但支持类型少,例如不支持 Python 类实例的保存;二进制文件不可读,但支持所有 Python 类型。