Python:列表、元组、字典、集合为什么不只是会用这么简单

列表、元组、字典、集合这四种容器,几乎每个 Python 初学者都会很早遇到。

问题通常不是“没学过”,而是学完之后一写实际脚本就开始混:

  • 明明要做去重,却还在用列表反复判断
  • 明明要做计数,却不知道为什么字典总是 KeyError
  • 明明只是想存一条记录,却没想清楚为什么这里该用元组而不是列表
  • 明明代码能跑,后面一排序、一分组、一统计就开始乱

这篇文章不按定义背诵,而是直接做一件实际的事:写一个接口访问日志汇总脚本

这个脚本要完成四件事:

  1. 统计总请求数
  2. 统计独立 IP 数量
  3. 统计每个接口被访问了多少次
  4. 找出出现错误状态码的 IP

只要这个脚本能稳定写出来,列表、元组、字典、集合就不再只是“知道怎么写出来”,而是开始进入实际使用阶段。

一、这篇文章要解决什么问题

读完这一篇,应该能独立完成下面这些动作:

  • 知道四种容器各自适合解决什么问题
  • 在一个实际脚本里同时使用 listtupledictset
  • 知道哪些场景必须选元组,不能随手用列表代替
  • 用字典完成计数和分组
  • 用集合完成去重和异常 IP 收集
  • 给这类脚本补最小测试
  • 遇到报错时,能从容器选型角度排查问题

二、先看这篇文章要完成的实际场景

先不讲概念,先把场景放出来。

假设现在手里有一份最简单的访问日志 sample_logs.txt

1
2
3
4
5
6
7
10.0.0.1,GET,/login,200
10.0.0.2,GET,/health,200
10.0.0.1,POST,/login,401
10.0.0.3,GET,/orders,200
10.0.0.2,GET,/orders,500
10.0.0.1,GET,/orders,200
10.0.0.3,GET,/orders,200

每一行代表一条请求记录,格式固定为:

1
IP,请求方法,路径,状态码

现在要写一个脚本,输出结果类似这样:

1
2
3
4
5
6
7
8
9
总请求数: 7
独立 IP 数: 3
异常 IP: ['10.0.0.1', '10.0.0.2']

接口访问次数:
GET /health -> 1
GET /login -> 1
GET /orders -> 3
POST /login -> 1

这个需求很小,但已经足够把四种容器全部用起来。

三、先给一个最小可运行示例

先不要一上来就做文件读取,先把最小版本跑通。

新建 log_report.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
logs = [
("10.0.0.1", "GET", "/login", 200),
("10.0.0.2", "GET", "/health", 200),
("10.0.0.1", "POST", "/login", 401),
("10.0.0.3", "GET", "/orders", 200),
]

unique_ips = set()
count_by_route = {}

for ip, method, path, status in logs:
unique_ips.add(ip)
route_key = (method, path)
count_by_route[route_key] = count_by_route.get(route_key, 0) + 1

print("独立 IP 数:", len(unique_ips))
print("接口访问次数:", count_by_route)

执行命令:

1
python log_report.py

输出:

1
2
独立 IP 数: 3
接口访问次数: {('GET', '/login'): 1, ('GET', '/health'): 1, ('POST', '/login'): 1, ('GET', '/orders'): 1}

这个最小示例已经把四种容器带出来了:

  • logs 是列表,负责保存“按顺序排列的多条记录”
  • 每一条记录是元组,负责保存“字段固定、顺序固定的一条数据”
  • count_by_route 是字典,负责做计数
  • unique_ips 是集合,负责去重

后面只是把这个最小版本继续补完整。

四、为什么这里不能随便挑个容器就写

1. 列表解决的是“顺序”和“批量处理”

日志天然是一批一批来的,脚本通常也要一条一条扫过去。

所以这类数据天然适合放在列表里:

1
2
3
4
logs = [
("10.0.0.1", "GET", "/login", 200),
("10.0.0.2", "GET", "/health", 200),
]

列表最直接的价值是两件事:

  • 保留原始顺序
  • 方便遍历

如果后面还要按时间窗口处理、分页处理、逐条清洗,列表通常都是第一层容器。

2. 元组解决的是“这是一条固定结构的记录”

这条日志记录有四个字段:

1
("10.0.0.1", "GET", "/login", 200)

它适合用元组,不适合用列表,原因不是“元组高级”,而是这条记录本身就不应该在处理中随意增删字段。

元组在这里至少有两个直接价值:

  • 用位置表达固定结构
  • 可以作为字典的键

第二点非常关键,后面做接口计数时会立刻用到。

3. 字典解决的是“按键查找”和“累计统计”

要统计每个接口被访问多少次,最自然的结构就是:

1
2
3
4
count_by_route = {
("GET", "/orders"): 3,
("POST", "/login"): 1,
}

字典最适合这类需求:

  • 已经见过这个键,就累加
  • 还没见过这个键,就先给默认值

4. 集合解决的是“去重”

独立 IP 数量不是“总行数”,而是“不同 IP 的个数”。

所以这里最自然的做法不是列表,而是集合:

1
unique_ips = {"10.0.0.1", "10.0.0.2", "10.0.0.3"}

集合的意义不是语法特别,而是它天然就只保留唯一值。

五、把脚本真正写完整

下面把这个脚本补成一个能读取文件、能输出结果、能处理坏数据的版本。

log_report.py

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
from pathlib import Path


def load_logs(file_path: str) -> list[tuple[str, str, str, int]]:
logs: list[tuple[str, str, str, int]] = []

for line_no, raw_line in enumerate(Path(file_path).read_text().splitlines(), start=1):
line = raw_line.strip()
if not line:
continue

parts = [part.strip() for part in line.split(",")]
if len(parts) != 4:
raise ValueError(f"line {line_no} format error: {raw_line}")

ip, method, path, status_text = parts
status = int(status_text)
logs.append((ip, method, path, status))

return logs


def build_summary(logs: list[tuple[str, str, str, int]]) -> dict:
unique_ips = set()
error_ips = set()
count_by_route = {}

for ip, method, path, status in logs:
unique_ips.add(ip)

route_key = (method, path)
count_by_route[route_key] = count_by_route.get(route_key, 0) + 1

if status >= 400:
error_ips.add(ip)

sorted_route_counts = sorted(count_by_route.items(), key=lambda item: item[0])

return {
"total_requests": len(logs),
"unique_ip_count": len(unique_ips),
"error_ips": sorted(error_ips),
"route_counts": sorted_route_counts,
}


def print_summary(summary: dict) -> None:
print("总请求数:", summary["total_requests"])
print("独立 IP 数:", summary["unique_ip_count"])
print("异常 IP:", summary["error_ips"])
print()
print("接口访问次数:")

for (method, path), count in summary["route_counts"]:
print(f"{method} {path} -> {count}")


if __name__ == "__main__":
logs = load_logs("sample_logs.txt")
summary = build_summary(logs)
print_summary(summary)

这份代码里,四种容器的职责已经分得很清楚:

  • list[tuple[str, str, str, int]]:表示整批日志
  • tuple[str, str, str, int]:表示单条日志记录
  • dict:表示汇总结果和接口计数
  • set:表示唯一 IP 和异常 IP

六、实际执行命令和实际输出

目录先放成这样:

1
2
3
4
python_containers_demo/
├── log_report.py
├── sample_logs.txt
└── test_log_report.py

执行命令:

1
python log_report.py

输出结果:

1
2
3
4
5
6
7
8
9
总请求数: 7
独立 IP 数: 3
异常 IP: ['10.0.0.1', '10.0.0.2']

接口访问次数:
GET /health -> 1
GET /login -> 1
GET /orders -> 3
POST /login -> 1

这份输出里最容易忽略的地方有两个:

  • GET /orders -> 3 证明字典计数已经生效
  • 异常 IP 只有两个,说明集合去重已经生效,10.0.0.1 即使多次报错也只会保留一份

七、最常见的错误示例和修复示例

1. 用列表当字典键,直接报错

错误写法:

1
2
route_key = [method, path]
count_by_route[route_key] = count_by_route.get(route_key, 0) + 1

报错:

1
TypeError: unhashable type: 'list'

原因很直接:列表可变,不能作为字典键。

修复写法:

1
2
route_key = (method, path)
count_by_route[route_key] = count_by_route.get(route_key, 0) + 1

这就是为什么元组在很多脚本里不是“可有可无”,而是直接决定某种写法能不能成立。

2. 把 {} 当成空集合

错误写法:

1
2
unique_ips = {}
unique_ips.add("10.0.0.1")

报错:

1
AttributeError: 'dict' object has no attribute 'add'

原因是 {} 在 Python 里表示空字典,不表示空集合。

修复写法:

1
2
unique_ips = set()
unique_ips.add("10.0.0.1")

3. 计数时直接 += 1,但键还没初始化

错误写法:

1
count_by_route[route_key] += 1

报错:

1
KeyError: ('GET', '/orders')

修复写法:

1
count_by_route[route_key] = count_by_route.get(route_key, 0) + 1

这类问题在字典计数里非常常见。只要是第一次出现的键,就必须先给默认值。

八、怎么测试这个知识点

这类文章不能只停在“脚本跑通了”。只要里面有统计逻辑,就值得补最小测试。

test_log_report.py

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
28
29
30
from log_report import build_summary


def test_build_summary_counts():
logs = [
("10.0.0.1", "GET", "/login", 200),
("10.0.0.1", "GET", "/login", 200),
("10.0.0.2", "POST", "/login", 401),
]

summary = build_summary(logs)

assert summary["total_requests"] == 3
assert summary["unique_ip_count"] == 2
assert summary["error_ips"] == ["10.0.0.2"]
assert summary["route_counts"] == [
(("GET", "/login"), 2),
(("POST", "/login"), 1),
]


def test_build_summary_deduplicate_error_ips():
logs = [
("10.0.0.2", "GET", "/orders", 500),
("10.0.0.2", "GET", "/orders", 404),
]

summary = build_summary(logs)

assert summary["error_ips"] == ["10.0.0.2"]

执行命令:

1
pytest -q

输出:

1
2
..                                                                   [100%]
2 passed in 0.02s

这两个测试分别验证了两件核心事实:

  • 字典计数没有算错
  • 集合去重没有失效

如果后面改了代码,比如把 set 又改回 list,这种错误很快就会被测试拦住。

九、一个实际排错场景

只要脚本开始读文件,就很容易遇到坏数据。

假设某天日志文件里混进了一行格式错误的数据:

1
10.0.0.8,GET,/orders

这时执行:

1
python log_report.py

报错可能是:

1
ValueError: line 8 format error: 10.0.0.8,GET,/orders

排查顺序应该直接按下面这条线走:

  1. 先确认是不是某一行字段数量不对
  2. 再确认分隔符是不是还是逗号
  3. 再确认状态码字段是不是缺失或不是整数

如果没有前面的 len(parts) != 4 校验,脚本就很可能在这里直接抛出另一种更难读的错误:

1
ValueError: not enough values to unpack (expected 4, got 3)

这两个报错的区别很大:

  • 后者只能说明程序崩了
  • 前者直接指出第几行、哪一行内容错了

修复方式也应该是实际的:

  • 先修坏数据
  • 再重新执行脚本
  • 再补一条针对坏数据的测试,避免下次同类问题继续混进来

例如可以补一个测试,保证格式不对时一定抛出清晰错误。

十、为什么“只是会用”远远不够

如果只是停在下面这种层面:

  • 知道 [] 是列表
  • 知道 () 是元组
  • 知道 {} 可以写字典
  • 知道 set() 可以去重

那一到实际脚本里还是会乱。

真正需要补的是下面这层判断:

  • 一批有顺序的数据,先放列表
  • 一条固定结构的记录,优先用元组
  • 需要分组、计数、查找时,用字典
  • 需要唯一值时,用集合

也就是说,容器不是单纯的语法点,而是代码组织方式。

一个脚本能不能越写越稳,往往不是取决于语法有多花,而是取决于这些基础容器是不是选对了。

十一、这一节学完后还要继续补什么

把四种容器放进实际脚本后,下一步最该继续补的是这几块:

  • 列表推导式和字典推导式怎么让清洗代码更短
  • sortedkeylambda 怎么把结果按不同规则排序
  • 函数拆分怎么让脚本从几十行走向可维护
  • 文件读取、异常处理、测试怎么接进脚本工程

也就是说,这一篇解决的是“基础容器在实际场景里怎么站住”,后面还要继续补“如何用这些容器把脚本继续拆稳”。

十二、一个实际练习

把这篇文章里的脚本再往前做一步,练习目标定成下面四件事:

  1. 额外统计每种状态码出现了多少次
  2. 输出访问次数最多的前 2 个接口
  3. 把异常 IP 单独写到 error_ips.txt
  4. 给“坏数据行”补一个失败测试

做这组练习时,重点不是把功能越写越多,而是继续保持容器职责清晰:

  • 状态码计数还是字典
  • 前 2 个接口还是建立在字典统计结果上
  • 异常 IP 仍然先用集合去重,再决定怎么输出
  • 一整批日志记录仍然应该用列表保存

如果这四件事都能自己做下来,列表、元组、字典、集合就已经不再只是“记住名字和写法”,而是真正进入脚本开发的基本功阶段。