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 里剩下的内容。