list · dict · tuple · set · 列表推导式 · 字典推导式 · 生成器表达式 · 切片 · Counter
列表、字典、推导式、切片:Java 程序员的 Python 集合速成
底下还是你熟悉的东西:列表是装引用的动态数组(≈ ArrayList<Object>),字典是按插入顺序排的哈希表(≈ LinkedHashMap)。Python 的精华是把它们做成了语法:[] {} 直接写数据,推导式一行写完“过滤 + 变换 + 收集”,切片用左闭右开的 [起:止:步] 取一段,得到的是一份新列表。不懂这一点的典型写法,是把 Java 习惯原样搬过来:for i in range(len(xs)) 加下标、到处 append,或者以为切片像 subList 一样是视图。
全景:一个 FastAPI 请求的一生 · 第 8 站地图:站 01 · 语法对照:列表、字典、推导式、切片以 CPython 3.13 为例
①为什么学:第 8 站要把 600 行日志变成 5 行能比的数
在全景第 8 站,这一步要解决的是:第 7 站把 5 台机器的 GC 日志拉回来了,每台是一大段文本。值班的你要的不是文本,而是“每台停了几次、最长多久、平均多久”,5 台放在一起,一眼看出哪台不对。从文本到这几个数,每一步都是在列表和字典之间倒腾。
ms = [float(m.group(1)) for line in text.splitlines() if (m := PAUSE.search(line))],就是前两个箭头。本页把这四个形状和它们之间的写法讲透。②老办法:照 Java 的习惯写,能跑,但又长又容易错
同一件事,“从 120 行里挑出停顿毫秒数”,三种写法并排。中间那种是 Java 程序员刚学 Python 时最常写出来的。
List<Double> ms = text.lines()
.map(PAUSE::matcher)
.filter(Matcher::find)
.map(m -> Double.parseDouble(
m.group(1)))
.collect(Collectors.toList());
double max = ms.stream()
.mapToDouble(d -> d)
.max().orElseThrow();
lines = text.splitlines()
ms = []
for i in range(len(lines)):
m = PAUSE.search(lines[i])
if m != None:
ms.append(float(m.group(1)))
mx = ms[0]
for i in range(1, len(ms)):
if ms[i] > mx:
mx = ms[i]
ms[0] 在空列表上直接抛 IndexError。ms = [float(m.group(1))
for line in text.splitlines()
if (m := PAUSE.search(line))]
mx = max(ms, default=None)
- Python 没有
.stream()。列表上没有filter、map方法,Java 的链式写法搬不过来,于是退回到 for 加下标。 range(len(xs))加xs[i]:多一个变量、多一处越界的机会。Python 的 for 本来就是 Java 的增强 for,直接拿元素。- 手写求最大值、求和、计数:内置的
maxsumlensorted都现成,还是 C 写的,比你写的循环快。 - 缺的那一块就是这页的精华:推导式 + 内置函数补上了 Stream 的位置,字面量和切片补上了 Java 里一堆
new和subList。
③选谁:做“过滤 + 变换”,Python 里有四种写法
同样是把 100 万个数各乘 2,四种写法本机各跑 5 次取最快 实测。速度差得不多,选哪个主要看结果用几次、逻辑多复杂。
for + append
out = []
for x in nums:
out.append(x * 2)
100 万个:74.6 ms
逻辑多、要 try/except、要打日志时用它。
列表推导式
out = [x * 2 for x in nums]
100 万个:64.4 ms
一层 for、一个 if,结果要留着用。默认就选它。
生成器表达式
total = sum(x * 2 for x in nums)
不建列表,只占几 KB
只遍历一次,直接喂给 sum max any。大日志用它。
map / filter + lambda
out = list(map(lambda x: x * 2, nums))
100 万个:95.2 ms
最像 Java,却最慢也最难读。只在已经有现成函数时用,比如 map(float, xs)。
再大一级,几百万行、要分组聚合、要透视表,就该上 pandas 或 polars,那是“下一圈”的事。还有一种想法是继续写 Java 风格的 for 循环:能跑,但团队代码评审会让你改。
本页学:四件套 list dict tuple set 的字面量和常用操作,列表 / 字典推导式,生成器表达式,切片。
④原理:四个容器装的都是引用,推导式是带作用域的循环,切片是一份新列表
先看内存里长什么样,⑥ 里的坑几乎都能从这四张图推出来。
- Python 里一切都是对象,没有
int/Integer之分。变量是贴在对象上的名字,相当于 Java 的引用变量;a = b只是多贴一个名字,不复制对象。 None≈null;缩进就是代码块,没有大括号;==比的是值(≈equals),is比的是不是同一个对象(≈ Java 的==)。- 空的东西当条件是假:
[]{}""0None都算 False,所以写if not ms:判断空列表。
1列表:一排指针,指向各自的对象 ≈ ArrayList<Object>
ArrayList<Double> 一个样:底下是一段连续的数组,存的是引用,扩容时整体搬家。所以按下标取 O(1),append 均摊 O(1),在头部插入或 x in xs 是 O(n)。和 Java 的差别:Java 的 List<Double> 只能放 Double,Python 的列表什么都能放,[1, "a", None] 合法。类型标注 list[float] 只是给人和工具看的,运行时不检查,这一点留给知识点“类型标注和 Pydantic”。
tuple 是不能改的列表,写成 (a, b),常用来一次返回几个值、或者当字典的键,≈ Java 的 record。拆包写成 kind, ms = pair,for h, s in stats.items() 也是在拆包。
2字典:一张稀疏的索引表,加一张按插入顺序排的条目表 ≈ LinkedHashMap
和 Java 的差别:d["order-09"] 取不到不是返回 null,而是抛 KeyError;要“取不到给默认值”写 d.get(k) 或 d.get(k, 0)。set 就是只有键的字典,x in s 是 O(1)。
3推导式:一个自带作用域的循环;生成器:一个暂停着的循环 ≈ Stream
float(m.group(1)) # ③ 变成什么 ≈ .map(...)
for line in text.splitlines() # ① 从哪来 ≈ text.lines()
if (m := PAUSE.search(line)) # ② 留下哪些 ≈ .filter(...)
# 外面的 [ ] 决定收集成什么:[ ] 列表 { } 集合 {k: v} 字典 ( ) 生成器 ≈ .collect(...)
# := 叫海象运算符(3.8 起):在表达式里赋值,≈ Java 的 if ((m = p.matcher(s)).find())
# 它让 search 只跑一次,结果 m 在 ③ 里还能用
IllegalStateException,生成器第二次什么都不给,安静地返回空(⑥ 第 3 条坑)。4切片:xs[起:止:步],左闭右开,越界不报错,得到一份新列表 ≈ subList,但不是视图
每个元素有两个下标:正着数从 0 开始,倒着数从 -1 开始。ms[-1] 是最后一个,不用写 ms.get(ms.size() - 1)。省略“起”就是从头,省略“止”就是到尾,步为负就倒着走。
切片实验台:先猜,再看
猜一猜结果是什么?
[256.2, 33.1]subList(1, 3):视图粉色格子是被选中的元素,格子上方的数字是它在结果里的顺序。下面两行小字分别是正下标和负下标。
5为什么这样设计
Python:切片复制一份选了它
top = ms[1:3] # 新列表,2 个指针
top[0] = 0 # 只改 top
print(ms) # [12.0, 256.2, 33.1, 80.4, 41.0] 没变
- 切出来的东西和原列表互不影响,传给谁都安全。
- 原列表可以被回收,不会因为留着一小段切片而把大列表一直挂在内存里。
- 代价:复制 k 个指针,O(k)。只复制指针,元素对象本身是共享的(浅拷贝)。
Java:subList 是视图没选
List<Double> top = ms.subList(1, 3); // O(1)
top.set(0, 0.0); // 原列表也变了
ms.add(1.0); // 再用 top:
// ConcurrentModificationException
- 不复制,O(1);
subList(a, b).clear()能删掉一段。 - 原列表结构一变,视图就作废;视图还把整个原列表留在内存里。
- Python 用别的语法做区间操作:
del xs[1:3]、xs[1:3] = [...]。
决定胜负的约束:Python 的列表通常不大,写代码的人多半不想操心“谁还拿着我的视图”,所以默认要安全,用一次复制换来没有共享。数据大到复制不起的场景交给 numpy,它的切片就是视图。
为什么左闭右开
len(xs[a:b]) == b - a;xs[:k] + xs[k:] == xs,切成两半不重不漏。Java 的 subList、substring 也是这个规矩。
为什么做成语法
“从哪来、留哪些、变成什么”是最常见的循环,写成一行表达式,读的人不用跟踪中间变量。写法来自数学的集合记号 {x² | x ∈ S, x > 0}。
为什么字面量这么省
[] {} 写出来就和 JSON 一样。Agent 工程里,模型的请求和返回、工具参数都是 JSON,在 Python 里直接就是列表和字典。
最少的语法:Java 怎么写,Python 怎么写 细节、版本差异放到 ⑥
| Java | Python | 说明 |
|---|---|---|
new ArrayList<>(List.of(1, 2)) | xs = [1, 2] | 列表字面量 |
xs.get(0) / xs.get(xs.size()-1) | xs[0] / xs[-1] | 越界抛 IndexError |
xs.add(x) / xs.size() | xs.append(x) / len(xs) | len 是函数,不是方法 |
xs.subList(1, 3) | xs[1:3] | 复制一份,不是视图 |
for (int i...) xs.get(i) | for i, x in enumerate(xs): | 要下标用 enumerate |
new LinkedHashMap<>() + put | d = {"order-01": 60} | 按插入顺序 |
m.get(k) → null | d[k] → KeyError;d.get(k) → None | 两种取法 |
m.getOrDefault(k, 0) | d.get(k, 0) | |
m.containsKey(k) | k in d | in 对 list 是 O(n),对 dict / set 是 O(1) |
for (var e : m.entrySet()) | for k, v in d.items(): | tuple 拆包 |
.filter(p).map(f).collect(toList()) | [f(x) for x in xs if p(x)] | 列表推导式 |
Collectors.toMap(k, v) | {k(x): v(x) for x in xs} | 字典推导式 |
groupingBy(f, counting()) | Counter(f(x) for x in xs) | from collections import Counter |
stream().max(...).orElse(null) | max(xs, default=None) | 还有 min sum any all |
sort(comparing(f).reversed()) | sorted(xs, key=f, reverse=True) | sorted 返回新列表;xs.sort() 原地排 |
record Pair(String h, double ms) | pair = ("order-01", 244.05) | tuple,h, ms = pair 拆包 |
⑤跑起来:在值班助手里,把 5 台机器的日志汇总成一张表
接着 uv 那一页建好的 gc-helper 项目做。只用标准库,不用装包。先造 5 台机器的日志,其中 order-04 的日志是空的,专门用来试第 8 站那个 max([]) 的雷。
import random
from pathlib import Path
random.seed(7)
Path("logs").mkdir(exist_ok=True)
for i in range(1, 6):
lines = []
for n in range(120):
t = f"[2026-11-08T20:{5 + n // 12:02d}:{n % 12 * 5:02d}.{n:03d}+0800][info][gc]"
if i == 4:
break # order-04 的日志代理没吐出东西
if n % 2:
lines.append(f"{t} GC({n}) Using 8 workers of 8 for evacuation")
else:
kind = "Young (Normal) (G1 Evacuation Pause)" if n % 10 else "Young (Concurrent Start) (G1 Humongous Allocation)"
ms = random.lognormvariate(3.2, 0.5) if n != 60 else 240 + i * 4.05
lines.append(f"{t} GC({n}) Pause {kind} 512M->128M(1024M) {ms:.3f}ms")
Path(f"logs/order-0{i}.log").write_text("\n".join(lines) + ("\n" if lines else ""))
import re
from collections import Counter
from pathlib import Path
PAUSE = re.compile(r"Pause (\w+ \(.+?\)) .* (\d+\.\d+)ms$")
HOSTS = [f"order-0{i}" for i in range(1, 6)] # 列表推导式
def parse(text: str) -> dict:
hits = [PAUSE.search(line) for line in text.splitlines()]
pauses = [(m.group(1), float(m.group(2))) for m in hits if m] # 过滤 + 变换,元素是 tuple
ms = [p for _, p in pauses] # 拆包,_ 表示不要
return { # 字典字面量
"pauses": len(ms),
"max_ms": max(ms, default=None), # 空列表不炸
"avg_ms": round(sum(ms) / len(ms), 1) if ms else None, # 空列表当条件是假
"top3": sorted(ms, reverse=True)[:3], # 切片取前 3
"kinds": Counter(k for k, _ in pauses), # 生成器喂给 Counter
}
stats = {h: parse(Path(f"logs/{h}.log").read_text()) for h in HOSTS} # 字典推导式
for host, s in stats.items(): # 按插入顺序
print(host, s["pauses"], s["max_ms"], s["avg_ms"], s["top3"], dict(s["kinds"]))
slow = [h for h, s in stats.items() if s["max_ms"] and s["max_ms"] > 200]
empty = [h for h, s in stats.items() if not s["pauses"]]
print("最长停顿超过 200 ms:", slow)
print("没拿到停顿:", empty)
$ uv run make_logs.py && uv run summary.py order-01 60 244.05 26.1 [244.05, 56.763, 41.742] {'Young (Concurrent Start)': 12, 'Young (Normal)': 48} order-02 60 248.1 33.6 [248.1, 97.911, 75.303] {'Young (Concurrent Start)': 12, 'Young (Normal)': 48} order-03 60 252.15 31.6 [252.15, 71.394, 67.652] {'Young (Concurrent Start)': 12, 'Young (Normal)': 48} order-04 0 None None [] {} order-05 60 260.25 30.6 [260.25, 89.736, 56.747] {'Young (Concurrent Start)': 12, 'Young (Normal)': 48} 最长停顿超过 200 ms: ['order-01', 'order-02', 'order-03', 'order-05'] 没拿到停顿: ['order-04'] # 对照:全景第 8 站原来的写法碰上 order-04 $ uv run python -c 'ms = []; max(ms)' ValueError: max() iterable argument is empty
实测 本机 uv 0.13.7、CPython 3.13.18,summary.py 连同 uv 启动 0.12 s。输出逐字复制。
⑥细节·规范·优化
第一圈的坑全部在本机复现过 实测,每条右边的标签点回 ④ 里推出它的那张图。
注意事项:不知道就会出事
max([]) 直接抛异常设计如此
- 现象
- 某台机器日志为空,
max(ms)抛ValueError: max() iterable argument is empty,第 8 站的parse跑在gather里,整个接口 500。 - 根源
- 空集合没有最大值,Python 选择报错而不是返回 None。
sum([])是 0 不报错,sum/len却会除以零。 - 做法
max(ms, default=None);求平均先if ms。
[[0] * 3] * 3 改一格,三行都变← ④ 列表存引用
- 现象
g[0][0] = 9后得到[[9, 0, 0], [9, 0, 0], [9, 0, 0]]。- 根源
* 3复制的是格子里的指针,三格指向同一个内层列表。- 做法
[[0] * 3 for _ in range(3)],推导式每轮新建一个。
- 现象
gen = (x for x in ms if x > 40),list(gen)得到 3 个,再list(gen)得到[]。先max(gen)再sum(gen),平均值就错了。- 根源
- 生成器走到头就停在那里,不会重来;也不报错,这点和 Java Stream 不同。
- 做法
- 要用两次以上就写成列表推导式
[...]。
- 现象
top = ms[1:3]; top[0] = 0,ms纹丝不动。反过来,切片里放的是可变对象(比如字典),改对象本身两边都看得到。- 根源
- 切片复制的是指针(浅拷贝),新列表、旧对象。
- 做法
- 想改原列表的一段,用
ms[1:3] = [...]或del ms[1:3];要连对象一起复制用copy.deepcopy。
- 现象
for k in d: del d[k]抛RuntimeError: dictionary changed size during iteration。- 根源
- 遍历就是在读条目表,表的大小变了迭代器就不可信。≈ Java 的
ConcurrentModificationException。 - 做法
- 新建一个:
d = {k: v for k, v in d.items() if v};或遍历list(d)这份副本。
d[k] 取不到不是 null,是 KeyError← ④ 字典
- 现象
- 模型返回的 JSON 少了一个字段,
resp["usage"]抛KeyError: 'usage',接口 500。 - 根源
- Python 把“没有这个键”当错误,不给你一个 null 悄悄往下传。
- 做法
- 确定一定有的用
d[k],可能没有的用d.get(k, 默认);计数用Counter,分组用defaultdict(list)。
is 比数字,有时对有时错← ④ 一切都是对象
- 现象
a = 256; b = 256时a is b是 True;c = 257时c is int("257")是 False,值却相等。- 根源
is比的是不是同一个对象。CPython 预先缓存了 -5~256 的小整数,和 Java 的Integer缓存 -128~127 是同一个坑。- 做法
- 比值一律
==;is只用来比None:if x is None。
- 现象
sorted(["order-10", "order-2", "order-1"])得到['order-1', 'order-10', 'order-2']。- 根源
- 字符串按字符逐个比,
"1" < "2",和 Java 的String.compareTo一样。 - 做法
sorted(hosts, key=lambda h: int(h.split("-")[1]));key 函数 ≈Comparator.comparing。
使用规范:团队里怎么写
两层以上、要 try/except、要打日志,改回 for 循环。
一行塞满逻辑,过两周自己也读不懂。
不在里面 print、不往别的列表 append、不发请求。只是想循环就写 for。
白建一个没人用的列表,还把循环藏起来。
sum(x for ...)、any(...)、max(...) 外面不再套 []。
大日志时多一份整表的内存。
白名单、去重、已处理过的 id,先转成 set。
在列表里 in 是逐个比,O(n)。
.get,自己造的用 []模型返回、日志代理返回的字段都可能缺。
一个缺字段让整个接口 500。
enumerate,要并排用 zip不写 range(len(xs));判空写 if not xs。
越界和差一错误。
优化和深处
推导式快,但瓶颈多半不在这里
100 万个数乘 2,推导式比 for 快约 14%。换成第 8 站真实的活,60 万行日志过正则,for 是 221.6 ms,推导式 217.7 ms,只差 2%:时间都花在正则上了 实测。
想快先换算法(预编译正则、先用 "Pause" in line 粗筛),别指望改写法。
大日志:列表 vs 生成器的内存
对 60 万行求平均停顿,两种写法结果一样,峰值内存差 5000 多倍(tracemalloc,不含行本身)实测。
再往前一步:别 read_text() 整个文件,用 for line in open(path) 一行一行读,整条链路都不建表。第 8 站在事件循环上跑,日志一大还要挪到线程池,见全景第 5 站的雷。
in:列表和集合差几千倍
1 万个主机名里找最后一个:列表 116 µs 一次,集合 0.054 µs 一次 实测。列表要从头比到尾,集合算一次 hash 直接落位(④ 字典那张图的索引表)。
同理,list.insert(0, x) 和 list.pop(0) 要把后面的指针全挪一遍;当队列用请换 collections.deque(≈ ArrayDeque)。
版本差异:这几行代码要求的最低版本
| 版本 | 变化 |
|---|---|
| 3.4 | max(…, default=…) |
| 3.7 | dict 按插入顺序写进语言规范(3.6 是 CPython 的实现细节) |
| 3.8 | 海象运算符 := |
| 3.9 | d1 | d2 合并字典;list[str] 直接当类型标注 |
| 3.12 | PEP 709:列表 / 字典 / 集合推导式内联,不再单独建一个函数帧,变量照样不外漏 |
值班助手锁的是 3.13,以上全部可用。
下一圈
itertools:islice、groupby、chain,对生成器做切片和分组。- 浅拷贝和深拷贝:
copy.copy、copy.deepcopy,以及默认参数def f(xs=[])的共享坑(放到“函数是一等公民”)。 - dataclass、TypedDict、Pydantic:什么时候该把字典换成有字段的对象。
- pandas / polars:几百万行日志的分组聚合,和推导式的分界线。
- 排序:Timsort 为什么稳定,多键排序
key=lambda s: (s["max_ms"], s["host"])。 - CPython 源码:
Objects/listobject.c的扩容公式,Objects/dictobject.c的紧凑布局。
↺回到⑤:拿⑥改进值班助手
1 · 一遍扫完 500 MB 日志
把 parse 改成接收文件路径,for line in open(path) 逐行读,一边读一边累计次数、最大、总和,不建任何整表。用 tracemalloc 量一下前后峰值。
用到:第三圈 列表 vs 生成器
2 · 最慢的 3 台排前面
按 max_ms 从大到小排主机,取前 3。注意 order-04 的 max_ms 是 None,直接比会抛 TypeError,想想 key 怎么写。
用到:切片、坑 8 的 key 函数、坑 1
3 · 哪一分钟停得最多
日志行的 line[12:17] 正好是 20:05 这样的分钟。用 Counter 数每分钟的停顿次数,.most_common(1) 找峰值。
用到:切片、生成器喂 Counter、坑 6
1. 用一句话说出这一页的精华。
列表和字典底下就是 ArrayList 和 LinkedHashMap,装的都是引用;Python 把它们做成了语法:字面量直接写数据,推导式一行完成过滤 + 变换 + 收集,切片左闭右开取一段并复制出新列表。
2. Java 味的 Python 写法(下标循环 + append + 手写求最大)痛在哪?
行数多、下标容易越界、ms[0] 在空列表上直接抛异常;而且绕开了 C 写的内置函数,更慢。缺的是推导式和 max sum 这些现成的积木。
3. [[0] * 3] * 3 为什么改一格三行都变?是 ④ 哪条原理?
④-1:列表格子里存的是指针,* 3 复制的是指针,三个格子指向同一个内层列表。
4. 预测:pauses = (float(m.group(1)) for line in lines if (m := PAUSE.search(line))),接着写 mx = max(pauses) 和 avg = sum(pauses) / len(pauses),会怎样?
max 正常;sum(pauses) 拿到的是已经走到头的生成器,结果 0;len(pauses) 抛 TypeError: object of type 'generator' has no len()。
从 ④-3 推出:生成器是暂停着的循环,只能走一遍,也不知道自己有多长。
5. 预测:top = ms[:3] 之后 top.append(1.0),ms 变不变?如果 ms 里装的是 5 台机器的统计字典,top[0]["max_ms"] = 0 呢?
第一种不变,切片是新列表。第二种变:新列表里的指针和原列表指向同一批字典,改的是字典本身。
从 ④-4 和“为什么这样设计”推出:切片是浅拷贝。
6. 放回全景:第 8 站前后各发生了什么?如果第 8 站还用 max(ms),order-04 的日志代理返回了空文本,整条链路会怎样?
之前是第 7 站:事件循环同时等 5 个 socket,fetch 拿到 r.text 后就地调用 parse。之后是第 9 站:GcSummary 按 response_model 序列化成 JSON。
parse 跑在 order-04 那个 Task 里,max([]) 抛 ValueError。fetch 只兜住了 TimeoutError,这个异常冒到 gather,gather 把它抛给端点,另外 4 台已经拿到的结果全部作废,curl 收到 500。值班的你一个数都看不到。
见 全景第 7 站“常出的事”:一个 Task 抛异常,gather 就把它抛给你。