Python实践提升-容器类型

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
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值