如果你写代码时遇到“统计一个列表里每个元素出现了几次”“数一数一段文本里每个字符的频率”“盘点日志里各种级别出现了多少条”这类需求,第一反应多半是循环加判断,再手动维护一个字典。做得多了你会发现,Python 里最顺手的计数统计写法,其实是一行dict.get()。这个技巧并不高深,但能让代码从四五行缩到一行,而且逻辑清楚、不容易出错,我自己在项目里用它替换了大量手工计数代码。
这篇文章会从最普通的实现方式讲起,拆解dict.get()一行计数的原理,对比get、setdefault、defaultdict、Counter这几个容易混淆的方案,再给出一批可以直接用的实战例子和踩坑记录。无论你是刚接触 Python 的初学者,还是写了好几年代码的老手,应该都能从中拿到一点能直接用上的东西。
1. 我为什么盯上dict.get()这个写法
1.1 先看看大多数人是怎么写计数统计的
假设有个需求:给出一份城市名单,统计每个城市出现了多少次。最直白的实现大概是这样的:
cities = ["北京", "上海", "广州", "北京", "深圳", "上海", "北京"] counter = {} for city in cities: if city in counter: counter[city] += 1 else: counter[city] = 1 print(counter) # {'北京': 3, '上海': 2, '广州': 1, '深圳': 1}逻辑没有任何问题:先看键在不在字典里,在就加一,不在就初始化成 1。当年我也是这么写的,写多了就覺得这段代码又长又重复,每一处计数都要写一遍if...else...。
后来有一阵我改用try...except...:
counter = {} for city in cities: try: counter[city] += 1 except KeyError: counter[city] = 1这是用异常处理来代替判断。对单个键来说,抛异常的成本比较高,如果键大量不存在,性能反而不理想;而且代码读起来绕,新人看到KeyError还要想一下这是哪来的。这种写法适合某些特殊场景,但作为通用计数方案并不算好。
1.2 这些老写法到底有什么问题
第一种if...else...写法最大的问题是重复。每一次“计数”这个动作,都要准备两份分支逻辑。一个脚本里如果有五个地方需要计数,那就要写五段几乎一样的代码,后面维护的人看着就想重构。
第二个问题,也是最容易被忽略的:当初始值不是 1,而是其他值的时候,if...else...会变得更啰嗦。比如统计总分:
scores = {} for name, score in data: if name in scores: scores[name] += score else: scores[name] = score这就不是“加一”了,而是“存在就累加,不存在就赋值”。分支结构会更明显。
我真正开始留心dict.get(),是因为有一次需要统计一份十几万行日志里的错误级别。当时想都没想就写了if...else...,回头去看那段代码的时候突然意识到:这一段逻辑明明只是一个“往字典里累加一个值”的动作,为什么要写这么多行?
于是我把那段代码改成了dict.get()的版本,这下整个人舒服了。
2. 一行计数统计的正确打开方式
2.1 核心代码拆解:counter[city] = counter.get(city, 0) + 1
直接看代码:
cities = ["北京", "上海", "广州", "北京", "深圳", "上海", "北京"] counter = {} for city in cities: counter[city] = counter.get(city, 0) + 1 print(counter) # {'北京': 3, '上海': 2, '广州': 1, '深圳': 1}循环体里只有一行:counter[city] = counter.get(city, 0) + 1。这一行做了三件事:
counter.get(city, 0):从字典里取当前城市的计数值。如果这个城市还是第一次出现,字典里没有这个键,就返回默认值 0。+ 1:在取出来的值基础上加一,得到新的计数值。counter[city] = ...:把新值写回字典,覆盖原来的计数值。
注意,get和直接用中括号取值的区别在于:counter[city]在键不存在时会抛出KeyError,而counter.get(city, 0)不会抛异常,只会返回一个默认值。这就是能用“一行”完成计数而不需要任何分支的根本原因。
这一行的等价写法其实就是最早的if...else...版本,只是把“判断键是否存在”这件工作交给了get内部的默认值机制。代码短了,但逻辑没有打折。
2.2 三个最常见的实际场景
这个模式可以套进很多需求里。比如统计字符串中每个字符的出现次数:
text = "hello world" char_count = {} for ch in text: char_count[ch] = char_count.get(ch, 0) + 1 print(char_count) # {'h': 1, 'e': 1, 'l': 3, 'o': 2, ' ': 1, 'w': 1, 'r': 1, 'd': 1}又比如统计一个列表里每个单词的词频:
words = ["apple", "banana", "apple", "orange", "banana", "apple"] word_count = {} for word in words: word_count[word] = word_count.get(word, 0) + 1 print(word_count) # {'apple': 3, 'banana': 2, 'orange': 1}再比如统计日志中不同错误级别的条数:
log_levels = ["ERROR", "INFO", "DEBUG", "ERROR", "INFO", "ERROR"] level_count = {} for level in log_levels: level_count[level] = level_count.get(level, 0) + 1 print(level_count) # {'ERROR': 3, 'INFO': 2, 'DEBUG': 1}同样的套路,换一个数据结构,需求就完成了。不需要if,不需要except,不需要每次循环前先初始化。
2.3 默认值这个参数是整个逻辑的灵魂
dict.get(key, default)的第二个参数很关键。很多人知道get可以传默认值,但用到计数上时容易犯一个错误:忘了传默认值,直接写counter.get(city) + 1。
这种写法会报错。因为当city不存在时,get返回的是None,None + 1直接抛出TypeError: unsupported operand type(s) for +: 'NoneType' and 'int'。
默认值填 0 的语义是:这个键如果不存在,就把它当成“已经出现过 0 次”来处理,然后加上本次的 1 次。这一步想清楚了,整个一行写法就很好理解。
还有一个小细节:如果你要累加的不是 1,而是某个权重,也可以直接改。比如统计一个店铺各商品的销售额:
transactions = [("sku123", 199), ("sku456", 99), ("sku123", 50)] sales = {} for sku, amount in transactions: sales[sku] = sales.get(sku, 0) + amount print(sales) # {'sku123': 249, 'sku456': 99}这个例子能说明get方案真正的价值:它不是只能“加一”,而是“取旧值加上新值”,所以计数、求和、累加都能用它来写。只要把+ 1换成+ amount就行。
3. 它为什么能这样写:原理与兄弟方法对比
3.1 一次调用背后到底发生了什么
dict.get()是字典对象的内置方法,它的行为可以简单理解为:
- 第一个参数是键名。
- 如果键存在于字典中,返回键对应的值。
- 如果键不存在,且传了第二个参数,返回第二个参数;没传则返回
None。
关键在于:get只负责“读”,它不会修改字典本身。不管键存不存在,调用get都不会往字典里写入任何东西。真正产生写入动作的,是那条赋值语句counter[city] = ...。
这个特性让它和setdefault有着本质区别。setdefault是“读不到就写入一个默认值再返回”,它是有副作用的;get是无副作用的。在计数场景里,如果每一轮循环都用setdefault,即使键已经存在,它也会反复构建默认值对象,白白浪费性能。get就不会有这个问题。
还有一个相关方法是pop(key, default),它也是带默认值机制的方法,但它的行为是“取走并删除”。计数的时候千万别用它,否则统计到一半,键被删掉了,数据全丢。
3.2get、setdefault、defaultdict、Counter到底怎么选
写计数的方法不止get一种。我整理了一下这几个方案的对比,方便以后选型:
| 方案 | 典型写法 | 特点 | 适用场景 |
|---|---|---|---|
dict.get | counter[k] = counter.get(k, 0) + 1 | 无副作用,不额外导入,写法直观 | 通用计数、累加、求和,最推荐 |
setdefault | counter.setdefault(k, 0); counter[k] += 1 | 两步操作,且每次都构造默认值 | 需要默认容器时(如列表)会用到 |
defaultdict | counter = defaultdict(int); counter[k] += 1 | 导入collections,键不存在时自动创建 | 字典里的值就是可变容器时很好用 |
Counter | Counter(iterable) | 专门为计数设计,自带most_common等方法 | 纯频次统计,不需要自定义逻辑时首选 |
defaultdict(int)写起来也很短,但它多了一个导入,而且如果后续有人不小心访问了一个不存在的键,字典会默默把这个键创建出来,有时候反而掩盖了代码里的问题。Counter是做纯计数的最专业方案,一行Counter(words)就能完成统计,但当你需要在循环里逐条累加、需要同时维护多个维度计数器时,灵活度不如dict.get()。
我个人的习惯是:如果只是给一个现成的列表做频次统计,直接Counter;如果是循环里逐步更新计数、累加金额、在多个字段上做统计,用dict.get()最顺手。
3.3 性能与可读性的平衡
有人会问:dict.get()这个写法在性能上是不是比if...else...慢?
说实话,影响微乎其微。在 Python 里get本身是 C 语言实现的哈希查找,和in判断加下标取值相比,核心操作都是哈希查找。硬要比较的话,get只做一次哈希查找,if...else...在某些路径下要做两次(先in判断,再counter[city] += 1取值),所以get在“键已存在”的高频场景下反而可能更快一点。当然这种差异在你统计一万个元素的时候根本感觉不出来。
真正值得注意的是另一个极端:千万不要为了“一行”而写这种代码:
[word_count.update({w: word_count.get(w, 0) + 1}) for w in words]这种用列表推导式的副作用来实现计数的写法,虽然真的只有一行,但它是错的。它生成了一个全是None的临时列表,纯属浪费内存,而且可读性极差。记住,“一行代码”正确的追求是“一个动作只写一行”,不是“整段逻辑挤在一行”。
4. 一行计数还能延伸出哪些变体
4.1 一行完成词频统计并按频次排序
计数只是第一步,统计完往往还要排序。有了get的基础,整条链路也可以写得很紧凑:
words = ["apple", "banana", "apple", "orange", "banana", "apple"] counter = {} for word in words: counter[word] = counter.get(word, 0) + 1 # 按频次从高到低排序 sorted_counter = sorted(counter.items(), key=lambda x: x[1], reverse=True) print(sorted_counter) # [('apple', 3), ('banana', 2), ('orange', 1)]取出现次数最多的前两个元素:
top2 = sorted(counter.items(), key=lambda x: x[1], reverse=True)[:2] print(top2) # [('apple', 3), ('banana', 2)]这里的sorted(counter.items(), key=lambda x: x[1])是按字典的值排序,reverse=True表示降序。这类写法在分析日志、统计热词、排行榜场景里非常常用。
除了sorted,还可以用max(counter, key=counter.get)直接拿到出现次数最多的键:
most_common_word = max(counter, key=counter.get) print(most_common_word) # apple这里max的key参数接收一个函数,counter.get会取出每个键对应的频次,max再根据这个频次找出最大的键。这个用法是dict.get()的另一个经典应用,也值得记下来。
4.2 合并两个字典的计数结果
有时候数据被拆成了两份,比如两天各自的访问统计,你想把它们合并成一份总数。用get写起来也很自然:
day1 = {"u001": 5, "u002": 3} day2 = {"u002": 2, "u003": 4} merged = {k: day1.get(k, 0) + day2.get(k, 0) for k in set(day1) | set(day2)} print(merged) # {'u001': 5, 'u002': 5, 'u003': 4}set(day1) | set(day2)是取两个字典所有键的并集,然后对每个键分别从两张表里取值,并用默认值 0 兜底。这里的核心技巧依然是get:不管这个键在不在原来的字典里,都能得到一个合理的数字。
如果不用get,写day1[k] + day2[k]会频繁触发KeyError,要么就得写一堆判断。用get直接把合并逻辑压成一行字典推导式,结果清晰又不会报错。
4.3 嵌套字典的分组计数
回到文章开头提到的“统计每个用户的各种行为次数”,这种需求会涉及嵌套字典。先看一个很容易想到的写法:
events = [ ("u001", "login"), ("u002", "page_view"), ("u001", "logout"), ("u001", "login"), ] action_count = {} for user, action in events: if user not in action_count: action_count[user] = {} action_count[user][action] = action_count[user].get(action, 0) + 1 print(action_count) # {'u001': {'login': 2, 'logout': 1}, 'u002': {'page_view': 1}}这个写法里,外层还保留了一个if,因为我们要用一个空字典作为内层默认值。如果想把这段也简化,可以使用setdefault:
for user, action in events: action_count.setdefault(user, {}) action_count[user][action] = action_count[user].get(action, 0) + 1或者用defaultdict(dict):
from collections import defaultdict action_count = defaultdict(dict) for user, action in events: action_count[user][action] = action_count[user].get(action, 0) + 1这三种写法都可以。内层的action_count[user].get(action, 0) + 1依然是我们那套一行计数,外层只是在解决“内层字典还不存在”的问题。真实项目中,像“用户行为统计”“按省份统计各品类销量”这类二维数据,用这个嵌套模式非常顺手。
4.4 列表扁平化与其他场景里的变通用法
dict.get()除了计数,在配置读取、缓存取值等场景也经常出现。比如从配置字典里读取某个参数,找不到就返回默认值:
config = {"host": "localhost", "port": 8080} timeout = config.get("timeout", 30)这几乎是最常见的一种用法:想要一个“存在就用配置值,不存在就用默认值”的读取逻辑,写的正是config.get(key, default)。这和计数时的思路完全一致,只是返回值不参与加一操作。
再比如把两个列表按元素分组,也可以用get和列表拼接的方式:
data = [("group_a", 1), ("group_b", 2), ("group_a", 3)] grouped = {} for group, value in data: grouped[group] = grouped.get(group, []) + [value] print(grouped) # {'group_a': [1, 3], 'group_b': [2]}注意这种写法会反复创建新列表,数据量大的时候不如setdefault或defaultdict(list)高效,但胜在代码短且无额外导入。在数据量可控的脚本里用一用完全没问题。
5. 实战中容易踩的坑与排查思路
5.1 默认值不能随便填:可变对象陷阱
get的默认值虽然方便,但第二个参数是一个普通值。假设你写了这么一段代码,想把用户分到不同分组里:
groups = {} for name in user_list: groups[name] = groups.get(name, []) + ["member"]功能上没问题,但请注意,这里的默认值[]每次调用get时都会被重新创建,且+ ["member"]也是新创建一个列表,整体效率不算高。如果列表很大,会明显浪费内存。
相比之下,setdefault和defaultdict(list)是在原列表上做append,效率更高,更适合这种“默认值是可变容器”的场景。一句话总结:计数加数字用get,默认容器要原地修改时用setdefault或defaultdict。
5.2 忘写第二个参数,直接 TypeError
这是我看到新手甚至老手最容易踩的坑。当键不存在时,get(key)返回None,如果接着做+ 1,就会抛出TypeError。排查的时候看到报错信息,第一反应往往以为是数据类型不匹配,实际上是因为默认值没写。
调试这种问题有一个经验:在计数代码里看到get方法,先检查第二个参数是否写了,再检查返回值参与运算的类型是否一致。把这一条养成习惯,这类报错基本一眼就能定位。
5.3 字符串统计时的隐藏字符
统计一段文本里字符频率的时候,for ch in text会把空格、换行符、制表符也当作普通字符统计进去。你要是对比两份统计结果,明明只改了排版,频率却对不上,十有八九是换行和空格的问题。
如果需要忽略空白字符,可以先用过滤器处理一下:
text = "hello world \n this is a test" char_count = {} for ch in text: if ch.strip(): char_count[ch] = char_count.get(ch, 0) + 1ch.strip()可以判断当前字符是否为空字符。这个细节在实际处理爬虫抓回来的网页文本时特别重要,因为 HTML 里有大量空白字符会干扰词频分析。
5.4 别在列表推导式里硬塞计数
前面提到过,用列表推导式的副作用做统计是不可取的。除此之外,还有一种看起来“聪明”的写法:
# 反例,不推荐 {words.count(w): w for w in words}如果只统计不排序,collections.Counter显然更合适。如果你在代码审查里看到某段计数逻辑写得很绕,先帮它简化成counter[k] = counter.get(k, 0) + 1再看看:绝大多数时候,这个简单版本已经够用了。
我后来在项目里养成一个习惯:只要看到“循环里维护字典并做累加”的需求,先写一行dict.get()版本,然后再判断是否需要升级成Counter或defaultdict。用最简单的方式开始,实在不够用了再升级,基本不会有大问题。
平时做日志统计、爬虫去重、用户行为分析这类事情,counter[key] = counter.get(key, 0) + 1已经足够顺手。偶尔需要统计完立刻取最高频项,再用max(counter, key=counter.get)补一行。这两个写法配合起来,足以应付绝大多数计数需求,而且每一行都看得懂,不会给自己留坑。