多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Python collections模块实战:Counter、deque、defaultdict等核心数据结构详解

Python collections模块实战:Counter、deque、defaultdict等核心数据结构详解 写得久了你会发现Python 内置的list、dict、tuple、set其实是通用容器它们解决的是存不存得下、取不取得出的问题而collections解决的是存得好不好、读起来像不像人话、运行起来快不快的问题。我第一次系统性读完整collections源码和文档后最大的感受是原来标准库里早就替我把那些高频场景的轮子造好了我之前却一直在手写 if 判断和循环去填内置容器的坑。这篇博文就把我实际项目中用collections的经验、踩过的坑、选型思路一次性整理出来。1. collections 到底补了什么内置容器到专用容器之间有一条很宽的缝1.1 一个简单的词频统计暴露出的样板代码先看一个再普通不过的需求统计一串单词里每个词出现的次数。很多新手会这么写words [apple, banana, apple, orange, banana, apple] count {} for w in words: if w not in count: count[w] 0 count[w] 1这代码没什么错但它把计数这个本来很直观的语义淹没在了if w not in count这种防御性样板里。如果团队里每个人写这种统计时都随手来一套自己的判断逻辑代码风格会迅速失控。用collections里的Counter事情变成这样from collections import Counter words [apple, banana, apple, orange, banana, apple] count Counter(words)两行语义直接。Counter本身就是dict的子类所以你能像操作字典一样取值、遍历、判断 key 是否存在。特别贴心的是它的__missing__逻辑访问一个不存在的 key 时返回 0而不是抛KeyError。我从这个例子得到的启发是collections表面上只是多给几个类本质上它是在告诉开发者——先想清楚你的数据结构是什么再开始写循环。内置类型是万能的正因为万能它不针对任何高频场景做优化。而collections里每个类型都对应一种非常具体、反复出现的数据组织模式。1.2 一张表看懂 collections 的每个成员补的是哪个内置类型的位collections模块的类不算多但它们跟内置容器的对应关系很清晰collections 类型补充/替代的内置类型解决的痛点namedtupletuple元组只能用索引取值data[0]没有可读性命名后可以用data.namedequelist列表头部插入/删除是 O(n)双端队列两端操作都是 O(1)defaultdictdict每次都要判断 key 是否存在否则给个默认值Counterdict专门做元素频次统计还带四则运算和 top-nOrderedDictdict保持插入顺序且能调整元素顺序ChainMapdict把多个字典逻辑上合并成一个查询时逐层找UserDict/UserList/UserStringdict/list/str方便子类化绕开 C 实现的底层细节注意我的用词是补充而不是替代。collections不是要你把所有dict都换成defaultdict它是在特定场景下让代码更精确。判断标准很简单如果某种内置类型用起来让你反复写配套的样板代码那就是它在提醒你——也许有更专门的数据结构。有个容易被忽略的细节set在collections里没有直接对应物但Counter在语义上可以做多重集合multiset因为一个元素可以出现多次。我后面会详细讲Counter的运算它就是冲着这个方向去的。1.3 为什么标准库愿意维护这些专用类型有人会问既然我可以自己写个函数包装dict来实现默认值功能为什么标准库要单独维护一个defaultdict答案很实在性能和一致性。defaultdict、deque、Counter这些核心类型的底层大多是用 C 实现的它们跟内置类型一样快甚至更快。你自己用 Python 代码包一层且不说逻辑上容易出 bug光是每次查 key 都要经过一层 Python 函数调用就已经慢了一个量级。标准库把它实现成原生类型等于把高频模式的高性能版本直接送到你手上。此外这些类型在 CPython 里经过了二十来年的打磨各种边界行为都被定义得很严谨。比如deque的maxlen限制、Counter的most_common排序行为都是社区反复讨论过的结果。自己造轮子不是不行但你得为所有边界条件负责而标准库已经帮你写好了这些答案。2. namedtuple给 tuple 字段命名之后代码的可读性完全不同2.1 裸元组为什么容易变成魔法数字索引我在代码评审里最怕看到两种写法一种是 dict 里塞一堆键另一种是函数返回一个裸元组然后调用方照着result[0]、result[1]、result[2]去取数据。比如def get_point(): return 1, 2 # x 1, y 2 point get_point() print(point[0]) # 你知道 0 是 x 吗如果这个函数在项目里流传了半年后来的人接手时根本不知道point[0]到底是什么。更麻烦的是哪天返回结构里多加一个字段所有用索引访问的代码全都得跟着改稍不留神就会错位。解决方案就是namedtuplefrom collections import namedtuple Point namedtuple(Point, [x, y]) point Point(1, 2) print(point.x, point.y) # 1 2它本质上仍是元组占用一块连续内存支持拆包、支持索引访问、支持比较只不过额外给了每个位置一个名字。从元组继承这一点很重要意味着所有原本用元组的地方都可以平滑替换成它比如把多个值塞进集合、作为字典的 key。2.2 namedtuple 的创建与常用方法细节namedtuple是个工厂函数调用后会生成一个新的元组子类。构造时第一个参数是类名第二个参数是字段名字段名可以是字符串列表也可以是一个带空格分隔的字符串Point namedtuple(Point, x y) # 还可以给字段设置默认值注意默认值从右往左对齐 Person namedtuple(Person, [name, age, country], defaults[中国, None]) p Person(张三, 18) print(p.country) # 中国子类生成后自带几个非常实用的方法_make(iterable)从一个可迭代对象批量创建实例等价于Point(*some_tuple)_asdict()转成OrderedDict做 JSON 序列化时特别好用_replace(**kwargs)基于现有实例创建一个新实例只替换指定字段适合修改一个字段但保持不可变的需求_fields拿到字段名的元组可以用它做反射或动态处理举个例子坐标移动的场景p Point(2, 3) p2 p._replace(x5) print(p2) # Point(x5, y3) print(p) # Point(x2, y3)原实例没变这种不可变特性让namedtuple在并发环境下很省心因为实例一旦创建就不会被外部不小心改掉。不过要注意它并非绝对不可变——如果字段本身是可变对象比如 list那通过字段修改 list 的内容依然做得到。这是所有不可变容器的通病不是它独有的。还有两个稍冷门的参数值得知道。renameTrue会在字段名非法或者跟 Python 关键字冲突时自动把违规名字改成_0、_1这种带下划线的名字避免整个定义过程直接抛错。module参数可以指定生成的类所属模块这在需要 pickle 序列化的场合很重要否则你可能遇到无法定位 namedtuple 类定义的报错。2.3 namedtuple 与 dataclass 的选型边界看到这里你可能会问Python 3.7 之后不是有dataclass吗它也能定义命名字段还支持类型注解和默认值是不是可以直接取代namedtuple我的观点是二者定位不同不应该互相取代。namedtuple的优点是轻量、内存占用小、性能更接近原生元组、天然支持拆包和字典 key。如果你的数据只是一个带名字的只读记录没有方法逻辑比如坐标、RGB 颜色、API 返回的临时数据用它最合适。dataclass的优点是灵活可以定义可变实例、写方法、类型注解、继承关系。当你需要的是一个有行为的对象或者字段会在生命周期里被频繁修改再或者你需要 IDE 补全时选它。举例来说一个用户信息对象如果只是从配置里读出来、传给其他函数展示我倾向namedtuple如果这个用户对象有自己的权限校验方法、状态会从 active 变成 disabled那我用dataclass。记住一个很实用的判断标准你需要不可变和元组语义时选namedtuple你需要可变状态和方法时选dataclass。还有一类特殊场景当你在写性能敏感的内层循环大量创建小记录对象时namedtuple的内存和 GC 压力明显小于dataclass。我在数据管道里曾用namedtuple替代过一版dataclass处理千万级记录时总耗时能降不少。3. deque双端队列如何在高频增删场景里跑赢 list3.1 list 头部操作是 O(n)这个代价在真实项目里是慢慢暴露的Python 的list底层是一段连续内存的数组所以append和pop在末尾操作时是 O(1)。但如果你用list.insert(0, x)或者list.pop(0)底层会触发所有元素的整体搬移左边空出一个位置或消除一个空位时间复杂度是 O(n)。一开始数据量小的时候感觉不到几百个元素随便折腾。但如果这个操作出现在一个持续运行的循环里比如 BFS 搜索、消息队列、滑动窗口随着数据量增长O(n) 的开销会迅速放大。我见过一个线上消息批处理脚本每天跑一次要处理几十万条消息只是因为在 FIFO 队列实现上用了list.pop(0)导致任务跑到后半段越来越慢直到干脆超时。这种能用但性能诡异的代码是最坑的因为它在小数据量测试时一切正常一到生产就暴露。3.2 deque 的核心方法与 maxlen 滑动窗口dequedouble-ended queue双端队列从头到尾是一块双向链表加一块块分页缓冲区实现的结构所以它在两端插入和删除都是 O(1)。它暴露的方法完全围绕两端操作设计from collections import deque d deque() d.append(1) # 右端入 d.appendleft(2) # 左端入 print(d.pop()) # 右端出得到 1 print(d.popleft()) # 左端出得到 2 d.extend([3, 4]) # 右侧批量入 d.extendleft([5, 6]) # 注意左侧逐个入结果是 6,5,3,4 d.rotate(1) # 整体向右循环移动一位最常用也最容易被低估的参数是maxlen。它限定了队列的最大长度超过这个长度时新元素从一端进来另一端的旧元素会被自动丢弃。这正是滑动窗口的天然实现from collections import deque window deque(maxlen5) for i in range(10): window.append(i) print(window) # 始终只有 5 个元素输出效果是这样的deque([0], maxlen5) deque([0, 1], maxlen5) deque([0, 1, 2], maxlen5) deque([0, 1, 2, 3], maxlen5) deque([0, 1, 2, 3, 4], maxlen5) deque([1, 2, 3, 4, 5], maxlen5) ...注意我实际运行时打印出来的内容比较多这里就不全列了。核心点是你不再需要手动判断长度到了就删掉最旧的一条数据结构直接替你做了。这种代码不仅少写几行更重要的是不容易漏掉边界条件。3.3 两个适合 deque 的真实场景BFS 队列和日志滚动场景一BFS广度优先搜索。用list做队列时最标准的错误就是queue.pop(0)。正确做法是用dequefrom collections import deque q deque([start_node]) visited {start_node} while q: node q.popleft() for neighbor in graph[node]: if neighbor not in visited: visited.add(neighbor) q.append(neighbor)popleft()是 O(1)整个 BFS 的时间复杂度才能维持在 O(V E)。用list.pop(0)的话理论复杂度直接飙到 O(V²)图一旦变大就缓不过来。场景二日志滚动。假设你的程序要保留最近 100 条用户操作记录同时需要随时取最后一条。deque(maxlen100)完美匹配每次append新记录最老的一条自动被顶掉。如果需要把日志整体读出来直接对这个 deque 做迭代顺序和插入顺序一致。这里要特别提醒deque对中间位置的随机访问是 O(n)也就是说d[50]会从头或尾部开始往后走并不是数组那种内存寻址。它没有切片能力Python 3.13 之后有了有限切片/索引增强实际上官方文档在 3.13 的deque.__getitem__仍然是 O(n) 的而且不支持切片。所以如果你需要频繁按下标随机读元素还是老老实实用list别拿 deque 硬撑。3.4 提醒deque 不是线程安全的生产消费队列很多新手看到 deque 有append和popleft就以为它是线程安全队列想在多线程程序里直接当任务队列用。这是个大坑。deque的单个方法操作是线程安全的但先判断空再取元素这种复合操作不是原子的。两个线程同时检查到队列非空然后一个popleft另一个可能就抛IndexError。更细的问题在于阻塞等待当队列为空时deque 会立刻抛异常做不到等生产者产出数据。这种场景应该用queue.Queue。它内部就是用deque实现的但在外面包了线程锁和条件变量提供了get(timeout...)、put(timeout...)这种带阻塞的 API。记住一句话deque 解决的是双端 O(1) 增删的数据结构问题queue 解决的是多线程间安全传递任务的同步问题两者完全不是一回事。4. defaultdict 与 Counter分组、计数、聚合的样板代码该删掉了4.1 defaultdict 的默认值工厂是如何消灭 if key not in 的defaultdict是dict的子类核心机制是当访问的 key 不存在时会自动调用你传入的工厂函数生成默认值并且写入字典。最常见的三种工厂是list、set、int分别对应分组、去重、计数。分组是最典型的场景。以按性别分组成员的用户名列表为例纯 dict 的写法要处理这个 key 是不是第一次出现data [(male, 张三), (female, 李四), (male, 王五)] groups {} for gender, name in data: if gender not in groups: groups[gender] [] groups[gender].append(name)用defaultdictfrom collections import defaultdict groups defaultdict(list) for gender, name in data: groups[gender].append(name)少了两行判断但更重要的是少了一种错误可能万一哪次忘了if分支直接groups[key].append()就会因为 KeyError 崩溃。defaultdict把这种防御变成了结构的一部分。同理defaultdict(set)面向去重场景defaultdict(int)面向计数场景。它们都不难难的是你形成看到分组就想到 defaultdict的条件反射。嵌套场景稍微复杂一点。比如你想维护一个两层的结构班级 - 姓名 - 分数列表nested defaultdict(lambda: defaultdict(list)) nested[一班][张三].append(95)lambda: defaultdict(list)的意思是外层第一次访问一班时自动创建一个新的defaultdict(list)作为值内层的张三再不存在时自动创建空列表。这种写法在做数据透视、多级聚合时特别顺手。4.2 Counter 不只是计数器四则运算与 most_commonCounter是collections里我使用频率最高的类。它继承自dict专门做计数但它的能力远超简单的c[k] 1。最基础的是most_common一键拿到出现次数最多的前 N 个元素from collections import Counter c Counter(hello world) print(c.most_common(3)) # [(l, 3), (o, 2), (h, 1)]然后是它的四则运算这个特性用好了可以写出非常简洁的聚合逻辑。两个 Counter 相加时相同 key 的计数累加相减时计数相减并丢弃非正数的项是交集取每个 key 的最小计数|是并集取最大计数。c1 Counter(a3, b1) c2 Counter(a1, b2, c3) print(c1 c2) # Counter({a: 4, b: 3, c: 3}) print(c1 - c2) # Counter({a: 2})b: 1-2-1 被丢弃 print(c1 c2) # Counter({a: 1, b: 1}) print(c1 | c2) # Counter({a: 3, b: 2, c: 3})我在做推荐系统候选分析时经常用来求两个用户都反复交互过的标签集合用来合并多日的行为计数一行代码抵掉原来的好几轮循环。Counter的update方法也很有个性。传入可迭代对象时它会自动统计可迭代对象里每个元素的出现次数并累加传入字典或 Counter 时则按 key 累加。这种两种更新模式恰好对应了两种需求从原始数据增量统计以及合并另一个计数结果。还有一个贴心设计是elements()它会按计数展开每个元素c Counter(a2, b1) print(list(c.elements())) # [a, a, b]这在需要把计数结果还原成元素列表的场景下很有用比如按权重抽样前先展开成列表。4.3 容易翻车的几个细节默认值工厂、负数计数与 Counter 的加减第一个坑是给defaultdict传错默认值工厂。有人图省事写成defaultdict({})这是错的。defaultdict的第一个参数必须是可调用对象{}是一个实例而不是可调用对象访问不存在的 key 时会直接抛TypeError。正确写法是defaultdict(dict)或者defaultdict(lambda: {})。还有一个隐蔽的坑把同一个可变对象当默认值。比如shared [] d defaultdict(lambda: shared)这会导致所有新 key 都共享同一个列表往d[a]里 append 的元素也会出现在d[b]里。如果你用的是defaultdict(list)每次会调用list()创建新列表不会有这个问题一旦你改成 lambda 或其他自定义工厂就必须确认它返回的是新对象。第二个坑是Counter可能出现负数计数。subtract方法允许计数相减后保留负值跟-运算符丢弃负数的行为不一样c1 Counter(a3) c2 Counter(a5) c1.subtract(c2) print(c1) # Counter({a: -2})负数计数本身不是 bug但如果你后续用most_common或者把 Counter 正常输出负数项会影响结果呈现。在写统计逻辑时要想清楚负值在你的业务里是否有意义。第三个细节是Counter访问不存在的 key 时返回 0但它不会像defaultdict那样把 key 自动写进去。也就是说c[x]的读取不会导致x出现在 Counter 里只有赋值或update才会真正写入。如果你期望的是读一次就自动带上默认值那要用defaultdict(int)如果你想要的是统计次数并支持缺失即 0用Counter。二者定位有区别。5. ChainMap 与 OrderedDictdict 补充里容易被低估的两个成员5.1 ChainMap 做多层配置合并比 update 更符合直觉ChainMap做的事情是把多个字典逻辑上拼接成一个查询时从前到后逐个找返回第一个命中的 key。它不是真的把字典合并而是保存了各层字典的引用。最典型的使用场景是分层配置默认配置、环境变量、用户自定义配置按优先级覆盖。用ChainMap写from collections import ChainMap defaults {theme: light, lang: zh, cache_size: 128} env_config {lang: en} user_config {theme: dark} merged ChainMap(user_config, env_config, defaults) print(merged[theme]) # dark print(merged[lang]) # en print(merged[cache_size]) # 128如果只用普通 dict你通常要用update一层层覆盖这会生成一个全新的字典并且丢掉层级关系的语义。ChainMap的好处是不复制底层数据内存开销小构建瞬间完成修改merged[key] value时只会写入最前面那一层字典不会污染默认配置各层字典后续如果发生变化ChainMap 查询结果会同步变化因为它是引用不是拷贝它还提供了new_child()方法在现有链前面加一层新字典以及parents属性去掉最前面一层后得到剩下的链。这在处理嵌套作用域、模板渲染上下文、命令行参数覆盖等场景下非常有用。我特别推荐在解析配置文件的代码里使用它。比如程序启动时加载一份默认配置然后读用户目录下的配置文件最后再覆盖命令行的指定项一个ChainMap就把三级优先级表达得清清楚楚不需要写多个临时变量挨个 update。5.2 OrderedDict 在 Python 3.7 还有什么不可替代的价值Python 3.7 起官方保证普通 dict 的插入顺序很多人因此觉得OrderedDict没必要存在了。这个判断在大部分场景是对的但OrderedDict有几个普通 dict 没有的能力在小众场景里不可替代。一是move_to_end(key, lastTrue)可以把一个已有 key 移动到末端。普通 dict 想实现同样的效果只能先 pop 再重新插入。二是popitem(lastFalse)默认是从末端弹出也可以传lastFalse从头部弹出。这两个方法在实现 LRU 缓存时价值很大——我后面会提到。另外有个容易忽略的差异两个 dict 比较是否相等时不管插入顺序有多不同只要键值对相同就算相等。但两个OrderedDict比较时不仅要求键值对相同还要求顺序相同。这在某些需要严格相等语义的逻辑中非常重要。from collections import OrderedDict o1 OrderedDict([(a, 1), (b, 2)]) o2 OrderedDict([(b, 2), (a, 1)]) print(o1 o2) # False print({a: 1, b: 2} {b: 2, a: 1}) # True如果你的代码依赖顺序也是一种状态比如某些协议报文的字段顺序、渲染模板的字段顺序用普通 dict 比较可能会得出错误结论。5.3 UserDict 一类当你确实想继承 dict 的时候UserDict、UserList、UserString这三个类是内置容器的包装器专门给想继承容器定制行为的场景准备的。直接继承内置类的坑在于它们的方法底层大量走 C 实现的捷径子类里重载的方法在某些路径下不会被调用。比如你继承dict并重写了__setitem__在update方法里可能不会生效因为底层直接操作了 C 结构。UserDict绕过这个问题的方式是内部存一个真实的 dict 作为self.data所有公开方法都通过 Python 层调用这样你重载的方法能稳定地生效。举例我想实现一个所有 key 必须是字符串的字典from collections import UserDict class StringKeyDict(UserDict): def __setitem__(self, key, value): if not isinstance(key, str): raise TypeError(key must be str) super().__setitem__(key, value) sd StringKeyDict() sd[ok] 1 # 正常 sd[123] 2 # 抛 TypeError用普通dict子类做这事update等其他入口就未必能拦截。UserDict让自定义容器行为这件事变得可预测。如果你只是想要一个带默认值的 dict直接defaultdict就好没必要上UserDict只有当你需要深度定制行为时才需要它。6. 选型清单与实战踩坑记录6.1 一张场景到类型的速查表项目做多了之后我基本形成了一套肌肉记忆。这里整理成一张速查表适合贴在代码评审规范里需求场景首选类型为什么不选内置/其他统计元素出现次数取 top-nCounterdict手写计数太啰嗦defaultdict(int)拿不到most_common运算给元素分组按 key 构建列表defaultdict(list)手写 if 判空容易漏分支嵌套场景更乱按优先级合并多层配置ChainMapupdate破坏层级关系且多了一次复制内存固定长度滑动窗口/滚动日志deque(maxlenN)list需要手动弹出旧值而且头部操作 O(n)BFS/双端操作dequelist.pop(0)O(n)大图会拖垮性能不可变记录对象namedtuple裸 tuple 没语义dataclass太重且有可变性风险需要保持顺序并频繁调整顺序OrderedDict普通 dict 3.7 虽保持顺序但没有move_to_end自定义 dict 行为比如类型校验UserDict直接继承 dict 时 C 底层会绕过部分重载方法6.2 我实际踩过的几个坑第一个坑是namedtuple的字段名里有空格或标点。定义时不会立刻报错用到的时候才发现_fields解析不对。正确做法是字段名里只用字母、数字、下划线且不能以数字开头。工具函数本身会校验但建议命名时就想清楚。第二个坑是把一个deque(maxlen100)数据流里的对象直接传到别的线程处理。虽然单个 append/popleft 是原子操作但取出来 处理 入列的组合过程依然需要同步锁。我早期拿 deque 当跨线程任务队列结果出现数据丢失排查了很久才意识到是并发问题而不是代码逻辑问题。从那时起我就记住了多线程之间的传递用queue.Queuedeque 只负责数据结构层面的双端操作。第三个坑是Counter和defaultdict混用时产生的心智负担。比如我用Counter加载已有数据然后又用if key not in c: c[key] 0这种 defaultdict 风格的判断完全没必要。Counter 可以直接拿c[key]因为它对缺失 key 返回 0。这不算 bug但两种缺失即默认的语义同时出现在一段代码里容易让读者困惑。第四个坑是性能测量的误判。我曾经在一个页面请求里用deque存中间结果然后频繁按下标访问d[100]这种位置结果性能反而比 list 差。后来用timeit测了一下才知道deque 的随机访问会从头或尾遍历到目标位置而 list 是 O(1) 的内存寻址。两端高频增删用 deque随机访问多就用 list这个边界一定要分清。6.3 什么时候不要用 collectionscollections很好用但绝不是越多越好。我的建议是小型脚本、一次性处理中如果内置 dict 和 list 已经写得很顺没必要特意引入defaultdict或deque来显得专业。代码的可读性来源是结构清晰而不是类型花哨。一个只在 10 行代码里用的普通 dict改成defaultdict反而增加读者的概念负担。性能优化时也别急着换容器。先用profile或timeit找到真正的热点确认瓶颈确实出在头部插入或判空循环上再考虑换类型。很多时候更值得优化的是循环算法本身比如把重复的 O(n) 查找改成 O(1) 的字典查询。容器类型的选型是锦上添花不是雪中送炭。还有一个容易忽略的点namedtuple适合定义静态结构的记录不适合承载带有复杂业务逻辑的对象。如果你发现给记录加的方法越来越多甚至开始维护状态转换那就该换成dataclass或普通类了。在错误的抽象上继续加功能成本会越来越高。6.4 每做一个真实项目我都会重新审视一遍容器选择最后分享一个我个人的工作习惯。每次接手一个 Python 项目我会先全局搜索两种代码模式一是手写的if key not in dict_逻辑二是list.pop(0)。这两个模式出现的地方大概率就是defaultdict和deque的潜在价值点但不一定都要替换需要结合数据量级和调用频率判断。替换的时候我会顺手写一个几行的微基准测试。比如要评估 list 和 deque 在某个场景下的差异简单的timeit就够了import timeit from collections import deque list_time timeit.timeit( l.insert(0, 1), setupl list(range(10000)), number10000 ) deque_time timeit.timeit( d.appendleft(1), setupfrom collections import deque; d deque(range(10000)), number10000 ) print(flist insert(0): {list_time:.4f}s) print(fdeque appendleft: {deque_time:.4f}s)这种测试的意义不在于得到一个绝对性能数值而在于让自己对O(n) 和 O(1) 的差距到底有多大保持体感。实测下来数据量上万之后list.insert(0)和deque.appendleft的差距会拉到两个数量级以上这是看文档很难建立的直觉。数据结构的正确选型不会直接让你写出业务功能但能让这些功能在数据量增长之后还能保持稳定。collections 的价值恰恰就在于此它把这些工程上反复出现的优化点沉淀成了标准库的一部分让你不需要每次从零设计。
返回列表