作者: andylin02
学习章节: 第 2 章 丰富的序列
关键词: 容器序列|扁平序列|可变序列|不可变序列|列表推导式|生成器表达式|元组拆包|具名元组|切片|增量赋值|bisect|array|deque


一、本章概述

《流畅的 Python》第 2 章详细讲解了 Python 中丰富的序列类型。Python 从 ABC 语言继承了对序列的统一处理方式——字符串、列表、字节序列、数组、XML 元素和数据库查询结果,这些序列在操作上有很多共通之处,都可以迭代、切片、排序和拼接。深入理解 Python 中不同的序列类型,不但能避免重新发明轮子,还可以从它们共通的接口上受到启发,在自己实现 API 时合理支持及利用现有和将来可能添加的序列类型。

Python 之父 Guido van Rossum 曾为 ABC 语言贡献过代码。在 Python 诞生以前,ABC 语言是一个历时 10 年的研究项目,旨在设计一种适合初学者的编程环境。现在来看,ABC 语言中的很多理念比较符合 Python 风格,例如对各种序列一视同仁、内置元组和映射类型、源码结构通过缩进实现、无须声明变量的强类型等。

二、内置序列类型概览

Python 标准库用 C 语言实现了丰富的序列类型,可以从两个维度进行分类:

2.1 按存储数据类型分类

  • 容器序列(container sequences)存放的是它们所包含的任意类型对象的引用,能存放不同类型的数据。容器序列包括:listtuplecollections.deque。由于存储的是引用,它们对内存的使用相对灵活,但每个元素占用更大的内存空间。

  • 扁平序列(flat sequences)存放的是值,是一段连续的内存空间,只能容纳一种类型。扁平序列包括:strbytesbytearraymemoryviewarray.array。由于元素是连续存储的,扁平序列更紧凑,内存占用更小,但只能存放字符、字节和数值这类基础类型。

# 容器序列示例:存储不同类型的引用
container = [42, "hello", [1, 2, 3]]  # list 可以存储各种类型

# 扁平序列示例:存储相同类型的值
flat_array = array.array('I', [1, 2, 3, 4])  # 'I' 代表无符号整数
flat_bytes = b"hello"  # 每个元素是 0-255 的整数
# 扁平序列只能存储同类型数据,不能混入文本

2.2 按可变性分类

  • 可变序列(mutable sequences) :可以被修改,包括 listbytearrayarray.arraycollections.dequememoryview
  • 不可变序列(immutable sequences) :创建后不能被修改,包括 tuplestrbytes

2.3 序列类型继承层次图

扁平序列

容器序列

可变序列

不可变序列

抽象基类层

collections.abc.Sequence
不可变序列协议

collections.abc.MutableSequence
可变序列协议

tuple
元组

str
字符串

bytes
字节串

list
列表

bytearray
字节数组

array.array
数组

collections.deque
双端队列

memoryview
内存视图

list

tuple

deque

str

bytes

bytearray

array.array

memoryview

三、列表推导与生成器表达式

3.1 列表推导式(list comprehension)

列表推导是构建列表(list)的快捷方式,能够写出可读性更好且更高效的代码。使用列表推导的原则:保持简洁,如果代码超过两行,建议使用 for 循环代替。

# 基本用法
symbols = '$¢£¥€¤'
codes = [ord(symbol) for symbol in symbols]
print(codes)  # [36, 162, 163, 165, 8364, 164]

# 带条件过滤的列表推导
odd_ascii = [ord(s) for s in symbols if ord(s) % 2 == 1]
print(odd_ascii)  # [163, 165, 8364, 164]

# 三层嵌套循环与条件(保持简洁!)
colors = ['black', 'white']
sizes = ['S', 'M', 'L']
tshirts = [(color, size) for color in colors for size in sizes]
print(tshirts)  # 内层 size 循环先变化
# [('black', 'S'), ('black', 'M'), ('black', 'L'), ('white', 'S'), ('white', 'M'), ('white', 'L')]

列表推导的执行顺序:第一个循环是外层循环,随后的循环是内层循环。如上例中,color 循环在外层,size 循环在内层,因此 size 先变化。

3.2 生成器表达式(generator expression)

生成器表达式的语法跟列表推导差不多,只不过把方括号换成圆括号。它遵守迭代器协议,可以逐个地产出元素,而不是先建立一个完整的列表,能节省大量内存。

# 生成器表达式
symbols = '$¢£¥€¤'
codes_gen = (ord(symbol) for symbol in symbols)
print(codes_gen)  # <generator object <genexpr> at 0x...>

# 可直接传递给构造函数,避免额外括号
# 注意:生成器是函数调用的唯一参数时,不需要额外圆括号
codes_tuple = tuple(ord(symbol) for symbol in symbols)
print(codes_tuple)  # (36, 162, 163, 165, 8364, 164)

# 生成器只能迭代一次!
codes_once = (x for x in range(3))
print(list(codes_once))  # [0, 1, 2]
print(list(codes_once))  # [] —— 空的!生成器已耗尽

3.3 列表推导 vs 生成器表达式对比

对比维度 列表推导式 生成器表达式
语法 [表达式 for 变量 in 可迭代对象] (表达式 for 变量 in 可迭代对象)
返回类型 list 对象 generator 对象
内存占用 一次性创建整个列表,内存占用与元素数量成正比 惰性求值,逐一产出,内存占用极低
迭代次数 可以多次迭代 只能迭代一次,迭代完成后耗尽
执行时机 立即执行 延迟执行(迭代时才触发)
适用场景 元素数量可控、需要多次复用、需要索引访问 大量数据处理、流式处理、无需索引访问

当输入的数据量较大时,列表推导可能会因为占用太多内存而出问题。由生成器表达式所返回的迭代器,可以逐次产生输出值,从而避免了内存用量问题。

四、元组不仅仅是不可变的列表

Python 中的元组有两个功能:

  1. 充当记录:记录数据的各个字段
  2. 充当不可变列表:确保数据不被修改

4.1 元组作为记录

元组拆包(unpacking)是元组充当记录的核心功能——通过拆包可以方便地提取字段到独立变量。

# 并行赋值——元组作为记录的基本方式
lax_coordinates = (33.9425, -118.408056)
latitude, longitude = lax_coordinates  # 元组拆包
print(f"纬度:{latitude}, 经度:{longitude}")

# 城市记录
city, year, pop, chg, area = ('Tokyo', 2003, 32450, 0.66, 8014)
print(f"{city}, {year}年, 人口:{pop}万")

# 交换变量值——最优雅的交换方式
a, b = 10, 15
b, a = a, b  # 无需临时变量
print(a, b)  # 15 10

4.2 拆包的强大功能

1. * 运算符处理剩余元素——扩展可迭代解包(PEP 3132)
# * 用于捕捉剩余元素
a, b, *rest = range(5)
print(a, b, rest)  # 0 1 [2, 3, 4]

a, *rest, b = range(5)
print(a, rest, b)  # 0 [1, 2, 3] 4

*rest, a, b = range(5)
print(rest, a, b)  # [0, 1, 2] 3 4

# 不需要的字段用 _ 占位符
obj = [i for i in range(5)]
_, second, _, _, _ = obj
print(second)  # 1
2. 函数返回多值
# Python 函数返回多个值,本质上返回一个元组
def div_mod(a, b):
    """返回商和余数"""
    return a // b, a % b

quotient, remainder = div_mod(20, 8)
print(f"20 ÷ 8 = {quotient}{remainder}")
3. 用 * 将可迭代对象拆开作为函数参数
# divmod 函数需要两个参数
t = (20, 8)
quotient, remainder = divmod(*t)  # * 将元组拆分成两个参数
print(f"20 ÷ 8 = {quotient}{remainder}")

# 获取文件路径中的文件名
import os
file_path = "/home/user/docs/notes.txt"
_, filename = os.path.split(file_path)
print(filename)  # notes.txt
4. 嵌套元组拆包——递归解包

嵌套元组拆包是元组拆包的一个强大功能,可以一层层解包嵌套的元组。

metro_areas = [
    ('Tokyo', 'JP', 36.933, (35.689722, 139.691667)),
    ('Delhi NCR', 'IN', 21.935, (28.613889, 77.208889)),
    ('Mexico City', 'MX', 20.142, (19.433333, -99.133333)),
    ('New York-Newark', 'US', 20.104, (40.808611, -74.020386)),
    ('Sao Paulo', 'BR', 19.649, (-23.547778, -46.635833)),
]

# 嵌套拆包:name, cc, pop, (lat, lon) 结构完全匹配
for name, cc, pop, (lat, lon) in metro_areas:
    if lon <= 0:
        print(f"{name:15} | {lat:9.4f} | {lon:9.4f}")

4.3 元组作为不可变列表

元组的不可变性体现在:不能对元组本身进行改变元素的赋值操作——但元组中存储的对象如果是可变的(如列表),那么这个可变对象的内容是可以被改变的。

# 不可变约束仅作用于元组本身
a = (10, 'a', [1, 2])
# a[0] = 20        # TypeError: 'tuple' object does not support item assignment
# a[-1] = [2, 3]   # TypeError: tuple doesn't support item assignment

# 但元组内包含的可变对象可以被修改!
a[-1].append(99)
print(a)            # (10, 'a', [1, 2, 99]) —— 元组还是原来的元组,但里面的列表变了

4.4 具名元组(namedtuple)

collections.namedtuple 是一个工厂函数,可以用来构建一个带字段名的元组和一个有名字的类。这个带名字的类对调试程序有很大帮助。

from collections import namedtuple

# 创建具名元组的类定义
# 参数1:类名;参数2:字段名列表(空格分隔的字符串或可迭代对象)
City = namedtuple('City', 'name country population coordinates')
tokyo = City('Tokyo', 'JP', 36.933, (35.689722, 139.691667))
print(tokyo)                # City(name='Tokyo', country='JP', population=36.933, coordinates=(35.689722, 139.691667))
print(tokyo.population)     # 36.933(可通过字段名访问)
print(tokyo[2])             # 36.933(也可通过索引访问)

# 专有属性和方法
print(City._fields)         # ('name', 'country', 'population', 'coordinates')——所有字段名元组

LatLong = namedtuple('LatLong', 'lat long')
delhi_data = ('Delhi NCR', 'IN', 21.935, LatLong(28.613889, 77.208889))
delhi = City._make(delhi_data)  # _make() 从可迭代对象构建实例,等价于 City(*delhi_data)
print(delhi._asdict())          # OrderedDict([('name', 'Delhi NCR'), ...])——转换为字典

namedtuple 构建的类的实例所消耗的内存跟普通元组是一样的,因为字段名都被存在对应的类里面,而不是每个实例的 __dict__ 中,这使得它比普通类实例更节省内存。

五、切片——深潜 Python 的切片操作

listtuplestr 等序列类型都支持切片操作。切片操作比人们想象的要强大很多。

5.1 忽略最后一个元素的设计约定

Python 的切片和区间操作遵循左闭右开约定:包含起始索引,不包含结束索引。这种设计有几大好处:

  • 可以清晰地表达区间长度:range(3)my_list[:3] 都返回 3 个元素
  • 分割区间时不会重叠:my_list[:x]my_list[x:] 正好无缝拼接
  • 使用 0 或负数索引时更容易计算
# 切片的基本使用
s = 'bicycle'
print(s[::3])   # 'bye'——步长 3
print(s[::-1])  # 'elcycib'——反转
print(s[::-2])  # 'eccb'——反向步长 2

5.2 切片赋值——就地修改序列

切片赋值是可变序列的一个强大特性,能修改原序列的值。

# 切片赋值——等长替换
a = list(range(10))
print(a)            # [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

# 替换索引 2 到 4 的区间(不包括 4)为 [20, 30]
a[2:4] = [20, 30]
print(a)            # [0, 1, 20, 30, 4, 5, 6, 7, 8, 9]

# 不等长替换——右边迭代的任意长度元素会被插入
a[2:4] = [100, 200, 300]
print(a)            # [0, 1, 100, 200, 300, 4, 5, 6, 7, 8, 9]

# 删除切片
a[2:5] = []         # 等价于 del a[2:5]
print(a)            # [0, 1, 4, 5, 6, 7, 8, 9]

5.3 背后原理:slice 对象

在切片表达式 my_seq[a:b:c] 中,Python 会创建一个 slice(a, b, c) 对象。这意味着切片可以存储起来重复使用,极大提高了代码的灵活性和可维护性。

# slice 对象可以命名复用
invoice = """
...    0.....6.........................40........52...55
...    1909  Pimoroni Pi3 Proto Board         $    8.99
...    1489  6mm Tactile Switch x20            $    4.95
... """

# 定义字段位置
SKU = slice(0, 6)
DESCRIPTION = slice(6, 40)
UNIT_PRICE = slice(40, 52)
QTY = slice(52, 55)

# 解析每一行
line_items = invoice.split('\n')[2:]
for item in line_items:
    if item:
        print(f"SKU: {item[SKU].strip()} | 描述: {item[DESCRIPTION].strip()} | 单价: {item[UNIT_PRICE].strip()} | 数量: {item[QTY].strip()}")

5.4 多维切片与省略号

在 NumPy 等科学计算库中,切片支持多个维度。...(省略号)表示省略任意多个维度。

# 多维切片示例(NumPy)
# x[i, ...] 等价于 x[i, :, :, :]
# 切片支持逗号分隔的多维索引

# 背后的原理:Python 将多维索引打包成元组传递给 __getitem__
# 例如 a[1:2, 3:4] 会被转换为 a.__getitem__((slice(1,2), slice(3,4)))

5.5 切片原理流程图

调用 __getitem__ / __setitem__

Python 转换过程

切片表达式语法

my_list[2]

my_list[2:5]

my_list[2:8:2]

my_list[:]

my_list[::-1]

整数键

slice(2,5,None)

slice(2,8,2)

slice(None,None,None)

slice(None,None,-1)

__getitem__(2)

__setitem__(slice(2,5), [a,b,c])

六、序列运算符

6.1 +* 运算符

+* 都不修改原有的操作对象,而是构建一个全新的序列。

# 基本用法
a = [1, 2, 3]
b = a * 4
print(b)  # [1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3]

# 列表的列表——潜藏的陷阱
board = [['_'] * 3 for _ in range(3)]  # ✅ 正确的三层方法
print(board)  # [['_', '_', '_'], ['_', '_', '_'], ['_', '_', '_']]
board[0][0] = 'X'
print(board)  # [['X', '_', '_'], ['_', '_', '_'], ['_', '_', '_']]

# ❌ 错误的方式:内部的列表是同一个对象的引用拷贝
bad_board = [['_'] * 3] * 3
bad_board[0][0] = 'X'
print(bad_board)  # 意外的结果:[['X', '_', '_'], ['X', '_', '_'], ['X', '_', '_']]

为什么 bad_board 会这样? ['_'] * 3 创建了一个列表,然后 [list] * 3同一个列表对象的引用复制了三遍。修改任意一个都会“传染”到所有行。

七、增量赋值:+=*= 的陷阱

+= 背后的特殊方法是 __iadd__,如果一个类没有实现 __iadd__ 方法,Python 会退一步调用 __add__ 方法。

  • 可变序列(如 list)实现了 __iadd__,因此 +=就地修改(对象 ID 保持不变)。
  • 不可变序列(如 tuple)没有实现 __iadd__,因此 += 会创建一个新对象

7.1 经典陷阱:元组中的 +=

t = (1, 2, [30, 40])
try:
    t[2] += [50, 60]  # 试图修改元组内的列表
except TypeError as e:
    print(f"TypeError: {e}")  # 'tuple' object does not support item assignment
print(t)  # (1, 2, [30, 40, 50, 60]) —— 列表确实修改成功!

为什么会这样? t[2] += [50, 60] 实际上分两步执行:第一步在列表上执行 __iadd__ 就地追加元素,列表成功被修改;第二步尝试将结果赋值回元组的第三个元素,此时 tuple 不支持元素赋值,所以抛出异常。但第一步的操作已经完成,这就是所谓的“意外成功”。

7.2 增量赋值工作流

执行 x += y

x 是否实现了 __iadd__?

调用 x.__iadd__(y)
就地修改对象

将结果绑定回 x

x 的同一性 ID 不变

调用 x.__add__(y)
创建新对象

将新对象赋值给 x

x 的 ID 发生变化

可变序列(list, array)

不可变序列(tuple, str, bytes)

八、高效管理序列:bisectarraydeque

8.1 用 bisect 管理已排序序列

bisect 模块包含两个主要函数:bisect 用于在有序序列中查找插入位置,insort 用于在有序序列中插入元素并保持有序。

import bisect

# bisect 查找插入点
haystack = [1, 4, 5, 6, 8, 12, 15, 20, 21, 23, 23, 26, 29, 30]
needles = [0, 1, 2, 5, 8, 10, 22, 23, 29, 30, 31]

for needle in needles:
    position = bisect.bisect_left(haystack, needle)  # 查找插入位置
    print(f"{needle:2} 应插入到索引 {position}")

# insort 插入元素
my_list = [1, 3, 5, 7]
bisect.insort(my_list, 4)
print(my_list)  # [1, 3, 4, 5, 7]

8.2 array:数值类型的高效存储

array.array 是在内存中紧凑存储同类型数值数据的方式。它比 list 更节省内存,尤其在处理数百万个数值时优势明显。

from array import array
from random import random

# 创建双精度浮点数数组
floats = array('d', (random() for _ in range(10**6)))
print(f"数组长度: {len(floats)}")
print(f"第一个元素: {floats[0]}")
print(f"最后一个元素: {floats[-1]}")

# 保存到文件
with open('floats.bin', 'wb') as f:
    floats.tofile(f)

# 从文件读取
floats2 = array('d')
with open('floats.bin', 'rb') as f:
    floats2.fromfile(f, 10**6)

print(floats == floats2)  # True

8.3 deque:双端队列的高效操作

deque(双端队列)支持在两端进行快速的插入和删除操作,时间复杂度均为 O(1)。相比之下,在 list 头部插入元素的时间复杂度是 O(n)

from collections import deque

# 创建双端队列,设置最大长度
dq = deque(range(10), maxlen=10)
print(dq)  # deque([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], maxlen=10)

# 两端操作
dq.append(10)          # 尾部添加
print(dq)              # deque([1, 2, ..., 9, 10], maxlen=10) —— 旧元素自动丢弃
dq.appendleft(-1)      # 头部添加
print(dq)              # deque([-1, 1, 2, ..., 9], maxlen=10)

# 旋转操作
dq.rotate(3)           # 向右旋转 3 步
print(dq)              # deque([7, 8, 9, -1, 1, 2, 3, 4, 5, 6], maxlen=10)
dq.rotate(-4)          # 向左旋转 4 步
print(dq)              # deque([3, 4, 5, 6, 7, 8, 9, -1, 1, 2], maxlen=10)

# 弹出操作
print(dq.pop())        # 从右端弹出
print(dq.popleft())    # 从左端弹出

九、内存管理与性能比较

本章介绍的几种序列类型在内存管理和性能上各有特点:

类型 分类 可变性 内存特点 适用场景
list 容器序列 可变 存储引用,内存开销大 通用动态数组
tuple 容器序列 不可变 存储引用,但结构不可变 不可变记录、字典键
deque 容器序列 可变 双端队列实现,两端操作 O(1) 队列、栈、滑动窗口
array 扁平序列 可变 紧凑存储,内存占用低 大量同类型数值
str 扁平序列 不可变 Unicode 字符紧凑存储 字符串处理
bytes 扁平序列 不可变 字节值紧凑存储 二进制数据处理
memoryview 扁平序列 可变 内存视图,无数据复制 处理大数据分片

memoryview 的核心价值:在不复制数据的前提下访问内存中的二进制数据。对于大型数组或文件,memoryview 可以显著减少内存拷贝开销。

十、本章思维导图

第2章 丰富的序列

序列分类

按存储分类

容器序列
list, tuple, deque

扁平序列
str, bytes, array

按可变性分类

可变序列
list, bytearray, deque

不可变序列
tuple, str, bytes

列表推导与生成器表达式

列表推导式

生成器表达式

内存占用对比

元组的双重身份

用作记录-拆包

用作不可变列表

基础拆包

* 扩展解包

嵌套拆包

具名元组namedtuple

切片

基本切片语法

切片赋值

slice对象复用

多维切片

序列运算符

+ 和 *

列表的列表-陷阱

增量赋值 +=

不可变序列中的陷阱

高效序列类型

bisect模块

array.array

deque双端队列

memoryview

十一、总结

第 2 章“丰富的序列”系统讲解了 Python 序列类型的全貌。学习后发现,Python 之所以让开发者爱不释手,很大程度源于它对序列的精妙设计:一致的接口、灵活的操作和强大的扩展能力。

  • 序列分类是理解的基础:容器序列存储引用,扁平序列存储值;可变序列可修改,不可变序列保证数据安全。
  • 列表推导和生成器表达式是代码精简的利器:但要注意生成器只能迭代一次,在大量数据处理时要优先使用生成器。
  • 元组不仅是不可变的列表,更是数据的记录载体:拆包功能的强大让人惊叹。具名元组在保留元组内存效率的同时,赋予了代码更好的可读性。
  • 切片是 Python 序列优雅设计的最佳体现:可以命名切片对象复用,切片赋值还能就地修改序列,效率惊人。
  • 增量赋值(+=)涉及可变性的陷阱:在元组上对可变元素使用 += 可能导致“一半成功、一半报错”的局面,理解可变性有利于避免踩坑。
  • 选择合适的序列类型至关重要bisect 用于维护有序序列,array 存储大量数值节省内存,deque 用于双端高效操作。根据场景选择合适的类型,代码质量和性能都能明显提升。

十二、思考题

  1. 列表拼接的陷阱a = [1, 2, 3]; a += (4, 5) 在 Python 中是合法的,执行后 a 变为 [1, 2, 3, 4, 5]。但为什么 a = a + (4, 5) 会抛出 TypeError

  2. 自己实现 namedtuple:你能用自定义类实现一个类似 namedtuple 的功能吗?如何确保实例的内存占用与普通元组一样小?

  3. list.sort()sorted() 的区别:前者原地排序并返回 None,后者返回一个新列表。为什么 Python 设计这样的区分?在哪些场景下各具优势?

  4. 切片赋值的内容要求:执行 a[2:4] = [100] 时,右边的可迭代对象是否必须是序列?能否是生成器?为什么?

  5. del 与切片删除的关系del a[2:5]a[2:5] = [] 本质上有什么区别和联系?它们的性能有什么差异?

十三、下一章预告

第 3 章《字典和集合》

下一章将从数组存储元素切换到 键值对映射和集合,这是 Python 中应用最广泛的数据结构之一。第 3 章将涵盖:

  • 字典推导式:如何用简洁的语法快速构建字典和集合。
  • defaultdict 自动处理缺失键:告别 KeyError,优雅构建分组。
  • OrderedDict 保留插入顺序:在 Python 3.7+ 中,普通字典也保留顺序,但 OrderedDict 仍保留了重新排序等特殊方法。
  • Counter 计数器:一行代码实现词频统计,内置许多统计方法。
  • 散列表的内部原理:深入理解 Python 字典的底层实现——是理解 Set 和 Dict 内存布局与性能的基石。
  • 可散列对象:理解什么样的对象可以作为字典的键。

学完第 3 章,你会发现 Python 字典远比想象中强大——它不仅是基础容器,更是 Python 语言本身的骨架!


本文为个人学习笔记,仅用于知识分享。如有错误,欢迎指正。
👍🏻 点赞 + 收藏 + 分享,让更多开发者看到这篇深度解析!❤️ 如果觉得有用,请给个赞支持一下作者!

更多推荐