可变对象与不可变对象
Python的每个对象都分为可变和不可变,主要的核心类型中,数字、字符串、元组是不可变的,列表、字典是可变的。
最简单的判断方法就是看这个变量值更新后,内存地址有没有变化。因为可变对象数据都是就地更新,而不可变对象数据修改都是开辟新内存存新数据。
比如:整数是不可变对象,所以值更新了会新开辟一块内存存新值;而集合是可变对象,会就地更新原内存地址指向的值。
a = {1, 2, 3}
print(id(a)) # 139654362815624
a.add(4)
print(id(a)) # 139654362815624
a = 2
print(id(a)) # 10919360
a += 1
print(id(a)) # 10919392
可散列的数据类型
只有可散列的数据类型才能作为字典里的键。
什么是可散列的数据类型?如果一个对象是可散列的,那么在这个对象的生命周期中,它的散列值是不变的,而且这个对象需要实现__hash__() 方法。另外可散列对象还要有__eq__()方法,这样才能跟其他键做比较。如果两个可散列对象是相等的,那么它们的散列值一定是一样的。
通俗点:哈希集合就是很多个桶,但每个桶里面只能放一个球。__hash__函数的作用就是找到桶的位置,到底是几号桶。__eq__函数的作用就是当桶里面已经有一个球了,但又来了一个球,它声称它也应该装进这个桶里面(__hash__函数给它说了桶的位置),双方僵持不下,那就得用__eq__函数来判断这两个球是不是相等的(equal),如果是判断是相等的,那么后来那个球就不应该放进桶里,哈希集合维持现状。
原子不可变数据类型(str、 bytes和数值类型)都是可散列类型,frozenset也是可散列的,因为frozenset里只能容纳可散列类型。元组的话,只有当一个元组包含的所有元素都是可散列类型的情况下,它才是可散列的。
一般来讲用户自定义的类型的对象都是可散列的,散列值就是它们的id()函数的返回值,所以所有这些对象在比较的时候都是不相等的。
字典推导
列表推导和生成器表达式的概念就移植到了字典上,从而有了字典推导。
DIAL_CODES = [
(86, 'China'),
(91, 'India'),
(1, 'United States'),
(62, 'Indonesia'),
(55, 'Brazil'),
(92, 'Pakistan'),
(880, 'Bangladesh'),
(234, 'Nigeria'),
(7, 'Russia'),
(81, 'Japan'),
]
country_code = {country: code for code, country in DIAL_CODES}
用 setdefault 处理找不到的键
my_dict.setdefault(key, []).append(new_value)
等同于
if key not in my_dict:
my_dict[key] = []
my_dict[key].append(new_value)
二者的效果是一样的,只不过后者至少要进行两次键查询——如果键不存在的话,就是三次,用setdefault只需要一次就可以完成整个操作。
defaultdict 弹性键查询
如果某个键在映射里不存在,我们也希望在通过这个键读取值的时候能得到一个默认值。
有两个途径能帮我们达到这个目的,一个是通过 defaultdict 这个类型而不是普通的 dict,另一个是给自己定义一个 dict 的子类,然后在子类中实现__missing__方法。
my_dict = collections.defaultdict(list)
- my_dict[‘zzxx’].append(‘1’),如果zzxx这个键在my_dict不存在的时候
- 它会调用 list() 来建立一个新列表
- 把这个新列表([])作为值, ‘zzxx’ 作为它的键,放到 my_dict 中
- 如果在创建 defaultdict 的时候没有指定 default_factory(这里是list),查询不存在的键会触发KeyError
特殊方法__missing__
所有的映射类型在处理找不到的键的时候,都会牵扯到__missing__方法。
如果有一个类继承了 dict,这个继承类就有__missing__方法,在__getitem__碰到找不到的键的时候, Python 就会自动调用它,而不是抛出一个 KeyError 异常。
魔术方法__missing__只会在__getitem__里被调用(即a[‘dd’])这种类型的访问。如果是a.get(‘dd’)这种直接调用get()方法的访问是不会调用到__missing__的。
例如:如果我需要把非字符串的键转成字符串的键取值
class StrKeyGet(dict):
def __missing__(self, key):
# 如果找不到的键本身就是字符串,那就抛出 KeyError 异常。
if isinstance(key, str):
raise KeyError(key)
return self[str(key)]
# get 方法把查找工作用 self[key] 的形式委托给 __getitem__,
# 这样在宣布查找失败之前,还能通过 __missing__ 再给某个键一个机会
def get(self, key, default=None):
try:
return self[key]
except KeyError:
return default
def __contains__(self, key):
return key in self.keys() or str(key) in self.keys()
字典的变种
collections.OrderedDict
这个类型在添加键的时候会保持顺序,因此键的迭代次序总是一致的。
collections.ChainMap
该类型可以容纳数个不同的映射对象,然后在进行键查找操作的时候,这些对象会被当作一个整体被逐个查找,直到键被找到为止。
Python 变量查询规则的代码片段:
import builtins
from collections import ChainMap
pylookup = ChainMap(locals(), globals(), vars(builtins))
print(pylookup)
多个字典数据查询:
from collections import ChainMap
d1 = dict(a=1, b=2, c=3)
d2 = dict(a=22, b=2244, d=555)
d3 = dict(f=6666)
lookup = ChainMap(d1, d2, d3)
print(lookup['a'])
print(lookup['f'])
colllections.Counter
这个映射类型会给键准备一个整数计数器。每次更新一个键的时候都会增加这个计数器,Counter 实现了 + 和 - 运算符用来合并记录。
a = Counter({'a': 1, 'b':2})
print(a) # Counter({'b': 2, 'a': 1})
a.update({'a': 222})
print(a) # Counter({'a': 223, 'b': 2})
a.update({'b': 10})
print(a) # Counter({'a': 223, 'b': 12})
子类化UserDict
就创造自定义映射类型来说,以 UserDict 为基类,总比以普通的 dict 为基类要来得方便。
UserDict 并不是 dict 的子类,但是 UserDict 有一个叫作 data 的属性,是 dict 的实例,这个属性实际上是 UserDict 最终存储数据的地方。
import collections
class StrKeyGet(collections.UserDict):
def __missing__(self, key):
if isinstance(key, str):
raise KeyError(key)
return self[str(key)]
def __contains__(self, key):
return str(key) in self.data
def __setitem__(self, key, item):
self.data[str(key)] = item
不可变映射类型
标准库里所有的映射类型都是可变的,但有时候你会有这样的需求,比如不能让用户错误地修改某个映射。
从 Python 3.3 开始, types 模块中引入了一个封装类名叫MappingProxyType。如果给这个类一个映射,它会返回一个只读的映射视图。虽然是个只读视图,但是它是动态的。这意味着如果对原映射做出了改动,我们通过这个视图可以观察到。
from types import MappingProxyType
a = {'a': 123, 'b': 456}
a_proxy = MappingProxyType(a)
print(a_proxy['a']) # 123
a['c'] = 5
print(a_proxy) # {'a': 123, 'c': 5, 'b': 456}
集合论
“集”或者“集合”既指 set,也指 frozenset。集合的本质是许多唯一对象的聚集。因此,集合可以用于去重。集合中的元素必须是可散列的,set类型本身是不可散列的,但是 frozenset 可以。因此可以创建一个包含不同 frozenset 的 set。
l = ['spam', 'spam', 'eggs', 'spam']
set(l)
除了保证唯一性,集合还实现了很多基础的中缀运算符。给定两个集合 a 和 b, a | b 返回的是它们的合集, a & b 得到的是交集,而 a - b 得到的是差集。
集合字面量
空集合set(),字面量集合{1}、 {1, 2}。像 {1, 2, 3} 这种字面量句法相比于构造方法set([1, 2, 3])要更快且更易读。
不要忘了,如果要创建一个空集,你必须用不带任何参数的构造方法 set()。如果只是写成 {} 的形式,跟以前一样,你创建的其实是个空字典。
像 {1, 2, 3} 这种字面量句法相比于构造方法(set([1, 2, 3]))要更快且更易读,因为Python 会利用一个专门的叫作 BUILD_SET 的字节码来创建集合。
集合推导
from unicodedata import name
a = {chr(i) for i in range(32, 256) if 'SIGN' in name(chr(i),'')}
print(a)
dict和set的背后
一个关于效率的实验
所有的 Python 程序员都从经验中得出结论,认为字典和集合的速度是非常快的。
为了对比容器的大小对 dict、 set 或 list 的 in 运算符效率的影响,我创建了一个有1000 万个双精度浮点数的数组,名叫 haystack。另外还有一个包含了 1000 个浮点数的needles 数组,其中 500 个数字是从 haystack 里挑出来的,另外 500 个肯定不在haystack 里。
测试结果表明:最快的时间来自“集合交集花费时间”这一列,这一列的结果是利用集合&操作的代码的效果。不出所料的是,最糟糕的表现来自“列表花费时间”这一列。由于列表的背后没有散列表来支持 in 运算符,每次搜索都需要扫描一次完整的列表,导致所需的时间跟据 haystack 的大小呈线性增长。
字典中的散列表
散列表其实是一个稀疏数组(总是有空白元素的数组称为稀疏数组),在一般的数据结构教材中,散列表里的单元通常叫作表元(bucket)。
在dict的散列表当中,每个键值对都占用一个表元,每个表元都有两个部分,一个是对键的引用,另一个是对值的引用。因为所有表元的大小一致,所以可以通过偏移量来读取某个表元。
Python 会设法保证大概还有三分之一的表元是空的,所以在快要达到这个阈值的时候,原有的散列表会被复制到一个更大的空间里面。
如果要把一个对象放入散列表,那么首先要计算这个元素键的散列值。Python中可以用hash()方法来做这件事情。内置的hash()方法可以用于所有的内置类型对象。如果是自定义对象调用 hash()的话,实际上运行的是自定义的__hash__,如果两个对象在比较的时候是相等的那么 hash(a) == hash(b) 也相等。例如,如果 1 == 1.0 为真,那么 hash(1) == hash(1.0) 也必须为真。
为了让散列值能够胜任散列表索引这一角色,它们必须在索引空间中尽量分散开来。这意味着在最理想的状况下,越是相似但不相等的对象,它们散列值的差别应该越大。
散列表算法
为了获取my_dict[search_key]背后的值, Python首先会调用hash(search_key)来计算search_key的散列值,把这个值最低的几位数字当作偏移量,在散列表里查找表元。若找到的表元是空的,则抛出KeyError 异常。若不是空的,则表元里会有一对found_key:found_value。这时候Python会检验search_key==found_key是否为真,如果它们相等的话,就会返回 found_value。
如果 search_key 和 found_key 不匹配的话,这种情况称为散列冲突。发生这种情况是因为,散列表所做的其实是把随机的元素映射到只有几位的数字上,而散列表本身的索引又只依赖于这个数字的一部分。
为了解决散列冲突,算法会在散列值中另外再取几位,然后用特殊的方法处理一下,把新得到的数字再当作索引来寻找表元。若这次找到的表元是空的,则同样抛出 KeyError;若非空,或者键匹配,则返回这个值;或者又发现了散列冲突,则重复以上的步骤。
在插入新值时, Python 可能会按照散列表的拥挤程度来决定是否要重新分配内存为它扩容。如果增加了散列表的大小,那散列值所占的位数和用作索引的位数都会随之增加,这样做的目的是为了减少发生散列冲突的概率。
dict 键必须是可散列的
一个可散列的对象必须满足以下要求
- 支持 hash() 函数,并且通过 hash() 方法所得到的散列值是不变的
- 支持通过__eq__()方法来检测相等性
- 若 a == b 为真,则 hash(a) == hash(b) 也为真
所有由用户自定义的对象默认都是可散列的,因为它们的散列值由 id() 来获取,而且它们都是不相等的。
如果你实现了一个类的__eq__方法,并且希望它是可散列的,那么它一定要有个恰当的__hash__方法,保证在 a == b 为真的情况下 hash(a) ==hash(b) 也必定为真。
字典在内存上的开销巨大
由于字典使用了散列表,而散列表又必须是稀疏的,这导致它在空间上的效率低下。
如果你需要存放数量巨大的记录,那么放在由元组或是具名元组构成的列表中会是比较好的选择;最好不要用由字典组成的列表来存放这些记录。
在用户自定义的类型中,__slots__属性可以改变实例属性的存储方式,由 dict 变成 tuple。
记住我们现在讨论的是空间优化。如果你手头有几百万个对象,而你的机器有几个GB的内存,那么空间的优化工作可以等到真正需要的时候再开始计划,因为优化往往是可维护性的对立面。
键查询很快
dict 的实现是典型的空间换时间,字典类型有着巨大的内存开销,但它们提供了无视数据量大小的快速访问——只要字典能被装在内存里。
往字典里添加新键可能会改变已有键的顺序
无论何时往字典里添加新的键, Python 解释器都可能做出为字典扩容的决定。扩容导致的结果就是要新建一个更大的散列表,并把字典里已有的元素添加到新表里。
如果你在迭代一个字典的所有键的过程中同时对字典进行修改,那么这个循环很有可能会跳过一些键——甚至是跳过那些字典中已经有的键。由此可知,不要对字典同时进行迭代和修改。
set的实现以及导致的结果
set 和 frozenset 的实现也依赖散列表,但在它们的散列表里存放的只有元素的引用。
前面所提到的字典和散列表的几个特点,对集合来说几乎都是适用的。
- 集合里的元素必须是可散列的。
- 集合很消耗内存。
- 可以很高效地判断元素是否存在于某个集合。
- 元素的次序取决于被添加到集合里的次序。
- 往集合里添加元素,可能会改变集合里已有元素的次序。