接下来我们进入本书的第二部分内容数据结构。首先我们将会从序列构成的数组讲起。
在python中,不管是哪种数据结构,字符串、列表、字节序列、数组、XML元素,抑或是数据库查询结果,它们都共用一套丰富的操作:迭代、切片、排序,还有拼接。
深入理解Python中的不同序列类型,不但能让我们避免重新发明轮子,它们的API还能帮助我们把自己定义的API设计得跟原生的序列一样,或者是跟未来可能出现的序列类型保持兼容。
本章讨论的内容几乎可以应用到所有的序列类型上,从我们熟悉的list,到Python 3中特有的str和bytes,还会特别提到跟列表、元组、数组以及队列有关的话题。
Python标准库用C实现了丰富的序列类型,根据存放内容的不同,可以分为:
类型 | 说明 |
---|---|
容器序列 | list、tuple和collections.deque这些序列能存放不同类型的数据。 |
扁平序列 | str、bytes、bytearray、memoryview和array.array,这类序列只能容纳一种类型。 |
容器序列存放的是它们所包含的任意类型的对象的引用,而扁平序列里存放的是具体的值而不是引用。也就是说扁平序列是一段连续的内存空间,结构虽然更加紧凑,但是只能存放字符、字节和数值这种基础类型。
当然也可以根据序列可不可以修改分为序列类型还能按照能否被修改分为list等的可变序列以及tuple、str等的不可变序列。
如果你用过python或者Matlab,你一定会对切片操作影响深刻,因为它实在好用到不行!!!本节会对python中的切片高级用法进行简单介绍。至于如何自己创建一个数据类型实现切片功能,我们在后面会讲到,当然我们在数据模型中也讲过,__getitem__方法带来的不仅是直接索引,类似于切片,可迭代等操作,剩下的我们等候更新吧。
(1)为什么切片和区间会忽略最后一个元素?
在切片和区间操作里不包含区间范围的最后一个元素是Python的风格,这个习惯符合Python、C和其他语言里以0作为起始下标的传统。这样做带来的好处如下:
(2)对对象进行切片
一个众所周知的秘密是,我们还可以用s[a :b :c]的形式对s在a和b之间以c为间隔取值。c的值还可以为负,负值意味着反向取值,所以你可以使用s[::-1]直接获取s的反向序列。
a: b :c这种用法只能作为索引或者下标用在[]中来返回一个切片对象:slice(a, b, c)。所以本质上说对seq[start:stop:step]进行求值的时候,Python会调用seq.__getitem__(slice(start, stop, step))。如果省略start,默认从0开始索引,省略end默认一直到最后,省略step默认步长是1。
除了对一维切片,也可以进行多维切片,例如arr[a:b:c, m:n:l],其切片的方式与前述的是一致的。
(3)对切片进行赋值
如果把切片放在赋值语句的左边,或把它作为del操作的对象,我们就可以对序列进行嫁接、切除或就地修改操作。
不过需要注意的是:如果赋值的对象是一个切片,那么赋值语句的右侧必须是个可迭代对象,即使只有一个元素
seq[:5]=[1] # 序列前5个数变成1
+可以将两侧相同数据类型的序列拼接,在拼接的过程中,两个被操作的序列都不会被修改,Python会新建一个包含同样类型数据的序列来作为拼接的结果。
如果想要把一个序列复制几份然后再拼接起来,更快捷的做法是把这个序列乘以一个整数。如果在a * n这个语句中,序列a里的元素是对其他可变对象的引用的话,你就需要格外注意了,因为这个式子的结果可能会出乎意料。在我初学python时,曾想利用list创建一个10个学生3门成绩的成绩表,刚开始都初始化为0,一开始我写下这样的代码
score_list = [[0] * 3] * 10
好像并没有什么问题,可当我尝试对第一个学生的语文成绩赋值的时候,才发现错误:
score_list[0][0] = 90
print(score_list)
# 输出
[[90, 0, 0], [90, 0, 0], [90, 0, 0], [90, 0, 0], [90, 0, 0], [90, 0, 0], [90, 0, 0], [90, 0, 0], [90, 0, 0], [90, 0, 0]]
我每改一个,不同学生的同一位置都发生了改变。或许当你看完列表就知道,如果在a * n这个语句中,序列a里的元素是对其他可变对象的引用的话,这n个引用指向的都是同一个可变序列,所以你操作的其实只有1个对象。类似与这个例子,我在找工作笔试的时候也经常会考到这样的问答:
a = [3]
b = a
a.append(4)
# 问:b是多少?[3,4]
事实上,这部分也是python被其他语言使用者经常吐槽的地方。可能你对这点有点云里雾里,没关系。后续会详细说明引用和可变对象背后的原理和陷阱,此处我们只对怎么修改进行说明
最简单的办法就是放弃这样通过*进行初始化,而是单独生成10个对象:
score_list = [[0] * 3 for i in range(10)]
score_list[0][0] = 90
print(score_list)
# 输出
[[90, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0]]
列表表达式事实上是python的基础内容,相比于其他方式如filter或者map函数生成列表更具可读性,例如下面的例子,尽管都是实现取奇数,但是明显列表表达式更容易理解。所以有一个不成文的规定,如果可以使用for…in…if来完成的,坚决不用lambda。
# 取奇数
x = [i for i in range(11)]
odd = [i for i in x if i % 2 == 1] # 列表表达式
odd2 = list(filter(lambda x: x % 2 == 1, x))
# 这个例子相对简单,当然你可以使用x[1::2]切片直接获得
# 下面的例子是各公司笔试中常用的,读取字符串的值
s = '1 2 3.2 4 5 6.6'
s_list = s.strip().split(' ')
nums1 = [float(x) for x in s_list] #使用列表推导式实现
nums2 = list(map(float, s_list)) # map函数实现
列表推导的作用只有一个:生成列表
。当然如果你非要说可以用列表推导来初始化元组、数组或其他序列类型,我也无话可说,但是生成器表达式是更好的选择。这是因为生成器表达式背后遵守了迭代器协议,可以逐个地产出元素,而不是先建立一个完整的列表,然后再把这个列表传递到某个构造函数里。
生成器表达式的语法跟列表推导差不多,只不过把方括号换成圆括号而已。不过是用圆括号千万不要以为是元组表达式,没有元组表达式这样的说法。
至于什么是生成器什么是迭代器,在后面我们会详细介绍。
元组笔者并不常用,在看这本书之前,我也只知道它访问速度比列表快,不可变,用()括起来,通常被称为不可变列表,自此我自以为再没用过它。
当然用()括起来并不准确,就像下面的例子,括上不一定是,不括有时候也是:
a = 1, 2, 3
print(type(a))
b = 1,
print(type(b))
c = (1)
print(type(c))
d = (1, )
print(type(d))
# 输出
<class 'tuple'>
<class 'tuple'>
<class 'int'>
<class 'tuple'>
对比c和d,看来一个逗号也是区分的成分。
不过上面的这些并没有完全概括元组的特性,因为元组除了用作不可变的列表存储(1,2,3)之外,还可以用于没有字段名的记录。官方介绍中就说到:元组就是用作存放彼此之间没有关系的数据的记录。就像下面的这样:
# 东京市的一些信息:市名、年份、人口(单位:百万)、人口变化(单位:百分比)和面积
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)
第二行可以提取各个属性值,for循环可以分别提取元组里的元素,这种提取操作就叫作拆包(unpacking)。
元组拆包可以应用到任何可迭代对象上,唯一的硬性要求是,被可迭代对象中的元素数量必须要跟接受这些元素的元组的空档数一致。事实上,我所见过最优雅的方式是不使用中间变量交换两个变量的值:
b, a = a, b
1、平行拆包
最好辨认的元组拆包形式就是平行赋值,也就是说把一个可迭代对象里的元素,一并赋值到由对应的变量组成的元组中:
city, year, pop, chg, area = ('Tokyo', 2003, 32450, 0.66, 8014) #元组拆包
如果你觉得有些元素没啥用,命名又太费脑子,可以赋值给“_”占位符。
city, _, pop, _, area = ('Tokyo', 2003, 32450, 0.66, 8014) #元组拆包
如果你觉得后面的元素都没啥用,可以使用“*”进行收集,数据会组织成列表的形式:
city, _, pop, *other = ('Tokyo', 2003, 32450, 0.66, 8014)
# 得到other = [0.66, 8014]
*也没有必要在最后面,可以出现在任何位置,只要你能保证元素的匹配:
city, *other, _, area = ('Tokyo', 2003, 32450, 0.66, 8014) #元组拆包
print(city, other, area)
# 输出
Tokyo [2003, 32450] 8014
使用“*”进行收集是不是觉得似曾相识?因为在函数中下面的写法太常见了:
def function(arg, *args, **kwargs):
print(arg, args, kwargs)
function(6, 7, 8, 9, a=1, b=2, c=3)
# 输出
6 (7, 8, 9) {'a': 1, 'b': 2, 'c': 3}
不同的是, *args 用来将参数打包成tuple给函数体调用,**kwargs 打包关键字参数成dict给函数体调用,而且注意函数中参数arg、*args、**kwargs三个参数的位置必须是一定的。必须是(arg,*args,**kwargs)这个顺序,否则程序会报错。
“*”运算符不但可以进行收集,通常还用来把一个可迭代对象拆开作为函数的参数:
twoSum = lambda x, y: print(x + y)
twoSum(*[3, 4])
# 输出两数之和
2、嵌套拆包
接受表达式的元组可以是嵌套式的,例如(a, b, (c, d))。只要这个接受元组的嵌套结构符合表达式本身的嵌套结构,Python就可以作出正确的对应。
infor=('Tokyo','JP',36.933,(35.689722,139.691667))
name, cc, pop, (latitude, longitude)=infor
print(name, cc, pop, latitude, longitude)
name, cc, pop, pos=infor
print(name, cc, pop, pos)
# 输出
Tokyo JP 36.933 35.689722 139.691667
Tokyo JP 36.933 (35.689722, 139.691667)
元组已经设计得很好用了,但作为记录来用的话,还是少了一个功能:记录中的字段命名。namedtuple函数的出现帮我们解决了这个问题。collections.namedtuple是一个工厂函数,它可以用来构建一个带字段名的元组和一个有名字的类,用namedtuple构建的类的实例所消耗的内存跟元组是一样的,因为字段名都被存在对应的类里面。这个实例跟普通的对象实例比起来也要小一些,因为Python不会用_dict_来存放这些实例的属性。
例如:
from collections import namedtuple
City = namedtuple('City', 'name country population coordinates')
tokyo = City('Tokyo', 'JP', 36.933, (35.689722, 139.691667))
print(tokyo[3])
print(tokyo.coordinates)
除了从普通元组那里继承来的属性之外,具名元组还有一些自己专有的属性。常用的有:_fields类属性、类方法、_make(iterable)和实例方法_asdict():
print(tokyo._fields)
delhi_data = ('Delhi NCR', 'IN', 21.935, (28.613889, 77.208889))
delhi = City._make(delhi_data)
print(delhi._asdict())
# 输出
('name', 'country', 'population', 'coordinates')
{'name': 'Delhi NCR', 'country': 'IN', 'population': 21.935, 'coordinates': 28.613889, 77.208889)}
_fields属性是一个包含这个类所有字段名称的元组。
_make通过接受一个可迭代对象来生成这个类的一个实例,它的作用跟City(*delhi_data)是一样的。
_asdict( )把具名元组以dict的形式返回。