脚本专家 发表于 3 天前

Python字典遍历items与keys区别及性能优化

Python 项目里经常看到这样的字典遍历:先用 keys() 拿键,再通过 d 取值。代码能跑,结果也对,但在数据量大或高频调用时,会多出一次次哈希查找。下面围绕 keys()、values()、items() 三个方法,说明它们的实现差异、性能差别和常见坑,重点看什么时候该用 items()。

从一个能跑但不够好的写法说起

小周写数据导出功能时,要把字典配置逐条写成一行:


config = {'host': 'localhost', 'port': 8080, 'debug': True}
for key in config.keys():
    line = f'{key}={config}'
    print(line)


输出结果正确:host=localhost、port=8080、debug=True。同事 review 时建议改成 items(),小周一开始觉得差别不大。后来处理几万条配置时,发现导出速度明显偏慢,改成 items() 后速度提升了一大截。问题不在结果,而在每次循环多做了一次字典取值。

keys()、values()、items() 的基本用法

Python 字典提供三个遍历方法:keys()、values()、items()。


config = {'host': 'localhost', 'port': 8080, 'debug': True}
for key in config.keys():
    print(key)
for value in config.values():
    print(value)
for key, value in config.items():
    print(key, value)


keys() 只给键,values() 只给值,items() 给键值对,每个元素是一个 (key, value) 元组。可以用通讯录来理解:keys() 只念姓名,values() 只念电话,items() 把姓名和电话整页念出来。只需要姓名时用 keys(),只需要电话时用 values(),同时需要姓名和电话时就应该用 items(),而不是先念姓名再回头翻电话。

小周原来的写法就是“先念姓名,再回头翻电话”:用 key 拿到键,再用 config 查值。结果没错,但多做了一次查找。

items() 的本质与解包

items() 返回的是一个视图对象,每个元素是 (key, value) 元组。在 for 循环里可以直接解包:


for key, value in config.items():
    print(key, value)


这等价于:


for item in config.items():
    key, value = item
    print(key, value)


Python 会自动把元组拆开,分别赋给 key 和 value。而用 keys() 再取值时:


for key in config.keys():
    value = config
    print(key, value)


每次循环都要执行一次 d 的哈希查找。单次查找很快,但循环几万次、几十万次时,累计开销不可忽略。

性能差异有多大?

用一个包含十万条数据的字典做 timeit 实测:


import timeit
d = {f'key_{i}': i for i in range(100000)}

def method1():
    total = 0
    for key in d.keys():
      total += d
    return total

def method2():
    total = 0
    for key, value in d.items():
      total += value
    return total

t1 = timeit.timeit(method1, number=10)
t2 = timeit.timeit(method2, number=10)
print(f'keys() + 取值: {t1:.4f} 秒')
print(f'items(): {t2:.4f} 秒')


原文实测中,keys() 方式大约 0.35 秒,items() 方式大约 0.25 秒,差距约 30%。原因是 keys() 方式每次循环都额外做了一次 d 哈希查找,而 items() 在遍历时直接同时取出键和值,不需要再查。数据量越大差距越明显:十万条差 0.1 秒,一千万条就可能差约 10 秒。高频调用的函数里,这个差距会被进一步放大。

坑一:items() 返回的不是列表

很多人以为 items() 返回列表,可以按索引访问:


config = {'a': 1, 'b': 2}
items = config.items()
print(items)


这会报 TypeError: 'dict_items' object is not subscriptable。items() 返回的是视图对象,不是列表。它支持迭代、支持 in 判断、支持 len(),但不支持索引和切片。


items = config.items()
print(len(items))
print(('a', 1) in items)
print('a' in items)


注意,in 判断的是 (key, value) 元组,不是单独的键。因此 ('a', 1) in items 为 True,而 'a' in items 为 False。如果确实需要索引访问,可以显式转换:list(config.items())。但这会创建一个新列表,占用额外内存,除非必要,否则没必要转换。

坑二:视图对象是动态的

keys()、values()、items() 返回的都是视图对象。视图不复制数据,反映的是字典实时状态。


config = {'a': 1, 'b': 2}
keys = config.keys()
print(list(keys))
config['c'] = 3
print(list(keys))


输出先是 ['a', 'b'],后来变成 ['a', 'b', 'c']。keys 变量没有重新赋值,但它“看到”了字典的新增内容。字典清空后,之前拿到的视图也会变空:


config = {'a': 1, 'b': 2}
keys = config.keys()
config.clear()
print(list(keys))


输出为 []。

坑三:遍历时修改字典

这是遍历场景里的经典错误:


config = {'a': 1, 'b': 2, 'c': 3}
for key in config.keys():
    if config == 2:
      del config


运行会报 RuntimeError: dictionary changed size during iteration。Python 不允许在遍历字典时改变它的大小。因为字典迭代依赖内部哈希表结构,插入或删除元素可能导致结构变化,使迭代器失效。

如果确实需要在遍历时删除元素,有两种方式。方式一,遍历副本:


for key in list(config.keys()):
    if config == 2:
      del config


list(config.keys()) 创建了一个键列表,遍历的是列表,而不是字典本身,删除字典元素不会影响列表遍历。方式二,用字典推导式生成新字典:


config = {k: v for k, v in config.items() if v != 2}


不修改原字典,而是创建过滤后的新字典,这种方式更 Pythonic,也更安全。

坑四:values() 拿不到键

values() 只给值,不给键。如果根据值做判断后还要操作对应键,values() 就不够用:


config = {'a': 1, 'b': 2, 'c': 3}
for value in config.values():
    if value == 2:
      pass


想删除对应键,但不知道键是什么。这时应该用 items():


for key, value in config.items():
    if value == 2:
      del config


但仍然不能在遍历时直接删。更稳妥的做法是先收集要删的键,再统一删除:


to_delete =
for key in to_delete:
    del config


坑五:忘了括号

这是一个很隐蔽的错误:


config = {'a': 1, 'b': 2}
for key in config.items:
    print(key)


config.items 是方法本身,不带括号就不会调用它。for 循环试图迭代方法对象,通常会报 TypeError,具体表现可能随 Python 版本和写法不同。正确写法是 config.items(),带括号。类似的还有 config.keys、config.values,都是忘了加括号。

items() 的解包细节

items() 返回的每个元素是元组,在 for 里可以直接解包。但如果字典的值本身也是元组或列表,解包时要小心:


data = {'a': (1, 2), 'b': (3, 4)}
for key, value in data.items():
    print(key, value)


这里 key 是 'a',value 是 (1, 2)。如果写成 for key, x, y in data.items(),会报错,因为 data.items() 的每个元素只有两个值:键和值。值内部的元组不会被自动展开。如果确实想同时拿到键和值内部元素,需要额外解包:


for key, (x, y) in data.items():
    print(key, x, y)


这样 Python 会把元组 (1, 2) 拆开,分别赋给 x 和 y。

遍历顺序:Python 3.7 之后的保证

Python 3.6 之前,字典是无序的,遍历顺序不确定。从 Python 3.7 开始,字典正式保证插入顺序:


config = {'b': 2, 'a': 1, 'c': 3}
for key in config.keys():
    print(key)


输出永远是 b、a、c。这个特性在 Python 3.7+ 中稳定可靠。但要注意,set 仍然是无序的,frozenset 也是。只有 dict 保证了插入顺序。如果需要按特定顺序遍历,比如按键排序,可以用 sorted():


for key in sorted(config.keys()):
    print(key, config)
for key, value in sorted(config.items()):
    print(key, value)


sorted(config.items()) 会按键排序,因为元组比较时先比较第一个元素,也就是键。

那 keys() 还有用吗?

keys() 在以下场景仍然有用。第一,只需要键,不需要值。比如把键收集到列表里:


keys = list(config.keys())
if 'host' in config.keys():
    print('有 host 配置')


第二,做集合运算。keys() 返回的视图对象支持集合操作:


d1 = {'a': 1, 'b': 2}
d2 = {'b': 3, 'c': 4}
print(d1.keys() & d2.keys())
print(d1.keys() | d2.keys())
print(d1.keys() - d2.keys())


输出分别是 {'b'}、{'a', 'b', 'c'}、{'a'},也就是交集、并集、差集。这是 items() 做不到的,视图对象的集合运算能力是 keys() 的独特优势。第三,检查键是否存在。if key in config.keys() 比 if key in config 更明确,但后者更简洁,性能也更好,所以实际中直接用 in config 就够了。

回到小周的代码

原写法:


for key in config.keys():
    line = f'{key}={config}'
    print(line)


改成:


for key, value in config.items():
    line = f'{key}={value}'
    print(line)


两行代码看起来差不多,但 items() 版本少了一次字典查找,速度更快,代码也更清晰。项目里还有类似写法:


for key in d.keys():
    if d > 0:
      pass


改成:


for key, value in d.items():
    if value > 0:
      pass


以及一处遍历时删元素的 bug:


for key in d.keys():
    if d == 0:
      del d


改成:


d = {k: v for k, v in d.items() if v != 0}


团队文档可以记成一句话:遍历字典要键值对,用 items(),别用 keys() 再取值;只要键,用 keys();只要值,用 values();遍历时别改字典,要改就先复制或者用推导式。

总结

keys()、values()、items() 是字典遍历的三把工具,各司其职。keys() 只给键,适合只需要键的场景,支持集合运算;values() 只给值,适合只需要值的场景;items() 给键值对,适合同时需要键和值的场景,性能比 keys() 加取值更好。它们的返回值都是视图对象,不是列表。视图是动态的,反映字典实时状态,不支持索引,但支持迭代和 in 判断。遍历时不要修改字典的大小。如果需要删除元素,遍历副本或者用字典推导式生成新字典。记住通讯录的比喻:keys() 念姓名,values() 念电话,items() 把整页念给你。想清楚要什么,再选对应工具。小周的坑不在于不会用 items(),而在于他从没想过 keys() 加取值有什么问题。代码能跑,结果也对,但性能差了一截。这种“能跑但不够好”的代码,往往最容易被忽略,也最值得回头审视。

热心网友3 发表于 3 天前

Re: Python字典遍历items与keys区别及性能优化

这篇写得很清楚,从小周那个能跑但不够好的写法切入,一下子就把 keys() 再取值多一次哈希查找的问题讲明白了。通讯录那个比喻也很直观:只要姓名用 keys(),只要电话用 values(),同时要姓名和电话就用 items(),确实没必要先念名字再回头翻。十万条数据差约 30% 这点很有参考价值,高频调用里更容易被放大。items() 返回视图不是列表、视图会动态反映字典变化,以及遍历时修改字典这几个坑也都很实用,特别是 in 判断的是键值元组而不是单独的键,这个细节很容易写错。以后我在写字典遍历时会优先想清楚是不是同时需要键和值,能 items() 就 items(),需要索引再考虑显式转列表。感谢分享。

热心网友3 发表于 3 天前

Re: Python字典遍历items与keys区别及性能优化

楼主这个总结很到位,尤其小周那个例子很有代入感。keys() 再 d 的写法确实容易让人忽略多出来的哈希查找,单次看不出来,数据一多差距就出来了。只要同时要键和值,items() 基本就是首选,解包写法也干净。 关于视图对象那几点也很实用。dict_items 不能下标访问这个坑我见过不少人踩,要索引就 list(config.items()),但也要知道会额外建列表、占内存。视图是动态的这一点也容易被忽略,拿着 keys 视图后面又改字典,它看到的内容会跟着变,如果逻辑上希望固定快照,就得先转成 list。 坑三遍历时改字典,稳妥点通常是先 list(config.items()) 再遍历,或者先把要删的键收集起来,循环结束后统一删,避免边遍历边改。整体看,keys()、values()、items() 不是谁绝对更好,而是看是否真需要对应数据;需要键和值时就别绕路,items() 更直接也更省性能。感谢分享,回头我也把项目里那些 keys() 加取值的循环检查一遍。

热心网友3 发表于 3 天前

Re: Python字典遍历items与keys区别及性能优化

这个总结很实用。平时写循环时,确实容易先用 keys() 再 d,逻辑上没错,但数据量大或高频调用时,多出来的哈希查找就会累积成实打实的开销。同时需要键和值就直接 items(),只念姓名或只念电话就用 keys() 或 values(),按需选择最清晰。几个坑也提醒得挺到位:items() 是视图不是列表,不能下标,in 判断的是键值对元组;视图会跟着字典动态变化;遍历时改字典大小容易出问题,最好先转成 list 或先收集要改的键再统一处理。最后遍历修改字典那段代码好像没贴完,不过核心意思已经表达出来了。整体对我很有帮助。
页: [1]
查看完整版本: Python字典遍历items与keys区别及性能优化