Python 编程基础与工程实践(三):容器、可变性与数据处理

Python 很多表达力来自内置容器:列表保存有序数据,字典表达映射,集合处理唯一性,元组表示稳定结构。真正决定代码是否可靠的,不只是会调用方法,而是理解它们的可变性、别名、哈希与复制语义。

本文以 CPython 3.14 为基线,建立容器选型和数据处理的完整心智模型。

1. 先按语义选择容器

类型是否有序是否可变是否允许重复元素典型用途
list动态序列、批量处理
tuple固定记录、复合键、函数多返回值
dict按插入顺序键唯一键值映射、结构化记录
set不承诺业务顺序去重、成员检查、集合运算
frozenset不承诺业务顺序不可变集合、可哈希集合键

“有序”指迭代语义,不代表所有位置操作都高效。list 适合尾部追加和按下标访问,却不适合频繁从头部删除;队列应考虑 collections.deque

2. 列表:可变的动态序列

1
2
3
4
5
6
measurements = [10.2, 9.8, 10.5]
measurements.append(10.1)
measurements.extend([9.9, 10.0])

first = measurements[0]
last = measurements[-1]

切片返回一个新的列表:

1
2
3
window = measurements[1:4]
every_other = measurements[::2]
reversed_copy = measurements[::-1]

新列表只复制元素引用,不递归复制元素。若元素本身可变,两个列表仍可能共享内部对象。

排序要区分原地修改和返回新值:

1
2
3
4
5
6
7
records = [
    {"name": "B", "score": 92},
    {"name": "A", "score": 92},
    {"name": "C", "score": 88},
]

ordered = sorted(records, key=lambda item: (-item["score"], item["name"]))

sorted() 接受任意可迭代对象并返回新列表;list.sort() 原地排序并返回 None。不要写 records = records.sort(),否则名称会被绑定到 None

3. 元组:不可变的是结构,不一定是内部对象

1
2
point = (10, 20)
x, y = point

单元素元组依靠逗号,而不是括号:

1
2
single = (42,)
not_a_tuple = (42)

元组不能替换元素,但可以包含可变对象:

1
2
record = ("sensor-1", [10.1, 10.2])
record[1].append(10.3)

因此,“元组不可变”只表示元组保存的引用序列不能改变。一个元组能否作为字典键,还取决于其所有成员是否可哈希;上面的 record 包含列表,不能作为键。

解包可以让数据流更清楚:

1
2
3
4
head, *middle, tail = [1, 2, 3, 4, 5]
assert head == 1
assert middle == [2, 3, 4]
assert tail == 5

4. 字典:键到值的映射

1
2
3
4
5
6
7
device = {
    "id": "EQ-01",
    "online": True,
    "temperature": 24.5,
}

device["temperature"] = 25.0

从 Python 3.7 起,字典保持插入顺序是语言保证;但它仍是映射,不应把“第几个键”当成主要建模方式。

读取缺失键有三种常见策略:

1
2
3
4
5
6
7
8
9
# 缺失就是程序错误:抛出 KeyError
device_id = device["id"]

# 缺失可使用默认值
retry_count = device.get("retry_count", 0)

# 缺失时创建并保存默认值
groups: dict[str, list[int]] = {}
groups.setdefault("A", []).append(1)

批量分组时,defaultdict 通常更清晰:

1
2
3
4
5
from collections import defaultdict

groups: defaultdict[str, list[int]] = defaultdict(list)
for name, value in [("A", 1), ("B", 2), ("A", 3)]:
    groups[name].append(value)

遍历键值对使用 items()

1
2
for key, value in device.items():
    print(key, value)

keys()values()items() 返回动态视图。字典变化后,已有视图也会反映变化;它们不是创建时的独立快照。

5. 集合:唯一性与集合代数

1
2
3
4
5
6
7
expected = {"A", "B", "C"}
actual = {"B", "C", "D"}

missing = expected - actual       # {'A'}
unexpected = actual - expected   # {'D'}
common = expected & actual       # {'B', 'C'},先后顺序不保证
all_names = expected | actual

空集合必须写成 set(){} 创建的是空字典。

集合迭代顺序不应作为业务契约。即使某次运行看起来稳定,也可能受哈希随机化、元素变化或实现差异影响。需要稳定输出时显式排序:

1
2
for name in sorted(all_names):
    print(name)

集合元素和字典键必须可哈希。常见可哈希对象包括整数、字符串、字节和成员均可哈希的元组;列表、字典、普通集合不可哈希,因为可变对象在作为键期间改变会破坏查找语义。

6. 推导式:把映射和筛选写在一起

1
2
3
4
5
raw = [1, 2, 3, 4, 5]
squares_of_even = [value * value for value in raw if value % 2 == 0]

lookup = {value: value * value for value in raw}
unique_lengths = {len(str(value)) for value in raw}

推导式适合一层映射和简单筛选。出现多层嵌套、异常处理或复杂分支时,普通循环更容易阅读和调试。

生成器表达式使用圆括号,按需产生元素:

1
total = sum(value * value for value in range(1_000_000))

它避免先构造一个包含一百万个平方值的列表,但“惰性”不保证一定更快;它主要改变内存占用和求值时机。

7. 浅拷贝、深拷贝与别名

赋值不复制对象:

1
2
original = [[1, 2], [3, 4]]
alias = original

浅拷贝创建新的外层容器,内部元素仍共享:

1
2
3
4
5
6
shallow = original.copy()
shallow[0].append(99)

assert original[0] == [1, 2, 99]
assert shallow is not original
assert shallow[0] is original[0]

深拷贝会递归复制对象图,并使用备忘录处理共享引用和循环引用:

1
2
3
4
5
6
7
from copy import deepcopy

original = [[1, 2], [3, 4]]
deep = deepcopy(original)
deep[0].append(99)

assert original[0] == [1, 2]

deepcopy() 不是“安全按钮”。文件句柄、数据库连接、锁和外部资源通常不能通过复制获得合理语义;大型对象图也可能代价很高。更好的做法是先明确所有权,只复制真正需要独立修改的数据。

8. 可变默认值为何危险

函数默认参数在函数定义执行时求值一次,不是每次调用重新创建:

1
2
3
4
# 错误示例:多次调用共享同一个列表
def collect_bad(value: int, bucket: list[int] = []) -> list[int]:
    bucket.append(value)
    return bucket

正确做法是使用 None 作为哨兵:

1
2
3
4
5
6
7
8
9
def collect(value: int, bucket: list[int] | None = None) -> list[int]:
    if bucket is None:
        bucket = []
    bucket.append(value)
    return bucket


assert collect(1) == [1]
assert collect(2) == [2]

同样的问题也会出现在类属性、缓存和全局容器中。关键不是机械记忆“不要用列表”,而是先问:这个可变对象应该由所有调用共享,还是每次调用独占?

9. 不要在遍历时随意修改容器

遍历字典或集合时改变其大小会抛出运行时错误。需要删除元素时,可以遍历快照或构造新容器:

1
2
3
4
5
6
7
scores = {"A": 80, "B": 45, "C": 90}

filtered = {
    name: score
    for name, score in scores.items()
    if score >= 60
}

遍历列表时删除元素未必抛错,却可能跳过元素,因为后续下标发生移动:

1
2
values = [1, 2, 3, 4]
values = [value for value in values if value % 2 != 0]

如果确实需要保持原列表对象,可使用切片赋值:

1
values[:] = [value for value in values if value % 2 != 0]

10. 一个数据清洗示例

下面把容器选择、解析和聚合组合起来:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from collections import defaultdict

rows = [
    ("A", "10.2"),
    ("B", "invalid"),
    ("A", "9.8"),
    ("B", "11.0"),
]

grouped: defaultdict[str, list[float]] = defaultdict(list)
errors: list[tuple[str, str]] = []

for name, raw_value in rows:
    try:
        value = float(raw_value)
    except ValueError:
        errors.append((name, raw_value))
        continue
    grouped[name].append(value)

averages = {
    name: sum(values) / len(values)
    for name, values in grouped.items()
}

print(averages)  # {'A': 10.0, 'B': 11.0}
print(errors)    # [('B', 'invalid')]

这段代码没有默默丢弃坏数据,而是把失败记录保留下来,便于后续告警或人工处理。容器不只是存放数据,也是在表达错误策略和所有权。

11. 小结

  • 按业务语义选容器:序列、固定记录、映射和集合是不同抽象。
  • 元组不可改变自身结构,但内部对象仍可能可变;可哈希性要递归判断。
  • 字典保持插入顺序,集合不提供可依赖的业务顺序。
  • 赋值不复制,浅拷贝共享内部元素,深拷贝也不是所有对象的通用方案。
  • 推导式适合简单映射与筛选;复杂控制流应回到普通循环。

下一篇将把“可迭代”背后的协议展开,并学习函数参数、闭包、装饰器、生成器和上下文管理器。

参考资料

Licensed under CC BY-NC-SA 4.0