精华 发表在 小白学python 07-29 14:40:11
Python 标准库用 C 实现了丰富的序列类型,列举如下。
容器序列
list、tuple 和 collections.deque 这些序列能存放不同类型的数据。
扁平序列
str、bytes、bytearray、memoryview 和 array.array,这类序列只能容纳一种类型。
容器序列存放的是它们所包含的任意类型的对象的引用,而扁平序列里存放的是值而不是引用。换句话说,扁平序列其实是一段连续的内存空间。由此可见扁平序列其实更加紧凑,但是它里面只能存放诸如字符、字节和数值这种基础类型。
序列类型还能按照能否被修改来分类。
可变序列
list、bytearray、array.array、collections.deque 和 memoryview。
不可变序列
tuple、str 和 bytes。
图 2-1 显示了可变序列(MutableSequence)和不可变序列(Sequence)的差异,同时也能看出前者从后者那里继承了一些方法。虽然内置的序列类型并不是直接从 Sequence 和 MutableSequence 这两个抽象基类(Abstract Base Class,ABC)继承而来的,但是了解这些基类可以帮助我们总结出那些完整的序列类型包含了哪些功能。
图 2-1:这个 UML 类图列举了 collections.abc 中的几个类(超类在左边,箭头从子类指向超类,斜体名称代表抽象类和抽象方法)
通过记住这些类的共有特性,把可变与不可变序列或是容器与扁平序列的概念融会贯通,在探索并学习新的序列类型时,你会更加得心应手。
最重要也最基础的序列类型应该就是列表(list)了。list 是一个可变序列,并且能同时存放不同类型的元素。作为这本书的读者,我想你应该对它很了解了,因此让我们直接开始讨论列表推导(list comprehension)吧。列表推导是一种构建列表的方法,它异常强大,然而由于相关的句法比较晦涩,人们往往不愿意去用它。掌握列表推导还可以为我们打开生成器表达式(generator expression)的大门,后者具有生成各种类型的元素并用它们来填充序列的功能。下一节就来看看这两个概念。
列表推导是构建列表(list)的快捷方式,而生成器表达式则可以用来创建其他任何类型的序列。如果你的代码里并不经常使用它们,那么很可能你错过了许多写出可读性更好且更高效的代码的机会。
如果你对我说的“更具可读性”持怀疑态度的话,别急着下结论,我马上就能说服你。
很多 Python 程序员都把列表推导(list comprehension)简称为 listcomps,生成式表达器(generator expression)则称为 genexps。我有时也会这么用。
先来个小测试,你觉得示例 2-1 和示例 2-2 中的代码,哪个更容易读懂?
示例 2-1 把一个字符串变成 Unicode 码位的列表
>>> symbols = '$¢£¥€¤' >>> codes = [] >>> for symbol in symbols: ... codes.append(ord(symbol)) ... >>> codes [36, 162, 163, 165, 8364, 164]
>>> symbols = '$¢£¥€¤' >>> codes = [ord(symbol) for symbol in symbols] >>> codes [36, 162, 163, 165, 8364, 164]
for 循环可以胜任很多任务:遍历一个序列以求得总数或挑出某个特定的元素、用来计算总和或是平均数,还有其他任何你想做的事情。在示例 2-1 的代码里,它被用来新建一个列表。
另一方面,列表推导也可能被滥用。以前看到过有的 Python 代码用列表推导来重复获取一个函数的副作用。通常的原则是,只用列表推导来创建新的列表,并且尽量保持简短。如果列表推导的代码超过了两行,你可能就要考虑是不是得用 for 循环重写了。就跟写文章一样,并没有什么硬性的规则,这个度得你自己把握。
句法提示Python 会忽略代码里 []、{} 和 () 中的换行,因此如果你的代码里有多行的列表、列表推导、生成器表达式、字典这一类的,可以省略不太好看的续行符 \。
列表推导不会再有变量泄漏的问题Python 2.x 中,在列表推导中 for 关键词之后的赋值操作可能会影响列表推导上下文中的同名变量。像下面这个 Python 2.7 控制台对话:Python 2.7.6 (default, Mar 22 2014, 22:59:38) [GCC 4.8.2] on linux2 Type "help", "copyright", "credits" or "license" for more information. >>> x = 'my precious' >>> dummy = [x for x in 'ABC'] >>> x 'C' 如你所见,x 原本的值被取代了,但是这种情况在 Python 3 中是不会出现的。列表推导、生成器表达式,以及同它们很相似的集合(set)推导和字典(dict)推导,在 Python 3 中都有了自己的局部作用域,就像函数似的。表达式内部的变量和赋值只在局部起作用,表达式的上下文里的同名变量还可以被正常引用,局部变量并不会影响到它们。这是Python 3 代码:>>> x = 'ABC' >>> dummy = [ord(x) for x in x] >>> x ➊ 'ABC' >>> dummy ➋ [65, 66, 67] >>> ➊ x 的值被保留了。➋ 列表推导也创建了正确的列表。
列表推导可以帮助我们把一个序列或是其他可迭代类型中的元素过滤或是加工,然后再新建一个列表。Python 内置的 filter 和map 函数组合起来也能达到这一效果,但是可读性上打了不小的折扣。
filter 和 map 合起来能做的事情,列表推导也可以做,而且还不需要借助难以理解和阅读的 lambda 表达式。详见示例 2-3。
示例 2-3 用列表推导和 map/filter 组合来创建同样的表单
>>> symbols = '$¢£¥€¤' >>> beyond_ascii = [ord(s) for s in symbols if ord(s) > 127] >>> beyond_ascii [162, 163, 165, 8364, 164] >>> beyond_ascii = list(filter(lambda c: c > 127, map(ord, symbols))) >>> beyond_ascii [162, 163, 165, 8364, 164]
第 5 章会更详细地讨论 map 和 filter。下面就来看看如何用列表推导来计算笛卡儿积:两个或以上的列表中的元素对构成元组,这些元组构成的列表就是笛卡儿积。
如前所述,用列表推导可以生成两个或以上的可迭代类型的笛卡儿积。笛卡儿积是一个列表,列表里的元素是由输入的可迭代类型的元素对构成的元组,因此笛卡儿积列表的长度等于输入变量的长度的乘积,如图 2-2 所示。
图 2-2:含有 4 种花色和 3 种牌面的列表的笛卡儿积,结果是一个包含 12 个元素的列表
如果你需要一个列表,列表里是 3 种不同尺寸的 T 恤衫,每个尺寸都有 2 个颜色,示例 2-4 用列表推导算出了这个列表,列表里有 6 种组合。
示例 2-4 使用列表推导计算笛卡儿积
>>> colors = ['black', 'white'] >>> sizes = ['S', 'M', 'L'] >>> tshirts = [(color, size) for color in colors for size in sizes] ➊ >>> tshirts [('black', 'S'), ('black', 'M'), ('black', 'L'), ('white', 'S'), ('white', 'M'), ('white', 'L')] >>> for color in colors: ➋ ... for size in sizes: ... print((color, size)) ... ('black', 'S') ('black', 'M') ('black', 'L') ('white', 'S') ('white', 'M') ('white', 'L') >>> tshirts = [(color, size) for size in sizes ➌ ... for color in colors] >>> tshirts [('black', 'S'), ('white', 'S'), ('black', 'M'), ('white', 'M'), ('black', 'L'), ('white', 'L')]
➋ 注意,这里两个循环的嵌套关系和上面列表推导中 for 从句的先后顺序一样。
➌ 如果想依照先尺码后颜色的顺序来排列,只需要调整从句的顺序。我在这里插入了一个换行符,这样顺序安排就更明显了。
在第 1 章的示例 1-1 中,有下面这样一段程序,它的作用是生成一个按花色分组的 52 张牌的列表,其中每个花色各有 13 张不同点数的牌。
self._cards = [Card(rank, suit) for suit in self.suits
for rank in self.ranks]
虽然也可以用列表推导来初始化元组、数组或其他序列类型,但是生成器表达式是更好的选择。这是因为生成器表达式背后遵守了迭代器协议,可以逐个地产出元素,而不是先建立一个完整的列表,然后再把这个列表传递到某个构造函数里。前面那种方式显然能够节省内存。
生成器表达式的语法跟列表推导差不多,只不过把方括号换成圆括号而已。
示例 2-5 展示了如何用生成器表达式建立元组和数组。
示例 2-5 用生成器表达式初始化元组和数组
>>> symbols = '$¢£¥€¤' >>> tuple(ord(symbol) for symbol in symbols) ➊ (36, 162, 163, 165, 8364, 164) >>> import array >>> array.array('I', (ord(symbol) for symbol in symbols)) ➋ array('I', [36, 162, 163, 165, 8364, 164])
➋ array 的构造方法需要两个参数,因此括号是必需的。array 构造方法的第一个参数指定了数组中数字的存储方式。2.9.1 节中有更多关于数组的详细讨论。
示例 2-6 则是利用生成器表达式实现了一个笛卡儿积,用以打印出上文中我们提到过的 T 恤衫的 2 种颜色和 3 种尺码的所有组合。与示例 2-4 不同的是,用到生成器表达式之后,内存里不会留下一个有 6 个组合的列表,因为生成器表达式会在每次 for 循环运行时才生成一个组合。如果要计算两个各有 1000 个元素的列表的笛卡儿积,生成器表达式就可以帮忙省掉运行 for 循环的开销,即一个含有 100 万个元素的列表。
示例 2-6 使用生成器表达式计算笛卡儿积
>>> colors = ['black', 'white'] >>> sizes = ['S', 'M', 'L'] >>> for tshirt in ('%s %s' % (c, s) for c in colors for s in sizes): ➊ ... print(tshirt) ... black S black M black L white S white M white L
第 14 章会专门讲到生成器的工作原理。这里只是简单看看如何用生成器来初始化除列表之外的序列,以及如何用它来避免额外的内存占用。
接下来看看 Python 中的另外一个很重要的序列类型:元组(tuple)。
有些 Python 入门教程把元组称为“不可变列表”,然而这并没有完全概括元组的特点。除了用作不可变的列表,它还可以用于没有字段名的记录。鉴于后者常常被忽略,我们先来看看元组作为记录的功用。
元组其实是对数据的记录:元组中的每个元素都存放了记录中一个字段的数据,外加这个字段的位置。正是这个位置信息给数据赋予了意义。
如果只把元组理解为不可变的列表,那其他信息——它所含有的元素的总数和它们的位置——似乎就变得可有可无。但是如果把元组当作一些字段的集合,那么数量和位置信息就变得非常重要了。
示例 2-7 中的元组就被当作记录加以利用。如果在任何的表达式里我们在元组内对元素排序,这些元素所携带的信息就会丢失,因为这些信息是跟它们的位置有关的。
示例 2-7 把元组用作记录
>>> lax_coordinates = (33.9425, -118.408056) ➊ >>> city, year, pop, chg, area = ('Tokyo', 2003, 32450, 0.66, 8014) ➋ >>> traveler_ids = [('USA', '31195855'), ('BRA', 'CE342567'), ➌ ... ('ESP', 'XDA205856')] >>> for passport in sorted(traveler_ids): ➍ ... print('%s/%s' % passport) ➎ ... BRA/CE342567 ESP/XDA205856 USA/31195855 >>> for country, _ in traveler_ids: ➏ ... print(country) ... USA BRA ESP
❷ 东京市的一些信息:市名、年份、人口(单位:百万)、人口变化(单位:百分比)和面积(单位:平方千米)。
❸ 一个元组列表,元组的形式为 (country_code, passport_number)。
❹ 在迭代的过程中,passport 变量被绑定到每个元组上。
❺ % 格式运算符能被匹配到对应的元组元素上。
❻ for 循环可以分别提取元组里的元素,也叫作拆包(unpacking)。因为元组中第二个元素对我们没有什么用,所以它赋值给“_”占位符。
拆包让元组可以完美地被当作记录来使用,这也是下一节的话题。
示例 2-7 中,我们把元组 ('Tokyo', 2003, 32450, 0.66, 8014) 里的元素分别赋值给变量 city、year、pop、chg 和area,而这所有的赋值我们只用一行声明就写完了。同样,在后面一行中,一个 % 运算符就把 passport 元组里的元素对应到了print 函数的格式字符串空档中。这两个都是对元组拆包的应用。
元组拆包可以应用到任何可迭代对象上,唯一的硬性要求是,被可迭代对象中的元素数量必须要跟接受这些元素的元组的空档数一致。除非我们用 * 来表示忽略多余的元素,在“用 * 来处理多余的元素”一节里,我会讲到它的具体用法。Python 爱好者们很喜欢用元组拆包这个说法,但是可迭代元素拆包这个表达也慢慢流行了起来,比如“PEP 3132—Extended Iterable Unpacking”的标题就是这么用的。
最好辨认的元组拆包形式就是平行赋值,也就是说把一个可迭代对象里的元素,一并赋值到由对应的变量组成的元组中。就像下面这段代码:
>>> lax_coordinates = (33.9425, -118.408056) >>> latitude, longitude = lax_coordinates # 元组拆包 >>> latitude 33.9425 >>> longitude -118.408056
>>> b, a = a, b
>>> divmod(20, 8) (2, 4) >>> t = (20, 8) >>> divmod(*t) (2, 4) >>> quotient, remainder = divmod(*t) >>> quotient, remainder (2, 4)
>>> import os >>> _, filename = os.path.split('/home/luciano/.ssh/idrsa.pub') >>> filename 'idrsa.pub'
如果做的是国际化软件,那么 _ 可能就不是一个理想的占位符,因为它也是 gettext.gettext 函数的常用别名,gettext 模块的文档里提到了这一点。在其他情况下,_ 会是一个很好的占位符。
除此之外,在元组拆包中使用 * 也可以帮助我们把注意力集中在元组的部分元素上。
用*来处理剩下的元素
在 Python 中,函数用 *args 来获取不确定数量的参数算是一种经典写法了。
于是 Python 3 里,这个概念被扩展到了平行赋值中:
>>> a, b, *rest = range(5) >>> a, b, rest (0, 1, [2, 3, 4]) >>> a, b, *rest = range(3) >>> a, b, rest (0, 1, [2]) >>> a, b, *rest = range(2) >>> a, b, rest (0, 1, [])
>>> a, *body, c, d = range(5) >>> a, body, c, d (0, [1, 2], 3, 4) >>> *head, b, c, d = range(5) >>> head, b, c, d ([0, 1], 2, 3, 4)
接受表达式的元组可以是嵌套式的,例如 (a, b, (c, d))。只要这个接受元组的嵌套结构符合表达式本身的嵌套结构,Python 就可以作出正确的对应。示例 2-8 就是对嵌套元组拆包的应用。
示例 2-8 用嵌套元组来获取经度
metro_areas = [
('Tokyo','JP',36.933,(35.689722,139.691667)), # ➊
('Delhi NCR', 'IN', 21.935, (28.613889, 77.208889)),
('Mexico City', 'MX', 20.142, (19.433333, -99.133333)),
('New York-Newark', 'US', 20.104, (40.808611, -74.020386)),
('Sao Paulo', 'BR', 19.649, (-23.547778, -46.635833)),
]
print('{:15} | {:^9} | {:^9}'.format('', 'lat.', 'long.'))
fmt = '{:15} | {:9.4f} | {:9.4f}'
for name, cc, pop, (latitude, longitude) in metro_areas: # ➋
if longitude <= 0: # ➌
print(fmt.format(name, latitude, longitude))
❷ 我们把输入元组的最后一个元素拆包到由变量构成的元组里,这样就获取了坐标。
❸ if longitude <= 0: 这个条件判断把输出限制在西半球的城市。
示例 2-8 的输出是这样的:
| lat. | long.
Mexico City | 19.4333 | -99.1333
New York-Newark | 40.8086 | -74.0204
Sao Paul | -23.5478 | -46.6358
元组已经设计得很好用了,但作为记录来用的话,还是少了一个功能:我们时常会需要给记录中的字段命名。namedtuple 函数的出现帮我们解决了这个问题。
collections.namedtuple 是一个工厂函数,它可以用来构建一个带字段名的元组和一个有名字的类——这个带名字的类对调试程序有很大帮助。
用 namedtuple 构建的类的实例所消耗的内存跟元组是一样的,因为字段名都被存在对应的类里面。这个实例跟普通的对象实例比起来也要小一些,因为 Python 不会用 __dict__ 来存放这些实例的属性。
在第 1 章的示例 1-1 中是这样新建 Card 类的:
Card = collections.namedtuple('Card', ['rank', 'suit'])
示例 2-9 定义和使用具名元组
>>> from collections import namedtuple >>> City = namedtuple('City', 'name country population coordinates') ➊ >>> tokyo = City('Tokyo', 'JP', 36.933, (35.689722, 139.691667)) ➋ >>> tokyo City(name='Tokyo', country='JP', population=36.933, coordinates=(35.689722, 139.691667)) >>> tokyo.population ➌ 36.933 >>> tokyo.coordinates (35.689722, 139.691667) >>> tokyo[1] 'JP'
❷ 存放在对应字段里的数据要以一串参数的形式传入到构造函数中(注意,元组的构造函数却只接受单一的可迭代对象)。
❸ 你可以通过字段名或者位置来获取一个字段的信息。
除了从普通元组那里继承来的属性之外,具名元组还有一些自己专有的属性。示例 2-10 中就展示了几个最有用的:_fields 类属性、类方法 _make(iterable) 和实例方法 _asdict()。
示例 2-10 具名元组的属性和方法(接续前一个示例)
>>> City._fields ➊ ('name', 'country', 'population', 'coordinates') >>> LatLong = namedtuple('LatLong', 'lat long') >>> delhi_data = ('Delhi NCR', 'IN', 21.935, LatLong(28.613889, 77.208889)) >>> delhi = City._make(delhi_data) ➋ >>> delhi._asdict() ➌ OrderedDict([('name', 'Delhi NCR'), ('country', 'IN'), ('population', 21.935), ('coordinates', LatLong(lat=28.613889, long=77.208889))]) >>> for key, value in delhi._asdict().items(): print(key + ':', value) name: Delhi NCR country: IN population: 21.935 coordinates: LatLong(lat=28.613889, long=77.208889) >>>
❷ 用 _make() 通过接受一个可迭代对象来生成这个类的一个实例,它的作用跟 City(*delhi_data) 是一样的。
❸ _asdict() 把具名元组以 collections.OrderedDict 的形式返回,我们可以利用它来把元组里的信息友好地呈现出来。
现在我们知道了,元组是一种很强大的可以当作记录来用的数据类型。它的第二个角色则是充当一个不可变的列表。下面就来看看它的第二重功能。
如果要把元组当作列表来用的话,最好先了解一下它们的相似度如何。在表 2-1 中可以清楚地看到,除了跟增减元素相关的方法之外,元组支持列表的其他所有方法。还有一个例外,元组没有 __reversed__ 方法,但是这个方法只是个优化而已,reversed(my_tuple) 这个用法在没有 __reversed__ 的情况下也是合法的。
表2-1:列表或元组的方法和属性(那些由object类支持的方法没有列出来)
| 列表 | 元组 |
|
---|---|---|---|
| • | • |
|
| • |
|
|
| • |
| 在尾部添加一个新元素 |
| • |
| 删除所有元素 |
| • | • |
|
| • |
| 列表的浅复制 |
| • | • |
|
| • |
| 把位于 |
| • |
| 把可迭代对象 |
| • | • |
|
|
| • | 在 |
| • | • | 在 |
| • |
| 在位置 |
| • | • | 获取 |
| • | • |
|
| • | • |
|
| • |
|
|
| • | • |
|
| • |
| 删除最后或者是(可选的)位于 |
| • |
| 删除 |
| • |
| 就地把 |
| • |
| 返回 |
| • |
|
|
| • |
| 就地对 |
每个 Python 程序员都知道序列可以用 s[a:b] 的形式切片,但是关于切片,我还想说说它的一些不太为人所知的方面。
在 Python 里,像列表(list)、元组(tuple)和字符串(str)这类序列类型都支持切片操作,但是实际上切片操作比人们所想象的要强大很多。
这一节主要讨论的是这些高级切片形式的用法,它们的实现方法则会在第 10 章的一个自定义类里提到。这么做主要是为了符合这本书的哲学:先讲用法,第四部分中再来讲如何创建新类。
在切片和区间操作里不包含区间范围的最后一个元素是 Python 的风格,这个习惯符合 Python、C 和其他语言里以 0 作为起始下标的传统。这样做带来的好处如下。
计算机科学家 Edsger W. Dijkstar 对这一风格的解释应该是最好的,详见“延伸阅读”中给出的最后一个参考资料。
接下来进一步看看 Python 解释器是如何理解切片操作的。
一个众所周知的秘密是,我们还可以用 s[a:b:c] 的形式对 s 在 a 和 b 之间以 c 为间隔取值。c 的值还可以为负,负值意味着反向取值。下面的 3 个例子更直观些:
>>> s = 'bicycle' >>> s[::3] 'bye' >>> s[::-1] 'elcycib' >>> s[::-2] 'eccb'
>>> deck[12::13] [Card(rank='A', suit='spades'), Card(rank='A', suit='diamonds'), Card(rank='A', suit='clubs'), Card(rank='A', suit='hearts')]
比如,要解析示例 2-11 中所示的纯文本文件,这时使用有名字的切片比用硬编码的数字区间要方便得多,注意示例里的 for 循环的可读性有多强。
示例 2-11 纯文本文件形式的收据以一行字符串的形式被解析
>>> invoice = """ ... 0.....6................................40........52...55........ ... 1909 Pimoroni PiBrella $17.50 3 $52.50 ... 1489 6mm Tactile Switch x20 $4.95 2 $9.90 ... 1510 Panavise Jr. - PV-201 $28.00 1 $28.00 ... 1601 PiTFT Mini Kit 320x240 $34.95 1 $34.95 ... """ >>> SKU = slice(0, 6) >>> DESCRIPTION = slice(6, 40) >>> UNIT_PRICE = slice(40, 52) >>> QUANTITY = slice(52, 55) >>> ITEM_TOTAL = slice(55, None) >>> line_items = invoice.split('\n')[2:] >>> for item in line_items: ... print(item[UNIT_PRICE], item[DESCRIPTION]) ... $17.50 Pimoroni PiBrella $4.95 6mm Tactile Switch x20 $28.00 Panavise Jr. - PV-201 $34.95 PiTFT Mini Kit 320x240
[] 运算符里还可以使用以逗号分开的多个索引或者是切片,外部库 NumPy 里就用到了这个特性,二维的 numpy.ndarray 就可以用 a[i, j] 这种形式来获取,抑或是用 a[m:n, k:l] 的方式来得到二维切片。稍后的示例 2-22 会展示这个用法。要正确处理这种 [] 运算符的话,对象的特殊方法 __getitem__ 和 __setitem__ 需要以元组的形式来接收 a[i, j] 中的索引。也就是说,如果要得到 a[i, j] 的值,Python 会调用 a.__getitem__((i, j))。
Python 内置的序列类型都是一维的,因此它们只支持单一的索引,成对出现的索引是没有用的。
省略(ellipsis)的正确书写方法是三个英语句号(...),而不是 Unicdoe 码位 U+2026 表示的半个省略号(...)。省略在 Python 解析器眼里是一个符号,而实际上它是 Ellipsis 对象的别名,而 Ellipsis 对象又是 ellipsis 类的单一实例。2 它可以当作切片规范的一部分,也可以用在函数的参数清单中,比如 f(a, ..., z),或 a[i:...]。在 NumPy 中,... 用作多维数组切片的快捷方式。如果 x 是四维数组,那么 x[i, ...] 就是 x[i, :, :, :] 的缩写。如果想了解更多,请参见“Tentative NumPy Tutorial”。
2是的,你没看错,ellipsis 是类名,全小写,而它的内置实例写作 Ellipsis。这其实跟 bool 是小写,但是它的两个实例写作 True 和 False 异曲同工。
在写这本书的时候,我还没有发现在 Python 的标准库里有任何 Ellipsis 或者是多维索引的用法。如果你知道,请告诉我。这些句法上的特性主要是为了支持用户自定义类或者扩展,比如 NumPy 就是个例子。
除了用来提取序列里的内容,切片还可以用来就地修改可变序列,也就是说修改的时候不需要重新组建序列。
如果把切片放在赋值语句的左边,或把它作为 del 操作的对象,我们就可以对序列进行嫁接、切除或就地修改操作。通过下面这几个例子,你应该就能体会到这些操作的强大功能:
>>> l = list(range(10)) >>> l [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] >>> l[2:5] = [20, 30] >>> l [0, 1, 20, 30, 5, 6, 7, 8, 9] >>> del l[5:7] >>> l [0, 1, 20, 30, 5, 8, 9] >>> l[3::2] = [11, 22] >>> l [0, 1, 20, 11, 5, 22, 9] >>> l[2:5] = 100 ➊ Traceback (most recent call last): File "", line 1, in TypeError: can only assign an iterable >>> l[2:5] = [100] >>> l [0, 1, 100, 22, 9]
序列的拼接操作可谓是众所周知,任何一本 Python 入门教材都会介绍 + 和 * 的用法,但是在这些用法的背后还有一些可能被忽视的细节。下面就来看看这两种操作。
Python 程序员会默认序列是支持 + 和 * 操作的。通常 + 号两侧的序列由相同类型的数据所构成,在拼接的过程中,两个被操作的序列都不会被修改,Python 会新建一个包含同样类型数据的序列来作为拼接的结果。
如果想要把一个序列复制几份然后再拼接起来,更快捷的做法是把这个序列乘以一个整数。同样,这个操作会产生一个新序列:
>>> l = [1, 2, 3] >>> l * 5 [1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3] >>> 5 * 'abcd' 'abcdabcdabcdabcdabcd'
如果在 a * n 这个语句中,序列 a 里的元素是对其他可变对象的引用的话,你就需要格外注意了,因为这个式子的结果可能会出乎意料。比如,你想用 my_list = [[]] * 3 来初始化一个由列表组成的列表,但是你得到的列表里包含的 3 个元素其实是 3 个引用,而且这 3 个引用指向的都是同一个列表。这可能不是你想要的效果。
下面来看看如何用 * 来初始化一个由列表组成的列表。
有时我们会需要初始化一个嵌套着几个列表的列表,譬如一个列表可能需要用来存放不同的学生名单,或者是一个井字游戏板 3上的一行方块。想要达成这些目的,最好的选择是使用列表推导,见示例 2-12。
3又称过三关,是一种在 3×3 的方块矩阵上进行的游戏。——译者注
示例 2-12 一个包含 3 个列表的列表,嵌套的 3 个列表各自有 3 个元素来代表井字游戏的一行方块
>>> board = [['_'] * 3 for i in range(3)] ➊ >>> board [['_', '_', '_'], ['_', '_', '_'], ['_', '_', '_']] >>> board[1][2] = 'X' ➋ >>> board [['_', '_', '_'], ['_', '_', 'X'], ['_', '_', '_']]
➋ 把第 1 行第 2 列的元素标记为 X,再打印出这个列表。
示例 2-13 展示了另一个方法,这个方法看上去是个诱人的捷径,但实际上它是错的。
示例 2-13 含有 3 个指向同一对象的引用的列表是毫无用处的
>>> weird_board = [['_'] * 3] * 3 ➊ >>> weird_board [['_', '_', '_'], ['_', '_', '_'], ['_', '_', '_']] >>> weird_board[1][2] = 'O' ➋ >>> weird_board [['_', '_', 'O'], ['_', '_', 'O'], ['_', '_', 'O']]
➋ 一旦我们试图标记第 1 行第 2 列的元素,就立马暴露了列表内的 3 个引用指向同一个对象的事实。
示例 2-13 犯的错误本质上跟下面的代码犯的错误一样:
row=['_'] * 3
board = []
for i in range(3):
board.append(row) ➊
相反,示例 2-12 中的方法等同于这样做:
>>> board = [] >>> for i in range(3): ... row=['_'] * 3 # ➊ ... board.append(row) ... >>> board [['_', '_', '_'], ['_', '_', '_'], ['_', '_', '_']] >>> board[2][0] = 'X' >>> board # ➋ [['_', '_', '_'], ['_', '_', '_'], ['X', '_', '_']]
➋ 正如我们所期待的,只有第 2 行的元素被修改。
如果你觉得这一节里所说的问题及其对应的解决方法都有点云里雾里,没关系。第 8 章里我们会详细说明引用和可变对象背后的原理和陷阱。
我们一直在说 + 和 *,但是别忘了我们还有 += 和 *=。随着目标序列的可变性的变化,这个两个运算符的结果也大相径庭。下一节就来详细讨论。
增量赋值运算符 += 和 *= 的表现取决于它们的第一个操作对象。简单起见,我们把讨论集中在增量加法(+=)上,但是这些概念对 *= 和其他增量运算符来说都是一样的。
+= 背后的特殊方法是 __iadd__ (用于“就地加法”)。但是如果一个类没有实现这个方法的话,Python 会退一步调用__add__ 。考虑下面这个简单的表达式:
>>> a += b
总体来讲,可变序列一般都实现了 __iadd__ 方法,因此 += 是就地加法。而不可变序列根本就不支持这个操作,对这个方法的实现也就无从谈起。
上面所说的这些关于 += 的概念也适用于 *=,不同的是,后者相对应的是 __imul__。关于 __iadd__ 和 __imul__,第 13 章中会再次提到。
接下来有个小例子,展示的是 *= 在可变和不可变序列上的作用:
>>> l = [1, 2, 3] >>> id(l) 4311953800 ➊ >>> l *= 2 >>> l [1, 2, 3, 1, 2, 3] >>> id(l) 4311953800 ➋ >>> t = (1, 2, 3) >>> id(t) 4312681568 ➌ >>> t *= 2 >>> id(t) 4301348296 ➍
❷ 运用增量乘法后,列表的 ID 没变,新元素追加到列表上。
❸ 元组最开始的 ID。
❹ 运用增量乘法后,新的元组被创建。
对不可变序列进行重复拼接操作的话,效率会很低,因为每次都有一个新对象,而解释器需要把原来对象中的元素先复制到新的对象里,然后再追加新的元素。4
4str 是一个例外,因为对字符串做 += 实在是太普遍了,所以 CPython 对它做了优化。为 str 初始化内存的时候,程序会为它留出额外的可扩展空间,因此进行增量操作的时候,并不会涉及复制原有字符串到新位置这类操作。
我们已经认识了 += 的一般用法,下面来看一个有意思的边界情况。这个例子可以说是突出展示了“不可变性”对于元组来说到底意味着什么。
读完下面的代码,然后回答这个问题:示例 2-14 中的两个表达式到底会产生什么结果? 5回答之前不要用控制台去运行这两个式子。
5感谢 Leonardo Rochael 在 2013 年的 Python 巴西会议上提到这个谜题。
示例 2-14 一个谜题
>>> t = (1, 2, [30, 40]) >>> t[2] += [50, 60]
a. t 变成 (1, 2, [30, 40, 50, 60])。
b. 因为 tuple 不支持对它的元素赋值,所以会抛出 TypeError 异常。
c. 以上两个都不是。
d. a 和 b 都是对的。
我刚看到这个问题的时候,异常确定地选择了 b,但其实答案是 d,也就是说 a 和 b 都是对的!示例 2-15 是运行这段代码得到的结果,用的 Python 版本是 3.4,但是在 2.7 中结果也一样。6
6有读者提出,如果写成 t[2].extend([50, 60]) 就能避免这个异常。确实是这样,但这个例子是为了展示这种奇怪的现象而专门写的。
示例 2-15 没人料到的结果:t[2] 被改动了,但是也有异常抛出
>>> t = (1, 2, [30, 40]) >>> t[2] += [50, 60] Traceback (most recent call last): File "", line 1, in TypeError: 'tuple' object does not support item assignment >>> t (1, 2, [30, 40, 50, 60])
图 2-3:元组赋值之谜的初始和最终状态(图表由 Python Tutor 网站生成)
下面来看看示例 2-16 中 Python 为表达式 s[a] += b 生成的字节码,可能这个现象背后的原因会变得清晰起来。
示例 2-16 s[a] = b 背后的字节码
>>> dis.dis('s[a] += b') 1 0 LOAD_NAME 0(s) 3 LOAD_NAME 1(a) 6 DUP_TOP_TWO 7 BINARY_SUBSCR ➊ 8 LOAD_NAME 2(b) 11 INPLACE_ADD ➋ 12 ROT_THREE 13 STORE_SUBSCR ➌ 14 LOAD_CONST 0(None) 17 RETURN_VALUE
➋ 计算 TOS += b。这一步能够完成,是因为 TOS 指向的是一个可变对象(也就是示例 2-15 里的列表)。
➌ s[a] = TOS 赋值。这一步失败,是因为 s 是不可变的元组(示例 2-15 中的元组 t)。
这其实是个非常罕见的边界情况,在 15 年的 Python 生涯中,我还没见过谁在这个地方吃过亏。
至此我得到了 3 个教训。
在见证了 + 和 * 的微妙之处后,我们把话题转移到序列类型的另一个重要部分上:排序。
list.sort 方法会就地排序列表,也就是说不会把原列表复制一份。这也是这个方法的返回值是 None 的原因,提醒你本方法不会新建一个列表。在这种情况下返回 None 其实是 Python 的一个惯例:如果一个函数或者方法对对象进行的是就地改动,那它就应该返回 None,好让调用者知道传入的参数发生了变动,而且并未产生新的对象。例如,random.shuffle 函数也遵守了这个惯例。
用返回 None 来表示就地改动这个惯例有个弊端,那就是调用者无法将其串联起来。而返回一个新对象的方法(比如说str 里的所有方法)则正好相反,它们可以串联起来调用,从而形成连贯接口(fluent interface)。详情参见维基百科中有关连贯接口的讨论。
与 list.sort 相反的是内置函数 sorted,它会新建一个列表作为返回值。这个方法可以接受任何形式的可迭代对象作为参数,甚至包括不可变序列或生成器(见第 14 章)。而不管 sorted 接受的是怎样的参数,它最后都会返回一个列表。
不管是 list.sort 方法还是 sorted 函数,都有两个可选的关键字参数。
reverse
如果被设定为 True,被排序的序列里的元素会以降序输出(也就是说把最大值当作最小值来排序)。这个参数的默认值是False。
key
一个只有一个参数的函数,这个函数会被用在序列里的每一个元素上,所产生的结果将是排序算法依赖的对比关键字。比如说,在对一些字符串排序时,可以用 key=str.lower 来实现忽略大小写的排序,或者是用 key=len 进行基于字符串长度的排序。这个参数的默认值是恒等函数(identity function),也就是默认用元素自己的值来排序。
可选参数 key 还可以在内置函数 min() 和 max() 中起作用。另外,还有些标准库里的函数也接受这个参数,像itertools.groupby() 和 heapq.nlargest() 等。
下面通过几个小例子来看看这两个函数和它们的关键字参数:7
7这几个例子还说明了 Python 的排序算法——Timsort——是稳定的,意思是就算两个元素比不出大小,在每次排序的结果里它们的相对位置是固定的。Timsort 在本章结尾的“杂谈”里会有进一步的讨论。
>>> fruits = ['grape', 'raspberry', 'apple', 'banana'] >>> sorted(fruits) ['apple', 'banana', 'grape', 'raspberry'] ➊ >>> fruits ['grape', 'raspberry', 'apple', 'banana'] ➋ >>> sorted(fruits, reverse=True) ['raspberry', 'grape', 'banana', 'apple'] ➌ >>> sorted(fruits, key=len) ['grape', 'apple', 'banana', 'raspberry'] ➍ >>> sorted(fruits, key=len, reverse=True) ['raspberry', 'banana', 'grape', 'apple'] ➎ >>> fruits ['grape', 'raspberry', 'apple', 'banana'] ➏ >>> fruits.sort() ➐ >>> fruits ['apple', 'banana', 'grape', 'raspberry'] ➑
❷ 原列表并没有变化。
❸ 按照字母降序排序。
❹ 新建一个按照长度排序的字符串列表。因为这个排序算法是稳定的,grape 和 apple 的长度都是 5,它们的相对位置跟在原来的列表里是一样的。
❺ 按照长度降序排序的结果。结果并不是上面那个结果的完全翻转,因为用到的排序算法是稳定的,也就是说在长度一样时,grape 和 apple 的相对位置不会改变。
❻ 直到这一步,原列表 fruits 都没有任何变化。
❼ 对原列表就地排序,返回值 None 会被控制台忽略。
❽ 此时 fruits 本身被排序。
已排序的序列可以用来进行快速搜索,而标准库的 bisect 模块给我们提供了二分查找算法。下一节会详细讲这个函数,顺便还会看看 bisect.insort 如何让已排序的序列保持有序。
bisect 模块包含两个主要函数,bisect 和 insort,两个函数都利用二分查找算法来在有序序列中查找或插入元素。
bisect(haystack, needle) 在 haystack(干草垛)里搜索 needle(针)的位置,该位置满足的条件是,把 needle 插入这个位置之后,haystack 还能保持升序。也就是在说这个函数返回的位置前面的值,都小于或等于 needle 的值。其中 haystack必须是一个有序的序列。你可以先用 bisect(haystack, needle) 查找位置 index,再用 haystack.insert(index, needle) 来插入新值。但你也可用 insort 来一步到位,并且后者的速度更快一些。
Python 的高产贡献者 Raymond Hettinger 写了一个排序集合模块,模块里集成了 bisect 功能,但是比独立的bisect 更易用。
示例 2-17 利用几个精心挑选的 needle,向我们展示了 bisect 返回的不同位置值。这段代码的输出结果显示在图 2-4 中。
示例 2-17 在有序序列中用 bisect 查找某个元素的插入位置
import bisect
import sys
HAYSTACK = [1, 4, 5, 6, 8, 12, 15, 20, 21, 23, 23, 26, 29, 30]
NEEDLES = [0, 1, 2, 5, 8, 10, 22, 23, 29, 30, 31]
ROW_FMT = '{0:2d} @ {1:2d} {2}{0:<2d}'
def demo(bisect_fn):
for needle in reversed(NEEDLES):
position = bisect_fn(HAYSTACK, needle) ➊
offset = position * ' |' ➋
print(ROW_FMT.format(needle, position, offset)) ➌
if __name__ == '__main__':
if sys.argv[-1] == 'left': ➍
bisect_fn = bisect.bisect_left
else:
bisect_fn = bisect.bisect
print('DEMO:', bisect_fn.__name__) ➎
print('haystack ->', ' '.join('%2d' % n for n in HAYSTACK))
demo(bisect_fn)
❷利用该位置来算出需要几个分隔符号。
❸ 把元素和其应该出现的位置打印出来。
❹ 根据命令上最后一个参数来选用 bisect 函数。
❺ 把选定的函数在抬头打印出来。
图 2-4:用 bisect 函数时示例 2-17 的输出。每一行以 needle @ position(元素及其应该插入的位置)开始,然后展示了该元素在原序列中的物理位置
bisect 的表现可以从两个方面来调教。
首先可以用它的两个可选参数——lo 和 hi——来缩小搜寻的范围。lo 的默认值是 0,hi 的默认值是序列的长度,即 len() 作用于该序列的返回值。
其次,bisect 函数其实是 bisect_right 函数的别名,后者还有个姊妹函数叫 bisect_left。它们的区别在于,bisect_left 返回的插入位置是原序列中跟被插入元素相等的元素的位置,也就是新元素会被放置于它相等的元素的前面,而 bisect_right 返回的则是跟它相等的元素之后的位置。这个细微的差别可能对于整数序列来讲没什么用,但是对于那些值相等但是形式不同的数据类型来讲,结果就不一样了。比如说虽然 1 == 1.0 的返回值是 True,1 和 1.0 其实是两个不同的元素。图 2-5 显示的是用 bisect_left 来运行上述示例的结果。
图 2-5:用 bisect_left 运行示例 2-17 得到的结果(跟图 2-4 对比可以发现,值 1、8、23、29 和 30 的插入位置变成了原序列中这些值的前面)
bisect 可以用来建立一个用数字作为索引的查询表格,比如说把分数和成绩 8 对应起来,见示例 2-18。
8成绩指的是在美国大学中普遍使用的 A~F 字母成绩,A 表示优秀,F 表示不及格。——译者注
示例 2-18 根据一个分数,找到它所对应的成绩
>>> def grade(score, breakpoints=[60, 70, 80, 90], grades='FDCBA'): ... i = bisect.bisect(breakpoints, score) ... return grades[i] ... >>> [grade(score) for score in [33, 99, 77, 70, 89, 90, 100]] ['F', 'A', 'C', 'C', 'B', 'A', 'A']
这些函数不但可以用于查找,还可以用来向序列中插入新元素,下面就来看看它们的用法。
排序很耗时,因此在得到一个有序序列之后,我们最好能够保持它的有序。bisect.insort 就是为了这个而存在的。
insort(seq, item) 把变量 item 插入到序列 seq 中,并能保持 seq 的升序顺序。详见示例 2-19 和它在图 2-6 里的输出。
示例 2-19 insort 可以保持有序序列的顺序
import bisect
import random
SIZE=7
random.seed(1729)
my_list = []
for i in range(SIZE):
new_item = random.randrange(SIZE*2)
bisect.insort(my_list, new_item)
print('%2d ->' % new_item, my_list)
图 2-6:示例 2-19 的输出
insort 跟 bisect 一样,有 lo 和 hi 两个可选参数用来控制查找的范围。它也有个变体叫 insort_left,这个变体在背后用的是 bisect_left。
目前所提到的内容都不仅仅是对列表或者元组有效,还可以应用于几乎所有的序列类型上。有时候因为列表实在是太方便了,所以 Python 程序员可能会过度使用它,反正我知道我犯过这个毛病。而如果你只需要处理数字列表的话,数组可能是个更好的选择。下面就来讨论一些可以替换列表的数据结构。
虽然列表既灵活又简单,但面对各类需求时,我们可能会有更好的选择。比如,要存放 1000 万个浮点数的话,数组(array)的效率要高得多,因为数组在背后存的并不是 float 对象,而是数字的机器翻译,也就是字节表述。这一点就跟 C 语言中的数组一样。再比如说,如果需要频繁对序列做先进先出的操作,deque(双端队列)的速度应该会更快。
如果在你的代码里,包含操作(比如检查一个元素是否出现在一个集合中)的频率很高,用 set(集合)会更合适。set 专为检查元素是否存在做过优化。但是它并不是序列,因为 set 是无序的。第 3 章会详细讨论它。
本章余下的内容都是关于在某些情况下可以替换列表的数据类型的,让我们从数组开始。
如果我们需要一个只包含数字的列表,那么 array.array 比 list 更高效。数组支持所有跟可变序列有关的操作,包括.pop、.insert 和 .extend。另外,数组还提供从文件读取和存入文件的更快的方法,如 .frombytes 和 .tofile。
Python 数组跟 C 语言数组一样精简。创建数组需要一个类型码,这个类型码用来表示在底层的 C 语言应该存放怎样的数据类型。比如 b 类型码代表的是有符号的字符(signed char),因此 array('b') 创建出的数组就只能存放一个字节大小的整数,范围从 -128 到 127,这样在序列很大的时候,我们能节省很多空间。而且 Python 不会允许你在数组里存放除指定类型之外的数据。
示例 2-20 展示了从创建一个有 1000 万个随机浮点数的数组开始,到如何把这个数组存放到文件里,再到如何从文件读取这个数组。
示例 2-20 一个浮点型数组的创建、存入文件和从文件读取的过程
>>> from array import array ➊ >>> from random import random >>> floats = array('d', (random() for i in range(10**7))) ➋ >>> floats[-1] ➌ 0.07802343889111107 >>> fp = open('floats.bin', 'wb') >>> floats.tofile(fp) ➍ >>> fp.close() >>> floats2 = array('d') ➎ >>> fp = open('floats.bin', 'rb') >>> floats2.fromfile(fp, 10**7) ➏ >>> fp.close() >>> floats2[-1] ➐ 0.07802343889111107 >>> floats2 == floats ➑ True
❷ 利用一个可迭代对象来建立一个双精度浮点数组(类型码是 'd'),这里我们用的可迭代对象是一个生成器表达式。
❸ 查看数组的最后一个元素。
❹ 把数组存入一个二进制文件里。
❺ 新建一个双精度浮点空数组。
❻ 把 1000 万个浮点数从二进制文件里读取出来。
❼ 查看新数组的最后一个元素。
❽ 检查两个数组的内容是不是完全一样。
从上面的代码我们能得出结论,array.tofile 和 array.fromfile 用起来很简单。把这段代码跑一跑,你还会发现它的速度也很快。一个小试验告诉我,用 array.fromfile 从一个二进制文件里读出 1000 万个双精度浮点数只需要 0.1 秒,这比从文本文件里读取的速度要快 60 倍,因为后者会使用内置的 float 方法把每一行文字转换成浮点数。另外,使用 array.tofile 写入到二进制文件,比以每行一个浮点数的方式把所有数字写入到文本文件要快 7 倍。另外,1000 万个这样的数在二进制文件里只占用 80 000 000 个字节(每个浮点数占用 8 个字节,不需要任何额外空间),如果是文本文件的话,我们需要 181 515 739 个字节。
另外一个快速序列化数字类型的方法是使用 pickle模块。pickle.dump 处理浮点数组的速度几乎跟 array.tofile一样快。不过前者可以处理几乎所有的内置数字类型,包含复数、嵌套集合,甚至用户自定义的类。前提是这些类没有什么特别复杂的实现。
还有一些特殊的数字数组,用来表示二进制数据,比如光栅图像。里面涉及的 bytes 和 bytearry 类型会在第 4 章提及。
表 2-2 对数组和列表的功能做了一些总结。
表2-2:列表和数组的属性和方法(不包含过期的数组方法以及那些由对象实现的方法)
| 列表 | 数组 |
|
---|---|---|---|
| • | • |
|
| • | • |
|
| • | • | 在尾部添加一个元素 |
|
| • | 翻转数组内每个元素的字节序列,转换字节序 |
| • |
| 删除所有元素 |
| • | • |
|
| • |
| 对列表浅复制 |
|
| • | 对 |
| • | • |
|
|
| • | 对 |
| • | • | 删除位置 |
| • | • | 将可迭代对象 |
|
| • | 将压缩成机器值的字节序列读出来添加到尾部 |
|
| • | 将二进制文件 |
|
| • | 将列表里的元素添加到尾部,如果其中任何一个元素导致了 |
| • | • |
|
| • | • | 找到 |
| • | • | 在位于 |
|
| • | 数组中每个元素的长度是几个字节 |
| • | • | 返回迭代器 |
| • | • |
|
| • | • |
|
| • | • |
|
| • | • |
|
| • | • | 删除位于 |
| • | • | 删除序列里第一次出现的 |
| • | • | 就地调转序列中元素的位置 |
| • |
| 返回一个从尾部开始扫描元素的迭代器 |
| • | • |
|
| • |
| 就地排序序列,可选参数有 |
|
| • | 把所有元素的机器值用 |
|
| • | 把所有元素以机器值的形式写入一个文件 |
|
| • | 把数组转换成列表,列表里的元素类型是数字对象 |
|
| • | 返回只有一个字符的字符串,代表数组元素在 C 语言中的类型 |
从 Python 3.4 开始,数组类型不再支持诸如 list.sort() 这种就地排序方法。要给数组排序的话,得用 sorted 函数新建一个数组:a = array.array(a.typecode, sorted(a)) 想要在不打乱次序的情况下为数组添加新的元素,bisect.insort 还是能派上用场(就像 2.8.2 节中所展示的)。
如果你总是跟数组打交道,却没有听过 memoryview,那就太遗憾了。下面就来谈谈 memoryview。
memoryview 是一个内置类,它能让用户在不复制内容的情况下操作同一个数组的不同切片。memoryview 的概念受到了 NumPy 的启发(参见 2.9.3 节)。Travis Oliphant 是 NumPy 的主要作者,他在回答 “When should a memoryview be used?”这个问题时是这样说的:
内存视图其实是泛化和去数学化的 NumPy 数组。它让你在不需要复制内容的前提下,在数据结构之间共享内存。其中数据结构可以是任何形式,比如 PIL 图片、SQLite 数据库和 NumPy 的数组,等等。这个功能在处理大型数据集合的时候非常重要。
memoryview.cast 的概念跟数组模块类似,能用不同的方式读写同一块内存数据,而且内容字节不会随意移动。这听上去又跟 C 语言中类型转换的概念差不多。memoryview.cast 会把同一块内存里的内容打包成一个全新的 memoryview 对象给你。
在示例 2-21 里,我们利用 memoryview 精准地修改了一个数组的某个字节,这个数组的元素是 16 位二进制整数。