Python容器应用的5个技术以及2个误区
“容器”这两个字很少被 Python 技巧文章提起。一看到“容器”,各人想到的可能是那头蓝色小鲸鱼:Docker,但这篇文章以及它不任何干系。本文里的容器,是 Python 中的一个形象概念,是对专门用来装其余工具的数据类型的统称。
正在 Python 中,有四类最多见的内建容器类型: 列表(list)、 元组(tuple)、 字典(dict)、 荟萃(set)。经过独自或是组合应用它们,能够高效的实现不少事件。
Python 言语本身的外部完成细节也与这些容器类型毫不相关。比方 Python 的类实例属性、全局变量 globals() 等就都是经过字典类型来存储的。
正在这篇文章里,我起首会沉着器类型的界说登程,测验考试总结出一些一样平常编码的最好理论。之后再环抱各个容器类型提供的非凡机能,分享一些编程的小技术。
当咱们议论容器时,咱们正在谈些甚么?
我正在后面给了“容器”一个简略的界说:专门用来装其余工具的就是容器。但这个界说太广泛了,无奈对咱们的一样平常编程孕育发生甚么指点代价。要真正把握 Python 里的容器,需求辨别从两个层面动手:
·底层完成:内置容器类型应用了甚么数据构造?某项操作若何工作?
·高层形象:甚么决议了某个工具是否是容器?哪些行为界说了容器?
上面,让咱们一同站正在这两个没有同的层面上,从新意识容器。
底层看容器
Python 是一门初级编程言语,它所提供的内置容器类型,都是通过高度封装以及形象后的后果。以及“链表”、“红黑树”、“哈希表”这些名字相比,一切 Python 内建类型的名字,都只形容了这个类型的性能特性,其余人齐全没法只经过这些名字理解它们的哪怕一丁点外部细节。
这是 Python 编程言语的劣势之一。相比 C 言语这种更靠近较量争论机底层的编程言语,Python 从新设计并完成了对编程者更敌对的内置容器类型,屏蔽掉了内存治理等额定工作。为咱们提供了更好的开发体验。
但若这是 Python 言语的劣势的话,为何咱们还要吃力去理解容器类型的完成细节呢?谜底是:存眷细节能够协助咱们编写出更快的代码。
写更快的代码
1. 防止频仍裁减列表/创立新列表
一切的内建容器类型都没有限度容量。假如你情愿,你能够把递增的数字一直塞进一个空列表,终极撑爆整台机械的内存。
正在 Python 言语的完成细节里,列表的内存是按需调配的[注1],当某个列表以后领有的内存不敷时,便会触发内存扩容逻辑。而调配内存是一项低廉的操作。尽管年夜局部状况下,它没有会对你的顺序功能孕育发生甚么重大的影响。然而当你解决的数据量特地年夜时,很容易由于内存调配拖累整个顺序的功能。
还好,Python 早就认识到了这个成绩,并提供了民间的成绩处理指引,那就是:“变懒”。
若何诠释“变懒”? range() 函数的进化是一个十分好的例子。
正在 Python 2 中,假如你挪用 range(100000000),需求期待好几秒能力拿到后果,由于它需求前往一个微小的列表,破费了十分多的工夫正在内存调配与较量争论上。但正在 Python 3 中,一样的挪用即刻就能拿到后果。由于函数前往的再也不是列表,而是一个类型为 range 的懈怠工具,只有正在你迭代它、或是对它进行切片时,它才会前往真实的数字给你。
以是说,为了进步功能,内建函数 range “变懒”了。而为了不过于频仍的内存调配,正在一样平常编码中,咱们的函数一样也需求变懒,这包罗:
·更多的应用 yield 要害字,前往天生器工具
·只管即便应用天生器表白式代替列表推导表白式
·天生器表白式: (iforinrange(100))
·列表推导表白式: [iforinrange(100)]
·只管即便应用模块提供的懈怠工具:
·应用 re.finditer 代替 re.findall
·间接应用可迭代的文件工具: forlineinfp,而没有是 forlineinfp.readlines()
2. 正在列表头部操作多的场景应用 deque 模块
列表是基于数组构造(Array)完成的,当你正在列表的头部拔出新成员( list.insert(0,item))时,它前面的一切其余成员都需求被挪动,操作的工夫复杂度是 O(n)。这招致正在列表的头部拔出成员远比正在尾部追加( list.append(item) 工夫复杂度为 O(1))要慢。
假如你的代码需求执行不少次这种操作,请思考应用 collections.deque 类型来代替列表。由于 deque 是基于双端行列步队完成的,无论是正在头部仍是尾部追加元素,工夫复杂度都是 O(1)。
3. 应用荟萃/字典来判别成员能否存正在
当你需求判别成员能否存正在于某个容器时,用荟萃比列表更合适。由于 itemin[...] 操作的工夫复杂度是 O(n),而 itemin{...} 的工夫复杂度是 O(1)。这是由于字典与荟萃都是基于哈希表(Hash Table)数据构造完成的。
# 这个例子没有是特地失当,由于当指标荟萃特地小时,应用荟萃仍是列表对效率的影响微不足道 # 但这没有是重点 :) VALID_NAMES = ["piglei", "raymond", "bojack", "caroline"] # 转换为荟萃类型专门用于成员判别 VALID_NAMES_SET = set(VALID_NAMES) def validate_name(name): if name not in VALID_NAMES_SET: # 此处应用了 Python 3.6 增加的 f-strings 特点 raise ValueError(f"{name} is not a valid name!")
Hint: 激烈倡议浏览 TimeComplexity - Python Wiki,理解更多对于常见容器类型的工夫复杂度相干内容。
假如你对字典的完成细节感兴味,也激烈倡议寓目 Raymond Hettinger 的演讲 Modern Dictionaries(YouTube)
相干保举:《Python入门教程》
高层看容器
Python 是一门“鸭子类型”言语:“当看到一只鸟走起来像鸭子、游泳起来像鸭子、叫起来也像鸭子,那末这只鸟就能够被称为鸭子。”以是,当咱们说某个工具是甚么类型时,正在基本上其实指的是:这个工具餍足了该类型的特定接口标准,能够被当成这个类型来应用。而关于一切内置容器类型来讲,一样如斯。
关上位于 collections 模块下的 abc(“形象类 Abstract Base Classes”的首字母缩写)子模块,能够找到一切与容器相干的接口(形象类)[注2]界说。让咱们辨别看看那些内建容器类型都餍足了甚么接口:
·列表(list):餍足 Iterable、 Sequence、 MutableSequence 等接口
·元组(tuple):餍足 Iterable、 Sequence
·字典(dict):餍足 Iterable、 Mapping、 MutableMapping [注3]
·荟萃(set):餍足 Iterable、 Set、 MutableSet [注4]
每一个内置容器类型,其实就是餍足了多个接口界说的组合实体。比方一切的容器类型都餍足 “可被迭代的”(Iterable) 这个接口,这象征着它们都是“可被迭代”的。然而反过去,没有是一切“可被迭代”的工具都是容器。就像字符串尽管能够被迭代,但咱们通常没有会把它当作“容器”来对待。
理解这个现实后,咱们将正在 Python 里从新意识面向工具编程中最首要的准则之一:面向接口而非详细完成来编程。
让咱们经过一个例子,看看若何了解 Python 里的“面向接口编程”。
写扩大性更好的代码
某日,咱们接到一个需要:有一个列表,外面装着不少用户评论,为了正在页面失常展现,需求将一切超越肯定长度的评论用省略号代替。
这个需要很好做,很快咱们就写出了第一个版本的代码:
# 注:为了增强示例代码的阐明性,本文中的局部代码片断应用了Python 3.5 # 版本增加的 Type Hinting 特点 def add_ellipsis(co妹妹ents: typing.List[str], max_length: int = 12): """假如评论列内外的内容超越 max_length,剩下的字符用省略号替代 """ index = 0 for co妹妹ent in co妹妹ents: co妹妹ent = co妹妹ent.strip() if len(co妹妹ent) > max_length: co妹妹ents[index] = co妹妹ent[:max_length] + '...' index += 1 return co妹妹ents co妹妹ents = [ "Implementation note", "Changed", "ABC for generator", ] print("\n".join(add_ellipsis(co妹妹ents))) # OUTPUT: # Implementati... # Changed # ABC for gene...
下面的代码里, add_ellipsis 函数接纳一个列表作为参数,而后遍历它,交换掉需求修正的成员。这所有看下来很正当,由于咱们接到的最原始需要就是:“有一个 列表,外面...”。但若有一天,咱们拿到的评论再也不是被持续装正在列内外,而是正在不成变的元组里呢?
那样的话,现有的函数设计就会欺压咱们写出 add_ellipsis(list(co妹妹ents)) 这类即慢又好看的代码了。
面向容器接口编程
咱们需求改良函数来防止这个成绩。由于 add_ellipsis 函数强依赖了列表类型,以是当参数类型变成元组时,如今的函数就再也不实用了(缘由:给 co妹妹ents[index] 赋值之处会抛出 TypeError 异样)。若何改善这局部的设计?法门就是:让函数依赖“可迭代工具”这个形象概念,而非实体列表类型。
应用天生器特点,函数能够被改为这样:
def add_ellipsis_gen(co妹妹ents: typing.Iterable[str], max_length: int = 12): """假如可迭代评论里的内容超越 max_length,剩下的字符用省略号替代 """ for co妹妹ent in co妹妹ents: co妹妹ent = co妹妹ent.strip() if len(co妹妹ent) > max_length: yield co妹妹ent[:max_length] + '...' else: yield co妹妹ent print("\n".join(add_ellipsis_gen(co妹妹ents)))
正在新函数里,咱们将依赖的参数类型从列表改为了可迭代的形象类。这样做有不少益处,一个最显著的就是:无论评论是来自列表、元组或是某个文件,新函数均可以轻松餍足:
# 解决放正在元组里的评论 co妹妹ents = ("Implementation note", "Changed", "ABC for generator") print("\n".join(add_ellipsis_gen(co妹妹ents))) # 解决放正在文件里的评论 with open("co妹妹ents") as fp: for co妹妹ent in add_ellipsis_gen(fp): print(co妹妹ent)
将依赖由某个详细的容器类型改成形象接口后,函数的实用面变患上更广了。除了此以外,新函数正在执行效率等方面也都更有劣势。如今让咱们再回到以前的成绩。从高层来看,甚么界说了容器?
谜底是:各个容器类型完成的接口协定界说了容器。没有同的容器类型正在咱们的眼里,应该是 能否能够迭代、 能否能够修正、 有无长度 等各类特点的组合。咱们需求正在编写相干代码时,更多的存眷容器的形象属性,而非容器类型自身,这样能够协助咱们写出更优雅、扩大性更好的代码。
Hint:正在 itertools 内置模块里能够找到更多对于解决可迭代工具的宝藏。
罕用技术
1. 应用元组改善分支代码
有时,咱们的代码里会呈现超越三个分支的 if/else 。就像上面这样:
import time def from_now(ts): """接纳一个过来的工夫戳,前往间隔以后工夫的绝对工夫文字形容 """ now = time.time() seconds_delta = int(now - ts) if seconds_delta < 1: return "less than 1 second ago" elif seconds_delta < 60: return "{} seconds ago".format(seconds_delta) elif seconds_delta < 3600: return "{} minutes ago".format(seconds_delta // 60) elif seconds_delta < 3600 * 24: return "{} hours ago".format(seconds_delta // 3600) else: return "{} days ago".format(seconds_delta // (3600 * 24)) now = time.time() print(from_now(now)) print(from_now(now - 24)) print(from_now(now - 600)) print(from_now(now - 7500)) print(from_now(now - 87500)) # OUTPUT: # less than 1 second ago # 24 seconds ago # 10 minutes ago # 2 hours ago # 1 days ago
下面这个函数挑没有出太多故障,不少不少人城市写出相似的代码。然而,假如你细心察看它,能够正在分支代码局部找到一些显著的“鸿沟”。比方,当函数判别某个工夫能否应该用“秒数”展现时,用到了 60。而判别能否应该用分钟时,用到了 3600。
从鸿沟提炼法则是优化这段代码的要害。假如咱们将一切的这些鸿沟放正在一个有序元组中,而后合营二分查找模块 bisect。整个函数的管制流就能被年夜年夜简化:
import bisect # BREAKPOINTS 必需是曾经排好序的,否则无奈进行二分查找 BREAKPOINTS = (1, 60, 3600, 3600 * 24) TMPLS = ( # unit, template (1, "less than 1 second ago"), (1, "{units} seconds ago"), (60, "{units} minutes ago"), (3600, "{units} hours ago"), (3600 * 24, "{units} days ago"), ) def from_now(ts): """接纳一个过来的工夫戳,前往间隔以后工夫的绝对工夫文字形容 """ seconds_delta = int(time.time() - ts) unit, tmpl = TMPLS[bisect.bisect(BREAKPOINTS, seconds_delta)] return tmpl.format(units=seconds_delta // unit)
除了了用元组能够优化过多的 if/else 分支外,有些状况下字典也能被用来做一样的事件。要害正在于从现有代码找到反复的逻辑与法则,并多多测验考试。
2. 正在更多中央应用静态解包
静态解包操作是支使用 * 或 ** 运算符将可迭代工具“解开”的行为,正在 Python 2 时代,这个操作只能被用正在函数参数局部,而且对呈现程序以及数目都有十分严格的要求,应用场景十分繁多。
def calc(a, b, multiplier=1): return (a + b) * multiplier # Python2 中只支持正在函数参数局部进举动态解包 print calc(*[1, 2], **{"multiplier": 10}) # OUTPUT: 30
不外,Python 3 尤为是 3.5 版本后, * 以及 ** 的应用场景被年夜年夜裁减了。举个例子,正在 Python 2 中,假如咱们需求兼并两个字典,需求这么做:
def merge_dict(d1, d2): # 由于字典是可被修正的工具,为了不修正原工具,此处需求复制一个 d1 的浅拷贝 result = d1.copy() result.update(d2) return result user = merge_dict({"name": "piglei"}, {"movies": ["Fight Club"]})
然而正在 Python 3.5 当前的版本,你能够间接用 ** 运算符来疾速实现字典的兼并操作:
user = {**{"name": "piglei"}, **{"movies": ["Fight Club"]}}
除了此以外,你还能够正在一般赋值语句中应用 * 运算符来静态的解包可迭代工具。假如你想具体理解相干内容,能够浏览上面保举的 PEP。
Hint:推动静态解包场景裁减的两个 PEP:
·PEP 3132 -- Extended Iterable Unpacking | Python.org
·PEP 448 -- Additional Unpacking Generalizations | Python.org
3. 最佳不必“猎取答应”,也无需“要求原谅”
这个小题目可能会略微让人有点懵,让我来冗长的诠释一下:“猎取答应”与“要求原谅”是两种没有同的编程格调。假如用一个经典的需要:“较量争论列表内各个元素呈现的次数” 来作为例子,两种没有同格调的代码会是这样:
# AF: Ask for Forgiveness # 要做就做,假如抛出异样了,再解决异样 def counter_af(l): result = {} for key in l: try: result[key] += 1 except KeyError: result[key] = 1 return result # AP: Ask for Permission # 做以前,先问问能不克不及做,能够做再做 def counter_ap(l): result = {} for key in l: if key in result: result[key] += 1 else: result[key] = 1 return result
整个 Python 社区对第一种 Ask for Forgiveness 的异样捕捉式编程格调有着显著的偏幸。这此中有不少缘由,起首,正在 Python 中抛出异样是一个很轻量的操作。其次,第一种做法正在功能上也要优于第二种,由于它不必正在每一次轮回的时分都做一次额定的成员反省。
不外,示例里的两段代码正在事实世界中都十分少见。为何?由于假如你想统计次数的话,间接用 collections.defaultdict 就能够了:
from collections import defaultdict def counter_by_collections(l): result = defaultdict(int) for key in l: result[key] += 1 return result
这样的代码既不必“猎取答应”,也无需“申请原谅”。整个代码的管制流变患上更明晰天然了。以是,假如可能的话,请只管即便想方法省略掉那些非外围的异样捕捉逻辑。一些小提醒:
·操作字典成员时:应用 collections.defaultdict 类型
·或许应用 dict[key]=dict.setdefault(key,0)+1 内建函数
·假如移除了字典成员,没有关怀能否存正在:
·挪用 pop 函数时设置默许值,比方 dict.pop(key,None)
·正在字典猎取成员时指定默许值: dict.get(key,default_value)
·对列表进行没有存正在的切片拜访没有会抛出 IndexError 异样: ["foo"][100:200]
4. 应用 next() 函数
next() 是一个十分适用的内建函数,它接纳一个迭代器作为参数,而后前往该迭代器的下一个元素。应用它合营天生器表白式,能够高效的完成“从列表中查找第一个餍足前提的成员”之类的需要。
numbers = [3, 7, 8, 2, 21] # 猎取并 **立刻前往** 列内外的第一个偶数 print(next(i for i in numbers if i % 2 == 0)) # OUTPUT: 8
5. 应用有序字典往来来往重
字典以及荟萃的构造特性保障了它们的成员没有会反复,以是它们常常被用往来来往重。然而,应用它们俩去重后的后果会失落原有列表的程序。这是由底层数据构造“哈希表(Hash Table)”的特性决议的。
>>> l = [10, 2, 3, 21, 10, 3] # 去重然而失落了程序 >>> set(l) {3, 10, 2, 21}
假如既需求去重又必需保存程序怎样办?咱们能够应用 collections.OrderedDict 模块:
Hint: 正在 Python 3.6 中,默许的字典类型修正了完成形式,曾经变为有序的了。而且正在 Python 3.7 中,该性能曾经从 言语的完成细节 变为了为 可依赖的正式言语特点。
然而我感觉让整个 Python 社区习气这一点还需求一些工夫,究竟结果今朝“字典是无序的”仍是被印正在有数本 Python 书上。以是,我依然倡议正在所有需求有序字典之处应用 OrderedDict。
常见误区
1. 留神那些曾经干涸的迭代器
正在文章后面,咱们提到了应用“懈怠”天生器的种种益处。然而,一切事物都有它的两面性。天生器的最年夜的缺陷之一就是:它会干涸。当你完好遍历过它们后,之后的反复遍历就不克不及拿到任何新内容了。
numbers = [1, 2, 3] numbers = (i * 2 for i in numbers) # 第一次轮回会输入 2, 4, 6 for number in numbers: print(number) # 此次轮回甚么都没有会输入,由于迭代器曾经干涸了 for number in numbers: print(number)
并且没有光是天生器表白式,Python 3 里的 map、filter 内建函数也都有同样的特性。漠视这个特性很容易招致代码中呈现一些难以觉察的 Bug。
Instagram 就正在名目从 Python 2 到 Python 3 的迁徙进程中碰着了这个成绩。它们正在 PyCon 2017 上分享了凑合这个成绩的故事。拜访文章 Instagram 正在 PyCon 2017 的演讲择要,搜寻“迭代器”能够查看具体内容。
2. 别正在轮回体内修正被迭代工具
这是一个不少 Python 初学者会犯的谬误。比方,咱们需求一个函数来删掉列内外的一切偶数:
def remove_even(numbers): """去掉列内外一切的偶数 """ for i, number in enumerate(numbers): if number % 2 == 0: # 有成绩的代码 del numbers[i] numbers = [1, 2, 7, 4, 8, 11] remove_even(numbers) print(numbers) # OUTPUT: [1, 7, 8, 11]
留意到后果里阿谁多进去的“8”了吗?当你正在遍历一个列表的同时修正它,就会呈现这样的事件。由于被迭代的工具numbers正在轮回进程中被修正了。遍历的下标正在一直增进,而列表自身的长度同时又正在一直缩减。这样就会招致列内外的一些成员其实基本就不被遍历到。
以是关于这种操作,请应用一个新的空列表保留后果,或许行使 yield 前往一个天生器。而没有是修正被迭代的列表或是字典工具自身。
以上就是Python容器应用的5个技术以及2个误区的具体内容,更多请存眷资源魔其它相干文章!
标签: 技巧 Python python教程 python编程 python使用问题 容器 误区
抱歉,评论功能暂时关闭!