Python 컨테이너 사용에 대한 5가지 팁과 2가지 오해
"컨테이너"라는 단어 Python에서는 거의 언급되지 않음 기술 기사. 사람들은 '컨테이너'라고 하면 작은 푸른 고래인 도커(Docker)를 주로 떠올리지만, 이 글은 그것과 아무런 관련이 없습니다. 이 문서의 컨테이너는 Python의 추상 개념이며 특히 다른 객체를 보유하는 데 사용되는 데이터 유형에 대한 일반적인 용어입니다.
Python에는 가장 일반적인 내장 컨테이너 유형 4가지(리스트, 튜플, 딕셔너리, 세트)가 있습니다. 이를 개별적으로 또는 조합하여 사용하면 많은 작업을 효율적으로 수행할 수 있습니다.
Python 언어 자체의 내부 구현 세부 사항도 이러한 컨테이너 유형과 밀접한 관련이 있습니다. 예를 들어 Python의 클래스 인스턴스 속성, 전역 변수 globals() 등은 모두 사전 유형을 통해 저장됩니다.
이 글에서는 먼저 컨테이너 유형의 정의부터 시작하여 일상적인 코딩에 대한 몇 가지 모범 사례를 요약하려고 합니다. 나중에 각 컨테이너 유형이 제공하는 특수 기능에 대한 몇 가지 프로그래밍 팁을 공유하겠습니다.
컨테이너에 관해 이야기할 때 무엇을 말하는 걸까요?
앞에서 "컨테이너"에 대해 간단히 정의했습니다. 컨테이너는 특히 다른 개체를 담는 데 사용됩니다. 그러나 이 정의는 너무 광범위하여 일상 프로그램에 어떤 지침도 제공할 수 없습니다. Python에서 컨테이너를 제대로 마스터하려면 두 가지 수준부터 시작해야 합니다.
·기본 구현: 내장 컨테이너 유형에서는 어떤 데이터 구조를 사용합니까? 특정 작업은 어떻게 작동하나요?
·고수준 추상화: 객체가 컨테이너인지 여부를 결정하는 것은 무엇입니까? 컨테이너를 정의하는 동작은 무엇입니까?
이제 이 두 가지 수준에 서서 컨테이너를 다시 이해해 보겠습니다.
컨테이너를 밑에서 보세요
Python은 고급 프로그래밍 언어이며, 이것이 제공하는 내장 컨테이너 유형은 다음과 같습니다. 최종 결과를 고도로 캡슐화하고 추상화했습니다. "연결된 목록", "레드-블랙 트리" 및 "해시 테이블"과 같은 이름과 비교할 때 모든 Python 내장 유형의 이름은 이 유형의 기능적 특성만을 설명할 뿐입니다. 약간의 내부 세부 사항.
이것은 Python 프로그래밍 언어의 장점 중 하나입니다. 기본 컴퓨터에 더 가까운 C와 같은 프로그래밍 언어에 비해 Python은 프로그래머에게 더 친숙한 내장 컨테이너 유형을 다시 설계하고 구현하여 메모리 관리와 같은 추가 작업으로부터 보호합니다. 더 나은 개발 경험을 제공합니다.
그러나 이것이 Python 언어의 장점이라면 왜 여전히 컨테이너 유형의 구현 세부 사항을 이해하려고 애쓰는 걸까요? 대답은: 세부 사항에 주의를 기울이는 것이 더 빠른 코드를 작성하는 데 도움이 된다는 것입니다.
더 빠른 코드 작성
1 자주 목록을 확장하거나 새 목록을 만드는 것을 피하세요#🎜🎜 ## 🎜🎜#모든 내장형 컨테이너 유형에는 용량 제한이 없습니다. 원하는 경우 전체 시스템 메모리가 가득 찰 때까지 빈 목록에 증가하는 숫자를 계속 채울 수 있습니다.
Python 언어의 구현 세부 사항에서 목록의 메모리는 요청 시 할당됩니다. [참고 1] 특정 목록이 현재 소유하고 있는 메모리가 충분하지 않은 경우 메모리 확장 논리가 사용됩니다. 트리거되었습니다. 그리고 메모리 할당은 비용이 많이 드는 작업입니다. 대부분의 경우 프로그램 성능에 심각한 영향을 미치지 않습니다. 그러나 처리하는 데이터의 양이 특히 많을 경우에는 메모리 할당으로 인해 전체 프로그램의 성능이 저하되기 쉽습니다.
다행스럽게도 Python은 오랫동안 이 문제를 인식하고 공식적인 문제 해결 지침, 즉 "게으르다"를 제공했습니다.
"게으르다"를 어떻게 설명할 수 있나요? range() 함수의 진화가 아주 좋은 예입니다.
Python 2에서는 range(100000000)을 호출하면 엄청난 양의 목록을 반환해야 하고 메모리 할당 및 계산에 많은 시간을 소비하기 때문에 결과를 얻기까지 몇 초를 기다려야 합니다. . 우수한. 그러나 Python 3에서는 동일한 호출이 즉시 결과를 얻습니다. 이 함수는 더 이상 목록을 반환하지 않고 범위 유형의 게으른 개체를 반환하므로 반복하거나 분할할 때 실수만 반환합니다.
그래서 성능 향상을 위해 내장 기능 범위가 "게으르게" 됩니다. 너무 빈번한 메모리 할당을 피하기 위해 일상적인 코딩에서 함수도 게으른 상태여야 합니다. 생성기 객체
·목록 이해 표현식 대신 생성기 표현식을 사용해 보세요
·생성기 표현식: (iforinrange(100))
·목록 이해식 : [iforinrange(100)]#🎜🎜 #·
모듈에서 제공하는 게으른 개체를 사용해 보세요.·
re.findall 대신 re.finditer 사용#🎜🎜 #· 반복 가능한 파일 객체를 직접 사용: forlineinfp.readlines() 대신 forlineinfp
2 목록 앞에 많은 작업이 있는 시나리오에서는 deque 모듈을 사용하세요# 🎜 🎜#
목록은 배열 구조(Array)를 기반으로 구현됩니다. 목록의 선두(list.insert(0,item))에 새 구성원을 삽입하면 그 뒤에 있는 다른 모든 구성원을 이동해야 하며 시간도 마찬가지입니다. 연산복잡도는 O(n)입니다. 이로 인해 목록의 머리 부분에 멤버를 삽입하는 것이 끝 부분에 추가하는 것보다 훨씬 느립니다(list.append(item) 시간 복잡도는 O(1)입니다).
코드에서 이러한 작업을 여러 번 수행해야 하는 경우 목록 대신 collections.deque 유형을 사용하는 것이 좋습니다. deque는 double-ended queue를 기반으로 구현되므로 요소를 head에 추가하든 tail에 추가하든 시간 복잡도는 O(1)입니다.
3. 멤버가 존재하는지 확인하려면 집합/사전을 사용하세요
컨테이너에 멤버가 있는지 확인해야 할 때는 목록보다 집합을 사용하는 것이 더 적합합니다. itemin[...] 작업의 시간 복잡도는 O(n)이고 itemin{...} 작업의 시간 복잡도는 O(1)이기 때문입니다. 이는 Dictionary와 Set 모두 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!")
힌트: 일반적인 컨테이너 유형의 시간 복잡도에 대해 자세히 알아보려면 TimeComplexity - Python Wiki를 읽는 것이 좋습니다.
사전의 구현 세부 사항에 관심이 있다면 Raymond Hettinger의 연설 Modern Dictionaries(YouTube)를 시청하는 것도 적극 권장합니다.
관련 권장 사항: "Python 튜토리얼 소개"
Look at Containers at a High level
Python은 "오리 유형" 언어입니다. "오리처럼 걷고, 오리처럼 헤엄치고, 오리처럼 꽥꽥거리는 새를 보면 그 새를 오리라고 부를 수 있습니다." 객체가 기본적으로 의미하는 것은 무엇입니까? 이 객체는 해당 유형의 특정 인터페이스 사양을 충족하며 이 유형으로 사용될 수 있습니다. 모든 내장 컨테이너 유형에 대해서도 마찬가지입니다.
collections 모듈 아래에 있는 abc("Abstract Base Classes"의 약어) 서브모듈을 열면 모든 컨테이너 관련 인터페이스(추상 클래스) [참고 2] 정의를 찾을 수 있습니다. 내장 컨테이너 유형이 만족하는 인터페이스를 살펴보겠습니다.
·List: Iterable, Sequence, MutableSequence 및 기타 인터페이스를 만족
·Tuple: Iterable, Sequence를 만족
·Dictionary( dict): Iterable, Mapping, MutableMapping 충족 [참고 3]
·Set: Iterable, Set, MutableSet 충족 [참고 4]
각 내장 컨테이너 유형은 실제로 여러 인터페이스로 정의된 복합 엔터티를 충족합니다. 예를 들어, 모든 컨테이너 유형은 "Iterable" 인터페이스를 충족합니다. 이는 모두 "Iterable"임을 의미합니다. 그러나 반대로 모든 "반복 가능한" 개체가 컨테이너는 아닙니다. 문자열이 반복될 수 있는 것처럼, 우리는 일반적으로 문자열을 "컨테이너"로 취급하지 않습니다.
이 사실을 이해한 후에는 Python의 객체 지향 프로그래밍의 가장 중요한 원칙 중 하나인 특정 구현이 아닌 인터페이스를 위한 프로그래밍을 다시 이해하게 됩니다.
파이썬에서 "인터페이스 지향 프로그래밍"을 이해하는 방법을 알아보기 위해 예를 들어 보겠습니다.
확장성을 높이는 코드 작성
어느 날 요청을 받았습니다. 많은 사용자 댓글이 포함된 목록이 있습니다. 페이지에 정상적으로 표시하려면 특정 길이를 초과하는 모든 댓글을 다음으로 바꿔야 합니다. 타원.
이 요구 사항은 구현하기 쉬우며 코드의 첫 번째 버전을 빠르게 작성했습니다.
# 注:为了加强示例代码的说明性,本文中的部分代码片段使用了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...
위 코드에서 add_ellipsis 함수는 목록을 매개변수로 받은 다음 이를 반복하고 필요한 멤버를 바꿉니다. 수정되었습니다. 우리가 받은 가장 원래의 요구 사항은 "... 목록이 있습니다."였기 때문에 이 모든 것이 합리적으로 보입니다. 하지만 언젠가 우리가 받은 댓글이 더 이상 목록에 저장되지 않고 불변의 튜플에 저장된다면 어떻게 될까요?
이 경우 기존 함수 설계에서는 느리고 보기 흉한 코드인 add_ellipsis(list(comments)) 를 작성해야 합니다.
컨테이너 인터페이스 프로그래밍
이 문제를 방지하려면 기능을 개선해야 합니다. add_ellipsis 함수는 목록 유형에 크게 의존하기 때문에 매개변수 유형이 튜플로 변경되면 현재 함수를 더 이상 적용할 수 없습니다(이유: comments[index]에 값을 할당할 때 TypeError 예외가 발생합니다). 디자인에서 이 부분을 어떻게 개선할 수 있나요? 비결은 함수가 엔터티 목록 유형이 아닌 "반복 가능한 객체"라는 추상 개념에 의존하도록 만드는 것입니다.
생성기 기능을 사용하면 함수를 다음과 같이 변경할 수 있습니다.
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)))
새 함수에서는 종속 매개변수 유형을 목록에서 반복 가능한 추상 클래스로 변경했습니다. 이렇게 하면 많은 이점이 있습니다. 가장 확실한 것 중 하나는 주석이 목록, 튜플 또는 파일에서 나오든 새 함수가 쉽게 만족할 수 있다는 것입니다.
# 处理放在元组里的评论 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)
종속성을 특정 컨테이너 유형에서 컨테이너 유형으로 변경한 후 추상 인터페이스로 인해 기능의 활용 범위가 넓어집니다. 또한, 새로운 기능은 실행 효율성 측면에서도 더 많은 이점을 갖고 있습니다. 이제 이전 질문으로 돌아가 보겠습니다. 높은 수준의 관점에서 컨테이너를 정의하는 것은 무엇입니까?
答案是:各个容器类型实现的接口协议定义了容器。不同的容器类型在我们的眼里,应该是 是否可以迭代、 是否可以修改、 有没有长度 等各种特性的组合。我们需要在编写相关代码时,更多的关注容器的抽象属性,而非容器类型本身,这样可以帮助我们写出更优雅、扩展性更好的代码。
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가지 오해의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!