前言
《流畅的Python》读书心得,结合之前自己的积累,记录一些Python的关键要点。同时,感觉之前的博客/整理,更多倾向于自我记录,缺少了整体思维脉络的梳理与拓展,之后会尽量加入一些自己个人的感悟和背景的补充。
首先,简单谈谈Python,自己比较熟悉的语言主要是C++和Python。尤其是工作之后,大部分时间,Python都是我的主要工作语言。这当然和个人的工作领域有关,Python的优点之一是适合数据分析领域,因此被广泛应用于人工智能、机器学习等领域。目前,流行的机器学习框架,如TensorFlow,Pytorch等都提供了Python接口。但是,涉及底层矩阵运算等都是依赖C++完成,因为C++速度快,运行效率更高。
Python属于动态语言,优势是上手简单,方便调试,初学者可以将精力集中在变成对象和思维方式上,而不关心语法,类型等外在因素;但是其劣势:执行效率低,Python的程序都需要一个编译器将其代码转化为特定的操作系统可理解的代码,且动态语言相对于静态语言,其稳定性更差,尤其是系统复杂之后,无法保证其稳定性。
基本数据结构
Python的基本数据结构包括:列表(list),元组(tuple),字典(dict),集合(set)。 简单介绍下:
- 列表是动态的,长度大小不固定,可以随意增/删/修元素
- 元组是静态的,长度大小固定,无法增/删/修元素
- 相对于列表和元组,字典的性能更优,特别是对于查找、添加和删除操作,字典都能在常数时间复杂度内完成
- 集合和字典基本相同,区别是集合没有键和值的配对,是一系列无序的、唯一的元素集合
关键特性:
列表和元组存储方式的差异:
列表的存储空间比元祖更大(16字节),主要包括两个部分:存储指针,指向对应元素(8字节);存储已经分配的长度大小(8字节),方便实时追踪列表空间的使用情况,当空间不足时,及时分配额外空间。
1 2 3 4 5 6 7
l = [1, 2, 3] t = tuple(l) print(t.__sizeof__()) print(l.__sizeof__()) #+RESULTS: : 48 : 64
列表的over-allocating机制:
列表在
append
进行元素扩充时,为了减小每次增加/删减操作时空间分配的开销,其在每次分配空间时都会额外多分配一些,保证其操作的高效性:增加/删除的时间复杂度都为O(1)。
1 2 3 4 5 6 7 8 9 10 11 12
l = [] print(l.__sizeof__()) # 空列表的存储空间为40字节 l.append(1) print(l.__sizeof__()) # 加入元素1,列表额外分配了4个元素的空间: size为72 l1 = [1] print(l1.__sizeof__()) # 初始化的列表不会额外分配:size为48 l.append(2) l.append(3) l.append(4) print(l.__sizeof__()) # 存储空间没有超出,size为72 l.append(5) print(l.__sizeof__()) # 存储空间超出,再次分配4个元素的空间:size为104
列表和元组的性能对比:
相对于列表,元组属于更轻量级的数据结构,其性能速度更优。另外,Python的垃圾回收机制(后续会讲),在对待一些静态变量,如元组,当其不被使用且占用空间不大时,Python会暂时缓存该部分内存。因此当下次重新创建同样大小的内存时,Python便不会发送寻找内存的请求,能够加快程序的运行速度。对于索引操作,两者差距很小
1 2 3 4
python3 -m timeit 'x = (1,2,3,4,5,6)' python3 -m timeit 'x = [1,2,3,4,5,6]' python3 -m timeit 'x = (1,2,3,4,5,6)' 'y=x[3]' 'x = [1,2,3,4,5,6]' python3 -m timeit 'x = [1,2,3,4,5,6]' 'y=x[3]' 'x = (1,2,3,4,5,6)'
字典和集合的工作原理:
初始化操作 字典和集合的内部结构都是一张哈希表,只是字典多了键/值。但是为了更好的空间利用率,哈希表会将索引和哈希值、键、值单独分开: Indices(稀疏):
None index None None index None … Entries(不稀疏):
hash0 key0 value0 hash1 key1 value1 hash2 key2 value2 - 插入操作 当向字典/集合插入一个元素时,Python会首先计算键的Hash值,然后和mask值(mask=PyDicMinSize - 1)做与操作:index=hash(key) & mask。index值存储的是Hash值表的位置。如果Index表中该位置为空,则该元素就会被插入其中。 如果该值被占用,则Python会比较两个元素的哈希值和键值是否相等:
- 如果两者都相等,则表明该元素已经存在,如果值不同,则更新值。
- 如果两者有一个不相等,则出现“哈希冲突”,即两者键不相等,但是哈希值相,最简单基于线性查找/二次查找寻找空位(内部实现当然会优化,这里不关心)
- 查找操作 直接基于哈希值找到Index表的位置,然后对应找到数据位置;然后,比较哈希表位置中元素的哈希值和键,如果相等直接返回;否则继续查找,直到找到或者抛出异常。
- 删除操作 删除操作,Python会暂时对该位置的元素赋予一个特殊的值(作标记),等到重新调整哈希表大小时,再将其删除。这样操作主要还是为了节省操作时间。 为了保证其高效性,字典和集合内的哈希表,通常会保证至少留有1/3的剩余空间。随着元素的不断插入,当剩余空间少于1/3时,Python会重新获取更大的内存空间,进行哈希表的扩充。这种情况下,表内的所有元素都会被重新排放。
函数装饰器和闭包
装饰器介绍:
用于源码中“标记”函,通过某种方式增强函数的行为。装饰器本身是可调用的对象,其参数是一个函数(被装饰的函数)。装饰器可能会处理被装饰的函数,然后将它返回,或者将其替换成另一个函数或者可调用的对象。因此,装饰器的一大特性是,能把被装饰的函数替换成其他函数;第二个特性是装饰器在加载模块时立即执行(而不是在调用时)。
闭包:闭包指的是延伸来作用域的函数,其中包含函数定义体中引用、但是不在定义体中定义的非全局变量:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
def make_averager(): series = [] def averager(new_value): # series会被绑定到返回的对象的__closure__属性中 # 此为闭包:保留定义函数时存在的自由变量的绑定,使得调用函数时虽然定义的作用域不可用了,但是仍能使用那些绑定 # 注意这里的自由变量为list对象,此处不存在赋值问题 # 但是对于数字,字符串,元组等不可变类型来说,如果存在赋值操作的话,则会隐式地创建局部变量,无法保存于闭包中,此时需要使用nonlocal声明,参照make_averager2() series.append(new_value) total = sum(series) return total / len(series) return averager def make_averager2(): count = 0 total = 0 def averager(new_value): nonlocal count, total count += 1 total += new_value return total / count return averager if __name__ == '__main__': # 测试用例 avg = make_averager() avg(10) # 10 avg(12) # 11
几个常见的装饰器用法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65
import time import functools # clock装饰器:打印函数的耗时情况 def clock(func): # functools.wraps:内置装饰器,作用是协助构建行为良好的装饰器 # 此处将func的相关属性复制到clocked函数中 # 其他常用的标准库装饰器: # functools.lru_cache: 能够将耗时函数结果保存,避免传入相同的参数时重复计算。 @functools.wraps(func) def clocked(*args, **kwargs): t0 = time.time() # func为自由变量 result = func(*args, **kwargs) elapsed = time.time() - t0 name = func.__name__ arg_list = [] if args: # 定位参数 arg_list.append(', '.join(repr(arg) for arg in args)) if kwargs: # 关键字参数 pairs = ['%s=%r' % (k, w) for k, w in sorted(kwargs.items())] arg_list.append(', '.join(pairs)) arg_str = ', '.join(arg_list) print('[%.8fs] %s(%s) -> %r' % (elapsed, name, arg_str, result)) return result return clocked # 参数化clock装饰器 DEFAULT_FMT = '[{elasped:0.8f}s] {name}({args}) -> {result}' # 本质上创建了一个装饰器工厂函数 def clock2(fmt=DEFAULT_FMT): def decorate(func): def clocked(*_args): t0 = time.time() _result = func(*_args) elasped = time.time() - t0 name = func.__name__ args = ', '.join(repr(arg) for arg in _args) result = repr(_result) print(fmt.format(**locals())) # fmt中引用clocked的局部变量 return _result # 返回被装饰函数的返回值 return clocked return decorate if __name__ == '__main__': @clock() def snooze(seconds): time.sleep(seconds) for i in range(3): snooze(.123) @clock2('{name}({args}) dt={elapsed:0.3f}s') def snooze2(seconds): time.sleep(seconds) for i in range(3): snooze2(.123)
对象引用、可变性和垃圾回收
标识/相等性/别名/浅复制/函数参数引用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
charles = {'name': 'Charles L.', 'born': 1832} lewis = charles alex = {'name': 'Charles L.', 'born': 1832} print(charles is lewis) # True, lewis只是charles的别名,两者标识相同,即:id(charles)==id(lewis) print(charles is alex) # False, 两者是不同的对象 print(charles == alex) # True, 调用的是dict类的__eq__函数 lewis['born'] = 1994 print(charles['born']) # 1994,修改lewis相当于修改charles # 元组的相对不可变性 t1 = (1, 2, [30, 40]) t1[-1].append(50) print(t1) # (1, 2, [30, 40, 50]), id(t1[-1])仍然是不可变的,但是t[-1]可变 # 默认浅复制 l1 = [3, [55, 44], (7, 8, 9)] l2 = list(l1) l1.append(66) print(l2) # [3, [55, 44], (7, 8, 9)], 不影响 l1[1] += [66] print(l2) # [3, [55, 44, 66], (7, 8, 9)], 有影响,因为绑定了同一个list l1[2] += (10, 11) print(l2) # [3, [55, 44, 66], (7, 8, 9)],无影响,和list不同,元组通过+=创建了一个新元组,然后进行了重新绑定,此时l1[2]/l2[2]的标识不再相同 # 函数参数引用:函数可能会修改作为参数传入的可变对象(如list),但是无法修改标识,即常量/元组不会发生改变 def f(a, b): a += b return a x = 1 y = 2 f(x, y) print(x, y) # 1,2 a = [1, 2] b = [3, 4] f(a, b) print(a, b) # [1,2,3,4], [3,4] t = (10, 20) u = (30, 40) f(t, u) print(t, u) # (10, 20), (30, 40)
del和垃圾回收:CPython的垃圾回收机制:主要算法为引用计数,即每个对象都会统计有多少引用指向自己。当引用计数归零时,对象立即被销毁。而del语句删除名称,而不是对象。当且仅当删除的变量保存的是对象的最后一个引用时,或者引用无法得到对象时,才会导致对象被当作垃圾回收。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
import weakref s1 = {1, 2, 3} s2 = s1 def bye(): print('Gone with the wind...') # 注册回调函数:在销毁对象时调用 ender = weaker.finalize(s1, bye) ender.alive # True def s1 ender.alive # True, 仍存在s2的引用 s2 = 'spam' # {1, 2, 3}对象引用为0,调用bye函数 ender.alive # False,注意虽然s1传入了finalize函数,但是属于弱引用
抽象基类
抽象基类:
类内定义了纯虚函数的类,纯虚函数一般只提供了接口,并不做具体的实现,具体实现需要通过派生类去重写。抽象基类属于残缺的类,无法被对象化。其意义在于增加代码的可维护性和重用性。常用的抽象基类为collections.abs中的抽象基类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
import abc class Tomola(abs.ABC): @abc.abstractmethod def load(self, iterable): # 抽象方法:load,从可迭代对象中添加元素 @abc.abstractmethod def pick(self): # 抽象方法:pick,随机删除元素,并返回 # 如实例为空,则返回'LookupError' def loaded(self): # 如果至少存在一个元素则返回True,否则返回False return bool(self.inspect()) def inspect(self): # 返回一个有序元组,由当前元素构成 items = [] while True: try: items.append(self.pick()) except LookupError: break self.load(items) return tuple(sorted(items))
register方式:
可以将基类的register方式作为装饰器(Python version>3.3)将类注册为抽象基类的虚拟子类。虚拟子类不会继承任何抽象基类的方法,且程序运行时不会检查它是否符合抽象基类的接口,但是需要实现自己所需的全部方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
from random import randrange from tombola import Tombola @Tomola.register class TomboList(list): # TomboList为Tomola的虚拟子类且为list的扩展 def pick(self): # 继承list的__bool__方法 if self: position = randrange(len(self)) return self.pop(position) else: raise LookupError('pop from empty TombList') load = list.extend def loaded(self): return bool(self) def inspect(self): return tuple(sorted(self))
TODO 可迭代的对象、迭代器和生成器
- yield from
并发细节
阻塞型I/O和GIL:
CPython解释器本身不是线程安全的,因此需要全局解释器(GIL),一次只允许一个线程执行Python字节码。因此一个Python进程通常无法同时使用多个CPU核心。但是Python标准库中的所有I/O阻塞型函数都会释放GIL,再运行一个线程。
- GIL原理:本质和Python的内存管理机制有关,如上文中提到的Python通过引用计数来管理对象。但是在多线程处理时,可能会发生引用计数管理混乱的情况,导致某一线程无法访问数据的情况,所以需要通过GIL来规避内存管理这样的风险问题。其实现机制是当单个线程执行的时候,会锁住GIL,以阻止其他线程执行;其间会通过间隔式检查来轮询检查GIL的锁住情况(间隔时间不太一致),强制当前线程释放GIL,以使其他线程有执行的机会。
TODO 元编程
- 元编程:动态创建属性。