变量与注释
当我们看到一段代码时,最先注意到的,不是代码有几层循环,用了什么模式,而是变量与注释,因为它们是代码里最接近自然语言的东西,最容易被大脑消化、理解。
变量常见用法
Python 支持灵活的动态解包语法,只要用星号表达式(*variables)作为变量名,它便会贪婪地捕获多个值对象,并将捕获到的内容作为列表赋值给 variables:
1 | >>> data = ['ethan', 'apple', 'orange', 'banana', 100] |
在 Python 交互式命令行里,_ 变量还有一层特殊含义——默认保存我们输入的上个表达式的返回值:
1 | >>> 'foo'.upper() |
给变量注明类型
相比编写 Sphinx 格式文档,类型注解更推荐使用:
1 | def remove_invalid(items): |
1 | from typing import List |
typing 是类型注解用到的主要模块,除了 List 外,该模块内还有许多与类型有关的特殊对象:
- Dict:字典类型,例如 Dict[str, int] 代表键为字符串,值为整型的字典;
- Callable:可调用对象,例如 Callable[[str, str], List[str]] 表示接收两个字符串作为参数,返回字符串列表的可调用对象;
- TextIO:使用文本协议的类文件类型,相应地,还有二进制类型 BinaryIO;
- Any:代表任何类型;
类型注解只是一种有关类型的注释,不提供任何校验功能。要校验类型正确性,需要使用其他静态类型检查工具(如 mypy 等)。
变量命名原则
There are only two hard things in Computer Science: cache invalidation and naming things.
-- Phil Karlton
PEP 8 是 Python 编码风格的事实标准:
- 对于普通变量,使用蛇形命名法,比如 max_value;
- 对于常量,采用全大写字母,使用下划线连接,比如 MAX_VALUE;
- 如果变量标记为仅内部使用,为其增加下划线前缀,比如 _local_val;
- 当名字与 Python 关键字冲突时,在变量末尾追加下划线,比如 class_;
为变量命名要结合代码情境和上下文,尽量不要超过 4 个单词。一些约定俗成的短名字:
- 数组索引三剑客 i、j、k;
- 某个整数 n;
- 某个字符串 s;
- 某个异常 e;
- 文件对象 fp;
如果项目中有一些长名字反复出现,可以为它们设置一些短名字作为别名:
1 | from django.utils.translation import gettext as _ |
注释基础知识
函数(类)文档(Docstring)也被称为接口注释(Interface Comment),注释作为代码之外的说明性文字,应该尽量提供无法从代码里读出来的信息。在编写接口文档时,我们应该站在函数设计者的角度,着重描述函数的功能、参数说明等,而函数自身的实现细节,无须放在接口文档里。
编程建议
在组织代码时,我们应该谨记:总是从代码的职责出发,而不是其他东西。通过把变量定义移动到每段各司其职的代码头部,可以大大缩短变量从初始化到被使用的距离。
直接翻译业务逻辑的代码,大多不是好代码。优秀的程序设计需要在理解原需求的基础上,恰到好处地抽象,只有这样才能同时满足可读性和可扩展性方面的需求。通过增加数据类,可以更有逻辑地组织函数内的局部变量,减少数量:
1 | def import_users_from_file(fp): |
1 | class ImportedSummary: |
每个函数的名称与接口注释,其实是一种比函数内部代码更为抽象的东西,在写出一句有说服力的接口注释前,别写任何函数代码。
数值与字符串
我们离不开数字和文字,正如同编程语言离不开数值与字符串,两者几乎是所有编程语言里最基本的数据类型,也是我们通过代码连接现实世界的基础。
数值基础
在定义数值字面量时,如果数字特别长,可以通过插入 _ 分隔符来让它变得更易读:
1 | >>> n = 1_000_000 |
通过 0 和 1 模拟表示浮点数时,计算机做不到绝对的精准,Python 使用符合 IEEE-754 规范的双精度尽力而为。
布尔值其实也是数字
在绝大数情况下,True 和 False 这两个布尔值可以直接当作 1 和 0 来使用,常用来简化统计总数操作:
1 | numbers = [1, 2, 4, 5, 7] |
1 | count = sum(i % 2 == 0 for i in numbers) |
字符串常用操作
假如你想反转一个字符串,可以使用切片操作或者 reversed 内置方法:
1 | >>> s = 'Hello, world!' |
三种主流的字符串格式化方式:
- C 语言风格的基于百分号 % 的格式化语句;
- str.format 字符串格式化方式;
- f-string 字符串字面量格式化表达式;
日常编码中推荐使用 f-string,并搭配 str.format 作为补充。它们共享了同一种复杂的字符串格式化微语言,通过这种微语言,我们可以方便地对字符串进行二次加工。str.format 支持用位置参数来格式化字符串,实现对参数的重复使用。
不常用但特别好用的字符串方法
使用 partition 函数代替 split,原本的分支判断逻辑可以消失:
1 | def extract_value(s): |
1 | def extract_value(s): |
str.translate(table) 方法可以按规则一次性替换多个字符,使用它比调用多次 replace 方法更快也更简单:
1 | >>> s = '明明是中文,却使用了英文标点.' |
字符串与字节串
广义上的字符串概念可以分为两类:
- 字符串:我们最常挂在嘴边的普通字符串,是给人看的,对应 Python 中的 str 类型。str 使用 Unicode 标准,可通过 .encode() 编码为字节串;
- 字节串:有时也称为二进制字符串,是给计算机看的,对应 Python 中的 bytes 类型。bytes 一定包含某种真正的字符串编码格式(默认为 UTF-8),可通过 .decode() 解码为字符串;
Python 里的字符串处理其实很简单,关键在于:用一个边缘转换层把人类和计算机的世界隔开:
- 程序从文件或其他外部存储读取字节串内容,将其解码为字符串,然后再在内部使用;
- 程序完成处理,要把字符串写入文件或其他外部存储,将其编码为字节串,然后继续执行其他操作;
编程建议
使用常量和枚举类型来代替字面量:
1 | def add_daily_points(user): |
1 | from enum import Enum |
当代码里出现复杂的裸字符串处理逻辑时:
- 结构化字符串:寻找是否有对应的开源专有模块,比如处理 SQL 语句的 SQLAlchemy、处理 XML 的 lxml 模块等;
- 非结构化字符串:请考虑使用 Jinja2 等模版引擎,而不是手动拼接;
别轻易成为 SQL 语句大师,对于 SQL 语句这种结构化、有规则的特殊字符串,用对象化的方式构建和编辑才是更好的做法。不光更短、更好维护,而且根本不需要担心 SQL 注入问题。
对象关系映射(ORM)指一种把数据库中的数据自动映射为程序内对象的技术
除了用反斜杠 \ 和加号 + 将长字符串拆分为几段,还有一种更简单的办法,那就是拿括号将长字符串包起来,之后就可以随意折行了:
1 | s = ('This is the first line of a long string, ' |
容器类型
代码里的容器,泛指那些专门用来装其他对象的特殊数据类型。
列表常用操作
内置函数 list(iterable) 可以把任何一个可迭代对象转换为列表,比如字符串:
1 | >>> list('foo') |
enumerate() 适用于任何可迭代对象,因此它不光可以用于列表,还可以用于元组、字典、字符串等其他对象。
理解列表的可变性
Python 里的内置数据类型,大致上可以分为两种:
- 可变:列表、字典、集合;
- 不可变:整数、浮点数、字符串、字节串、元组;
两种函数参数传递机制:
- 值传递(Pass by Value):调用函数时,传过去的是变量所指向对象值的拷贝,因此对函数内变量的任何修改,都不会影响原始变量;
- 引用传递(Pass by Reference):调用函数时,传过去的是变量自身的内存地址,因此修改函数内的变量会直接影响原始变量;
Python 在进行函数调用传参时,采用的既不是值传递,也不是引用传递,而是传递了变量所指对象的引用(Pass by Object Reference),一切行为取决于对象的可变性。
常用元组操作
没有元组推导式,只有生成器推导式:
1 | >>> results = (n * 100 for n in range(10) if n % 2 == 0) |
元组经常用来存放结构化数据:
1 | >>> user_info = ('ethan', 'MALE', 30, True) |
具名元组
具名元组(Namedtuple)在保留普通元组功能的基础上,允许为元组的每个成员命名,这样你便能通过名称而不止是数字索引访问成员。使用 typing.NamedTuple 和类型注解语法来定义具名元组类型:
1 | from typing import NamedTuple |
1 | >>> print(rect[0]) |
字典常用操作
当用不存在的键访问字典内容时,程序会抛出 KeyError 异常,我们通常称之为程序里的边界情况(Edge Case)。针对这种边界情况,比较常见的处理方式有两种:
1 | if 'rating' in movie: |
1 | try: |
Python 中更推崇第二种写法,因为它看起来更简洁,执行效率也更高。不过,如果只是提供默认值的读取操作,其实可以直接使用字典的 .get() 方法。
有时,我们需要修改字典中某个可能不存在的键,使用 setdefault 取值并修改:
1 | >>> d = {'title': 'foobar'} |
认识字典的有序性和无序性
Python 里的字典在底层使用了哈希表(Hash Table)数据结构。当你往字典里存放一对 key:value 时,解释器会先通过哈希算法计算出 key 的哈希值,然后根据这个哈希值,决定数据在表里的具体位置。
OrderedDict 与字典其实有着一些细微区别:
1 | >>> d1 = {'name': 'ethan', 'fruit': 'apple'} |
1 | >>> d1 = OrderedDict('name': 'ethan', 'fruit': 'apple') |
集合常用操作
集合的所有操作都可以用方法和运算符两种方式来进行:
& | | | - |
---|---|---|
intersection | union | difference |
集合里只能存放可哈希(Hashable)的对象,假如把不可哈希的对象放入集合,程序就会抛出 TypeError 异常:
1 | >>> invalid_set = {'foo', [1, 2, 3]} |
了解对象的可哈希性
某种类型是否可哈希遵循下面的规则:
- 所有的不可变内置类型,都是可哈希的,比如 str、int 等;
- 所有的可变内置类型,都不是可哈希的,比如 dict、list 等;
- 对于不可变容器类型,仅当它的所有成员都不可变时,它自身才是可哈希的,比如 tuple、frozenset 等;
- 用户定义的类型默认都是可哈希的;
深拷贝与浅拷贝
实现浅拷贝的方式:
- 使用 copy 模块下的 copy() 方法;
- 推导式也可以产生一个浅拷贝对象;
- 使用各容器类型的内置构造函数;
- 全切片也可以实现浅拷贝效果;
- 有些类型自身提供了浅拷贝方法;
对于一些层层嵌套的复杂数据来说,浅拷贝仍然无法解决嵌套对象被修改的问题。要解决这个问题,可以用 copy.deepcopy() 函数来进行深拷贝操作:
1 | >>> items = [1, ['foo', 'bar'], 2, 3] |
编程建议
如果你想创建一个自定义字典,继承 collections.abc 下的 MutableMapping 抽象类是个更好的选择。自定义字典和普通字典很像,但它可以给字典的默认行为加上一些变化,将分散的代码逻辑封装到自定义字典的内部逻辑。
用按需返回替代容器,比如 range() 函数不会一次性耗费大量内存和时间,生成一个巨大的列表,而是仅在被迭代时按需返回数字。range() 的进化过程虽然简单,但它其实代表了一种重要的编程思维:按需生成,而不是一次性返回。
Python 在实现列表时,底层使用了数组(Array)数据结构,所以要判断某个成员是否存在,唯一的办法是从前往后遍历所有成员,执行该操作的时间复杂度是 O(n)。如果代码需要进行 in 判断,可以考虑将目标容器转换成集合类型,作为查找时的索引使用。
除了使用 ** 解包字典,你还可以使用 * 运算符来解包任何可迭代对象:
1 | >>> d1 = {'name': 'apple'} |
1 | >>> l1 = [1, 2] |
集合里的成员不会重复,因此它经常用来去重。但是,使用集合去重有一个很大的缺点——得到的结果会丢失集合内成员原有的顺序:
1 | >>> nums = [10, 2, 3, 21, 10, 3] |
此时,可以使用 OrderedDict 来完成这件事:
1 | >>> from collections import OrderedDict |
推导式的核心意义在于它会返回值,如果你不需要这个新的返回值,就失去了使用表达式的意义。