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

本文的目的是快速了解 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 的安装包约为 500 Mb。如果只想用 conda 的核心功能,则安装轻量版本的 Miniconda 即可。

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

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

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

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

conda 环境

从思想上,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 工具导入进去:

pip install -r requirements.txt

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

开发环境

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

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

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

在此之后,我们可以专注于项目工程,在大部分情况下无需再手动通过终端与 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 会将 age 和 name 两个变量的值嵌入到 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)
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值