Maison  >  Article  >  développement back-end  >  5 conseils et 2 malentendus sur l'utilisation des conteneurs Python

5 conseils et 2 malentendus sur l'utilisation des conteneurs Python

爱喝马黛茶的安东尼
爱喝马黛茶的安东尼avant
2019-09-24 17:51:022445parcourir

5 conseils et 2 malentendus sur l'utilisation des conteneurs Python

5 conseils et 2 malentendus sur l'utilisation des conteneurs Python

Le mot « conteneur » est rarement mentionné dans les articles techniques Python. Quand les gens voient « conteneur », ils pensent surtout à la petite baleine bleue : Docker, mais cet article n'a rien à voir avec ça. Le conteneur dans cet article est un concept abstrait en Python et est un terme général désignant les types de données spécifiquement utilisés pour contenir d'autres objets.

En Python, il existe quatre types de conteneurs intégrés les plus courants : liste, tuple, dictionnaire et ensemble. En les utilisant individuellement ou en combinaison, de nombreuses choses peuvent être accomplies efficacement.

Les détails d'implémentation internes du langage Python lui-même sont également étroitement liés à ces types de conteneurs. Par exemple, les attributs d'instance de classe Python, les variables globales globals(), etc. sont tous stockés via des types de dictionnaire.

Dans cet article, je partirai d'abord de la définition des types de conteneurs et tenterai de résumer quelques bonnes pratiques pour le codage quotidien. Plus tard, je partagerai quelques conseils de programmation autour des fonctions spéciales fournies par chaque type de conteneur.

Quand on parle de conteneurs, de quoi parle-t-on ?

J'ai donné une définition simple de « conteneur » plus tôt : un conteneur est spécifiquement utilisé pour contenir d'autres objets. Mais cette définition est trop large et ne peut fournir aucune valeur indicative pour notre programmation quotidienne. Pour véritablement maîtriser les conteneurs en Python, vous devez partir de deux niveaux :

·Implémentation sous-jacente : quelles structures de données sont utilisées par les types de conteneurs intégrés ? Comment se déroule une certaine opération ?

·Abstraction de haut niveau : Qu'est-ce qui détermine si un objet est un conteneur ? Quels comportements définissent un conteneur ?

Maintenant, plaçons-nous sur ces deux niveaux différents et recompréhendons les conteneurs.

Regardez les conteneurs au niveau inférieur

Python est un langage de programmation de haut niveau, et les types de conteneurs intégrés qu'il fournit sont le résultat d'un haut degré d'encapsulation et d'abstraction. Par rapport aux noms tels que « liste chaînée », « arbre rouge-noir » et « table de hachage », les noms de tous les types intégrés Python ne décrivent que les caractéristiques fonctionnelles de ce type. D'autres ne peuvent pas les comprendre uniquement à travers ces noms. un tout petit détail interne.

C'est l'un des avantages du langage de programmation Python. Par rapport aux langages de programmation tels que C qui sont plus proches de l'ordinateur sous-jacent, Python repense et implémente un type de conteneur intégré plus convivial pour les programmeurs, le protégeant des travaux supplémentaires tels que la gestion de la mémoire. Nous offre une meilleure expérience de développement.

Mais si c'est l'avantage du langage Python, pourquoi prenons-nous la peine de comprendre les détails d'implémentation des types de conteneurs ? La réponse est : prêter attention aux détails nous aide à écrire du code plus rapidement.

Écrivez du code plus rapidement

1. Évitez d'étendre fréquemment les listes/de créer de nouvelles listes

Tous les conteneurs intégrés Aucun type. limite la capacité. Si vous le souhaitez, vous pouvez continuer à insérer des nombres croissants dans une liste vide jusqu'à ce qu'elle remplisse toute la mémoire de la machine.

Dans les détails d'implémentation du langage Python, la mémoire de la liste est allouée à la demande [Note 1]. Lorsque la mémoire actuellement détenue par une certaine liste n'est pas suffisante, la logique d'extension de mémoire sera déclenchée. Et l’allocation de mémoire est une opération coûteuse. Bien que dans la plupart des cas, cela n’aura pas d’impact sérieux sur les performances de votre programme. Mais lorsque la quantité de données que vous traitez est particulièrement importante, il est facile de réduire les performances de l'ensemble du programme en raison de l'allocation de mémoire.

Heureusement, Python est conscient de ce problème depuis longtemps et a fourni des directives officielles de résolution de problèmes, à savoir : "devenir paresseux".

Comment expliquer « devenir paresseux » ? L’évolution de la fonction range() en est un très bon exemple.

Dans Python 2, si vous appelez range(100000000), vous devez attendre plusieurs secondes pour obtenir le résultat, car il doit renvoyer une liste énorme et passe beaucoup de temps en allocation de mémoire et en calcul. Mais dans Python 3, le même appel obtiendra le résultat immédiatement. Étant donné que la fonction ne renvoie plus une liste, mais un objet paresseux de type range, elle ne vous renverra le nombre réel que lorsque vous le parcourirez ou le découperez.

Ainsi, afin d'améliorer les performances, la gamme de fonctions intégrées "devient paresseuse". Afin d'éviter une allocation de mémoire trop fréquente, dans le codage quotidien, nos fonctions doivent également être paresseuses, ce qui inclut :

·Utiliser davantage de mots-clés de rendement et de générateurs de retour d'objet

· Essayez d'utiliser des expressions génératrices au lieu d'expressions de compréhension de liste

·Expression génératrice : (iforinrange(100))

 · Expression de compréhension de liste : [iforinrange(100)]

·Essayez d'utiliser les objets paresseux fournis par le module :

·Utiliser re.finditer au lieu de re.findall

·Utilisez directement les objets fichier itérables : forlineinfp au lieu de forlineinfp.readlines()

2. Utilisez le module deque. dans des scénarios où il y a de nombreuses opérations en tête de liste

La liste est implémentée en fonction de la structure du tableau (Array). Lorsque vous insérez un nouveau membre en tête de la liste (list.insert(0,item)), tous les autres membres derrière lui doivent être déplacés, et le temps de fonctionnement La complexité est O(n). Cela a pour conséquence que l'insertion de membres en tête de liste est beaucoup plus lente que l'ajout en queue (la complexité temporelle de list.append(item) est O(1)).

Si votre code doit effectuer ces opérations plusieurs fois, pensez à utiliser le type collections.deque au lieu d'une liste. Étant donné que deque est implémenté sur la base d'une file d'attente à double extrémité, la complexité temporelle est de O(1), qu'il s'agisse d'ajouter des éléments en tête ou en queue.

3. Utilisez un ensemble/dictionnaire pour déterminer si un membre existe

Lorsque vous devez déterminer si un membre existe dans un conteneur, il est plus approprié d'utiliser un ensemble qu'une liste. Parce que la complexité temporelle de l'opération itemin[...] est O(n), tandis que la complexité temporelle de itemin{...} est O(1). En effet, les dictionnaires et les ensembles sont implémentés sur la base de la structure de données de la table de hachage.

# 这个例子不是特别恰当,因为当目标集合特别小时,使用集合还是列表对效率的影响微乎其微
# 但这不是重点 :)
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!")

Astuce : Il est fortement recommandé de lire TimeComplexity - Python Wiki pour en savoir plus sur la complexité temporelle des types de conteneurs courants.

Si vous êtes intéressé par les détails d'implémentation des dictionnaires, il est également fortement recommandé de regarder le discours de Raymond Hettinger Modern Dictionaries (YouTube)

Recommandations associées : "Tutoriel d'introduction à Python"

Vue de haut niveau des conteneurs

Python est un langage de type "canard" : "Quand vous voyez un oiseau qui marche comme un canard, nage comme un canard et charlatans Comme un canard, alors cet oiseau peut être appelé un canard. « Ainsi, lorsque nous disons de quel type est un objet, ce que nous voulons dire en réalité est : cet objet satisfait à la spécification d'interface spécifique du type et peut être considéré comme un canard. Utilisez ce type. Il en va de même pour tous les types de conteneurs intégrés.

Ouvrez le sous-module abc (abréviation de "Abstract Base Classes") situé sous le module collections, et vous pourrez trouver toutes les définitions d'interface liées aux conteneurs (classe abstraite) [Note 2]. Jetons un coup d'œil aux interfaces satisfaites par les types de conteneurs intégrés :

·Liste : satisfont Iterable, Sequence, MutableSequence et autres interfaces

·Tuple : satisfait à Iterable, Sequence

·Dictionnaire (dict) : satisfait à Iterable, Mapping, MutableMapping [Note 3]

·Set : satisfait Iterable, Set, MutableSet [Note 4]

Chaque type de conteneur intégré est en fait une entité composite qui satisfait plusieurs définitions d'interface. Par exemple, tous les types de conteneurs satisfont à l’interface « Iterable », ce qui signifie qu’ils sont tous « Iterable ». Mais à l’inverse, tous les objets « itérables » ne sont pas des conteneurs. Tout comme même si une chaîne peut être itérée, nous ne la traitons généralement pas comme un « conteneur ».

Après avoir compris ce fait, nous comprendrons à nouveau l'un des principes les plus importants de la programmation orientée objet en Python : la programmation pour les interfaces plutôt que pour les implémentations spécifiques.

Utilisons un exemple pour voir comment comprendre la « programmation orientée interface » en Python.

Écrire du code avec une meilleure évolutivité

Un jour, nous avons reçu une demande : il existe une liste avec de nombreux commentaires d'utilisateurs afin de l'afficher correctement sur la page, Tous les commentaires dépassant une certaine longueur doivent être remplacés par des points de suspension.

Cette exigence est facile à remplir, et nous avons rapidement écrit la première version du code :

# 注:为了加强示例代码的说明性,本文中的部分代码片段使用了Python 3.5
# 版本添加的 Type Hinting 特性
 
def add_ellipsis(comments: typing.List[str], max_length: int = 12):
    """如果评论列表里的内容超过 max_length,剩下的字符用省略号代替
    """
    index = 0
    for comment in comments:
        comment = comment.strip()
        if len(comment) > max_length:
            comments[index] = comment[:max_length] + '...'
        index += 1
    return comments
comments = [
    "Implementation note",
    "Changed",
    "ABC for generator",
]
print("\n".join(add_ellipsis(comments)))
# OUTPUT:
# Implementati...
# Changed
# ABC for gene...

Dans le code ci-dessus, la fonction add_ellipsis reçoit une liste en tant que paramètre, puis parcourt il, Remplacez les membres qui doivent être modifiés. Tout cela semble raisonnable, car la demande la plus originale que nous ayons reçue était : "Il y a une liste dans laquelle...". Mais et si un jour, les commentaires que nous recevons n’étaient plus stockés dans une liste, mais dans un tuple immuable ?

Dans ce cas, la conception de la fonction existante nous obligera à écrire add_ellipsis(list(comments)) qui est un code lent et laid.

Programmation pour les interfaces de conteneurs

Nous devons améliorer les fonctions pour éviter ce problème. Étant donné que la fonction add_ellipsis s'appuie fortement sur le type de liste, lorsque le type de paramètre change en tuple, la fonction actuelle n'est plus applicable (raison : une exception TypeError sera levée lors de l'attribution d'une valeur aux commentaires[index]). Comment améliorer cette partie du design ? Le secret est le suivant : faites en sorte que la fonction s'appuie sur le concept abstrait d'"objet itérable" plutôt que sur le type de liste d'entités.

À l'aide de la fonctionnalité générateur, la fonction peut être modifiée comme suit :

def add_ellipsis_gen(comments: typing.Iterable[str], max_length: int = 12):
    """如果可迭代评论里的内容超过 max_length,剩下的字符用省略号代替
    """
    for comment in comments:
        comment = comment.strip()
        if len(comment) > max_length:
            yield comment[:max_length] + '...'
        else:
            yield comment
print("\n".join(add_ellipsis_gen(comments)))

Dans la nouvelle fonction, nous avons changé le type de paramètre dépendant d'une liste à une classe abstraite itérable. Il y a de nombreux avantages à faire cela, l'un des plus évidents est : que le commentaire provienne d'une liste, d'un tuple ou d'un fichier, la nouvelle fonction peut facilement satisfaire :

# 处理放在元组里的评论
comments = ("Implementation note", "Changed", "ABC for generator")
print("\n".join(add_ellipsis_gen(comments)))
# 处理放在文件里的评论
with open("comments") as fp:
    for comment in add_ellipsis_gen(fp):
        print(comment)

Changer la dépendance depuis un conteneur spécifique type Après être devenu une interface abstraite, l'applicabilité des fonctions devient plus large. De plus, les nouvelles fonctions présentent également davantage d’avantages en termes d’efficacité d’exécution. Revenons maintenant à la question précédente. D'un point de vue de haut niveau, qu'est-ce qui définit un conteneur ?

答案是:各个容器类型实现的接口协议定义了容器。不同的容器类型在我们的眼里,应该是 是否可以迭代、 是否可以修改、 有没有长度 等各种特性的组合。我们需要在编写相关代码时,更多的关注容器的抽象属性,而非容器类型本身,这样可以帮助我们写出更优雅、扩展性更好的代码。

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 返回一个生成器。而不是修改被迭代的列表或是字典对象本身。

Ce qui précède est le contenu détaillé de. pour plus d'informations, suivez d'autres articles connexes sur le site Web de PHP en chinois!

Déclaration:
Cet article est reproduit dans:. en cas de violation, veuillez contacter admin@php.cn Supprimer
Article précédent:multithread python3Article suivant:multithread python3