跳转到内容

Python笔记

Python的函数调用

Python 在进行函数调用传参时,采用的既不是值传递,也不是引用传递,而是传递了“变量所指对象的引用”(pass-by-object-reference)。

Python 的函数调用不能简单归类为“值传递”或者“引用传递”,一切行为取决于对象的可变性

浅拷贝与深拷贝

假如我们想让两个变量的修改操作互不影响,就需要拷贝变量所指向的可变对象,做到让不同变量指向不同对象。

按拷贝的深度,常用的拷贝操作可分为两种:浅拷贝与深拷贝。

  • copy.copy()
  • copy.deepcopy()

深拷贝操作:

py
>>> items_deep = copy.deepcopy(items)

深拷贝会遍历并拷贝 items 里的所有内容——包括它所嵌套的子列表。做完深拷贝后,items 和 items_deep 的子列表不再是同一个对象,它们的修改操作自然也不会再相互影响:

py
>>> id(items[1]), id(items_deep[1])
(4467751104, 4467286400)

子列表的对象 ID 不再一致。

别在遍历列表时修改

许多人在初学 Python 时会写出类似下面的代码——遍历列表的同时根据某些条件修改它:

py
def remove_even(numbers):
    """去掉列表里所有的偶数"""
    for number in numbers:
        if number % 2 == 0:
            # 有问题的代码
             numbers.remove(number)

numbers = [1, 2, 7, 4, 8, 11]
remove_even(numbers)
print(numbers)

运行上述代码会输出下面的结果:

text
[1, 7, 8, 11]

注意到那个本不该出现的数字 8 了吗?遍历列表的同时修改列表就会发生这样的怪事。

之所以会出现这样的结果,是因为:在遍历过程中,循环所使用的索引值不断增加,而被遍历对象 numbers 里的成员又同时在被删除,长度不断缩短——这最终导致列表里的一些成员其实根本就没被遍历到。

因此,要修改列表,请不要在遍历时直接修改。只需选择启用一个新列表来保存修改后的成员,就不会碰到这种奇怪的问题。

让函数返回NamedTuple

对于这种未来可能会变动的多返回值函数来说,如果一开始就使用 NamedTuple 类型对返回结果进行建模,兼容性会好不少。

示例:

py
from typing import NamedTuple

class Address(NamedTuple):
    """地址信息结果"""
    country: str
    province: str
    city: str

def latlon_to_address(lat, lon):
    return Address(
        country=country,
        province=province,
        city=city,
    )

addr = latlon_to_address(lat, lon)
# 通过属性名来使用 addr
addr.country / addr.province / addr.city

与None比较时使用is运算符

== 的行为可被魔法方法改变,那我们如何严格检查某个对象是否为 None 呢?答案是使用 is 运算符。

虽然二者看上去差不多,但有着本质上的区别:

  • == 对比两个对象的值是否相等,行为可被 eq 方法重载;
  • is 判断两个对象是否是内存里的同一个东西,无法被重载。

换句话说,当你在执行 x is y 时,其实就是在判断 id(x) 和 id(y) 的结果是否相等,二者是否是同一个对象。

除了 None、True 和 False 这三个内置对象以外,其他类型的对象在 Python 中并不是严格以单例模式存在的。换句话说,即便值一致,它们在内存中仍然是完全不同的两个对象。

因此,仅当你需要判断某个对象是否是 None、True、False 时,使用 is,其他情况下,请使用 ==

Python中令人迷惑的整型驻留技术:

py
>>> x = 6300
>>> y = 6300
>>> x is y
False

# 它们在内存中是不同的两个对象
>>> id(x), id(y)
(4412016144, 4412015856)

假如我们稍微调整一下上面的代码,把数字从 6300 改成 100,会获得完全相反的执行结果:

py
>>> x = 100
>>> y = 100
>>> x is y
True

# 二者 id 相等,在内存中是同一个对象
>>> id(x), id(y)
(4302453136, 4302453136)

为什么会这样?这是因为 Python 语言使用了一种名为“整型驻留”(integer interning)的底层优化技术。

对于从 -5 到 256 的这些常用小整数,Python 会将它们缓存在内存里的一个数组中。当你的程序需要用到这些数字时,Python 不会创建任何新的整型对象,而是会返回缓存中的对象。这样能为程序节约可观的内存。

除了整型以外,Python 对字符串也有类似的“驻留”操作。如果你对这方面感兴趣,可自行搜索“Python integer/string interning”关键字了解更多内容。

Python的元组

py
# 元祖初始化方法1:使用字面量语法定义元组
>>> t = (0, 1, 2)

真相:“括号”其实不是定义元组的关键标志——直接删掉两侧括号同样也能完成定义,“逗号”才是让解释器判定为元组的关键。

py
>>> t = 0, 1, 2
>>> t
py
# 元祖初始化方法2:使用 tuple(iterable)
# 内置函数初始化
>>> t = tuple('foo')
>>> t
('f', 'o', 'o')

在 Python 中,函数可以一次返回多个结果,这其实是通过返回一个元组来实现的。

py
>>> results = tuple((n * 100 for n in range(10) if n % 2 == 0))
>>> results
(0, 200, 400, 600, 800)

(n * 100 for n in range(10) if n % 2 == 0)并没有生成元组,而是返回了一个生成器(generator)对象。因此它是生成器推导式,而非元组推导式。但生成器仍然是一种可迭代类型,所以我们还是可以对它调用 tuple() 函数,获得元组。

元组经常用来存放结构化数据,但只能通过数字来访问元组成员其实特别不方便;为了解决这个问题,我们可以使用一种特殊的元组: 具名元组(namedtuple)。具名元组在保留普通元组功能的基础上,允许为元组的每个成员命名,这样你便能通过名称而不止是数字索引访问成员。

py
from collections import namedtuple

Rectangle = namedtuple('Rectangle', 'width,height')

在 Python 3.6 版本以后,除了使用 namedtuple() 函数以外,你还可以用 typing.NamedTuple 和类型注解语法来定义具名元组类型。这种方式在可读性上更胜一筹:

py
class Rectangle(NamedTuple):
    width: int
    height: int

rect = Rectangle(100, 20)

但需要注意的是,上面的写法虽然给 width和 height 加了类型注解,但 Python 在执行时并不会做真正的类型校验。

Python的字典

字典的动态解包。

在字典中使用 **dict_obj 表达式,可以动态解包 dict_obj 字典的所有内容。

py
>>> d1 = {'name': 'apple'}
>>> d2 = {'price': 10}

# d1、d2 原始值不会受影响
>>> {**d1, **d2}
{'name': 'apple', 'price': 10}

除了使用 *解包字典,你还可以使用单星号 运算符来解包任何可迭代对象:

py
>>> [1, 2, *range(3)]
[1, 2, 0, 1, 2]

>>> l1 = [1, 2]
>>> l2 = [3, 4]
# 合并两个列表
>>> [*l1, *l2]
[1, 2, 3, 4]

合理利用 * 和 ** 运算符,可以帮助我们高效构建列表与字典对象。

Python 发布了 3.9 版本。在这个版本中,字典类型新增了对 |运算符的支持。只要执行 d1 | d2,就能快速拿到两个字典合并后的结果:

py
>>> d1 = {'name': 'apple'}
>>> d2 = {'name': 'orange', 'price': 10}
>>> d1 | d2
{'name': 'orange', 'price': 10}
>>> d2 | d1  ➊
{'name': 'apple', 'price': 10}

➊ 运算顺序不同,会影响最终的合并结果。d1和d2本身不会被改变。d1.update(d2) 则会改变d1原来的值。

dict.setdefault(key, default) 会产生两种结果:当 key 不存在时,该方法会把 default 值写入字典的 key 位置,并返回该值;假如 key 已经存在,该方法就会直接返回它在字典中的对应值。

假设你只是单纯地想去掉某个键,并不关心它存在与否、删除有没有成功,那么使用 dict.pop(key, default) 方法就够了。

只要在调用 pop 方法时传入默认值 None,在键不存在的情况下也不会产生任何异常。

py
d.pop(key, None)

和列表类似,字典同样有自己的字典推导式。(比元组待遇好多啦!)你可以用它来方便地过滤和处理字典成员:

py
>>> d1 = {'foo': 3, 'bar': 4}
>>> {key: value * 10 for key, value in d1.items() if key == 'foo'}
{'foo': 30}

字典的有序性与无序性的理解。

在 Python 3.6 版本以前,几乎所有开发者都遵从一条常识:“Python 的字典是无序的。”这里的无序指的是:当你按照某种顺序把内容存进字典后,就永远没法按照原顺序把它取出来了。

但 Python 语言在不断进化。Python 3.6 为字典类型引入了一个改进:优化了底层实现,同样的字典相比 3.5 版本可节约多达 25% 的内存。而这个改进同时带来了一个有趣的副作用:字典变得有序了。一开始,字典变为有序只是作为 3.6 版本的“隐藏特性”存在。但到了 3.7 版本,它已经彻底成了语言规范的一部分。如今当你使用字典时,假如程序的目标运行环境是 Python 3.7 或更高版本,那你完全可以依赖字典类型的这种有序特性。

但如果你使用的 Python 版本没有那么新,也可以从 collections 模块里方便地拿到另一个有序字典对象 OrderedDict,它可以在 Python 3.7 以前的版本里保证字典有序:

py
>>> from collections import OrderedDict
>>> d = OrderedDict()
>>> d['FIRST_KEY'] = 1
>>> d['SECOND_KEY'] = 2

>>> for key in d:
...     print(key)
FIRST_KEY
SECOND_KEY

OrderedDict 比起普通字典仍然有一些优势。最直接的一点是,OrderedDict 把“有序”放在了自己的名字里,因此当你在代码中使用它时,其实比普通字典更清晰地表达了“此处会依赖字典的有序特性”这一点。

另外从功能上来说,OrderedDict 与新版本的字典其实也有着一些细微区别。比如,在对比两个内容相同而顺序不同的字典对象时,解释器会返回 True 结果;但如果是 OrderedDict对象,则会返回 False,OrderedDict对象的键的顺序也是比较条件。

除此之外,OrderedDict 还有 .move_to_end() 等普通字典没有的一些方法。所以,即便 Python 3.7 及之后的版本已经提供了内置的“有序字典”,但 OrderedDict 仍然有着自己的一席之地。

内置模块 collections里的 defaultdict 类型。

为了更好地理解 defaultdict 的特点,我们来做个小实验。首先初始化一个空 defaultdict 对象:

py
>>> from collections import defaultdict
>>> int_dict = defaultdict(int)

然后直接对一个不存在的 key 执行累加操作。普通字典在执行这个操作时,会抛出 KeyError 异常,但 defaultdict 不会:

py
>>> int_dict['foo'] += 1

当 int_dict 发现键 'foo' 不存在时,它会调用 default_factory——也就是 int()——拿到结果 0,将其保存到字典后再执行累加操作:

py
>>> int_dict
defaultdict(<class 'int'>, {'foo': 1})
>>> dict(int_dict)
{'foo': 1}

通过引入 defaultdict 类型,代码的两处初始化逻辑都变得更简单了。

如果你想创建一个自定义字典,继承 collections.abc 下的 MutableMapping 抽象类,比继承原生的dict是个更好的选择, 继承原生dict有行为不一致等问题;而对于列表等其他容器类型来说,这条规则也同样适用。

Python的集合

集合是一种不重复、无序的可变容器类型。

py
>>> fruits = {'apple', 'orange', 'apple', 'pineapple'}

重新查看上面 fruits 变量的值,你会马上体会到集合最重要的两个特征——去重与无序——重复的 'apple' 消失了,成员顺序也被打乱了:

py
>>> fruits
{'pineapple', 'orange', 'apple'}

所以使用集合实现去重,得到的结果会丢失集合内成员原有的顺序。

如果你既需要去重,又想要保留原有顺序,怎么办?可以使用前文提到过的有序字典 OrderedDict 来完成这件事。因为 OrderedDict 同时满足两个条件:

  • 它的键是有序的;
  • 它的键绝对不会重复。

因此,只要根据列表构建一个字典,字典的所有键就是有序去重的结果:

py
>>> from collections import OrderedDict
>>> list(OrderedDict.fromkeys(nums).keys())
[10, 2, 3, 21]

调用 fromkeys 方法会创建一个有序字典对象。字典的键来自方法的第一个参数:可迭代对象(此处为 nums 列表),字典的值默认为 None。

要初始化一个空集合,只能调用 set() 方法,因为 {} 表示的是一个空字典,而不是一个空集合。

py
# 正确初始化一个空集合
>>> empty_set = set()

集合也有自己的推导式语法:

py
>>> nums = [1, 2, 2, 4, 1]
>>> {n for n in nums if n < 3}
{1, 2}

frozenset集合是一种可变类型,使用 .add() 方法可以向集合追加新成员:

py
>>> new_set = set(['foo', 'foo', 'bar'])
>>> new_set.add('apple')
>>> new_set
{'apple', 'bar', 'foo'}

假如你想要一个不可变的集合,可使用内置类型 frozenset,它和普通 set 非常像,只是少了所有的修改类方法:

py
>>> f_set = frozenset(['foo', 'bar'])
>>> f_set.add('apple')
# 报错:没有 add/remove 那些修改集合的方法

假如你想要一个不可变的集合,可使用内置类型 frozenset,它和普通 set 非常像,只是少了所有的修改类方法:

py
>>> f_set = frozenset(['foo', 'bar'])
>>> f_set.add('apple')
# 报错:没有 add/remove 那些修改集合的方法
AttributeError: 'frozenset' object has no attribute 'add'

集合只能存放可哈希对象。

比如下面的集合可以被成功初始化:

py
>>> valid_set = {'apple', 30, 1.3, ('foo',)}

但这个集合就不行:

py
>>> invalid_set = {'foo', [1, 2, 3]}
...
TypeError: unhashable type: 'list'

正如上面的报错信息所示,集合里只能存放“可哈希”(hashable)的对象。假如把不可哈希的对象(比如上面的列表)放入集合,程序就会抛出 TypeError 异常。

首先,那些不可变的内置类型都是可哈希的:

py
>>> hash('string')
-3407286361374970639
>>> hash(100)
# 有趣的事情,整型的 hash 值就是它自身的值
100
>>> hash((1, 2, 3))
529344067295497451

而可变的内置类型都无法正常计算哈希值:

py
>>> hash({'key': 'value'})
TypeError: unhashable type: 'dict'

>>> hash([1, 2, 3])
TypeError: unhashable type: 'list'

可变类型的不可哈希特点有一定的“传染性”。比如在一个原本可哈希的元组里放入可变的列表对象后,它也会马上变得不可哈希:

py
>>> hash((1, 2, 3, ['foo', 'bar']))
TypeError: unhashable type: 'list'

由用户定义的所有对象默认都是可哈希的:

py
>>> class Foo:
...     pass
...
>>> foo = Foo()
>>> hash(foo)
273594269

总结一下,某种类型是否可哈希遵循下面的规则:

  • 所有的不可变内置类型,都是可哈希的,比如 str、int、tuple、frozenset 等;
  • 所有的可变内置类型,都是不可哈希的,比如 dict、list 等;
  • 对于不可变容器类型 (tuple, frozenset),仅当它的所有成员都不可变时,它自身才是可哈希的;
  • 用户定义的类型默认都是可哈希的。

谨记,只有可哈希的对象,才能放进集合或作为字典的键使用

Python生成器

在 Python 2 时代,如果你想用 range() 生成一个非常大的数字序列——比如 0 到 1 亿间的所有数字,速度会非常慢。这是因为 range()需要组装并返回一个巨大的列表,整个计算与内存分配过程会耗费大量时间。

但到了 Python 3,调用 range(100000000) 瞬间就会返回结果。因为它不再返回列表,而是返回一个类型为 range 的惰性计算对象。

py
>>> r = range(100000000)
>>> r
range(0, 100000000)
>>> type(r)
<class 'range'>
>>> for i in r:
...     print(i)
...
0
1
...

r 是 range 对象,而非装满数字的列表。

只有在迭代 range 对象时,它才会不断生成新的数字。

range()的进化过程虽然简单,但它其实代表了一种重要的编程思维——按需生成,而不是一次性返回。

在日常编码中,实践这种思维可以有效提升代码的执行效率。Python 里的生成器对象非常适合用来实现“按需生成”。

一个最简单的生成器如下:

py
def generate_even(max_number):
    """一个简单生成器,返回 0 到 max_number 之间的所有偶数"""
    for i in range(0, max_number):
        if i % 2 == 0:
            yield i

for i in generate_even(10):
    print(i)

执行后输出:

text
0
2
4
6
8

虽然都是返回结果,但 yield 和 return的最大不同之处在于,return 的返回是一次性的,使用它会直接中断整个函数执行,而 yield 可以逐步给调用方生成结果:

py
>>> i = generate_even(10)
>>> next(i)
0
>>> next(i)
2

因为生成器是可迭代对象,所以你可以使用 list() 等函数方便地把它转换为各种其他容器类型:

py
>>> list(generate_even(10))
[0, 2, 4, 6, 8]

实践中可以用生成器来替代列表。

在日常工作中,我们经常需要编写下面这样的代码:

py
def batch_process(items):
    """
    批量处理多个 items 对象
    """
    # 初始化空结果列表
    results = []
    for item in items:
        # 处理 item,可能需要耗费大量时间……
        # processed_item = ...
        results.append(processed_item)
    # 将拼装后的结果列表返回
    return results

这样的函数遵循同一种模式:“初始化结果容器→处理→将结果存入容器→返回容器”。这个模式虽然简单,但它有两个问题。

一个问题是,如果需要处理的对象 items过大,batch_process() 函数就会像 Python 2 里的 range() 函数一样,每次执行都特别慢,存放结果的对象 results 也会占用大量内存。

另一个问题是,如果函数调用方想在某个 processed_item 对象满足特定条件时中断,不再继续处理后面的对象,现在batch_process() 函数也做不到——它每次都得一次性处理完所有 items 才会返回。

为了解决这两个问题,我们可以用生成器函数来改写它。简单来说,就是用 yield item 替代 append 语句:

py
def batch_process(items):
    for item in items:
        # 处理 item,可能需要耗费大量时间……
        # processed_item = ...
        yield processed_item

生成器函数不仅看上去更短,而且很好地解决了前面的两个问题。当输入参数 items很大时,batch_process() 不再需要一次性拼装返回一个巨大的结果列表,内存占用更小,执行起来也更快。

如果调用方需要在某些条件下中断处理,也完全可以做到:

py
# 调用方
for processed_item in batch_process(items):
    # 如果某个已处理对象过期了,就中断当前的所有处理
    if processed_item.has_expired():
        break

在上面的代码里,当调用方退出循环后,batch_process() 函数也会直接中断,不需要再接着处理 items 里剩下的内容。