Python容器使用的5个技巧和2个误区-Python教程

资源魔 50 0

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使用问题 容器 误区

抱歉,评论功能暂时关闭!