首页 Python 学习笔记
文章
取消

Python 学习笔记

这是很早之前学 Python 记的一些东西,经过我重新整理发布在个人网站上。

我本科正式学习的是 C 和 C++ 语言,所以学 Python 的时候有点走马观花,会用就行,不够扎实。本文我将更关注 Python 基础知识中自己不太熟悉或未理解到位的部分,关注编程语言的逻辑,完善自己的知识体系。

我不打算像网上的教程那样举很多生动形象的例子,也不打算拆成一大串系列文章。此笔记是较抽象的高度总结性的文章,旨在把事情言简意赅地说明白,定义清楚,再加上一些对难点的深层次的理解,就够了;不适合当作顺序阅读的从入门到精通的教程,内容组织方式不是按照学习顺序或难易顺序,更适合当作概念手册。

本笔记内容仅限原生的 Python,参考较多的资料是廖雪峰的 Python 教程。其他 Python 的库在另外的笔记中(Numpy、Pandas 学习笔记《动手学深度学习》读书笔记系列);也不再介绍安装、配置、界面等杂事(在 Conda 学习笔记 等笔记中会涉及);具体代码的细节我放在自己整理的速查手册上。


目录


p


一、杂七杂八的事情

运行 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 的内置类型(包括了各种基本数据类型与数据结构),它们是逻辑上基本的类型,还有一些零散的内置类型是为其他功能服务的,虽然也是内置类型,但不方便划归到主要的分类逻辑,在后面穿插讲解。

type

应当强调一件事情: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 包结构:

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-8cp1252 等。如果不加这行注释,则表示默认为 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,而不是BaseExceptionBaseException 除了 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 类型。
本文由作者按照 CC BY 4.0 进行授权,转载请注明

日语学习笔记:《标准日本语》按课单词总结

Conda 学习笔记