变量与基础类型

变量与注释

当我们看到一段代码时,最先注意到的,不是代码有几层循环,用了什么模式,而是变量与注释,因为它们是代码里最接近自然语言的东西,最容易被大脑消化、理解。

变量常见用法

Python 支持灵活的动态解包语法,只要用星号表达式(*variables)作为变量名,它便会贪婪地捕获多个值对象,并将捕获到的内容作为列表赋值给 variables:

1
2
3
4
>>> data = ['ethan', 'apple', 'orange', 'banana', 100]
>>> username, *fruits, score = data
>>> fruits
['apple', 'orange', 'banana', 100]

在 Python 交互式命令行里,_ 变量还有一层特殊含义——默认保存我们输入的上个表达式的返回值:

1
2
3
4
>>> 'foo'.upper()
'FOO'
>>> print(_)
FOO

给变量注明类型

相比编写 Sphinx 格式文档,类型注解更推荐使用:

1
2
3
4
5
6
def remove_invalid(items):
""" 剔除 items 里面无效的元素

:param items: 待剔除对象
:type items: 包含整数的列表,[int, ...]
"""
1
2
3
4
5
6
7
from typing import List

def remove_invalid(items: List[int]):
""" 剔除 items 里面无效的元素

:param items: 待剔除对象
"""

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
2
3
from django.utils.translation import gettext as _

print(_('text'))

注释基础知识

函数(类)文档(Docstring)也被称为接口注释(Interface Comment),注释作为代码之外的说明性文字,应该尽量提供无法从代码里读出来的信息。在编写接口文档时,我们应该站在函数设计者的角度,着重描述函数的功能、参数说明等,而函数自身的实现细节,无须放在接口文档里。

编程建议

在组织代码时,我们应该谨记:总是从代码的职责出发,而不是其他东西。通过把变量定义移动到每段各司其职的代码头部,可以大大缩短变量从初始化到被使用的距离。

直接翻译业务逻辑的代码,大多不是好代码。优秀的程序设计需要在理解原需求的基础上,恰到好处地抽象,只有这样才能同时满足可读性和可扩展性方面的需求。通过增加数据类,可以更有逻辑地组织函数内的局部变量,减少数量:

1
2
3
4
5
6
7
8
def import_users_from_file(fp):
duplicated_users, banned_users, normal_users = [], [], []
for line in fp:
parsed_user = parse_user(line)
# TODO: 进行判断处理,修改前面定义的 xx_users
succeeded_count, failed_count = 0, 0
# TODO: 读取 xx_users,写入数据库并修改成功与失败的数量
return succeeded_count, failed_count
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class ImportedSummary:
def __init__(self):
self.succeeded_count = 0
self.failed_count = 0

class ImportingUserGroup:
def __init__(self):
self.duplicated = []
self.banned = []
self.normal = []

def import_users_from_file(fp):
importing_user_group = ImportingUserGroup()
for line in fp:
parsed_user = parse_user(line)
# TODO: 进行判断处理,修改前面定义的 importing_user_group
summary = ImportedSummary()
# TODO: 读取 importing_user_group,写入数据库并修改成功与失败的数量
return summary.succeeded_count, summary.failed_count

每个函数的名称与接口注释,其实是一种比函数内部代码更为抽象的东西,在写出一句有说服力的接口注释前,别写任何函数代码

数值与字符串

我们离不开数字和文字,正如同编程语言离不开数值与字符串,两者几乎是所有编程语言里最基本的数据类型,也是我们通过代码连接现实世界的基础。

数值基础

在定义数值字面量时,如果数字特别长,可以通过插入 _ 分隔符来让它变得更易读:

1
2
3
>>> n = 1_000_000
>>> i + 10
1000010

通过 0 和 1 模拟表示浮点数时,计算机做不到绝对的精准,Python 使用符合 IEEE-754 规范的双精度尽力而为。

布尔值其实也是数字

在绝大数情况下,True 和 False 这两个布尔值可以直接当作 1 和 0 来使用,常用来简化统计总数操作:

1
2
3
4
5
6
numbers = [1, 2, 4, 5, 7]

count = 0
for i in numbers:
if i % 2 == 0:
count += 1
1
count = sum(i % 2 == 0 for i in numbers)

字符串常用操作

假如你想反转一个字符串,可以使用切片操作或者 reversed 内置方法:

1
2
3
4
5
>>> s = 'Hello, world!'
>>> s[::-1]
'!dlrow ,olleH'
>>> ''.join(reversed(s))
'!dlrow ,olleH'

三种主流的字符串格式化方式:

  • C 语言风格的基于百分号 % 的格式化语句;
  • str.format 字符串格式化方式;
  • f-string 字符串字面量格式化表达式;

日常编码中推荐使用 f-string,并搭配 str.format 作为补充。它们共享了同一种复杂的字符串格式化微语言,通过这种微语言,我们可以方便地对字符串进行二次加工。str.format 支持用位置参数来格式化字符串,实现对参数的重复使用。

不常用但特别好用的字符串方法

使用 partition 函数代替 split,原本的分支判断逻辑可以消失:

1
2
3
4
5
6
def extract_value(s):
items = s.split(':')
if len(items) == 2:
return items[1]
else:
return ''
1
2
def extract_value(s):
return s.partition(':')[-1]

str.translate(table) 方法可以按规则一次性替换多个字符,使用它比调用多次 replace 方法更快也更简单:

1
2
3
4
>>> s = '明明是中文,却使用了英文标点.'
>>> table = s.maketrans(',.', ',。')
>>> s.translate(table)
'明明是中文,却使用了英文标点。'

字符串与字节串

广义上的字符串概念可以分为两类:

  • 字符串:我们最常挂在嘴边的普通字符串,是给人看的,对应 Python 中的 str 类型。str 使用 Unicode 标准,可通过 .encode() 编码为字节串;
  • 字节串:有时也称为二进制字符串,是给计算机看的,对应 Python 中的 bytes 类型。bytes 一定包含某种真正的字符串编码格式(默认为 UTF-8),可通过 .decode() 解码为字符串;

Python 里的字符串处理其实很简单,关键在于:用一个边缘转换层把人类和计算机的世界隔开

  • 程序从文件或其他外部存储读取字节串内容,将其解码为字符串,然后再在内部使用;
  • 程序完成处理,要把字符串写入文件或其他外部存储,将其编码为字节串,然后继续执行其他操作;

编程建议

使用常量和枚举类型来代替字面量:

1
2
3
4
5
6
7
8
def add_daily_points(user):
if user.type == 13:
return
if user.type == 3:
user.points += 120
return
user.points += 100
return
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from enum import Enum

DAILY_POINTS_REWARDS = 100
VIP_EXTRA_POINTS = 20

class UserType(int, Enum):
VIP = 3
BANNED = 13

def add_daily_points(user):
if user.type == UserType.BANNED:
return
if user.type == UserType.VIP:
user.points += DAILY_POINTS_REWARDS + VIP_EXTRA_POINTS
return
user.points += DAILY_POINTS_REWARDS
return

当代码里出现复杂的裸字符串处理逻辑时:

  • 结构化字符串:寻找是否有对应的开源专有模块,比如处理 SQL 语句的 SQLAlchemy、处理 XML 的 lxml 模块等;
  • 非结构化字符串:请考虑使用 Jinja2 等模版引擎,而不是手动拼接;

别轻易成为 SQL 语句大师,对于 SQL 语句这种结构化、有规则的特殊字符串,用对象化的方式构建和编辑才是更好的做法。不光更短、更好维护,而且根本不需要担心 SQL 注入问题。

对象关系映射(ORM)指一种把数据库中的数据自动映射为程序内对象的技术

除了用反斜杠 \ 和加号 + 将长字符串拆分为几段,还有一种更简单的办法,那就是拿括号将长字符串包起来,之后就可以随意折行了:

1
2
s = ('This is the first line of a long string, '
'this is the second line.')

容器类型

代码里的容器,泛指那些专门用来装其他对象的特殊数据类型。

列表常用操作

内置函数 list(iterable) 可以把任何一个可迭代对象转换为列表,比如字符串:

1
2
>>> list('foo')
['f', 'o', 'o']

enumerate() 适用于任何可迭代对象,因此它不光可以用于列表,还可以用于元组、字典、字符串等其他对象。

理解列表的可变性

Python 里的内置数据类型,大致上可以分为两种:

  • 可变:列表、字典、集合;
  • 不可变:整数、浮点数、字符串、字节串、元组;

两种函数参数传递机制:

  • 值传递(Pass by Value):调用函数时,传过去的是变量所指向对象值的拷贝,因此对函数内变量的任何修改,都不会影响原始变量;
  • 引用传递(Pass by Reference):调用函数时,传过去的是变量自身的内存地址,因此修改函数内的变量会直接影响原始变量;

Python 在进行函数调用传参时,采用的既不是值传递,也不是引用传递,而是传递了变量所指对象的引用(Pass by Object Reference),一切行为取决于对象的可变性。

常用元组操作

没有元组推导式,只有生成器推导式:

1
2
3
>>> results = (n * 100 for n in range(10) if n % 2 == 0)
>>> results
<generator object <genexpr> at 0x109fd03c0>

元组经常用来存放结构化数据:

1
2
3
>>> user_info = ('ethan', 'MALE', 30, True)
>>> user_info[2]
30

具名元组

具名元组(Namedtuple)在保留普通元组功能的基础上,允许为元组的每个成员命名,这样你便能通过名称而不止是数字索引访问成员。使用 typing.NamedTuple 和类型注解语法来定义具名元组类型:

1
2
3
4
5
6
7
from typing import NamedTuple

class Rectangle(NamedTuple):
width: int
height: int

rect = Rectangle(100, 20)
1
2
3
4
>>> print(rect[0])
100
>>> print(rect.width)
100

字典常用操作

当用不存在的键访问字典内容时,程序会抛出 KeyError 异常,我们通常称之为程序里的边界情况(Edge Case)。针对这种边界情况,比较常见的处理方式有两种:

1
2
3
4
if 'rating' in movie:
rating = movie['rating']
else:
rating = 0
1
2
3
4
try:
rating = movie['rating']
except KeyError:
rating = 0

Python 中更推崇第二种写法,因为它看起来更简洁,执行效率也更高。不过,如果只是提供默认值的读取操作,其实可以直接使用字典的 .get() 方法。

有时,我们需要修改字典中某个可能不存在的键,使用 setdefault 取值并修改:

1
2
3
4
>>> d = {'title': 'foobar'}
>>> d.setdefault('items', []).append('foo')
>>> d
{'title': 'foobar', 'items': ['foo']}

认识字典的有序性和无序性

Python 里的字典在底层使用了哈希表(Hash Table)数据结构。当你往字典里存放一对 key:value 时,解释器会先通过哈希算法计算出 key 的哈希值,然后根据这个哈希值,决定数据在表里的具体位置。

OrderedDict 与字典其实有着一些细微区别:

1
2
3
4
>>> d1 = {'name': 'ethan', 'fruit': 'apple'}
>>> d2 = {'fruit': 'apple', 'name': 'ethan'}
>>> d1 == d2
True
1
2
3
4
>>> d1 = OrderedDict('name': 'ethan', 'fruit': 'apple')
>>> d2 = OrderedDict('fruit': 'apple', 'name': 'ethan')
>>> d1 == d2
False

集合常用操作

集合的所有操作都可以用方法和运算符两种方式来进行:

& | -
intersection union difference

集合里只能存放可哈希(Hashable)的对象,假如把不可哈希的对象放入集合,程序就会抛出 TypeError 异常:

1
2
>>> invalid_set = {'foo', [1, 2, 3]}
TypeError: unhashable type: 'list'

了解对象的可哈希性

某种类型是否可哈希遵循下面的规则:

  • 所有的不可变内置类型,都是可哈希的,比如 str、int 等;
  • 所有的可变内置类型,都不是可哈希的,比如 dict、list 等;
  • 对于不可变容器类型,仅当它的所有成员都不可变时,它自身才是可哈希的,比如 tuple、frozenset 等;
  • 用户定义的类型默认都是可哈希的;

深拷贝与浅拷贝

实现浅拷贝的方式:

  • 使用 copy 模块下的 copy() 方法;
  • 推导式也可以产生一个浅拷贝对象;
  • 使用各容器类型的内置构造函数;
  • 全切片也可以实现浅拷贝效果;
  • 有些类型自身提供了浅拷贝方法;

对于一些层层嵌套的复杂数据来说,浅拷贝仍然无法解决嵌套对象被修改的问题。要解决这个问题,可以用 copy.deepcopy() 函数来进行深拷贝操作:

1
2
3
4
5
>>> items = [1, ['foo', 'bar'], 2, 3]
>>> import copy
>>> items_deep = copy.deepcopy(items)
>>> id(items[1]), id(items_deep[1])
(4463367680, 4462513856)

编程建议

如果你想创建一个自定义字典,继承 collections.abc 下的 MutableMapping 抽象类是个更好的选择。自定义字典和普通字典很像,但它可以给字典的默认行为加上一些变化,将分散的代码逻辑封装到自定义字典的内部逻辑

用按需返回替代容器,比如 range() 函数不会一次性耗费大量内存和时间,生成一个巨大的列表,而是仅在被迭代时按需返回数字。range() 的进化过程虽然简单,但它其实代表了一种重要的编程思维:按需生成,而不是一次性返回

Python 在实现列表时,底层使用了数组(Array)数据结构,所以要判断某个成员是否存在,唯一的办法是从前往后遍历所有成员,执行该操作的时间复杂度是 O(n)。如果代码需要进行 in 判断,可以考虑将目标容器转换成集合类型,作为查找时的索引使用。

除了使用 ** 解包字典,你还可以使用 * 运算符来解包任何可迭代对象:

1
2
3
>>> d1 = {'name': 'apple'}
>>> {'foo': 'bar', **d}
{'foo': 'bar', 'name': 'apple'}
1
2
3
4
>>> l1 = [1, 2]
>>> l2 = [3, 4]
>>> [*l1, *l2]
[1, 2, 3, 4]

集合里的成员不会重复,因此它经常用来去重。但是,使用集合去重有一个很大的缺点——得到的结果会丢失集合内成员原有的顺序:

1
2
3
>>> nums = [10, 2, 3, 21, 10, 3]
>>> set(nums)
{3, 10, 2, 21}

此时,可以使用 OrderedDict 来完成这件事:

1
2
3
>>> from collections import OrderedDict
>>> list(OrderedDict.fromkeys(nums).keys())
[10, 2, 3, 21]

推导式的核心意义在于它会返回值,如果你不需要这个新的返回值,就失去了使用表达式的意义。