Python实践提升-容器类型
在我们的日常生活中,有一类物品比较特别,它们自身并不提供“具体”的功能,最大的用处就是存放其他东西——小学生用的文具盒、图书馆的书架,都可归入此类物品,我们可以统称它们为“容器”。
而在代码世界里,同样也有“容器”这个概念。代码里的容器泛指那些专门用来装其他对象的特殊数据类型。在 Python 中,最常见的内置容器类型有四种:列表、元组、字典、集合。
列表(list)是一种非常经典的容器类型,通常用来存放多个同类对象,比如从 1 到 10 的所有整数:
>>> numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
元组(tuple)和列表非常类似,但跟列表不同,它不能被修改。这意味着元组完成初始化后就没法再改动了:
>>> names = ('foo', 'bar')
>>> names[1] = 'x'
...
TypeError: 'tuple' object does not support item assignment
字典(dict)类型存放的是一个个键值对(key: value)。它功能强大,应用广泛,就连 Python 内部也大量使用,比如每个类实例的所有属性,就都存放在一个名为 dict 的字典里:
class Foo:
def __init__(self, value):
self.value = value
foo = Foo('bar')
print(foo.__dict__, type(foo.__dict__))
执行后输出:
{
'value': 'bar'} <class 'dict'>
集合(set)也是一种常用的容器类型。它最大的特点是成员不能重复,所以经常用来去重(剔除重复元素):
>>> numbers = [1, 2, 2, 1]
>>> set(numbers)
{
1, 2}
这四种容器类型各有优缺点,适用场景也各不相同。本章将简单介绍每种容器类型的特点,深入分析它们的应用场景,帮你厘清一些常见的概念。更好地掌握容器能帮助你写出更高效的 Python 代码。
3.1 基础知识
在基础知识部分,我将按照列表、元组、字典、集合的顺序介绍每种容器的基本操作,并在其中穿插一些重要的概念解释。
3.1.1 列表常用操作
列表是一种有序的可变容器类型,是日常编程中最常用的类型之一。常用的列表创建方式有两种:字面量语法与 list() 内置函数。
使用 [] 符号来创建一个列表字面量:
>>> numbers = [1, 2, 3, 4]
内置函数 list(iterable) 则可以把任何一个可迭代对象转换为列表,比如字符串:
>>> list('foo')
['f', 'o', 'o']
对于已有列表,我们可以通过索引访问它的成员。要删除列表中的某些内容,可以直接使用 del 语句:
#通过索引获取内容,如果索引越界,会抛出 IndexError 异常
>>> numbers[2]
3
#使用切片获取一段内容
>>> numbers[1:]
[2, 3, 4]
#删除列表中的一段内容
>>> del numbers[1:]
>>> numbers
[1]
在遍历列表时获取下标
当你使用 for 循环遍历列表时,默认会逐个拿到列表的所有成员。假如你想在遍历的同时,获取当前循环下标,可以选择用内置函数 enumerate() 包裹列表对象 1:
>>> names = ['foo', 'bar']
>>> for index, s in enumerate(names):
... print(index, s)
...
0 foo
1 bar
enumerate() 接收一个可选的 start 参数,用于指定循环下标的初始值(默认为 0):
>>> for index, s in enumerate(names, start=10):
... print(index, s)
...
10 foo
11 bar
enumerate() 适用于任何“可迭代对象”,因此它不光可以用于列表,还可以用于元组、字典、字符串等其他对象。
你可以在 6.1.1 节找到关于“可迭代对象”的更多介绍。
列表推导式
当我们需要处理某个列表时,一般有两个目的:修改已有成员的值;根据规则剔除某些成员。
举个例子,有个列表里存放了许多正整数,我想要剔除里面的奇数,并将所有数字乘以 100。假如用传统写法,代码如下所示:
def remove_odd_mul_100(numbers):
"""剔除奇数并乘以 100"""
results = []
for number in numbers:
if number % 2 == 1:
continue
results.append(number * 100)
return results
一共 6 行代码,看上去并不算太多。但其实针对这类需求,Python 为我们提供了更精简的写法:列表推导式(list comprehension)。使用列表推导式,上面函数里的 6 行代码可以压缩成一行:
#用一个表达式完成 4 件事情
#
#1. 遍历旧列表:for n in numbers
#2. 对成员进行条件过滤:if n % 2 == 0
#3. 修改成员: n * 100
#4. 组装新的结果列表
#
results = [n * 100 for n in numbers if n % 2 == 0]
相比传统风格的旧代码,列表推导式把几类操作压缩在了一起,结果就是:代码量更少,并且维持了很高的可读性。因此,列表推导式可以算得上处理列表数据的一把“利器”。
但在使用列表推导式时,也需要注意不要陷入一些常见误区。在 3.3.6 节中,我会谈谈使用列表推导式的两个“不要”。
1我把 enumerate 称作“函数”(function)其实并不准确。因为 enumerate 实际上是一个“类”(class),而不是普通函数,但为了简化理解,我们暂且叫它“函数”吧。
3.1.2 理解列表的可变性
Python 里的内置数据类型,大致上可分为可变与不可变两种。
可变(mutable):列表、字典、集合。
不可变(immutable):整数、浮点数、字符串、字节串、元组。
前面提到,列表是可变的。当我们初始化一个列表后,仍然可以调用 .append()、.extend() 等方法来修改它的内容。而字符串和整数等都是不可变的——我们没法修改一个已经存在的字符串对象。
在学习 Python 时,理解类型的可变性是非常重要的一课。如果不能掌握它,你在写代码时就会遇到很多与之相关的“惊喜”。
拿一个最常见的场景“函数调用”来说,许多新手在刚接触 Python 时,很难理解下面这两个例子。
示例一:为字符串追加内容
在这个示例里,我们定义一个往字符串追加内容的函数 add_str(),并在外层用一个字符串参数调用该函数:
def add_str(in_func_obj):
print(f'In add [before]: in_func_obj="{
in_func_obj}"')
in_func_obj += ' suffix'
print(f'In add [after]: in_func_obj="{
in_func_obj}"')
orig_obj = 'foo'
print(f'Outside [before]: orig_obj="{
orig_obj}"')
add_str(orig_obj)
print(f'Outside [after]: orig_obj="{
orig_obj}"')
运行上面的代码会输出这样的结果:
Outside [before]: orig_obj="foo"
In add [before]: in_func_obj="foo"
In add [after]: in_func_obj="foo suffix"
#重要:这里的 orig_obj 变量还是原来的值
Outside [after]: orig_obj="foo"
在这段代码里,原始字符串对象 orig_obj 被作为参数传给了 add_str() 函数的 in_func_obj 变量。随后函数内部通过 += 操作修改了 in_func_obj 的值,为其增加了后缀字符串。但重点是:函数外的 orig_obj 变量所指向的值没有受到任何影响。
示例二:为列表追加内容
在这个例子中,我们保留一模一样的代码逻辑,但是把 orig_obj 换成了列表对象:
def add_list(in_func_obj):
print(f'In add [before]: in_func_obj="{
in_func_obj}"')
in_func_obj += ['baz']
print(f'In add [after]: in_func_obj="{
in_func_obj}"')
orig_obj = ['foo', 'bar']
print(f'Outside [before]: orig_obj="{
orig_obj}"')
add_list(orig_obj)
print(f'Outside [after]: orig_obj="{
orig_obj}"')
执行后会发现结果大不一样:
Outside [before]: orig_obj="['foo', 'bar']"
In add [before]: in_func_obj="['foo', 'bar']"
In add [after]: in_func_obj="['foo', 'bar', 'baz']"
#注意:函数外的 orig_obj 变量的值已经被修改了!
Outside [after]: orig_obj="['foo', 'bar', 'baz']"
当操作对象变成列表后,函数内的 += 操作居然可以修改原始变量的值 !
示例解释
如果要用其他编程语言的术语来解释这两个例子,上面的函数调用似乎分别可以对应两种函数参数传递机制。
(1) 值传递(pass-by-value):调用函数时,传过去的是变量所指向对象(值)的拷贝,因此对函数内变量的任何修改,都不会影响原始变量——对应 orig_obj 是字符串时的行为。
(2) 引用传递(pass-by-reference):调用函数时,传过去的是变量自身的引用(内存地址),因此,修改函数内的变量会直接影响原始变量——对应 orig_obj 是列表时的行为。
看了上面的解释,你也许会发出灵魂拷问:为什么 Python 的函数调用要同时使用两套不同的机制,把事情搞得这么复杂呢?
答案其实没有你想得那么“复杂”——Python 在进行函数调用传参时,采用的既不是值传递,也不是引用传递,而是传递了“变量所指对象的引用”(pass-by-object-reference)。
换个角度说,当你调用 func(orig_obj) 后,Python 只是新建了一个函数内部变量 in_func_obj,然后让它和外部变量 orig_obj 指向同一个对象,相当于做了一次变量赋值:
def func(in_func_obj): ...
orig_obj = ...
func(orig_obj)
这个过程如图 3-1 所示。
图 3-1 进行函数调用后,变量与值对象间的关系示意图
一次函数调用基本等于执行了 in_func_obj = orig_obj。
所以,当我们在函数内部执行 in_func_obj += … 等修改操作时,是否会影响外部变量,只取决于 in_func_obj 所指向的对象本身是否可变。
如图 3-2 所示,浅色标签代表变量,白色方块代表值。在左侧的图里,in_func_obj 和 orig_obj 都指向同一个字符串值 ‘foo’。
图 3-2 对字符串对象执行 += 操作
在对字符串进行 += 操作时,因为字符串是不可变类型,所以程序会生成一个新对象(值):‘foo suffix’,并让 in_func_obj 变量指向这个新对象;旧值(原始变量 orig_obj 指向的对象)则不受任何影响,如图 3-2 右侧所示。
但如果对象是可变的(比如列表),+= 操作就会直接原地修改 in_func_obj 变量所指向的值,而它同时也是原始变量 orig_obj 所指向的内容;待修改完成后,两个变量所指向的值(同一个)肯定就都受到了影响。如图 3-3 所示,右边的列表在操作后直接多了一个成员:‘bar’。
图 3-3 对列表对象执行 += 操作
由此可见,Python 的函数调用不能简单归类为“值传递”或者“引用传递”,一切行为取决于对象的可变性。
3.1.3 常用元组操作
元组是一种有序的不可变容器类型。它看起来和列表非常像,只是标识符从中括号 [] 变成了圆括号 ()。由于元组不可变,所以它也没有列表那一堆内置方法,比如 .append()、.extend() 等。
和列表一样,元组也有两种常用的定义方式——字面量表达式和 tuple() 内置函数:
#使用字面量语法定义元组
>>> t = (0, 1, 2)
#真相:“括号”其实不是定义元组的关键标志——直接删掉两侧括号
#同样也能完成定义,“逗号”才是让解释器判定为元组的关键
>>> t = 0, 1, 2
>>> t
(0, 1, 2)
#使用 tuple(iterable) 内置函数
>>> t = tuple('foo')
>>> t
('f', 'o', 'o')
因为元组是一种不可变类型,所以下面这些操作都不会成功:
>>> del user_info[1]
#报错:元组成员不允许被删除
#TypeError: 'tuple' object doesn't support item deletion
>>> user_info.append(0)
#报错:元组压根儿就没有 append 方法
#AttributeError: 'tuple' object has no attribute 'append'
返回多个结果,其实就是返回元组
在 Python 中,函数可以一次返回多个结果,这其实是通过返回一个元组来实现的:
def get_rectangle():
"""返回长方形的宽和高"""
width = 100
height = 20
return width, height
#获取函数的多个返回值
result = get_rectangle()
print(result, type(result))
#输出:
#(100, 20) <class 'tuple'>
将函数返回值一次赋值给多个变量时,其实就是对元组做了一次解包操作:
width, height = get_rectangle()
#可以理解为:width, height = (width, height)
没有“元组推导式”
前提到,列表有自己的列表推导式。而元组和列表那么像,是不是也有自己的推导式呢?瞎猜不如尝试,我们把 [ ] 改成 () 符号来试试看:
>>> results = (n * 100 for n in range(10) if n % 2 == 0)
>>> results
<generator object <genexpr> at 0x10e94e2e0>
很遗憾,上面的表达式并没有生成元组,而是返回了一个生成器(generator)对象。因此它是生成器推导式,而非元组推导式。
不过幸运的是,虽然无法通过推导式直接拿到元组,但生成器仍然是一种可迭代类型,所以我们还是可以对它调用 tuple() 函数,获得元组:
>>> results = tuple((n * 100 for n in range(10) if n % 2 == 0))
>>> results
(0, 200, 400, 600, 800)
有关生成器和迭代器的更多内容,可查看 6.1.1 节。
存放结构化数据
和列表不同,在同一个元组里出现不同类型的值是很常见的事情,因此元组经常用来存放结构化数据。比如,下面的 user_info 就是一份包含名称、年龄等信息的用户数据:
>>> user_info = ('piglei', 'MALE', 30, True)
>>> user_info[2]
30
正因为元组有这个特点,所以 Python 为我们提供了一个特殊的元组类型:具名元组。
3.1.4 具名元组
和列表一样,当我们想访问元组成员时,需要用数字索引来定位:
>>> rectangle = (100, 20)
>>> rectangle[0] ➊
100
>>> rectangle[-1] ➋
20
❶ 访问第一个成员
❷ 访问最后一个成员
前面提到,元组经常用来存放结构化数据,但只能通过数字来访问元组成员其实特别不方便——比如我就完全记不住上面的 rectangle[0] 到底代表长方形的宽度还是高度。
为了解决这个问题,我们可以使用一种特殊的元组:具名元组(namedtuple)。具名元组在保留普通元组功能的基础上,允许为元组的每个成员命名,这样你便能通过名称而不止是数字索引访问成员。
创建具名元组需要用到 namedtuple() 函数,它位于标准库的 collections 模块里,使用前需要先导入:
from collections import namedtuple
Rectangle = namedtuple('Rectangle', 'width,height') ➊
❶ 除了用逗号来分隔具名元组的字段名称以外,还可以用空格分隔:‘width height’,或是直接使用一个字符串列表:[‘width’, ‘height’]
使用效果如下:
>>> rect = Rectangle(100, 20) ➊
>>> rect = Rectangle(width=100, height=20) ➋
>>> print(rect[0]) ➌
100
>>> print(rect.width) ➍
100
>>> rect.width += 1 ➎
...
AttributeError: can't set attribute