万字总结 Python 构建指南与设计模式概览

2,942 阅读1小时+

本文的目的是快速了解 Python 数据结构和语法糖,包括如何使用 Python 表达那些我们熟悉的设计思想和设计模式,然后,基于成熟的环境管理工具和优秀的第三方库快速开发 Python 工程。大致可分为四部分内容:

  1. Python 环境配置 ( Anaconda ) 与基础语法。
  2. Python 工程化内容 ( 见 Python 基础:工程化 )。
  3. 如何在 Python 引入 OOP,FP 范式的设计,以及元编程。
  4. 简要介绍 Numpy 和 Pandas 两个基础的数值分析库。

环境与配置

所谓:工欲善其事,必先利其器,我们的 Python 工程需要各种软件包的加持。与其事后手动管理依赖包和运行环境,不妨事先就将这些麻烦的问题交给更高效的工具处理,好让我们专注于工程开发。因此,在介绍 Python 之前,有必要先了解 conda 工具。

conda 自身是一个开源的软件包管理系统和环境管理系统。在这里,软件包既指代 Python 生态中流通的依赖包,也包含了那些由其它语言 ( 比如 C/C++) 开发的,可直接运行的二进制程序 ( 不需要用户再手动编译 ),如 mklcuda。这些二进制程序或许不会直接体现在用户的 Python 项目中,但是项目本身所依赖的包在底层可能会需要对这些二进制程序进行本地调用。

目前被开发者熟知的是 Anaconda,Ana- 是英文 "分析" 的前缀,它相当于 conda + Python + 180 个科学计算包的集成。见:Anaconda | The World's Most Popular Data Science Platform

Anaconda 的安装包约为 500 Mb。如果只想用 conda 的核心功能,则安装轻量版本的 Miniconda 即可。见:Miniconda — Conda documentation

安装过程中,建议不选择将 Anaconda 目录加入到 $PATH 环境变量中,以免与本机已经单独安装的 Python 路径产生冲突。这样做的结果是,直接使用终端和 conda 交互会出现找不到命令的提示。

不用对此过度担心。Anaconda 会单独提供 Anaconda Prompt 工具,它在启动时会设置必要的环境变量,从而允许用户和 conda 工具进行交互。

安装路径不要带空格,也最好不要带上中文。

在下文中,*title 样式的标题是重点部分,title* 样式的标题是可选部分。

conda 环境

该节内容来源于:Anaconda入门:安装及包与环境的管理 CSDN博客

完整的 Anaconda 安装教程还可以参考:Anaconda介绍、安装及使用教程 - 知乎 (zhihu.com)

从思想上,conda 和 docker 这类容器管理工具很像。conda 创建各种环境的目的就在于隔离各个 Python 项目的运行环境,使得它们之间互不干扰。

在安装完毕之后,首先通过 conda --version 确认 conda 版本号信息。可以通过 update 对 conda 自身进行更新。

conda update conda

通过 env list 检查当前 conda 下的所有环境及其物理路径,conda 会将当前所在 ( 官方称之 "激活" ) 的环境标识为 * 号。在还没有激活任何环境的情况下,默认指向 base 环境。

conda env list

使用 list 可以打印出当前环境下的软件包清单。没有激活任何环境的情况下,默认打印 base 环境的软件包。

conda list

正是因为 conda 自带 Python 软件包的关系,我们无需再去 Python 官网单独安装。

通过 activite 命令切换到指定环境,之后就可以使用该环境下的各种依赖和软件包了。比如说,切换到 base 环境之后,可以通过输入 python 命令直接和当前环境下的 Python 解释器交互。

conda activite base

使用 deactivate 命令离开当前的 base 环境。

conda source

创建环境与管理依赖

Anaconda 将各种科学计算包放到了 base 环境下。尽管原则上,所有 Python 项目都可以只运行在这一个 base 环境,但是多个项目可能会依赖同一个软件包的不同版本,从而导致依赖冲突。因此,在实际开发中,我们总会为每一个 Python 项目单独创建一个 conda 环境

conda create <--name|-n> <env_name> [pkg1[=v1]] [pkg2[=v2]]

可以在环境名后面罗列出所需要的一个或多个软件包,以空格区分开,每个软件包都可以显式地注明版本号。比如,我们为新项目创建一个名为 py3env 的环境,并安装 Python 以及 pandas 包,同时,将 Python 的版本显式指定为 3.8。

conda create -n py3env python=3.8 pandas

如果不显式指定 Python 的版本,那么 conda 默认选择和 Anaconda 同步的 Python 发行版本。

如果引入的包还依赖其它更基础的包,conda 会将它们也一同安装到环境下。对于一些 Anaconda 本身不提供的软件包,conda 需要联网到镜像源中下载。由于默认的镜像源在国外,因此可能会存在下载速度慢甚至下载失败的现象,这是任何库管理工具 ( 无论 conda,yum,docker,sdkman! 还是 maven ) 都普遍存在的一个问题。

对于国内的开发者而言,使用清华大学提供的镜像源是一个不错的选择。

conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/free/

可以通过 config 确认 conda 当前的镜像源配置:

conda config --show-sources

可以通过 search 令 conda 根据当前的镜像源配置寻找可用的软件包。默认情况下 conda 会根据给定的名称进行模糊匹配,也可以通过 --full-name 选项进行精确匹配。

conda search <pkg_name>
conda search --full-name <full_pkg_name>

可以通过 clone 的方式令 conda 根据已有的环境复制出一个新环境:

conda create -n <new_env_name> --clone <other_env_name>

可以通过 install 安装第三方软件包。默认情况下安装到当前环境,也可以通过 --name 选项指定安装环境。

conda install <pkg[=ver1]> [pkg[=ver2]] ...
conda install --name <target_env> <pkg[=ver1]> [pkg[=ver2]] ...

当某些软件包不再被需要时,可以简单地上述的命令改成 remove 进行卸载。当某个环境已经不再需要时,可以通过 conda env remove 删除。conda 不能删除当前正激活的环境,需要先 conda deactivate 退出。

conda env remove -n <env_name>

在 Windows 系统下,在安装 Anaconda 时还会附带名为 Anaconda Navigator 的软件。它提供了一个图形化 conda 操作界面,允许用户以简便的方式实现上述的各种操作;这降低了用户的上手成本。

导出 / 导入 conda 环境

Python 项目对软件包的版本 非常敏感。当我们最终决定将某个开源的 Python 项目上传到 Github 时,应当明确地声明项目的各种依赖包以及版本号信息,否则其他人将很难顺利地运行我们的代码

首先,conda 本身可以通过将环境整体导入导出的方式解决问题。现在假设已经进入到了 py3env 环境下,我们可以通过以下命令将当前环境的各种依赖信息以文本形式导出,注意拓展名必须是 *.txt*.yaml*.yml 的其中一个。

conda env export > imports.yml

再假设在另一个机器安装好了 conda。在另一个机器创建新环境时可以通过 -f 选项将依赖文件所记载的环境名,依赖包及版本号,镜像源全部导入进来。

conda env create -f imports.yml

第二种方式是通过 pip 工具导入 / 导出项目依赖 ( 比如有些机器不用 conda 管理环境 )。pip 是一个专门下载并管理 Python 依赖库的工具,conda 总是会将它内置到各个环境下。每个环境下的 pip 只管理当前环境的依赖。使用 pip 导出的依赖文件约定上以 requirements.txt 来命名。

pip list --format=freeze > requirements.txt

网上大部分推荐的是 pip freeze 命令。在此不直接这么做的原因,见:pip freeze 导出含有路径 (@ file:///) 问题小记-CSDN博客

同样地,这份文件可以被其它环境下的 pip 工具导入进去:

pip install -r requirements.txt

仅从下载依赖这一功能上看,conda 和 pip 有一点重合,但是使用 conda 进行依赖管理要比 pip 更加方便。只是对于部分 Python 依赖,conda 可能无法安装,这时候可以再去尝试用 pip 进行安装。

开发环境

PyCharm: the Python IDE for Professional Developers by JetBrains

环境的问题解决之后,下一步就是挑选一个趁手的 IDE 开发项目了,这里选择 Jet Brains 公司的 PyCharm。在使用 PyCharm 创建新的工程时,选择 New Conda environment,然后将本机安装好的 conda 设置为解释器 ( interpreter ) 。

我们不需要事先手动通过 conda 创建环境,PyCharm 会借助本地的 conda.exe 替我们搞定。默认新创建的环境名称和项目名保持一致。

pycharm_init.png

随着开发的进行,可能需要进一步引入更多的依赖。我们可以在 PyCharm 的 Settings 设置中直接进行对 conda 环境进行包管理工作:

pycharm_env.png

在此之后,我们可以专注于项目工程,在大部分情况下无需再手动通过终端与 conda 交互了。

Python 基础

本章使用的 Python 版本是 3.8。

Python 对代码的书写格式制定了各种规范,它们被收录在了 Python Enhancement Proposals ( PEP ) 中。不过,随着学习的进行,你自然会适应并遵守这些书写格式,因此这里不再赘述。在 PyCharm 当中,你可以使用 Ctrl + Alt + L 快速规范代码书写。

基础数据类型

数值型

这里仅需简单地将数值分为三种类型:整型 int,浮点数 float,布尔值 bool,复数 complex。其中,浮点数不区分单精度和双精度。Python 是一个动态类型语言,所有的变量都是动态确定类型的。可以使用 type() 函数确定一个变量当前的类型。如:

# <class 'float'>
x = 1.00
# python 只内置 print 进行控制台输出,默认情况下自带回车。
print(type(x))

# <class 'int'>
x = 1
print(type(x))

# bool: True, False
# <class 'bool'>
x = True
print(type(x))

# <class 'complex'>
x = 3 + 2j
print(type(x))

在这个例子中,打印了四次变量 x 的数据类型,且每一次 x 的类型都不同。可以通过 :type 的方式主动声明变量的数据类型,但事实上并不会影响脚本的执行。

x: int = 10
x = "hello"
print(x, end="\n")

Python 会自动处理数值计算的精度转换。比如说:

print(1/2)

程序输出的结果将是 0.5 ,而非 0。然而,Python 提供了 int()float()str()complex() 等类型转换函数,可以实现强制类型转换的效果。下面的输出将是 0

print(int(1/2))

字符串

Python 的字符串类型为 str。无论是使用 '' 或者 "" 包括的文本都可以被认为是字符串。如:

h = "hello"  # str
w = 'world'  # str

print(h, w)

可以使用三引号的形式表示一段文本块 ( 仍然属于 str 类型 ),它的另一个用法是做脚本内的长文注释。如:

"""
2022/6/17
    author: Me
    This is the first python script written by myself.
    you can use text block as the code description.
"""
print("hello world")

Python 的 str 有两个实用的运算符重载。其中,+ 操作符可以简单地将两个字符串拼接起来,而 * 操作符可以令字符串自身重复拼接。

x = "hello"

print(x + "world")  # helloworld
print(x * 2)  # hellohello

注,字符串在 Python 中可被视作由单个字符组成的字符列表 list。后文在列表中介绍的所有操作同样适用于字符串。

Python 有另外一种嵌入拼接的字符串模板写法,如:

age = 18
name = "me"
info = f"""
    studentInfo:{age}
    name: {name}
"""

字符串前面的 f 代表 format。Python 会将 agename 两个变量的值嵌入到 info 字符串内部。

复合数据类型

列表 list 与区间 range

列表 list 是最常用的线性数据结构,使用 [] 声明。Python 不要求一个列表下的所有元素都保持同一类型。比如:

xs = [1, "2", 3, 4.00, 5]

# len() 是 Python 的内置函数,可以打印列表的长度。
print(len(xs))

可以通过列表嵌套的形式生成高维列表。不过,我们更倾向于使用 numpy 库去生成高维数组 ( 或称矩阵 ),后者在数值运算中的性能更高。

xxs = [[1, 2, 3], [3, 4, 5]]
print(xxs)

在 Python 中,可以使用 0 起始的非负下标 n 表示列表中从左到右数的第 n + 1 个位置,以 -1 起始的负数下标 -m 表示列表中从右到左的第 m 个位置。比如:

xs = [1, "2", 3, 4.00, 5]
p1 = xs[-2]  # 4.00
p2 = xs[2]   # 3

在 Python 中,这种 x[0] 下标访问的底层指向 __getitem__() 方法,它本质上是操作符重载的一种。

列表内的元素引用是可更改的。比如:

xs = [1, 2, 3]
xs[2] = 4

print(xs)  # [1, 2, 4]

列表可以像字符串那样使用 + 操作符拼接,或者是使用 * 操作符重复。

xs = [1, 2, 3, 4] * 2
ys = [1, 2, 3, 4] + [5, 6, 7, 8]

print(xs)  # [1, 2, 3, 4, 1, 2, 3, 4]
print(ys)  # [1, 2, 3, 4, 5, 6, 7, 8]

利用这个特性可以快速生成一个元素初值为 i,长度为 n 的列表。如:

i = 0
n = 10
xs = [i] * n
print(*xs)

遍历列表是最常见的程序逻辑。在 Python 中可表示为:

for x in xs:
    print(x)

如果 xs 是一个对象列表,则在每次迭代中,Python 会以 引用拷贝 的形式将列表元素提取给临时变量 x。换句话说,如果在循环体内修改了 x 的引用,那么后续对它的状态修改将不会传递到原列表,因为引用共享关系被破坏掉了。比如:

# 这个对象有一个值 v
class Foo:
    def __init__(self, v_):
        self.v = v_

xs = [Foo(1)]

for x in xs:
    # 破坏引用共享
    x = Foo(2)
    x.v = 3

# 1, not 2 or 3
print(xs[0].v)

在不破坏共享引用的情况下,对 x 的内部状态的修改会传递到原列表。比如:

# 这个对象有一个值 v
class Foo:
    def __init__(self, v_):
        self.v = v_

xs = [Foo(1)]

for x in xs:
    x.v = 2

# 2.
print(xs[0].v)

在后文介绍的切片中也会有类似的现象。与之相对的是,数值类型 ( 包括 str ) 都是 不可变 的。此时对 x 做何修改都不会传递到原列表。

xs = [1, 2, 3, 4, 5]

# 试图通过这种方式将 xs 内的数值 x 全部映射成 2x
for x in xs:
    x = x * 2

# 仍然打印 [1, 2, 3, 4, 5]
print(*xs)

如果要以简明的形式实现 list → list 的映射,可以参考后文的推导式来完成,而不是绞尽脑汁思考如何复现 for(i=0;i<n;i++) 这样的语法。

如果要生成像 [0, 1, 2,..., n] 这样的等差序列,可以直接使用 range() 函数生成一个区间,支持自行设置步长。如:

# 生成的是左闭右开区间。[0, 1, ... 9]
xs = range(0, 10)
# 若起始元素为 0,则可以简写。
xs = range(10)

# [10, 7, 4, 1]
xs = range(10, 0, -3)

综上,对一个列表的逆序遍历还可以写成:

# start: len(xs)-1 -> 由于 0 下标的存在,数组的最后一个下标是其长度 -1. 
# stop: -1         -> 遍历到 -1 下标之前,即 0 号下标。
# step: -1         -> 每次迭代下标 -1.
for x in range(len(xs) - 1, -1, -1):
    print(xs[x])

Python 内置了一个返回 逆序迭代器 的函数:recersed()

sx = reversed("hello")  # 字符串也是列表的一种
s = "".join([x for x in sx])  # 见后文的生成式

# 切片形式的最简化版本:
sx = "hello"[::-1]

区间 range 和列表 list 是两个不同的类型,可以通过 type 函数检查出区别。range 可以被视作一种 抽象的不可变列表,因此它也可以被迭代,但是 range 类型不提供下标索引方式的访问。如:

xs = range(10)
xs[1] = -1  # don't do this

如果想利用区间生成列表,可以使用 list() 函数进行转换。

切片

切片是基于列表 ( 或区间 ) 截取出的子序列 ( 或子区间 ),并不是一个独立的数据类型。比如,下面的代码表示从 xs[2,4) 下标位置截取出切片:

xs = [1, 2, 3, 4, 5]
ss = xs[2:4]  # [3,4]

切片同样可以 [start:stop:step]的顺序指定步长。其中 start <= stop

rs = range(1,101)

# [51, 53, ... ,99]
ss = rs[50:100:2]

# *ss 表示将子区间切片 ss 的每一个元素作为独立的参数传入,否则只会打印: range(51, 101, 2)
# 见后文的可变参数部分。
print(*ss)

startstopstep 均是可以缺省的,缺省值依次为 0len(rs)1。切片还可以分为两个方向:

  1. 如果 step > 0,则表示从左到右的顺序切片,默认值 start = 0stop = len(rs)
  2. 如果 step < 0 ,则表示从右到左的顺序切片,默认值 start = -1stop = -len(rs)-1

因此,切片有非常灵活的声明方式,以下写法均成立:

rs = range(1, 10)  # [1, 2,..., 9]

print(*rs[:2])  # [1, 2]
print(*rs[4:])  # [5, 6..., 9] == xs[4::]
print(*rs[::])  # [1, 2,..., 9] == xs
print(*rs[::2])  # [1, 3, 5, 7, 9] != xs[:2]
print(*rs[4::2])  # [5, 7, 9]
print(*rs[4::])  # [5, 6,..., 9] == xs[4:]

其中,可以特别记忆切片 rs[::-1] 的写法,它相当于 rs 的逆序排列,对于字符串同样适用。

Python 是通过 引用拷贝 截取对象元素的。换句话说,对切片内元素状态的更改会发生传递。

class VV:
    def __init__(self, v_):
        self.v = v_

x = [VV(1)]
y = x[:]
y[0].v = 2

# 2 2
print(x[0].v, y[0].v)

想要避免这种耦合性,可以使用新的实例引用进行赋值,从而破坏掉引用共享。

class VV:
    def __init__(self, v_):
        self.v = v_

x = [VV(1)]
y = x[:]
y[0] = VV(2)

# 1 2
print(x[0].v, y[0].v)

对于数值型的列表则不会有这样的问题,因为这里不涉及引用拷贝。

a = [1]
b = a[:]
b[0] = 2

# [1] [2]
print(a, b)

元组 tuple

元组可被视作一个轻量的 引用不可变 数据容器,标准的写法是使用 () 声明。比如:

t = (1, 2, 3)

# 可以用下标索引的方式访问元素,但不可修改。
e = t[1]
print(e)

基于元组可以引申出相当多的特性。比如,可以利用元组进行多重赋值,或者 理解成是元组的提取式。对于丢弃不用的元素,可以使用 _ 符号简单地忽略掉。

(x, y, _) = (1, 2, 3)
print(x, y) # x = 1, y = 2 

Python 函数也可以返回元组,或者理解成是像 Go 语言的函数一样返回了多个值。如:

def swap(x, y): return (y, x)

(x,y) = swap(1,2)
print(x, y) # x = 2, y = 1

Python 的元组可以省略 (),多个元素间仅以 , 相隔。上面的代码还可以简写成:

def swap(x, y): return y, x
a, b = swap(1, 2)

print(a, b)

特殊地,如果要把单个元素视作元组,则在元素后加上 ,,比如如 a,

*集合 set

关于集合和字典部分,我们事实上是在讨论更深刻的话题:Python 对象的相等性。

集合 set 类型和列表的重要区别是:集合内的元素不会发生重复,使用 {} 声明。首先,可以直接放入集合内的元素有数值,字符串,元组。比如:

sets = {1, 1, 2}

# len(sets) == 2, 说明重复的 1 被筛除掉了。
print(len(sets))  

我们再来讨论保存对象的集合是什么样的。首先是一段代码示例:

class Foo:
    def __init__(self, v_):
        self.v = v_


ref = Foo(1)
sets = {ref, ref}

#  len(sets) == 1
print(len(sets))

Python 内部以 计算哈希值 的方式判断元素是否重复。在默认情况下,Python 会使用对象的引用计算哈希值。显然,相同的引用必然会发生哈希碰撞。如果能理解这一点,下面的运行结果就很好解释了:两个 Foo(1) 是不同的引用,因此它们可以在同一个集合下共存。

sets = {Foo(1), Foo(1)}

# len(sets) == 2
print(len(sets))

然而我们更希望能构建一个值不重复的对象集合。一个有效的方案是根据实例的所有状态 ( 或称属性 ) 计算哈希值。显然易见的是:如果两个对象的状态全都相等,那它们的哈希值也必然相等,从而进一步推导出两者重复。

为此,在类定义中需要同时重写 __eq__()__hash__() 两个方法。

class Foo:
    def __init__(self, v_):
        self.v = v_

    # 官方推荐的做法是将实例的属性全部混入到一个元组中,使用元组计算哈希值。
    def __hash__(self): return hash(self.v,)

    def __eq__(self, other): return self.v == other.v
    
st = {Foo(1),Foo(2),Foo(1)}
print(len(st))  # 集合的实际元素只有 2 个。

后文简称这样的类是可计算哈希的 hashable type。Python 规定仅重写 __eq__() 但未重写 __hash__() 的类是 unhashable type,它们无法作为元素放入集合,也不能作为后文字典的 key。值得一提的是,__eq__() 函数本身还是 == 操作符的重载。

Python 内置了大量 __XX__ 命名的内置方法或函数,它们又被称之为 "魔法" 函数。Python 依赖这些函数生成语法糖或者执行内部机制。

Python 提供各种操作符进行集合基本的交并补计算,如:

A = {1, 2, 3, 4, 5}
B = {3, 4, 5, 6, 7}

print(A - B)  # {1, 2}
print(B - A)  # {6, 7}
print(A ^ B)  # {1, 2, 6, 7}, 对称差
print(A | B)  # {1, 2, 3, 4, 5, 6, 7} 并集
print(A & B)  # {3, 4, 5} 交集

*字典 dictionary

字典是一个特殊的集合,它的内部存放以 key : value 表示的键值对,同样使用 {} 声明,记作 dict 类型。同一个字典内部,key 不会发生重复。可以充当 key 的有 数值,字符串,元组,以及 hashable type,理由同集合。

dictionary = {"a": "abandon", "b": "banana", "c": "clap"}

Python 提供两种方式来从 dict 字典中获取 value 值:

  1. 以下标索引的方式访问。不过,字典中在查询不到指定 key 时会抛出异常。
  2. 调用 dictget 方法。当搜索 key 失败时以第二个参数提供的默认值进行代替,是更加安全的访问方式。
dictionary = {"a": "abandon", "b": "banana", "c": "clap"}

maybeKey = dictionary["d"]  # error
"""
	这里有一个特殊的细节。不要这么做:
	dictionary.get("d", default="Nil")
	这里的 get 方法是 C 语言层面实现的 (为了更高的性能),
	而它不兼容 default=xxx 这样的传参方式。
"""
getNilIfNull = dictionary.get("d","Nil")

可以使用 in 关键字查询字典内是否有某个 key 值,这个关键字后文还会给出进一步说明。

dictionary = {"a": "abandon", "b": "banana", "c": "clap"}

boolean = "d" in dictionary
print(boolean)  # False

可以使用 del 关键字删除字典内的指定键值对。如果这个 key 并不存在,则会抛出异常。

dictionary = {"a": "abandon", "b": "banana", "c": "clap"}

del_key = "c"
if del_key in dictionary:
    del dictionary["c"]

print(dictionary)

可以用另一个 dict 更新当前的字典。原字典中已存在的键值对会被覆盖,不存在的键值对将会添加。如:

dictionary = {"a": "abandon", "b": "banana", "c": "clap"}

dictionary.update({"a": "abuse", "d": "desk"})

# {'a': 'abuse', 'b': 'banana', 'c': 'clap', 'd': 'desk'}
print(dictionary)

字典可以使用 for 循环遍历。在下面的例子中,字典中每个键 key 被提取到了临时变量 k。比如:

dictionary = {"a": "abandon", "b": "banana", "c": "clap"}
for k in dictionary:
    print(dictionary[k], end=",")

*推导式

推导式是 Python 独特的,用于对复合数据类型进行映射的有效方式之一。比如:

xs = [1, 2, 3, 4, 5]
"""
    xs.map{x => 2*x}
"""
x2s = [2*x for x in xs] 
print(x2s)

上述的代码相当于对 xs 做了一步映射 ( map ) 操作:首先通过 for 表达式提取出 xs 的每一个值,经变换后收集到一个新的列表中去。

再进一步,如果在推导式内安插 if 守卫,这个推导式还将可以实现对 xs 的过滤 ( fitler ) 操作。如:

xs = [1, 2, 3, 4, 5]
"""
	xs.filter{_ % 2 == 0}.map{_ * 2}
"""
x2s = [2*i for i in xs if i % 2 == 0]
print(x2s)

推导式适用于列表,区间,元组,集合,字典。比如,生成一连串的键值对:

words = ["hello", "world", "python"]

# {'h': 'hello', 'w': 'world', 'p': 'python'}
k = {w[0]: w for w in words}
print(k)

列表推导式可以嵌套表达。比如:

matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]

# seq2d -> row -> num
def flatten(seq2d): return [num for row in seq2d for num in row]

# 1 2 3 ... 8 9
xs = flatten(matrix)
print(*xs)

Python 关键字

or & and & not

为了提高代码的可读性,Python 分别使用 or 代替了 "或",and 代替了 "与",not 代替了 "非",这些运算符常用于条件判断式。比如:

print(not False)  # True
print(False or False)  # False
print(False or True)  # True
print(True and False)  # Fale
print(True and True)  # True
print(1 not in [1, 2, 3])  # False

除此之外,orand 还有一个延伸用法。比如,对于两个数值型而言,x or y 可以返回两者中的较小值,而 x and y 可以返回两者中的较大值。如:

print(2 or 3)  # 2
print(3 and 5)  # 5

pass

pass 关键字充当 Python 语法上的占位符。比如:

if x >= 10:
    pass
else:
    print("x >= 10")

或者将一些仅声明但未给出实现的函数 foo 加上 pass 以保持语法完整。比如:

def foo():  # TODO waiting for implementing
    pass

None

None 在 Python 中作为一个特殊的常量,它的类型为 NoneType。它代表语义上的空值,但自身既不是 0,也不是 False

None 可以用于设计部分函数 ( partial function )。比如说,当某个函数 f 选择不去对某些输入进行处理时,它可以不选择抛出异常或者是其它预设好的默认值,而是简单地以 None 代替。

def div(x, y):
    if y == 0:
        return None
    else:
        return x / y

print(div(3, 0))

这种设计思想被广泛用于函数式编程。比如:Scala:函数式编程下的异常处理 - 掘金 (juejin.cn)

*is & ==

is 关键字常和 == 放到一起去讨论。两者的主要区别是:

  1. == 进行的是值比较,强调相等。
  2. is 进行的是引用比较,强调相同。

在 Python 中,可以通过内置的 id() 函数获得某个对象的全局标识号,该标识号的作用相当于 C 语言的地址。若两者的标识号相同,则认为两者的引用相同。此时使用 is 比较的结果为 True,否则为 False

而对象的 == 操作符底层指向 __eq__() 方法,它和 __hash__() 方法 成对出现。同理,还可以为对象定义 >=<= 等运算符重载。

class Obj:
    # __init__ 相当于其它语言中的对象构造器
    # self.x 表示声明对象的内部属性。
    def __init__(self, v_):
        self.v = v_
        
    def __eq__(self, other): return self.v == other.v
    def __hash__(self): return hash(self.v,)

o1 = Obj(1)
o2 = Obj(1)

print(o1 == o2)  # True
print(o1 is o2)  # False

数值之间的比较应使用 == ,另外,比较一个值是否为 None 时使用 is。因为 None 相当于是一个全局的单例对象,所有被赋值为 None 的变量总会指向同一处引用。

*in

in 是一个实用的关键字。我们可以快速地利用该关键字验证某个元素是否在可迭代的数据结构,如列表,切片,元组,集合,字典。而查找机制的底层仍然离不开比较,即 相等性判断。如果查找的元素是 对象,Python 会优先尝试调用用户重写的 __eq()__ 方法,否则仍然按照引用进行比较,见下面的例子。

class Foo:
    def __init__(self, v_):
        self.v = v_

    def __eq__(self, other): return self.v == other.v
    def __hash__(self): return hash(self.v,)


class Goo:
    def __init__(self, v_):
        self.v = v_


cond1 = Foo(1) in [Foo(1)]
print(cond1)  # True

cond2 = Goo(1) in [Goo(1)]
print(cond2)  # False

由此可见,重写 __eq__() 方法对明确类的语义很重要。否则,看似高可读的代码实际上会返回完全相悖的结果。

yield from*

懒加载是一个偏 Functional 的话题。

首先,yield 关键字可用于生成一个 懒加载 的数据流,避免一次性将要处理的数据全部读入内存,从而减少资源浪费。比如,下面的 seq() 函数用于生成无限流:

def seq(start: int = 0):
    while True:
        yield start
        start += 1

gen = seq(0)

一旦某个函数使用 yield 作为返回值,Python 就会将其翻译为生成器 ( Generator )。

上述的代码通过调用 seq(0) 创建了一个生成器实例并赋值给了 gennext() 函数能够调用一次生成器并得到一个返回值。生成器每被调用一次,就会执行函数体到 下一条 yield 语句,产生出一个值返回给外界,随后停下来等待被下一次调用,直到执行最后一条 yield 之后退出。

如你所见,yield 可以使函数在运行到某一段代码处后被 "暂停"。这种特性可以被用来设计协程。感兴趣的同学可以参考:Python 的关键字 yield 有哪些用法和用途? - 知乎 (zhihu.com)

比如,利用上面的生成器 gen,我们可以不断地生成递增的连续序列:

"""
    这里的 for 循环是为了反复调用 next(gen) 生成 10 个连续自然数
    xs = [0, 1, 2, ... , 9]
    ys = [10, 11, 12, ... , 19]
"""
n = 10

xs = [next(gen) for _ in range(n)]
ys = [next(gen) for _ in range(n)]

print(*xs)
print(*ys)

由于 seq() 函数本身是一个死循环,因此 gen 总是能够源源不断地返回值。下面是一个更容易被理解的简单生成器,它没有包含任何循环语句:

def finite_seq():
    yield 1
    yield 3
    yield 5

finite_gen = finite_seq()

print(next(finite_gen))  # 返回第一个 yield 值 1
print(next(finite_gen))  # 返回第二个 yield 值 3
print(next(finite_gen))  # 返回第一个 yield 值 5

print(next(finite_gen))  # StopIteration

生成器 finite_gen 会在依次产生数据 1 3 5 之后关闭。如果此时企图再生成更多的数据,则程序会抛出 StopIteration 异常。

生成器也是可遍历对象。在这个例子中,可以直接使用 for 循环将流内的元素全部提取出来,因为 finite_gen 不会无休止地生成元素。

def finite_seq():
    yield 1
    yield 3
    yield 5

finite_gen = finite_seq()

for x in finite_gen:
    print(x,end=", ")

不要在无限流中这么做,否则程序会陷入死循环。

有些高阶的生成器会依赖其它生成器 ( 或者递归调用自身 ) 生成元素,此时需要引入 yield from 关键字。回到最开始的例子:我们现在能够以递归的形式定义一个不断累增的无限流:

# 这种无限流也称之为共递归。
def seq(start):
    yield start
    yield from seq(start+1)

gen = seq(0)
xs = [next(gen) for _ in range(10)]
print(*xs)

下面是一个稍稍复杂的案例:

def flatten(xs: list):
    for i in range(len(xs)):
        if isinstance(xs[i], list):
            yield from flatten(xs[i])
        else:
            yield xs[i]

xxs = [1,[2,3,[4,5]],6,[7,8]]
xs = [x for x in flatten(xxs)]
print(*xs) # 1 2 3 4 5 6 7 8

flattten 生成器会检测 xs 的元素是否还包含列表。若是,则递归地创建一个子生成器提取该子列表的元素。因此,flatten 可以将任意复杂的列表展平成一维列表。

*小结

在 Python 的设计理念中,对象的相等性是重复性的子问题:

  1. __eq__() 定义了相等性,这决定了 ==in 操作符的结果。
  2. __eq__()__hash__() 定义了哈希计算中的重复性,这进一步决定了它是否可作为集合 set 的元素,或者是字典 dict 的 key。

其次,在遍历列表或切片时,避免意外的引用共享,抑或无意中破坏了它,导致设计出的程序与预期不符。

最后是对不可变的理解。数值和字符串的不可变,相等性,重复性都是直观的,而元组的不可变指引用不可变,但元素内部的状态仍然是可变的。为了避免意外的麻烦,如果要将某个元组作为字典的 key,则需使内部所有对象元素都是 hashable type。

额外地,和 Java 不同,Python 的引用相等性,是靠 id() 全局标识决定的,这决定了 is 操作符的结果。它和哈希 __hash__() 函数是两回事。

重要语法

这里仅罗列一些重要内容,其它部分可以自行去 Python3 教程 | 菜鸟教程 (runoob.com) 搜索。

选择分支

Python 没有 switch 分支,所有的多选择分支都使用 if 作为代替。其中,else if 语法被简化成了 elif。如:

identify = "Student"

if identify is "Student":
    print("he is a student.")
elif identify is "Tutor":
    print("he is a tutor")
elif identify is "Professor":
    print("he is a Professor")
else:
    print("unknown.")

Python 的 if 语句还有另外一个用途:充当其它编程语言中的三目运算符。逻辑为:若 if 的表达式成立,则赋前值,否则赋后值。如:

# a = if (10 > 1) ? true : false
# 如果 10 > 1 成立,则 a = True。否则,a = False。
a = True if 10 > 1 else False
print(a)

最值判断

Python 提供了内置的 max()min() 函数简化了查找操作。比如:

seq = [3,6,7,8,1,4,2]
max(seq)
min(seq)

如果内部的元素是对象,则需要额外传入一个表达式规定比较的字段,比如:

class Foo:
    def __init__(self,v_):
        self.v = v_

list = [Foo(1),Foo(2),Foo(3)]
#  规定按照 Foo 的 v 值进行比较。
mx = max(list, key=lambda foo: foo.v)
print(mx.v)

区间判断

若判断某数值变量的值域,Python 提供了可读性更高的写法。如:

x = 100
# other lang: if(x >= 0 && x <= 100){...}
if 0 <= x <= 100:
    print("x in [0,100]")
else:
    print("x not in [0,100]")

断言

断言是一种严格的条件判断。使用 assert 关键字创建一个断言,并附带由条件式 condmsg 组成的二元组。当条件式判别为 False 时,程序会抛出一个 AssertionError 异常,并将 msg 消息输出到控制台。比如:

x = 100
y = 0

assert y != 0, "y should not be 0."
# 下方的代码是不可达的。
z = x / y

异常捕获

Python 使用 tryexceptfinally ( 可缺省 ) 来守护一段代码块,并在代码块抛出异常时捕捉,避免程序中断退出。

try:
    100 / 0
except ZeroDivisionError as e: # 将捕获到的异常赋值给 e
    print(f"error! => {e}")
finally: # finally 是可选的
    print("done.")

如果要捕获多个异常,可以将 except 写成:exception (ErrorType1, ErrorType2, ...) as e:

可以通过 raise 关键字主动抛出异常。比如:

raise Exception("throw a new Exception by user code")

在函数式风格的数据流处理中,函数会一般会将异常值收集起来,并统一抛给上层代码。比如:

def f(x: int): return (x, None) if x > 0 else (None, ArithmeticError(f"{x} is a invalid value"))

nonNegative = [f(x) for x in xs]
right = map(lambda x: x[0], filter(lambda x: x[0] is not None, nonNegative))
left  = map(lambda x: x[1], filter(lambda x: x[0] is None, nonNegative))

print(*right)  # 输出正常处理之后的数据
print(*left, sep="\n")  # 输出数据处理过程中都遇到了哪些异常

通过这种方式,我们可以将数据流处理的逻辑和异常处理的逻辑相互分离。

with 关键字实现资源开闭

把 with 语法看作是更加抽象的 try - catch 模型。

当涉及到打开 IO 流,或者是加锁这类场景时,自动化关闭资源的手段会帮我们省下很多功夫,就像 Go 语言的 defer 机制。

f = open(filePath, mode="r", encoding="UTF-8")
f.readline()
f.close()

Python 通过 with .. as 关键字提供了 通用的后置通知操作。现在,文件关闭可以改写成下面的逻辑:

with open(filePath, mode="r", encoding="UTF-8") as f:
    f.readline()
    pass

with 语句块在底层借助了 __enter__()__exit__()。换句话说,任何实现了这两个魔法函数的类实例都可以使用 with 语句块。下面是一个简单的例子:

class Daemon:
    def __enter__(self):
        # TODO
        pass

    def __exit__(self, exc_type, exc_val, exc_tb):
        r = "success" if exc_type is None else "failure"
        print(f"end.{r}")

d = Daemon()
with d:
    print("do something")
    pass

在这段代码中,简单表达式 d 指向一个 Daemon 类实例。内部的代码块将被 __exit__() 函数 守护,无论代码块执行成功与否,该函数总被调用。当语句块内部抛出异常时,exc_typeexc_valexc_tb 三个参数将为非 None 值。

d = Daemon()
with d:
    #  end.failure
    print(1 / 0)
    pass

除此之外,如果 __enter__() 函数返回了有意义的非 None 值,我们可以通过 as 关键字来接收。比如:

class Daemon:
    def __enter__(self):
        return 10, 5

    def __exit__(self, exc_type, exc_val, exc_tb):
        r = "success" if exc_type is None else "failure"
        print(f"end.{r}")

d = Daemon()

# Daemon 的 __enter__() 函数返回 10, 5 两个值,因此这里使用元组提取到 x1, x2 参数。
with d as (x1, x2):
    #  2.0
    #  end.success
    print(x1 / x2)
    pass

不难想到,with 语句块还能用来设计隐蔽的 try ... catch ... finally 逻辑,以此屏蔽掉清除资源的各种细节,这可以提升用户代码的可读性。

函数声明的细节

直接存在于模块的函数定义一般被称之为 function,而定义在类的函数一般称之方法 method。

Python 的函数不严格要求定义返回值类型,但是严格要求使用显式的 return 关键字声明返回值。

def add(a, b): return a + b

一个规范参数与返回值类型的函数可以声明为:

def add(a: int, b: int) -> int: return a + b

这种类型规范只有声明的意味,因为 Python 并不是一门编译型的语言。因此,即使传入了不匹配类型的参数,解释器也不会拒绝执行。

如果一个函数不返回任何有意义的值,那么返回值类型相当于 None。比如:

def println(anything) -> None: print(anything)

在定义函数可以设定参数的默认值。比如:

def f(v1=0, v2=0): return v1 + v2 
print(f()) # 0

函数可以声明 可变参数 表示该参数位置接收任意多个值,参数名前面使用一个 * 号作修饰。比如:

# non-keyword arguments
def receive(*args):
    print(type(args))
    for i in args:
        print(i, end=", ")

receive(1, 2, 3, 4, 5)

# 将列表拆分为可变参数传入
xs = [1, 2, 3, 4, 5]
receive(*xs)

其中,输入的多参数 1, 2, ... 5 被包裹为一个元组 tuple 类型。而如果参数名前面使用两个星号 ** 修饰,则表示它接收的是任意多个键值对。比如:

# keyword arguments
def config(**kwargs):
    print(type(kwargs))
    for k in kwargs:
        print(kwargs[k], end=", ")


config(port=8080, server="tomcat", max_connetion=500)

整个参数列表将会被包裹成一个 dict 字典,这里要求键 key 必须是 str 类型。可以将外部的字典作为可变键值对参数传入函数内。比如:

dictionary = {"a": "abandon", "b": "banana", "c": "clap", "d:": "12"}
conf(**dictionary)

为了避免混淆,Python 规定普通参数,可变参数,可变键值对参数的排列顺序依次为:

def foo(parm,*args,**kwargs):pass

有时为了提高代码的可读性,我们也会选择以 **kwargs 的形式传入参数,这在绝大部分情况都没有什么问题。比如:

def f(v1=0, v2=0): return v1 + v2
print(f(v2=3))  # 3

比如字典的 get() 方法是个例外,见:python - TypeError: get() takes no keyword arguments - Stack Overflow

一切 C-level 层次的 Python API 都不支持传入 **kwargs

函数内部可以定义函数,且函数自身可以返回另一个函数。比如:

def hof(p1):
    # 函数 f 只在 hof 定义域内部可用。
    def f(p2): return 2 * p2 + p1
    # 函数标识符 f 表示返回 f 本身。
    return f

ff = hof(5)
y = ff(1)
print(y)

这种特性有很多可引申的话题,见后文设计模式中的面向函数编程。

工程化

在 Python 中,一个单独的 *.py 文件被称之为一个模块 ( module );多个模块组织成了一个包 ( package );一个庞大的项目由各种层次的包组成。有一点值得注意的是,父包并不会自动地导入子包的模块

引入依赖

import 既可以导入包,也可以导入包内具体的 模块。比如,先通过 conda 在项目环境中安装 numpy,然后再通过 import 将它导入到当前的脚本。

# 可以同时引入多个包,多个包使用逗号相隔,下同。
import numpy

# 也可以通过 . 运算符单独导入包下的某个具体的模块。
# 这里仅作演示,我们事实上只导入 numpy 就足够了。
import numpy.core.multiarray


# 通过固定数据类型的方式,numpy 可以申请一块整齐且紧凑的连续内存空间保存数据。 
arr = numpy.array([1, 2, 3], dtype=float)
print(*arr)

导入的包或模块可以通过 as 关键字起别名。比如:

import numpy as np

arr = np.array([1, 2, 3], dtype=float)
print(*arr)

Python 还提供了一种 from .. import 语句,它支持更细粒度的导入。有两种用法:

一:如果 from 后面是一个包,则可以 import 该包下的模块。

from pkg import module1 as m1

m1.var1 # ok
var1    # error

二:如果 from 后面是一个模块,则可以 import 该模块下定义的变量,函数,类定义。这些被导入的内容可以直接使用,不需要再指定模块名

from pkg.module1 import var1 as v1, var2 as v2, func1 as f1,
v1 # ok

Script or Module

任何一个 Python 模块都有两种用途:

  1. 作为组件为其它模块提供功能,包括变量,函数,类定义。
  2. 作为脚本送入 Python 解释器执行。

当一个模块被引用时,解释器会从头到尾执行内部所有的代码行以获取变量,函数,类的定义。但是,该模块仅作为组件被使用时,其作为脚本部分的逻辑一般是不需要被执行的。为了将这两部分职责区分开,可以在模块文件的顶级声明中编写一个特殊的条件分支:

# some definations
def func(): pass
var = 10

print("as module")

if __name__ == '__main__':
	print("as script")
    # ...

该分支内的代码块 只有在该模块作为程序入口时 才会执行,即同时打印 as moduleas script。当它仅作为组件被其它模块引用时,解释器只会打印 as module

目录结构

一个大型的 Python 工程通常由多个包构成。比如,一个简单的 Python 工程可以包含以下层次:

Project/
|
|-- project/
|   |-- test/
|   |   |-- __init__.py
|   |   |-- test_main.py
|   |   
|   |-- __init__.py
|   |-- main.py
|
|-- setup.py
|-- requirements.txt
|-- README.md

作为项目程序入口的模块一般命名为 main.py,但这并不是必须的。比如有些数据处理项目用多个可执行模块来提供不同的功能。这里简单介绍 __init__.pysetup.py 这两个模块。

_init_.py

每个工程的子目录下面都可以创建一个特殊的模块:__init__.py,Python 会将任何包含这个模块的目录识别为包 Package。同时,当其它模块引入这个包时,Python 会首先加载 __init__.py 声明的逻辑。

所有在 __init__.py 声明的,或者是通过 from .. import 语句块引入的变量,函数,类定义,都将被纳入该包的定义域下。任何引用了此包的模块都将自动地获得它们。换句话说,如果项目开发者在声明包 __init__.py 文件时就声明了对子包模块的导入,那么对于项目用户而言,他只需要单独导入这一个包即可。

除了提供数据定义以外,项目开发者通常还会在这里编写一些初始化,或者是功能验证等前置工作。如:

# Let users know if they're missing any of our hard dependencies
hard_dependencies = ("numpy", "pytz", "dateutil")
missing_dependencies = []

for dependency in hard_dependencies:
    try:
        __import__(dependency)
    except ImportError as e:
        missing_dependencies.append(f"{dependency}: {e}")
        
if missing_dependencies:
    raise ImportError(
        "Unable to import required dependencies:\n" + "\n".join(missing_dependencies)
    )
del hard_dependencies, dependency, missing_dependencies

这是来自 pandas/__init__.py 模块的一部分代码。在初始化过程中,pandas 会首先尝试从环境中引入 numpypytzdateutil 包,并在导入失败时抛出 ImportError 异常。

setup.py*

如果你并不专门从事 Python 工具库的开发,而是聚焦于构建可运行的应用或数据处理任务,则此模块就不是必须的。

前文曾介绍过如何使用 pip 将项目的依赖导出到 requirements.txt 文件,这份文件相当于声明了能够保证项目正常运行的环境 env。假设我们编写的是可直接运行的 Python 项目,并将它开源到了远程的 Git 仓库上,那么这份文件将有助于其它 clone 项目的用户快速复制出项目的启动环境。

当然,我们开发的也可以是更加基础的 Python 底层库,而其它开发者可以通过 pip install pkg 命令将我们的工具包作为依赖项引入并安装。

这时候就需要一个名为 setuptools 的工具将项目进行打包,它也被 conda 预装好了。打包时需携带版本号,开源协议,项目主页,开发者邮箱,第三方依赖 ( 重要 ) 等元数据信息,而我们只需要在 setup.py ( 另一个途径是 setup.cfg ) 中按照格式进行设置即可。

下面的 setup.py 是一份简单的演示。工具包依赖的其他库可以通过 install_requires 参数进行配置,pip 在安装此工具包时会通过读取该项配置解决依赖问题。

from setuptools import setup

setup(
    name='pythonProject3',
    version='1.0.0',
    packages=['project', 'project.core'],
    author='lijunhu',
    author_email='junhuwiki@163.com',
    description='a sample',
    # 如果你确信依赖是向后兼容的,也可以设置成 'numpy>=1.22.0'
    install_requires=[
        'numpy==1.22.0'
    ]
)

在 PyCharm UI 上方的 Tools > Create setup.py / Run setup task 可以快速生成一份 setup 模板,并选择打包方式,比如 .egg.whl ( 可被 pip 直接安装 ),或者是 Windows 平台可运行的 .rmi,乃至 Linux 平台的 *.rpm

打包好的文件会生成在项目根路径下的 /dist 目录。取它用户可以通过 pip install your_project.whi 将此工具包安装到环境下,同时 pip 会自行下载 1.22.0 版本的 numpy 并安装到环境中。

可以参考以下连接:

setup.py 与 requirements.txt 区别 - SegmentFault 思否

Python 中的 requirements.txt 与 setup.py_deephub的博客-CSDN博客_requirements.txt

花了两天,终于把 Python 的 setup.py 给整明白了 - 知乎 (zhihu.com)

Python的打包工具(setup.py)实战篇 - 尹正杰 - 博客园 (cnblogs.com)

命令行参数解析

工程项目的入口程序通常会附带配置项,用户可以在程序启动之前传入必要的参数,或者是做额外的配置。比如:

python main.py --address "hadoop1" "hadoop2" "hadoop3" --num 3

Python 在标准库中内置了 argparse 库,它可以帮助开发者进行命令行参数解析。在脚本的内部,我们仅仅需要实例化一个 ArgumentParser 解析器对象,然后将设置的命令行参数添加到该对象内部即可。约定上,以 - 为前缀的标识符被识别为配置项,而后面的参数为该配置项的值,见下方的实现:

import argparse

parser = argparse.ArgumentParser(description="config your cluster")
parser.add_argument("--address", "-a", help="your server ip", nargs="+", type=str,required=True)
parser.add_argument("--num", "-n", help="number of the slaves", type=int, default="3")

这里做一些简要说明:

  1. 通过 help 参数给出该配置项相关的提示信息。
  2. 通过 type 指定参数值的类型。
  3. 通过 default 指定用户未指定配置项时的默认值。
  4. 通过 nargs="+" 表示该配置项至少接收一个或更多的值。该配置项的值会被收集到一个列表 list 内部。
  5. 通过 required=True 表示该项配置是 必须的

这里仅罗列出常用的命令行参数配置。完整内容可以参考官网:argparse — Parser for command-line options, arguments and sub-commands — Python 3.10.5 documentation 以及:argparse模块用法实例详解 - 知乎 (zhihu.com)

通过调用解析器的 parse_args() 方法来提取用户从外部传入的参数。出于测试的目的,这里手动以数组形式传入命令行参数:

# conf = parser.parse_args()
conf = parser.parse_args(["-a", "hadoop1", "hadoop2", "hadoop3", "--num", "3"])

解析成功的 conf 为 Namespace 类型的命名空间。直接通过 conf.{arg} 即可从中提取出解析出的配置项值。如:

print(conf.num)  # 3
print(conf.address)  # ['hadoop1', 'hadoop2', 'hadoop3']

这一手段是基于重写 __getattr__() 魔法函数的属性动态注入来实现的,见后文的元编程技术。

argparse 预留了 -h--help 配置项,它可以根据开发者的设置打印出帮助信息。比如:

"""
    usage: main.py [-h] --address ADDRESS [ADDRESS ...] [--num NUM]

    config your cluster

    optional arguments:
      -h, --help            show this help message and exit
      --address ADDRESS [ADDRESS ...], -a ADDRESS [ADDRESS ...]
                            your server ip
      --num NUM, -n NUM     number of the slaves
"""
parser.parse_args(["-h"])

设计模式

能力式设计

能力式设计是所有动态语言的特性,比如,同为脚本语言的 Groovy。见:通过 Groovy 了解动态语言 - 掘金 (juejin.cn)。下面的函数 f 是这样声明的:

def f(anyone) -> None:
    anyone.g()

在没有 上下文 ( Context ) 的环境下,我们不知道 anyone 是什么样的类型,也不确保它具备 g() 方法;这对于 Python 解释器也一样。anyone 参数的类型是被动态确定的,脚本语言通过牺牲了一部分性能换取了动态派发的能力。

但从积极的角度思考,我们也可以认为:f() 函数不对 anyone 做任何的约束。正因如此,我们不需要事先从顶层定义任何的接口定义规范,仅仅是 "认为" anyone 应当能够提供 g() 方法。这种 "契约式开发" 的思想非常适用于轻量级项目的敏捷开发,同时保留了可拓展性。

当然,一定要在 Python 里采用接口式编程来对类型作强约束也无可厚非。只不过代码可能会写成这个样子:

class Foo:
    def g(self): raise NotImplementedError("declare a sub type and implement it.")

class Foo1(Foo):
    def g(self): print("method from Foo1")


class Foo2(Foo):
    def g(self): print("method from Foo2")

        
def f(anyone: Foo) -> None:
    assert isinstance(anyone, Foo), "anyone should implement class: Foo"
    anyone.g()
    
f(Foo1())
f(Foo2())    

不带任何类型标识的变量也被人戏称为 "鸭子类型"。它的典故来自于 Python 的这一设计理念:"如果它走路像鸭子,叫声也像鸭子,那它就是一只鸭子"。

这里通过 isinstance 判断对象是否满足类型。我们还有其它手段去动态判断 ( 甚至是 ) 修改一个对象的属性和方法,见元编程部分。

面向对象编程

准确的说,Python 的类 ( class ) 更贴近其它语言的特质 ( Trait ),或者是富接口 ( Interface ) 的概念,因为 Python 的类支持多重继承,我们能通过 组合 的方式让一个类获得强大的各种功能。比如:

class Swim:  # trait
    def swim(self):print(f"{self.__class__.__name__} swim")

class Quack:  # trait
    def quack(self):print(f"{self.__class__.__name__} quack")

# 括号表示继承,允许多重继承
class Duck(Swim,Quack): pass

duck = Duck()
duck.swim()  # Duck swim
duck.quack()  # Duck quack

前文已经涉及了一些 Python 类定义与实例创建的内容,这里主要对细节做进一步补充。

方法接收者 self

Python 的实例方法 ( method ) 的首个参数必须为 self,它指代被调用的对象本身,可以把它理解成是类似 Go 语言的 "方法接收者"。但在调用时,应当忽略掉 self 参数。如果方法的参数列表不带 self 参数,则需要通过一个 @staticmethod 装饰器 ( 这个符号在 Java 中称之注解,见下文 ) 将其标注为静态方法。

class Foo:
    def methood(self): print("a method")

    @staticmethod
    def functioon(): print("a function")

foo = Foo()
foo.methood()   # 调用实例方法 'methood'

# 不需要创建 'Foo' 的实例
Foo.functioon()  # 调用静态方法: 'functioon'

静态方法的设计主要是考虑到了模块命名空间的规范化管理。另外,通过类的实例调用静态方法也不会报错。比如:

foo.functioon()

另一个类似的装饰器是 @classmethod,用于标注类方法。它和 @staticmethod 的区别在于:它会携带一个 cls 参数表示类型。它可以被拿来做很多事情,比如说元编程。

*下划线前缀标识符

首先,单下划线 _xx 命名的标识符表示 模块空间或类的静态域 下的私有声明,这些声明不对外公开。比如以下声明:

def _private_func(): print("only accessible in this module")
_private_var = 100

双下划线 __xx 命名的标识符表示实例的私有属性,这些属性不对外公开。比如以下声明:

class Foo:
    def __init__(self,v_):
        self.__private_v = v_
    
    def __private_method(self): print("private method.")    

__xx__ 命名的方法称之为 Python 的魔法函数,Python 利用它们来实现各种语法糖,或者是 trick 机制。到目前为止,我们使用最多的魔法函数是 __init__() ,它可以被认为是类的初始化器。其内部通过 self.xxx 声明了类实例的属性。但事实上,我们是通过元信息注入的方式实现的属性声明,见后文的元编程。

操作符重载

在 Python 提供的魔法函数中,有一部分是用于操作符重载的。这些函数名和操作符一一对应,比如:__add__() 对应 + 操作符,__sub__() 对应 - 操作符,__getitem__ 对应 [] 访问操作符等等,这里不一一列举。操作符重载机制极大丰富了 Python 程序的表达能力。比如:

class Pipeline:
    def __init__(self, seq_):
        self.seq = seq_
	
    def __getitem__(self, lamb):
        stream = [lamb(x) for x in self.seq if x is not None]
        return Pipeline(stream)

    def __iter__(self): return iter(self.seq)

# 这种设计可以优化,见后文的 "免费定理"。
pipe = Pipeline([1, 2, 3, 4, 5])[lambda x: x + 1][lambda x: x * 3] \
    [lambda x: x * 2 if x % 2 == 0 else x]  # 实现一个若为偶数则翻倍的偏函数

# [2, 4, 6, 8, 10]
print(*pipe) 

这里利用了指令链接和操作符重载创建了一个符号化的数据流管道,用户可以通过紧凑的 [] 操作符连续传递 lambda 表达式。管道内的数组将依次执行映射变换。

除了 __getitem__() 可以重载 x[] 操作符之外,Python 还提供了可以重载 x() 操作符的 __call__() 方法。或者说:重载了此方法的对象将变成 可调用对象

class Foo:
    def __call__(self, *args, **kwargs):
        param = kwargs.get("param", "none")
        print(f"callable test:{param}")


foo = Foo()

print(callable(foo))  # True
foo(param="test")  # 可传入参数

由此可见,我们熟悉的操作符,在 Python 的不同语境下可能有完全不同的语义。

单例模式

Python 的每一个 *.py 模块天然就是单例模式。比如,我们可以在第一个模块 A 内作如下定义:

class Foo: pass
foo = Foo()

然后在另一个模块 B 下仅引入 foo 这一个引用。

from moduleA import foo

如果我们在网上搜索 "Python 单例模式",通常会得到五花八门的答案。但无论如何实现,Python 的单例模式仅仅是建立在约定上的,就像能力式设计那样。本质的原因是:我们无法从根本上禁止其它用户调用构造器

元编程*

基于 Python 动态执行的特性,一个类的实例应当有哪些属性 ( field ) 和方法 ( 这些定义被称之元信息 ),并不像其它编译型语言那样在程序运行之前就确定不变了,而可能是随着脚本的运行而被临时修改甚至创建。换句话说,Python 可以在运行时随时修改类或实例的元信息,简称元编程。

元编程极大地拓展了脚本语言的灵活性,你或许还可以从另一门 Groovy 语言的 MOP 元对象协议中获取一些有意思的灵感。见:一文通读 Groovy 元对象协议 MOP - 掘金 (juejin.cn)

元信息检查

首先从元信息检查开始说起。这可以通过两个内置函数进行:

  1. vars() 函数能以字典形式打印出某个实例所具有的属性。
  2. dir() 函数能以列表形式打印出某个实例的所有属性以及方法 ( 包括从类 object 获取的魔法函数 ) 的标识符。比如:
class Foo:
    def __init__(self,v_):
        self.v = v_

    def f(self):pass

    @staticmethod
    def g():pass

foo = Foo(1)
print(dir(foo))  # ['__class__', '__delattr__', ... , 'f', 'g', 'v']
print(vars(foo))  # {'v': 1}

除此之外,Python 对象内部通用内置了属性或方法来反射元信息:

  1. foo.__dict__ 属性,相当于调用 vars(foo)
  2. foo.__dir__() 方法,相当于调用 dir(foo)

使用 hasattr() 函数可以检测查看某个对象的元信息内是否包含某个标识符。若要动态获取标识符,可以使用 getattr() 函数。

print(hasattr(foo,"f"))
print(getattr(foo,"f"))

如果 getattr() 函数返回的标识符是属性,那么该方法会直接返回它的值或引用。若返回的标识符是方法,则可以将它看作一个可调用对象 ( 它是 method 类型 ) 调用,如:

foo = Foo(1)
invokable = getattr(foo,"f")
invokable()

Python 的 inspect 模块提供了内置的 ismethod() 函数,用于检测该标识符是否为方法。比如:

mthd = inspect.ismethod(getattr(foo,"f"))
print(mthd)  # True
mthd()

元信息注入

使用 setattr() 函数可以向已构造的对象内部插入新的属性值。比如:

foo = Foo(1)
setattr(foo, "a", 2)
print(getattr(foo,"a"))  # 2

或者直接以硬编码的方式注入属性。比如:

class Goo: pass

goo1 = Goo()
goo1.i = 20

print(goo1.i)

通过 MethodType 可以直接将表达式注入对象内作为方法。比如:

goo1 = Goo()

#  保留第一个参数作为方法接收者。
def method_(this): print(this.i)

#  lambda 表达式也需要保留第一个参数作为方法接收者
lambda_ = lambda this: print(this.i)

goo1.invocable = MethodType(lambda_, goo1)
goo1.invocable()

不难理解,如果方法直接注入到 Goo 类型内,则该方法将作为类方法。此时,表达式的 this 将指代 cls 而非 self

访问拦截器

Python 对象内置了 __getattr__()__getattribute__()__setattr__() 三个魔法函数:

  1. 当访问 尚未定义的属性或方法 时,该行为会被对象的 __getattr__() 方法拦截。
  2. 无论访问的属性或方法是否定义,该行为总会被对象的 __getattribute__() 方法拦截。在 __getattribute__()__getattr__() 同时被重写的场合,优先调用前者。当前者抛出 AttributeError 时,再尝试访问后者。
  3. 向一个程序注入属性或方法时,该行为总会被该对象的 __setattr__() 方法拦截。

通过合理利用这三个魔法函数,我们可以创建出一个安全的,可被安全访问的动态对象。这里举一个简单的例子:

class Foo:

    def __init__(self):
        self.v_ = 10

    def __getattribute__(self, item):
        try:
            v = object.__getattribute__(self, item)
        except AttributeError:
            return None
        return v
    
foo = Foo()
foo.a = 100
print(foo.a)  #  100
print(foo.b)  #  None

基于这种实现,当访问未定义的属性时,Foo 实例将以返回 None 的形式代替抛出 AttributeError

装饰器模式

函数内部可以定义函数,且函数本身还可以返回函数。装饰器模式是实现面向切面编程 Aspect Oriented Programming ( AOP ) 的一种有效手段之一。

Python 的装饰器本身是一个函数。下面是一个简单的实例:

def before(ff):
    print("before")
    return ff

@before
def f(): print("f")
f()  # before, f

函数 f()before() 函数修饰。当调用 f() 函数时,该函数会被传入到 before() 函数内部。根据 before 的逻辑,它会首先执行一些前置操作,然后再将被拦截的操作 ff ( 即被装饰的函数 f ) 返回并执行,以此实现前置操作,比如,在这里插入统一的日志记录逻辑。

进一步,如何实现对目标函数的环绕操作呢?下文通过声明嵌套函数 ( 或称闭包 ) 的方式来实现。

def around(ff):
    def wrap():
        print("before")
        ff()
        print("after")
    return wrap

@around
def f(): print("f")
f()  # before, f, after

再进一步,考虑到目标函数也是有参数和返回值的。因此,我们在 wrap 闭包内设置 *args**kwargs 参数传递到目标函数内。

def around(ff):
    def wrap(*args, **kwargs):
        print("before")
        r = ff(*args, **kwargs)
        print("after")
        return r
    return wrap

@around
def f(v1, v2): return v1 + v2

result = f(3, 4)
print(result)  # before, after, 7

更进一步,装饰器 around 本身也可以携带参数值。为了实现这个目的,下文创建了更深的嵌套。

def around(param):
    def deepwarp(ff):
        def wrap(*args, **kwargs):
            print(param)
            print("before")
            r = ff(*args, **kwargs)
            print("after")
            return r
        return wrap
    return deepwarp

@around(param="decorator param")
def f(v1, v2): return v1 + v2

result = f(3, 4)
print(result)  # before, after, 7

我们可以从嵌套层次中总结参数传递的次序:装饰器函数参数 > 目标函数自身 > 目标函数参数。这种嵌套有一个更加专业的术语,即函数柯里化 ( currying )。

函数式编程*

函数式编程的思想非常适合编写无状态的流数据处理系统。

递归

像广义的链表,树都可以认为是递归定义的,使用递归逻辑来处理递归数据类型再合适不过了。除此之外,一些回溯问题,动态规划问题也非常适合用递归去解决。这里举一个简单的例子:我们仅需要简短几句就能描述出快速排序的逻辑。

# 每一轮迭代只关注三个部分: 比seq[mid]更小的数,和seq[mid]相等的数,比seq[mid]更大的数。
def quicksort(seq: list) -> list:
    if len(seq) <= 1: return seq
    mid = int(len(seq) / 2)

    l, m, r = [], [], []
    for i in seq:
        if i < seq[mid]:
            l.append(i)
        elif i > seq[mid]:
            r.append(i)
        else:
            m.append(i)

    return quicksort(l) + m + quicksort(r)

递归的核心思想是将看似复杂的问题分解成重复子问题的组合。同时,临界条件已经暗示了程序将在何时退出结束,因此也就不再需要信号量,breakcontinue 这样的机制了。

如果递归返回的要么是一个简单值,要么是对自身的递归调用而没有其它动作,则它可被称之尾递归。当前的 quicksort 实现并不是尾递归,因为它在返回时还进行了列表组合。理论上,尾递归函数只需要一个栈帧,因此不会出现栈溢出的问题。所有的尾递归都可以被解析为等价的 while 或者 for 循环,这部分研究可被称之尾递归优化。

所有 非尾递归函数 理论上都存在栈溢出的风险,这是相比于 for, while 这种命令式迭代的一个明显缺陷,但递归胜在更加简洁的表述。

lambda 表达式

在 Python 中,函数标识符可以被认为是 表达式,表达式就像变量那样可以在程序的任意一处传递。比如:

def f(): print("method")
invokable = f
print(callable(invokable))  # True
invokable()

而对于一些 简单表达式,通常使用 lambda 表达式声明,使用 lambda: 关键字。如:

f1 = lambda: print("hello")  # 不接受参数的 lambda 表达式
f2 = lambda x, y: print(f"x={x}, y={y}")  # 接受参数的 lambda 表达式
f3 = lambda x, y: x + y  # 具备返回值的 lambda 表达式

简单表达式自身的运算结果即返回值 ( 没有则认为返回 None ),因此不需要显式声明 return 关键字。

函数式转换子

除了前文的列表推导式之外,list -> list 的映射还存在另外一种编写风格。这里主要介绍常用的五种转换子:mapreducesetfilterzip

map()filter() 是基础且常用的算子,运算将分别产生 map 和 filter 对象,它们本质上是一次性消费数据的迭代器,需要额外转换为列表。前者用于列表映射,后者用于列表元素过滤。比如:

list = [1,2,3,4,5]

xs = map(lambda x: x + 1,list)
print(*xs)  # 2, 3, 4, 5, 6
xxs = list(xs)  # 转换为 list
print(xxs)

ys= filter(lambda x: x > 1,list)
print(*ys)  # 2, 3, 4, 5
yys = list(ys)  # 转换为 list
print(yys)

zip(),故名思意,能够按序将两个列表的元素缝合为二元组,并产生一个 zip 对象,需要额外转换为列表,理由同上。若缝合的两个列表长度不一致,较长列表的后面元素会被丢弃

key = ["c", "j", "g", "p"]
value = ["c++", "java", "golang"]

kws = zip(key,value)
print(*kws)  # ('c', 'c++') ('j', 'java') ('g', 'golang')

xs = list(kws)  # 转换为 list
print(xs) # [('c', 'c++') ('j', 'java') ('g', 'golang')]

归并算子 reduce() 需要从 functools 库中引入。如果我们操作的是 可折叠数据结构 ( 比如将类型记作 AB ,而我们定义了 [A] + [A] = [B] 的法则 ),那么使用 reduce() 算子可以快速实现规约 ( 用 SQL 的话说是聚合函数 )。事实上,前文提到的 sum()max()min() 都可以认为是归并算子的特例。

class Foo:
    def __init__(self, v_):
        self.v = v_

from functools import reduce
xs = [Foo(1), Foo(2), Foo(3)]


merge = lambda foo1, foo2: Foo(foo1.v + foo2.v)
fooN = reduce(merge, xs)
print(fooN.v)

set() 算子用于去除列表中重复的元素,或者将该方法理解成是列表向集合的强制转换函数。放入的元素必须是 hashable type,见前文的集合。

class Foo:
    def __init__(self,v_):
        self.v = v_

    def __hash__(self): return hash(self.v,)
    def __eq__(self, other): return self.v == other.v

xs = [2,2,3,4,5,Foo(1),Foo(1)]

x = set(xs)
print(x)

柯里化与闭包

柯里化是记忆参数以及推迟函数执行的重要手段。它的核心思路是使用嵌套闭包构建一个高阶函数。比如:

def telnet(host):
    def _port(port):
        print(f"connect to {host}:{port}")
    return _port

# telnet("192.168.229.140")(6379)
server = telnet("192.168.229.140")
server(6379)

假定 telnet 是用于服务器连接的函数,当程序需要连接同一个机器的不同端口时,函数柯里化可以实现复用配置路径 ( 或称复用输入参数 ) 的效果:

master = telnet("192.168.229.140")
master(6379)  # host = 192.168.229.140, port = 6379
master(8080)  # host = 192.168.229.140, port = 6379

follower = telnet("192.168.229.141")
follower(6379)  # host = 192.168.229.141, port = 6379
follower(8080)  # host = 192.168.229.141, port = 6379 

通过嵌套闭包定义的方式实现柯里化很麻烦,并且参数确认的顺序是固定的。python 提供了函数柯里化的更简单方式,这需要引入 functools.partial() 函数将严格求值的函数变为 thunk。

def telnet(host, port): print(f"connect to {host}:{port}")

#  第一个参数填写的是函数标识名,表示传递函数本身。
#  partial(telnet, host="192.168.229.150")(port=6379)
leader = partial(telnet, host="192.168.229.150")
leader(port=6379)

在这个例子中,telnet() 是一个普通的函数,而 partial() 函数将允许以任意次序对其进行柯里化。额外地,这里的参数需要以 **kwargs 的形式进行指定。

函数组合

给定两个函数 fg,它们有两种基本的组合方式:

  1. compose(f,g),等价于 f(g()),其中 g 的返回值作为 f 的入参。
  2. andThen(f,g),等价于 g(f()),其中 f 的返回值作为 g 的入参。

其命名借鉴于 Java/Scala 的函数式接口,法则 compose(f,g) == andThen(g,f) 总是成立。它们在 Python 中可以如下定义:

def compose(f, g):
    def closure(*args, **kwargs): return f(g(*args, **kwargs))
    return closure

def andThen(f, g):
    def closure(*args, **kwargs): return g(f(*args, **kwargs))
    return closure

f = compose(lambda t: t[0] + t[1], lambda x, y: (x + 1, y + 1))
print(f(2, 3))  # 7

g = andThen(lambda x, y: (x + 1, y + 1), lambda t: t[0] + t[1])
print(g(2,3))

这种思想常用于 map 算子融合,来自 1989 年 Philip Wadler 的论文《 Theorems for free 》,名为免费定理,论文可以去参考 free.dvi (ttic.edu)。比如:

lambda_1 = lambda x: x + 1
lambda_2 = lambda x: x * 2

xs = [1, 2, 3, 4, 5]
y1s = map(lambda_2, map(lambda_1, xs))   # 先执行 λ1,再执行 λ2,列表转换了两次,额外生成了一个保存中间结果的列表。
y2s = map(andThen(lambda_1,lambda_2),xs) # 先组合 andThen(λ1, λ2),再一次性进行转换,不需要保存中间结果。

print(*y1s)
print(*y2s)

附:数值分析库

Numpy 预览

完整内容可移步至官方指南:Numpy and Scipy Documentation — Numpy and Scipy documentation,这里仅介绍基本功能。

Numpy 是于 2005 年创建并开源的库,旨在提供比传统 Python 列表快 50 倍的数组对象。为了保证更高的性能,Numpy 有近 35% 的部分是由 C 语言实现的。见:GitHub - numpy/numpy: The fundamental package for scientific computing with Python.

Numpy 常用于科学计算,因此在本章默认数组的元素全都是不可变数值,且仅讨论一维数组和二维数组。

数组与矩阵

使用 Numpy 可以创建出数组 ( array )。它可以被看作是原 Python 列表类型的内存优化版本 ( 通过 dtype 指定元素类型以从内存中分配更加紧凑的空间 ),因此兼容原列表的各种操作。

import numpy as np

arr1d = np.array([1, 2, 3, 4], dtype="int")

#  支持列表推导式
print(*[x*2 for x in arr1d])

arr2d = np.array(
    [[1, 2, 3],
     [4, 5, 6],
     [7, 8, 9]], dtype="int"
)

# 支持 (高维的) 切片
sub = arr2d[0:2, 0:2]
"""
[[1,2],
 [4,5]]
"""
print(*sub)

其中,二维数组可被视为矩阵 ( Matrix ) 或列向量 ( Vector )。可以通过调用数组的 shape 属性确定维度。比如:

print(arr1d.shape)  # (4,) -> 长度为 4 的一维数组
print(sub.shape)  # (2, 2) -> 2×2 的方阵

使用 reshape() 方法可以将一维数组堆叠为二维数组。-1 参数有特殊的语义,表示 "任意长度",实际取决于原数组的形状。比如对一个二维数组做如下操作:

  1. reshape(-1) 表示展平成任意长度的一维数组。
  2. reshape(-1, 1) 表示变成任意行数,但列数为 1 的列向量,是二维数组。
  3. reshape(1, -1) 表示变成行数为 1,但列数任意的行向量,是二维数组。
"""
    np.arange(n) 相当于 range(n) 的 numpy 版本。
    matrix:
    [[ 0  1  2  3]
     [ 4  5  6  7]
     [ 8  9 10 11]]
"""
matrix = np.arange(12).reshape(3, 4)
print(matrix)

# [ 0  1  2  3  4  5  6  7  8  9 10 11]
vect_1d = matrix.reshape(-1)
print(vect_1d)
# [[ 0  1  2  3  4  5  6  7  8  9 10 11]]
vect_v = matrix.reshape(1,-1)
print(vect_v)

"""
[[ 0]
 [ 1]
   :
   :
 [11]]
"""
vect_h = matrix.reshape(-1,1)
print(vect_h)

对于一维数组向二维数组的堆叠,可以通过 order 参数指定填充顺序。选 "C" 进行横向次序填充 ( 默认的 ) 或者 "F" 纵向次序填充,比如:

 """
    [[ 0  1  2  3]
     [ 4  5  6  7]
     [ 8  9 10 11]]
"""
m1 = np.arange(12).reshape([3,4],order="C")
print(m1)

"""
    [[ 0  3  6  9]
     [ 1  4  7 10]
     [ 2  5  8 11]]
"""
m2 = np.arange(12).reshape([3,4],order="F")
print(m2)

通过向 np.where() 函数传入搜索条件,可以得到满足条件的元素下标。比如:

v1 = np.array([1, 2, 3, 3, 6, 3])
#  谓词 'v1 == 3' 中的 'v1' 表示 v1 数组中的每一个元素。
indexes = np.where(v1 == 3)
#  表示 [2, 3, 5] 下标出现了值为 3 的元素。 
print(indexes)

数组拼接

Numpy 支持对数组进行拼接。对于一维数组的拼接,称之 堆叠 ( stack )。堆叠可以分为水平堆叠 ( hstack() 函数 ) 和纵向堆叠 ( stack() 函数 )。水平堆叠的结果仍然是一维数组,而纵向堆叠的结果将是矩阵。

v1 = np.array([1, 2, 3])
v2 = np.array([4, 5, 6])

v3 = np.hstack([v1,v2])  # [1, 2, 3, 4, 5, 6]
print(v3)

"""
    [[1 2 3]
    [4 5 6]]
"""
v4 = np.stack([v1,v2])
print(v4)

np.concatenate() 函数支持矩阵拼接,类似地,这里使用 axis 标识了拼接方向。在不指定参数的情况下默认 axis=1

m1 = np.array([
    [1, 2],
    [3, 4]
])

m2 = np.array([
    [5, 6],
    [7, 8]
])

"""
    列数不变,对行追加 (纵向拼接)
    [[1 2]
     [3 4]
     [5 6]
     [7 8]]
"""
m3 = np.concatenate([m1, m2], axis=0)
print(m3)
"""
    行数不变,对列追加 (横向拼接)
    [[1 2 5 6]
    [3 4 7 8]]
"""
m4 = np.concatenate([m1, m2], axis=1)
print(m4)

线性代数

由于二维数组可被视作矩阵或者向量,因此可以延伸出线性代数的相关计算。比如,通过 matrix.T 即可实现转置操作:

matrix = np.arange(12).reshape(3, 4)
"""
    [[ 0  4  8]
     [ 1  5  9]
     [ 2  6 10]
     [ 3  7 11]]
"""
print(matrix.T)

Numpy 还提供四个函数用于生成单位矩阵,全 0 矩阵,全 1 矩阵,以及随机数矩阵。见:

n, m = 3, 4

E = np.eye(n)  # 生成 m 阶单位矩阵
I = np.zeros([m, n])  # 生成 m × n 维全 0 矩阵
K = np.ones([m, n])  # 生成 m × n 维全 1 矩阵
H = np.empty([m, n]) # 生成 m × n 随机数矩阵

np.linalg.det() 函数可以展开矩阵行列式并计算求值,而 np.linalg.inv() 函数可求矩阵的逆 ( 底层是求解 Ax = E 的解 x,因此存在精度问题 )。比如:

A = np.array([
    [1,2],
    [3,4]
],dtype=int)

det = int(np.linalg.det(A))
print(det)  # 1*4 - 2*3

A_inverse = np.linalg.inv(A)
print(A_inverse)

现在假设有另一个矩阵 B。通过 np.matmul() 函数或者 @ 运算符 ( 运算符重载 ) 可以计算矩阵的乘积 ( 不满足交换律 ):

B = np.array([
    [1,3],
    [2,4]
], dtype=int)

"""
[[1*1 + 2*2, 1*3 + 2*4],
 [3*1 + 4*2, 3*3 + 4*4]]
"""
print(np.matmul(A, B))
print(A @ B)

特殊地,假设有两个一维数组 a 和 b,此时的 @ 将视作向量内积 ( 满足交换律 ),结果是一个数值。比如:

a = np.array([1, 2, 3], dtype=int)
b = np.array([4, 5, 6], dtype=int)
c = np.arrat([1, 2], dtype=int)
print(np.matmul(a,b))

通过 np.multiply() 函数或者 * 运算符可以计算矩阵与标量的积 ( 满足交换律 ):

"""
[[1*3, 2*3],
 [3*3, 4*3]]
"""
print(np.multiply(A,3))
print(A * 3)

以上方法可以使用一个 np.dot() 函数概括。根据不同的输入,它将表示不同的行为:

  1. 当两个参数的其中一个为标量时,等价于 A * n
  2. 当两个参数均为二维矩阵时,等价于 A @ B
  3. 当两个参数分别为一个二维矩阵和一维数组时,将一维数组提升为二维列向量,然后作矩阵乘积 @
  4. 当两个参数均为一维矩阵时,视作向量内积,即 a·b

见:numpy中dot()、outer()、multiply()以及matmul()的区别 - 简书 (jianshu.com)

# np.array_equal 用于比较两个数组元素是否按位相同。
assert np.array_equal(np.dot(a, b), a @ b)
assert np.array_equal(np.dot(A, B), A @ B)
assert np.array_equal(np.dot(A, c), A @ c)
assert np.array_equal(np.dot(A, 3), A * 3)
print("test passed")

为了加以区分,二维矩阵的运算更推荐使用 np.matmul() 或者 @

Pandas 预览

如果你很熟悉 SQL 操作,那么 Pandas 上手起来会非常容易。完整内容见官方指南:User Guide — pandas 1.4.3 documentation (pydata.org),这里仅介绍基础功能。

我们主要使用 Pandas 库处理 表结构 的数据,它们被抽象为了 Dataframe 类型。首先演示基本的 IO 操作:先将 Python 原生的数据结构转换为 Dataframe,然后将它输出到 music.csv 文件,程序演示如下:

import pandas as pd

#  在 Pandas 中,表数据的单个列被称之 Series.
artist = ["Billie Holiday", "Jimi Hendrix", "Miles Davis", "SIA"]
genre = ["Jazz", "Rock", "Jazz", "Pop"]
listeners = [1_300_000, 2_700_000, 1_500_000, 2_000_000]
plays = [27_000_000, 70_000_000, 48_000_000, 74_000_000]

dict_ = {"artist": artist,
        "genre": genre,
        "listeners": listeners,
        "plays": plays
        }

# dict 的 kw 对已经隐含了列名信息,因此这里不需要显式指定 columns。
df = pd.DataFrame(dict_)

#  这里不需要额外生成索引下标序列,因此做以下设置: index=False。
df.to_csv(path_or_buf="music.csv",index=False)

pd.DataFrame 可以将 Python 原生的 dict 字典结构转化为表。其中 key 表示了列名,而 value 表示了该列的数据。另一种更加自然的想法是:以行的形式组织 DataFrame,这里将每一行抽象成了元组。

table = [
    ("Billie Holiday", "Jazz", 1_300_000, 27_000_000),
    ("Jimi Hendrix", "Rock", 2_700_000, 70_000_000),
    ("Miles Davis", "Jazz", 1_500_000, 48_000_000),
    ("SIA", "Pop", 2_000_000, 74_000_000)
]
df = pd.DataFrame(data=table, columns=["artist", "genre", "listeners", "plays"])
df.to_csv(path_or_buf="music.csv", index=False)

生成的表结构如下:

artistgenrelistenerplays
Billie HolidayJazz130000027000000
Jimi HendrixRock270000070000000
Miles DavisJazz150000048000000
SIAPop200000074000000

下一步,试着 pd.read_csv(path) 重新将它以 Dataframe 的形式读取进内存。在默认情况下,Pandas 将读取到的第一行视作表头 Header 。

df = pd.read_csv("music.csv")
"""
               artist genre  listeners     plays
    0  Billie Holiday  Jazz    1300000  27000000
    1    Jimi Hendrix  Rock    2700000  70000000
    2     Miles Davis  Jazz    1500000  48000000
    3             SIA   Pop    2000000  74000000
"""
print(df)

数据提取

下面从最基本的操作开始说起。若要提取出 df 的某一列数据,可以直接使用重载的 [] 运算符 ( 见前一章的运算符重载 ) 进行指定,支持传入单个列名,或者是由多个列名组成的列表。下面演示了提取单个列和多个列的情形:

df["artist"]  # 提取一个列
df[["artist","plays"]]  # 提取多个列

特地强调,如果仅提取一个列,得到的将是 Series 类型;如果提取多个列,得到的将是 Dataframe 类型。

如果要从 df 中提取出第 n 行,则使用 df.loc[n] 实现。事实上,这里的 n 代表索引,在默认情况下是 0 起始的数据下标。比如提取第一行的行号:

# 提取下标 0 行的 row
# 0  Billie Holiday  Jazz    1300000  27000000
row = df.loc[0]

# 从 row 中再提取 artist 列
# Billie Holiday
print(row["artist"])

Pandas 提供了查看数据的前 n 行或最后 n 行的函数:

n = 2
print(df.head(n))  # 查看前 n 行数据
print(df.tail(n))  # 查看后 n 行数据

过滤 ( WHERE 谓词 ) 是最基本的表操作。比如,筛选出 plays 数据高于 5000 万的数据:

top_df = df[df["plays"] > 50_000_000]
print(top_df)

另一种直观的方式是调用 query() 方法并直接传递谓词语句,字符串内容遵守 Python 语法。比如:

top_df = df.query("plays >= 50_000_000 and listeners >= 2_500_000")
print(top_df)

假定要从 genre 列中归纳出音乐人的歌曲类型,则可以使用 unique() 方法进行去重。比如:

col = df["genre"].unique()

# ['Jazz' 'Rock' 'Pop']
# col.tolist()
print(col)

排列 ( SORT BY 谓词 ) 也是常见的表操作之一。比如,这里按照 plays 列的数据以降序 Desc 排列:

sorted_df = df.sort_values("plays",ascending=False)
"""
               artist genre  listeners     plays
    3             SIA   Pop    2000000  74000000
    1    Jimi Hendrix  Rock    2700000  70000000
    2     Miles Davis  Jazz    1500000  48000000
    0  Billie Holiday  Jazz    1300000  27000000
"""
print(sorted_df)

聚合操作

可以参考这篇优质文章:Pandas教程 | 超好用的Groupby用法详解 - 知乎 (zhihu.com)

Pandas 就像 SQL 那样提供聚合操作。比如我们期望按照 genre 字段对表进行分组,并统计每个组的行数。它可以被表达为:

"""SQL:
    select genre, count(*)
    from df
    group by genre
"""
print(df.groupby("genre")["artist"].count())

除了 count() 计数方法之外,其它的聚合方法还包括:mean() 均值,sum() 求和,median() 中位数,min() 最小值,max() 最大值,var() 方差,std() 标准差。一种更通用的聚合方法为 agg(),它允许接收 **kwargs 来对不同列进行不同的聚合操作,key 为列名,value 为聚合函数名。比如:

report_df = df.groupby("genre").agg({
    "artist": "count",
    "listeners": "mean",
    "plays": "max"
})

"""
           artist  listeners     plays
    genre                             
    Jazz        2  1400000.0  48000000
    Pop         1  2000000.0  74000000
    Rock        1  2700000.0  70000000
"""
print(report_df)

转换操作

Pandas 提供的 Dataframe可变 数据,这意味着我们可以对原数据表进行改动。比如,根据 listensersplays 计算每个音乐人的影响力 score,并将其作为新的数据列附着在原数据表上:

df = pd.read_csv("music.csv")
"""
           artist genre  listeners     plays  score
0  Billie Holiday  Jazz    1300000  27000000  0.605
1    Jimi Hendrix  Rock    2700000  70000000  1.535
2     Miles Davis  Jazz    1500000  48000000  1.035
3             SIA   Pop    2000000  74000000  1.580
"""
# 假定 score = 5 * listeners_count + 2 * plays
df["score"] = (df["listeners"] * 5 + df["plays"] * 2) / 10**8
print(df)

map() 函数提供了以 lambda 表达式对 单列数据 ( 即 Series 类型 ) 进行变换的途径。比如:

"""
               artist genre  listeners   plays
    0  Billie Holiday  Jazz    1300000  50mio-
    1    Jimi Hendrix  Rock    2700000  50mio+
    2     Miles Davis  Jazz    1500000  50mio-
    3             SIA   Pop    2000000  50mio+
"""
df["plays"] = df["plays"].map(lambda x: "50mio+" if x > 50_000_000 else "50mio-")
print(df)

如果要基于多列数据 ( 此时为 DataFrame 类型 ) 进行变换,则需要引入 apply() 方法。比如:

# axis=1 表示按数据列进行操作
df["scores"] = df[["listeners","plays"]].apply(lambda t: (t["listeners"]*5 + 2*t["plays"]) / 10**8, axis=1)
print(df)

表连接

可以通过 merge() 方法实现类似 SQL 表的内连接 ( 默认 ),左连接,右连接,全外连接操作,通过 how 参数进行配置。比如:

info = [
    ("Billie Holiday", "US"),
    ("Jimi Hendrix", "US"),
    ("Justin bieber", "Canada"),
]
artist_info = pd.DataFrame(data=info, columns=["artist", "country"])

m0 = df.merge(artist_info)  # how="inner"
m1 = df.merge(artist_info, how="outer")
m2 = df.merge(artist_info, how="left")
m3 = df.merge(artist_info, how="right")

使用 Pandas 自身提供的 concat() 函数可以连接两个数据表。比如:

row = [("Justin Bieber", "Pop", 300_000, 1_000_000)]
append_df = pd.DataFrame(data=row, columns=["artist", "genre", "listeners", "plays"])

# axis=0 表示按行进行尾追加
ndf = pd.concat([df, append_df], axis=0)
print(ndf)

附录

一般情况下,Linux 都会自带 Python 解释器。比如:

#!/bin/python
#!/bin/env python

shell脚本学习之解释器(#!) - 爱码网 (likecs.com)