Python入门指南(超详细)
Python 是一门非常容易上手的语言,通过查阅资料和教程,也许一晚上就能写出一个简单的爬虫。但 Python 也是一门很难精通的语言,因为简洁的语法背后隐藏了许多黑科技。本文主要针对的读者是:
- 毫无 Python 经验的小白
- 有一些简单 Python 经验,但只会复制粘贴代码,不知其所以然的读者
- 觉得单独一篇文章太琐碎,质量没保证,却没空读完一本书,但又想对 Python 有全面了解的读者
当然, 用一篇文章来讲完某个语言是不可能的事情,我希望读完本文的读者可以:
- 对 Python 的整体知识结构形成初步的概念
- 了解 Python 特有的知识点,比如装饰器、上下文、生成器等等,不仅会写 Demo,还对背后的原理有一定了解
- 避免 C++/Java 等风格的 Python 代码,能够写出地道的 Python 代码
- 能够熟练的使用 Python 编写脚本实现日常的简单需求,能够维护小型 Python 项目,能够阅读较复杂的 Python 源码
如果以上介绍符合你对自己的定位,在开始阅读前,还需要明确几点:
- 本文不会只介绍用法,那样太肤浅
- 本文不会深入介绍某个知识点,比如分析源码等,那样太啰嗦,我希望做一名引路人,描述各个知识点的概貌并略作引申,为读者指出下一步的研究方向
- 代码注释非常重要,一定要看,几乎所有的代码段都可以执行,强烈建议手敲一遍!
0. 准备工作
请不要在学习 Python2 还是 Python3 之间犹豫了,除非你很明确自己只接触 Python2,否则就从 Python3 学起,新版本的语言总是意味着进步的生产力(Swift 和 Xcode 除外)。Python 2 和 3 之间语法不兼容,但这并不影响熟悉 Python3 的开发者迅速写出 Python 2 的代码,反之亦然。所以与其在反复纠结中浪费时间,不如立刻行动起来。
推荐使用 CodeRunner 来运行本文中的 demo,它比文本编辑器功能更强大,比如支持自动补全和断点调试,又比 PyCharm 轻量得多。
1. 数据结构
1.1 数组
1.1.1 列表推导
如果要对数组中的所有内容做一些修改,可以用 for 循环或者 map 函数:
- array = [1, 2, 3, 4, 5, 6]
- small = []
- for n in array:
- if n < 4:
- small.append(n * 2)
- print(small) # [2, 4, 6]
比较地道的 Python 写法是使用列表推导:
- array = [1, 2, 3, 4, 5, 6]
- small = [n * 2 for n in array if n < 4]
for in
可以写两次,类似于嵌套的 for 循环,会得到一个笛卡尔积:
- signs = ['+', '-']
- numbers = [1, 2]
- ascii = ['{sign}{number}'.format(sign=sign, number=number)
- for sign in signs for number in numbers]
- # 得到:['+1', '+2', '-1', '-2']
1.1.2 元组
元组可以简单的理解为不可变的数组,也就是没有 append
、del
等方法,一旦创建,就无法新增或删除元素,元素自身的值也不能改变,但元素内部的属性是否可变并不受元组的影响,这一点符合其他语言中的常识。
- t = (1, [])
- t[0] = 3 # 抛出错误 TypeError: 'tuple' object does not support item assignment
- t[1].append(2) # 正常运行,现在的 t 是 (1, [2])
除了不可变性以外,有时候元组也会被当做不具名的数据结构,这时候元素的位置就不再是可有可无的了:
- coordinate = (33.9425, -118.408056)
- # coordinate 的第一个位置用来表示经度,第二个位置表示纬度
在解析元组数据时,可以一一对应的写上变量名:
- t = (1, 2)
- a, b = t # a = 1, b = 2
有时候变量名比较长, 但我只关心其中某一个,可以这样写:
- t = (1, 2)
- a, _ = t # a = 1
如果元组中元素特别多,即使挨个写下划线也比较累,可以用 \* 来批量解包:
- t = (1, 2, 3, 4, 5)
- first, *middle, last = t
- # first = 1
- # middle = [2, 3, 4]
- # last = 5
当然,如果元素数量较多,含义较复杂,我还是建议使用具名元组:
- import collections
- People = collections.namedtuple('People', ['name', 'age'])
- p = People('bestswifter', '22')
- p.name # bestswifter
具名元组更像是一个不能定义方法的简化版的类,能提供友好的数据展示。
元组的一个小技巧是可以避免用临时变量来交换两个数的值:
- a = 1
- b = 2
- a, b = b, a
- # a = 2, b = 1
1.1.3 数组切片
切片的基本格式是 array[start<span class="emoji emoji-sizer" style="background-image:url(/emoji-data/img-apple-64/1f51a.png)" data-codepoints="1f51a"></span>step]
,表示对 array 在 start 到 end 之前以 step 为间隔取切片。注意这里的区间是 [start, end),也就是左闭右开。比如:
- s = 'hello'
- s[0:5:2]
- # 表示取 s 的第 0、2、4 个字符,结果是 'hlo'
再举几个例子
- s[0:5] # 不写 step 默认就是 1,因此得到 'hello'
- s[1:] # 不写 end 默认到结尾,因此还是得到 'ello'
- s[n:] # 获取 s 的最后 len(s) - n 个元素
- s[:2] # 不写 start 默认从 0 开始,因此得到 'he'
- s[:n] # 获取 s 的前 n 个元素
- s[:-1] # 负数表示倒过来数,因此这会刨除最后一个字符,得到 'hell'
- s[-2:] # 同上,表示获取最后两个字符,得到 'lo'
- s[::-1] # 获取字符串的倒序排列,相当于 reverse 函数
step 和它前面的冒号要么同时写,要么同时不写,但 start 和 end 之间的冒号不能省,否则就不是切片而是获取元素了。再次强调 array[start:end]
表示的区间是 [a, b),也许你会觉得这很难记,但同样的,这会得出以下美妙的公式:
array[:n] + array[n:] = array (0 <= n <= len(array))
用代码来表示就是:
- s = 'hello'
- s[:2] + s[2:] == s
- # True,因为 s[:2] 是 'he',s[2:] 是 'llo'
切片不仅可以用来获取数组的一部分值,修改切片也可以直接修改数组的对应部分,比如:
- a = [1, 2, 3, 4, 5, 6]
- a[1:3] = [22, 33, 44]
- # a = [1, 22, 33, 44, 4, 5, 6]
并没有人规定切片的新值必须和原来的长度一致:
- a = [1, 2, 3, 4, 5, 6]
- a[1:3] = [3]
- # a = [1, 3, 4, 5, 6]
- a[1:4] = []
- # a = [1, 6],相当于删除了中间的三个数字
但切片的新值必须也是可迭代的对象,比如这样写是不合法的:
- a = [1, 2, 3, 4, 5, 6]
- a[1:3] = 3
- # TypeError: can only assign an iterable
1.1.4 循环与遍历
一般来说,在 Python 中我们不会写出 for (int i = 0; i < len(array); ++i)
这种风格的代码,而是使用 for in
这种语法:
- for i in [1, 2, 3]:
- print(i)
虽然大家都知道 for in
语法,但它的某些灵活用法或许就不是那么众所周知了。有时候,我们会在 if
语句中对某个变量的值做多次判断,只要满足一个条件即可:
- name = 'bs'
- if name == 'hello' or name == 'hi' or name == 'bs' or name == 'admin':
- print('Valid')
这种情况推荐用 in
来代替:
- name = 'bs'
- if name in ('hello', 'hi', 'bs', 'admin'):
- print('Valid')
有时候,如果我们想要把某件事重复固定的次数,用 for in
会显得有些啰嗦,这时候可以借助 range
类型:
- for i in range(5):
- print('Hi') # 打印五次 'Hi'
range
的语法和切片类似,比如我们需要访问数组所有奇数下标的元素,可以这么写:
- a = [1, 2, 3, 4, 5]
- for i in range(0, len(a), 2):
- print(a[i])
在这种写法中,我们不仅能获得元素,还能知道元素的下标,这与使用 enumerate(iterable [, start ])
函数类似:
- a = [1, 2, 3, 4, 5]
- for i, n in enumerate(a):
- print(i, n)
1.1.5 魔术方法
也许你已经注意到了,数组和字符串都支持切片,而且语法高度统一。这在某些强类型语言(比如我经常接触的 Objective-C 和 Java)中是不可能的,事实上,Python 能够支持这样统一的语法,并非巧合,而是因为所有用中括号进行下标访问的操作,其实都是调用这个类的 __getitem__
方法。
比如我们完全可以让自己的类也支持通过下标访问:
- class Book:
- def __init__(self):
- self.chapters = [1, 2, 3]
- def __getitem__(self, n):
- return self.chapters[n]
- b = Book()
- print(b[1]) # 结果是 2
需要注意的是,这段代码几乎不会出问题(除非数组越界),这是因为我们直接把下标传到了内部的 self.chapters
数组上。但如果要自己处理下标,需要牢记它不一定是数字,也可以是切片,因此更完整的逻辑应该是:
- def __getitem__(self, n):
- if isinstance(n, int): # n是索引
- # 处理索引
- if isinstance(n, slice): # n是切片
- # 通过 n.start,n.stop 和 n.step 来处理切片
与静态语言不同的是,任何实现了 __getitem__
都支持通过下标访问,而不用声明为实现了某个协议,这种特性也被称为 “鸭子类型”。鸭子类型并不要求某个类 是什么,仅仅要求这个类 能做什么。
顺便说一句,实现了 __getitem__
方法的类都是可迭代的,比如:
- b = Book()
- for c in b:
- print(c)
后续的章节还会介绍更多 Python 中的魔术方法,这种方法的名称前后都有两个下划线,如果读作 “下划线-下划线-getitem” 会比较拗口,因此可以读作 “dunder-getitem” 或者 “双下-getitem”,类似的,我想每个人都能猜到 __setitem__
的作用和用法。
1.2 字典
1.2.1 初始化字典
最简单的创建一个字典的方式就是直接写字面量:
- {'a': 61, 'b': 62, 'c': 63, 'd': 64, 'e': 65}
字典字面量由大括号包住(注意区别于数组的中括号),键值对之间由逗号分割,每个键值对内部用冒号分割键和值。
如果数组的每个元素都是二元的元组,这个数组可以直接转成字典:
- dict([('a', 61), ('b', 62), ('c', 63), ('d', 64), ('e', 65)])
就像数组可以推导一样,字典也可以推导:
- a = [('a', 61), ('b', 62), ('c', 63), ('d', 64), ('e', 65)]
- d = {letter: number for letter, number in a} # 这里用到了元组拆包
只要记得外面还是大括号就行了。
两个独立的数组可以被压缩成一个字典:
- numbers = [61, 62, 63, 64, 65]
- letters = ['a', 'b', 'c', 'd', 'e']
- dict(zip(letters, numbers))
正如 zip 的意思所表示的,超出长处的那部分数组会被抛弃。
1.2.2 查询字典
最简单方法是直接写键名,但如果键名不存在会抛出 KeyError
:
- d = {'a': 61}
- d['a'] # 值是 61
- d['b'] # KeyError: 'b'
可以用 if key in dict
的判断来检查键是否存在,甚至可以先 try
再 catch KeyError
,但更加优雅简洁一些的写法是用 get(k, default)
方法来提供默认值:
- d = {'a': 61}
- d.get('a', 62) # 得到 61
- d.get('b', 62) # 得到 62
不过有时候,我们可能不仅仅要读出默认属性,更希望能把这个默认属性能写入到字典中,比如:
- d = {}
- # 我们想对字典中某个 Value 做操作,如果 Key 不存在,就先写入一个空值
- if 'list' not in d:
- d['list'] = []
- d['list'].append(1)
这种情况下,setdefault(key, default)
函数或许更合适:
- d.setdefault('key', []).append(1)
这个函数虽然名为 set
,但作用其实是查找,仅仅在查找不到时才会把默认值写入字典。
1.2.3 遍历字典
直接遍历字典实际上是遍历了字典的键,因此也可以通过键获取值:
- d = {'a': 61, 'b': 62, 'c': 63, 'd': 64, 'e': 65}
- for i in d:
- print(i, d[i])
- #b 62
- #a 61
- #e 65
- #d 64
- #c 63
我们也可以用字典的 keys()
或者 values()
方法显式的获取键和值。字典还有一个 items()
方法,它返回一个数组,每个元素都是由键和值组成的二元元组:
- d = {'a': 61, 'b': 62, 'c': 63, 'd': 64, 'e': 65}
- for (k, v) in d.items():
- print(k, v)
- #e 65
- #d 64
- #a 61
- #c 63
- #b 62
可见 items()
方法和字典的构造方法互为逆操作,因为这个公式总是成立的:
dict(d.items()) == d
1.2.4 字典的魔术方法
在 1.1.4 节中介绍过,通过下标访问最终都会由 __getitem__
这个魔术方法处理,因此字典的 d[key]
这种写法也不例外, 如果键不存在,则会走到 __missing__
方法,再给一次挽救的机会。比如我们可以实现一个字典, 自动忽略键的大小写:
- class MyDict(dict):
- def __missing__(self, key):
- if key.islower():
- raise KeyError(key)
- else:
- return self[key.lower()]
- d = MyDict({'a': 61})
- d['A'] # 返回 61
- 'A' in d # False
这个字典比较简陋,比如 key 可能不是字符串,不过我没有处理太多情况,因为它主要是用来演示 __missing__
的用法,如果想要最后一行的 in
语法正确工作,需要重写 __contains__
这个魔术方法,过程类似,就不赘述了。
虽然通过自定义的函数也能实现相似的效果,不过这个自定义字典对用户更加透明,如果不在文档中说明,调用方很难察觉到字典的内部逻辑被修改了。 Python 有很多强大的功能,可以具备这种内部进行修改,但是对外保持透明的能力。这可能是我们第一次体会到,后续还会不断的经历。
1.2.5 集合
集合更像是不会有重复元素的数组,但它的本质是以元素的哈希值作为 Key,从而实现去重的逻辑。因此,集合也可以推导,不过得用字典的语法:
- a = [1,2,3,4,5,4,3,2,1]
- d = {i for i in a if i < 5}
- # d = {1, 2, 3, 4},注意这里的大括号
回忆一下,二进制逻辑运算一共有三个运算符,按位或 |
,按位与 &
和异或 ^
,这三个运算符也可以用在集合之间,而且含义变化不大。比如:
- a = {1, 2, 3}
- b = {3, 4, 5}
- c = a | b
- # c = {1, 2, 3, 4, 5}
这里的 |
运算表示并集,也就是 c 中的任意元素,要么在 a,要么在 b 集合中。类似的,按位与 &
运算求的就是交集:
- a = {1, 2, 3}
- b = {3, 4, 5}
- c = a & b
- # c = {3}
而异或则表示那些只在 a 不在 b 或者只在 b 不在 a 的元素。或者换个说法,表示那些在集合 a 和 b 中出现了且仅出现了一次的元素:
- a = {1, 2, 3}
- b = {3, 4, 5}
- c = a ^ b
- # c = {1, 2, 4, 5}
还有一个差集运算 -
,表示在集合 a 中但不在集合 b 中的元素:
- a = {1, 2, 3}
- b = {3, 4, 5}
- c = a - b
- # c = {1, 2}
回忆一下韦恩图,就会得到以下公式(虽然并没有什么卵用):
A | B = (A ^ B) | (A & B)
A ^ B = (A - B) | (B - A)
1.3 字符串
1.3.1 字符串编码
用 Python 写过爬虫的人都应该感受过被字符串编码支配的恐惧。简单来说,编码指的是将可读的字符串转换成不太可读的数字,用来存储或者传输。解码则指的是将数字还原成字符串的过程。常见的编码有 ASCII、GBK 等。
ASCII 编码是一个相当小的字符集合,只有一百多个常用的字符,因此只用一个字节(8 位)就能表示,为了存储本国语言,各个国家都开发出了自己的编码,比如中文的 GBK。这就带来了一个问题,如果我想要在一篇文章中同时写中文和日文,就无法实现了,除非能对每个字符指定编码,这个成本高到无法接受。
Unicode 则是一个最全的编码方式,每个 Unicode 字符占据 6 个字节,可以表示出 2 ^ 48 种字符。但随之而来的是 Unicode 编码后的内容不适合存储和发送,因此诞生了基于 Unicode 的再次编码,目的是为了更高效的存储。
更详细的概念分析和配图说明可以参考我的这篇文章:字符串编码入门科普,这里我们主要聊聊 Python 对字符串编码的处理。
首先,编码的函数是 encode
,它是字符串的方法:
- s = 'hello'
- s.encode() # 得到 b'hello'
- s.encode('utf16') # 得到 b'\xff\xfeh\x00e\x00l\x00l\x00o\x00'
encode
函数有两个参数,第一个参数不写表示使用默认的 utf8
编码,理论上会输出二进制格式的编码结果,但在终端打印时,被自动还原回字符串了。如果用 utf16
进行编码,则会看到编码以后的二进制结果。
前面说过,编码是字符转到二进制的转化过程,有时候在某个编码规范中,并没有指定某个字符是如何编码的,也就是找不到对应的数字,这时候编码就会报错:
- city = 'São Paulo'
- b_city = city.encode('cp437')
- # UnicodeEncodeError: 'charmap' codec can't encode character '\xe3' in position 1: character maps to <undefined>
此时需要用到 encode
函数的第二个参数,用来指定遇到错误时的行为。它的值可以是 'ignore'
,表示忽略这个不能编码的字符,也可以是 'replace'
,表示用默认字符代替:
- b_city = city.encode('cp437', errors='ignore')
- # b'So Paulo'
- b_city = city.encode('cp437', errors='replace')
- # b'S?o Paulo'
decode
完全是 encode
的逆操作,只有二进制类型才有这个函数。它的两个参数含义和 encode
函数完全一致,就不再介绍了。
从理论上来说,仅从编码后的内容上来看,是无法确定编码方式的,也无法解码出原来的字符。但不同的编码有各自的特点,虽然无法完全倒推,但可以从概率上来猜测,如果发现某个二进制内容,有 99% 的可能性是 utf8
编码生成的,我们就可以用 utf8
进行解码。Python 提供了一个强大的工具包 Chardet
来完成这一任务:
- octets = b'Montr\xe9al'
- chardet.detect(octets)
- # {'encoding': 'ISO-8859-1', 'confidence': 0.73, 'language': ''}
- octets.decode('ISO-8859-1')
- # Montréal
返回结果中包含了猜测的编码方式,以及可信度。可信度越高,说明是这种编码方式的可能性越大。
有时候,我们拿到的是二进制的字符串字面量,比如 68 65 6c 6c 6f
,前文说过只有二进制类型才有 decode
函数,所以需要通过二进制的字面量生成二进制变量:
- s = '68 65 6c 6c 6f'
- b = bytearray.fromhex(s)
- b.decode() # hello
1.3.2 字符串的常用方法
字符串的 split(sep, maxsplit)
方法可以以指定的分隔符进行分割,有点类似于 Shell 中的 awk -F ' '
',第一个 sep
参数表示分隔符,不填则为空格:
- s = 'a b c d e'
- a = s.split()
- # a = ['a', 'b', 'c', 'd', 'e']
第二个参数 maxsplit
表示最多分割多少次,因此返回数组的长度是 maxsplit + 1
。举个例子说明下:
- s = 'a;b;c;d;e'
- a = s.split(';')
- # a = ['a', 'b', 'c', 'd', 'e']
- b = s.split(';', 2)
- # b = ['a', 'b', 'c;d;e']
如果想批量替换,则可以用 replace(old, new[, count])
方法,由中括号括起来的参数表示选填。
- old = 'a;b;c;d;e'
- new = old.replace(';', ' ', 3)
- # new = 'a b c d;e'
strip[chars]
用于移除指定的字符们:
- old = "*****!!!Hello!!!*****"
- new = old.strip('*') # 得到 '!!!Hello!!!'
- new = old.strip('*!') # 得到 'Hello'
如果不传参数,则默认移除空格。其实 strip
等价于分别执行 lstrip()
和 rstrip()
,即分别从左侧和右侧进行移除。比如 lstrip()
表示从左侧第一个字符开始,移除空格,直到第一个非空格字符为止,所以字符串中间的空格,无论是 lstrip
还是 strip()
都是无法移除的。
- old = ' Hello world '
- new = old.strip() # 得到 'Hello wrold'
- new = old.lstrip() # 得到 'Hello world '
最后一个常用方法是 join
,其实这个可以理解为字符串的构造方法,它可以把数组转换成字符串:
- array = 'a b c d e'.split() # 之前说过,结果是 ['a', 'b', 'c', 'd', 'e']
- s = ';'.join(array) # 以分号为连接符,把数组中的元素连接起来
- # s = 'a;b;c;d;e'
所以 join
可以理解为 split
的逆操作,这个公式始终是成立的:
c.join(string.split(c)) = string
上面这些字符串处理的函数,大多返回的还是字符串,因此可以链式调用,避免使用临时变量和多行代码,但也要避免过长(超过 3 个)的链式调用,以免影响可读性。
1.3.3 字符串格式化
最初级的字符串格式化方法是使用 +
来拼接:
- class Person:
- def __init__(self):
- self.name = 'bestswifter'
- self.age = 22
- self.sex = 'm'
- p = Person()
- print('Name: ' + p.name + ', Age: ' + str(p.age) + ', Sex: ' + p.sex)
- # 输出:Name: bestswifter, Age: 22, Sex: m
这里必须要把 int
类型的年龄转成字符串以后才能进行拼接,这是因为 Python 是强类型语言,不支持类型的隐式转换。
这种做法的缺点在于如果输出结构比较复杂,极容易出现引号匹配错误的问题,可读性非常低。
Python 2 中的做法是使用占位符,类似于 C 语言中 printf
:
- content = 'Name: %s, Age: %i, Sex: %c' % (p.name, p.age, p.sex)
- print(content)
从结构上看,要比上一种写法清楚得多, 但每个变量都需要指定类型,这和 Python 的简洁不符。实际上每个对象都可以通过 str()
函数转换成字符串,这个函数的背后是 __str__
魔术方法。
Python 3 中的写法是使用 format
函数,比如我们来实现一下 __str__
方法:
- class Person:
- def __init__(self):
- self.name = 'bestswifter'
- self.age = 22
- self.sex = 'm'
- def __str__(self):
- return 'Name: {user.name}, Age: {user.age}, Sex: {user.sex}'.format(user=self)
- p = Person()
- print(p)
- # 输出:Name: bestswifter, Age: 22, Sex: m
除了把对象传给 format
函数并在字符串中展开以外, 也可以传入多个参数,并且通过下标访问他们:
- print('{0}, {1}, {0}'.format(1, 2))
- # 输出:1, 2, 1,这里的 {1} 表示第二个参数
1.3.4 HereDoc
Heredoc 不是 Python 特有的概念, 命令行和各种脚本中都会见到,它表示一种所见即所得的文本。
假设我们在写一个 HTML 的模板,绝大多数字符串都是常量,只有有限的几个地方会用变量去替换,那这个字符串该如何表示呢?一种写法是直接用单引号去定义:
- s = '<HTML><HEAD><TITLE>\nFriends CGI Demo</TITLE></HEAD>\n<BODY><H3>ERROR</H3>\n<B>%s</B><P>\n<FORM><INPUT TYPE=button VALUE=Back\nONCLICK=\'window.history.back()\'></FORM>\n</BODY></HTML>'
这段代码是自动生成的还好,如果是手动维护的,那么可读性就非常差,因为换行符和转义后的引号增加了理解的难度。如果用 heredoc 来写,就非常简单了:
- s = '''<HTML><HEAD><TITLE>
- Friends CGI Demo</TITLE></HEAD>
- <BODY><H3>ERROR</H3>
- <B>%s</B><P>
- <FORM><INPUT TYPE=button VALUE=Back
- ONCLICK='window.history.back()'></FORM>
- </BODY></HTML>
- '''
Heredoc 主要是用来书写大段的字符串常量,比如 HTML 模板,SQL语句等等。
2 函数
2.1 函数是一等公民
一等公民指的是 Python 的函数能够动态创建,能赋值给别的变量,能作为参传给函数,也能作为函数的返回值。总而言之,函数和普通变量并没有什么区别。
函数是一等公民,这是函数式编程的基础,然而 Python 中基本上不会使用 lambda 表达式,因为在 lambda 表达式的中仅能使用单纯的表达式,不能赋值,不能使用 while、try 等语句,因此 lambda 表达式要么难以阅读,要么根本无法写出。这极大的限制了 lambda 表达式的使用场景。
上文说过,函数和普通变量没什么区别,但普通变量并不是函数,因为这些变量无法调用。但如果某个类实现了 __call__
这个魔术方法,这个类的实例就都可以像函数一样被调用:
- class Person:
- def __init__(self):
- self.name = 'bestswifter'
- self.age = 22
- self.sex = 'm'
- def __call__(self):
- print(self)
- def __str__(self):
- return 'Name: {user.name}, Age: {user.age}, Sex: {user.sex}'.format(user=self)
- p = Person()
- p() # 等价于 print(p)
2.2 函数参数
2.2.1 函数传参
对于熟悉 C 系列语言的人来说,函数传参的方式一目了然。默认是拷贝传值,如果传指针是引用传值。我们先来看一段简单的 Python 代码:
- def foo(arg):
- arg = 5
- print(arg)
- a = 1
- foo(a)
- print(a)
- # 输出 5 和 1
这段代码的结果符合我们的预期,从这段代码来看,Python 也属于拷贝传值。但如果再看这段代码:
- def foo(arg):
- arg.append(1)
- print(arg)
- a = [1]
- foo(a)
- print(a) # 输出两个 [1, 1]
你会发现参数数组在函数内部被改变了。就像是 C 语言中传递了变量的指针一样。所以 Python 到底是拷贝传值还是引用传值呢?答案都是否定的!
Python 的传值方式可以被理解为混合传值。对于那些不可变的对象(比如 1.1.2 节中介绍过的元组,还有数字、字符串类型),传值方式是拷贝传值;对于那些可变对象(比如数组和字典)则是引用传值。
2.2.2 默认参数
Python 的函数可以有默认值,这个功能很好用:
- def foo(a, l=[]):
- l.append(a)
- return l
- foo(2,[1]) # 给数组 [1] 添加一个元素 2,得到 [1,2]
- foo(2) # 没有传入数组,使用默认的空数组,得到 [2]
然而如果这样调用:
- foo(2) # 利用默认参数,得到 [2]
- foo(3) # 竟然得到了 [2, 3]
函数调用了两次以后,默认参数被改变了,也就是说函数调用产生了副作用。这是因为默认参数的存储并不像函数里的临时变量一样存储在栈上、随着函数调用结束而释放,而是存储在函数这个对象的内部:
- foo.__defaults__ # 一开始确实是空数组
- foo(2) # 利用默认参数,得到 [2]
- foo.__defaults__ # 如果打印出来看,已经变成 [2] 了
- foo(3) # 再添加一个元素就得到了 [2, 3]
因为函数 foo
作为一个对象,不会被释放,因此这个对象内部的属性也不会随着多次调用而自动重置,会一直保持上次发生的变化。基于这个前提,我们得出一个结论:函数的默认参数不允许是可变对象,比如这里的 foo
函数需要这么写:
- def foo(a, l=None):
- if l is None:
- l = []
- l.append(a)
- return l
- print(foo(2)) # 得到 [2]
- print(foo(3)) # 得到 [3]
现在,给参数添加默认值的行为在函数体中完成,不会随着函数的多次调用而累积。
对于 Python 的默认参数来说:
如果默认值是不可变的,可以直接设置默认值,否则要设置为 None 并在函数体中设置默认值。
2.2.3 多参数传递
当参数个数不确定时,可以在参数名前加一个 *
:
- def foo(*args):
- print(args)
- foo(1, 2, 3) # 输出 [1, 2, 3]
如果直接把数组作为参数传入,它其实是单个参数,如果要把数组中所有元素都作为单独的参数传入,则在数组前面加上 *
:
- a = [1, 2, 3]
- foo(a) # 会输出 ([1,2,3], ) 因为只传了一个数组作为参数
- foo(*a) # 输出 [1, 2, 3]
这里的单个 *
只能接收非关键字参数,也就是仅有参数值的哪些参数。如果想接受关键字参数,需要用 **
来表示:
- def foo(*args, **kwargs):
- print(args)
- print(kwargs)
- foo(1,2,3, a=61, b=62)
- # 第一行输出:(1, 2, 3)
- # 第二行输出:{'a': 61, 'b': 62}
类似的,字典变量传入函数只能作为单个参数,如果要想展开并被 **kwargs
识别,需要在字典前面加上两个星号 **
:
- a = [1, 2, 3]
- d = {'a': 61, 'b': 62}
- foo(*a, **d)
2.2.4 参数分类
Python 中函数的参数可以分为两大类:
- 定位参数(Positional):表示参数的位置是固定的。比如对于函数
foo(a, b)
来说,foo(1, 2)
和foo(2, 1)
就是截然不同的,a 和 b 的位置是固定的,不可随意调换。 - 关键词参数(Keyword):表示参数的位置不重要,但是参数名称很重要。比如
foo(a = 1, b = 2)
和foo(b = 2, a = 1)
的含义相同。
有一种参数叫做仅限关键字(Keyword-Only)参数,比如考虑这个函数:
- def foo(*args, n=1, **kwargs):
- print(n)
这个函数在调用时,如果参数 n 不指定名字,就会被前面的 *args
处理掉,如果指定的名字不是 n,又会被后面的 **kwargs
处理掉,所以参数 n 必须精确的以 (n = xxx)
的形式出现,也就是 Keyworld-Only。
2.3 函数内省
在 2.2.2 节中,我们查看了函数变量的 __defaults__
属性,其实这就是一种内省,也就是在运行时动态的查看变量的信息。
前文说过,函数也是对象,因此函数的变量个数,变量类型都应该有办法获取到,如果你需要开发一个框架,也许会对函数有各种奇葩的检查和校验。
以下面这个函数为例:
- g = 1
- def foo(m, *args, n, **kwargs):
- a = 1
- b = 2
首先可以获取函数名,函数所在模块的全局变量等:
- foo.__globals__ # 全局变量,包含了 g = 1
- foo.__name__ # foo
我们还可以看到函数的参数,函数内部的局部变量:
- foo.__code__.co_varnames # ('m', 'n', 'args', 'kwargs', 'a', 'b')
- foo.__code__.co_argcount # 只计算参数个数,不考虑可变参数和仅限关键字参数,所以得到 1
或者用 inspect
模块来查看更详细的信息:
- import inspect
- sig = inspect.signature(foo) # 获取函数签名
- sig.parameters['m'].kind # POSITIONAL_OR_KEYWORD 表示可以是定位参数或关键字参数
- sig.parameters['args'].kind # VAR_POSITIONAL 定位参数构成的数组
- sig.parameters['n'].kind # KEYWORD_ONLY 仅限关键字参数
- sig.parameters['kwargs'].kind # VAR_KEYWORD 关键字参数构成的字典
- inspect.getfullargspec(foo)
- # 得到:ArgSpec(args=['m', 'n'], varargs='args', keywords='kwargs', defaults=None)
本节的新 API 比较多,但并不要求记住这些 API 的用法。再次强调,本文的写作目的是为了建立读者对 Python 的总体认知,了解 Python 能做什么,至于怎么做,那是文档该做的事。
2.4 装饰器
2.4.1 设计模式的消亡
经典的设计模式有 23 个,虽然设计模式都是常用代码的总结,理论上来说与语法无关。但不得不承认的是,标准的设计模式在不同的语言中,有的因为语法的限制根本无法轻易实现(比如在 C 语言中实现组合模式),有的则因为语言的特定功能,变得冗余啰嗦。
以策略模式为例,有一个抽象的策略类,定义了策略的接口,然后使用者选择一个具体的策略类,构造他们的实例并且调用策略方法。具体代码可以参考:策略模式在百度百科的定义。
然而这些对象本身并没有作用,它们仅仅是可以调用相同的方法而已,只不过在 Java 中,所有的任务都需要由对象来完成。即使策略本身就是一个函数,但也必须把它包裹在一个策略对象中。所以在 Python 中更优雅写法是直接把策略函数作为变量使用。不过这就引入一个问题,如何判断某个函数是个策略呢,毕竟在面向对象的写法中,只要检查它的父类是否是抽象的策略类即可。
也许你已经见过类似的写法:
- @strategy
- def strategyA(n):
- print(n * 2)
下面就开始介绍装饰器。
2.4.2 装饰器的基本原理
首先,装饰器是个函数,它的参数是被装饰的函数,返回值也是一个函数:
- def decorate(origin_func): # 这个参数是被装饰的函数
- print(1) # 先输出点东西
- return origin_func # 把原函数直接返回
- @decorate # 注意这里不是函数调用,所以不用加括号,也不用加被修饰的函数名
- def sayHello():
- print('Hello')
- sayHello() # 如果没有装饰器,只会打印 'Hello',实际结果是打印 1 再打印 'Hello'
因此,使用装饰器的这种写法:
- @decorate
- def foo():
- pass
和下面这种写法是完全等价的, 初学者可以把装饰器在心中默默的转换成下一种写法,以方便理解:
- def foo():
- pass
- foo = decorate(foo)
需要注意的是,装饰器函数 decorate
在模块被导入时就会执行,而被装饰的函数只在被调用时才会执行,也就是说即使不调用 sayHello
函数也会输出 1,但这样就不会输出 Hello 了。
有了装饰器,配合前面介绍的函数对象,函数内省,我们可以做很多有意思的事,至少判断上一节中某个函数是否是策略是非常容易的。在装饰器中,我们还可以把策略函数都保存到数组中, 然后提供一个“推荐最佳策略”的功能, 其实就是遍历执行所有的策略,然后选择最好的结果。
2.4.3 装饰器进阶
上一节中的装饰器主要是为了介绍工作原理,它的功能非常简单,并不会改变被装饰函数的运行结果,仅仅是在导入时装饰函数,然后输出一些内容。换句话说,即使不执行函数,也要执行装饰器中的 print
语句,而且因为直接返回函数的缘故,其实没有真正的起到装饰的效果。
如何做到装饰时不输出任何内容,仅在函数执行最初输出一些东西呢?这是常见的 AOP(面向切片编程) 的需求。这就要求我们不能再直接返回被装饰的函数,而是应该返回一个新的函数,所以新的装饰器需要这么写:
- def decorate(origin_func):
- def new_func():
- print(1)
- origin_func()
- return new_func
- decorate
- def sayHello():
- print('Hello')
- sayHello() # 运行结果不变,但是仅在调用函数 sayHello 时才会输出 1
这个例子的工作原理是,sayHello
函数作为参数 origin_func
被传到装饰器中,经过装饰以后,它实际上变成了 new_func
,会先输出 1 再执行原来的函数,也就是 sayHello
。
这个例子很简陋,因为我们知道了 sayHello
函数没有参数,所以才能定义一个同样没有参数的替代者:nwe_func
。如果我们在开发一个框架,要求装饰器能对任意函数生效,就需要用到 2.2.3 中介绍的 *
和 **
这种不定参数语法了。
如果查看 sayHello
函数的名字,得到的结果将是 new_func
:
- sayHello.__name__ # new_func
这是很自然的,因为本质上其实执行的是:
- new_func = decorate(sayHello)
而装饰器的返回结果是另一个函数 new_func
,两者仅仅是运行结果类似,但两个对象并没有什么关联。
所以为了处理不定参数,并且不改变被装饰函数的外观(比如函数名),我们需要做一些细微的修补工作。这些工作都是模板代码,所以 Python 早就提供了封装:
- import functools
- def decorate(origin_func):
- @functools.wraps(origin_func) # 这是 Python 内置的装饰器
- def new_func(*args, **kwargs):
- print(1)
- origin_func(*args, **kwargs)
- return new_func
2.4.4 装饰器工厂
在 2.4.2 节的代码注释中我解释过,装饰器后面不要加括号,被装饰的函数自动作为参数,传递到装饰器函数中。如果加了括号和参数,就变成手动调用装饰器函数了,大多数时候这与预期不符(因为装饰器的参数一般都是被装饰的函数)。
不过装饰器可以接受自定义的参数,然后返回另一个装饰器,这样外面的装饰器实际上就是一个装饰器工厂,可以根据用户的参数,生成不同的装饰器。还是以上面的装饰器为例,我希望输出的内容不是固定的 1,而是用户可以指定的,代码就应该这么写:
- import functools
- def decorate(content): # 这其实是一个装饰器工厂
- def real_decorator(origin_func): # 这才是刚刚的装饰器
- @functools.wraps(origin_func)
- def new_func():
- print('You said ' + str(content)) # 现在输出内容可以由用户指定
- origin_func()
- return new_func # 在装饰器里,返回的是新的函数
- return real_decorator # 装饰器工厂返回的是装饰器
装饰器工厂和装饰器的区别在于它可以接受参数,返回一个装饰器:
- @decorate(2017)
- def sayHello():
- print('Hello')
- sayHello()
其实等价于:
- real_decorator = decorate(2017) # 通过装饰器工厂生成装饰器
- new_func = real_decorator(sayHello) # 正常的装饰器工作逻辑
- new_func() # 调用的是装饰过的函数
3 面向对象
3.1 对象内存管理
3.1.1 对象不是盒子
C 语言中我们定义变量用到的语法是:
- int a = 1;
这背后的含义是定义了一个 int
类型的变量 a
,相当于申请了一个名为 a
的盒子(存储空间),里面装了数字 1。
然后我们改变 a
的值:a = 2;
,可以打印 a
的地址来证明它并没有发生变化。所以只是盒子里装的内容(指针指向的位置)发生了改变:
但是在 Python 中,变量不是盒子。比如同样的定义变量:
- a = 1
这里就不能把 a
理解为 int
类型的变量了。因为在 Python 中,变量没有类型,值才有,或者说只有对象才有类型。因为即使是数字 1,也是 int
类的实例,而变量 a
更像是给这个对象贴的一个标签。
如果执行赋值语句 a = 2
,相当于把标签 a 贴在另一个对象上:
基于这个认知,我们现在应该更容易理解 2.2.1 节中所说的函数传参规则了。如果传入的是不可变类型,比如 int
,改变它的值实际上就是把标签挂在新的对象上,自然不会改变原来的参数。如果是可变类型,并且做了修改,那么函数中的变量和外面的变量都是指向同一个对象的标签,所以会共享变化。
3.1.2 默认浅复制
根据上一节的描述,直接把变量赋值给另一个变量, 还算不上复制:
- a = [1, 2, 3]
- b = a
- b == a # True,等同性校验,会调用 __eq__ 函数,这里只判断内容是否相等
- b is a # True,一致性校验,会检查是否是同一个对象,调用 hash() 函数,可以理解为比较指针
可见不仅仅数组相同,就连变量也是相同的,可以把 b 理解为 a 的别名。
如果用切片,或者数组的构造函数来创建新的数组,得到的是原数组的浅拷贝:
- a = [1, 2, 3]
- b = list(a)
- b == a # True,因为数组内容相同
- b is a # False,现在 a 和 b 是两个变量,恰好指向同一个数组对象
但如果数组中的元素是可变的,可以看到这些元素并没有被完全拷贝:
- a = [[1], [2], [3]]
- b = list(a)
- b[0].append(2)
- a # 得到 [[1, 2], [2], [3]],因为 a[0] 和 b[0] 其实还是挂在相同对象上的不同标签
如果想要深拷贝,需要使用 copy
模块的 deepcopy
函数:
- import copy
- b = copy.deepcopy(a)
- b[0].append(2)
- b # 变成了 [[1, 2], [2], [3]]
- a # 还是 [[1], [2], [3]]
此时,不仅仅是每个元素的引用被拷贝,就连每个元素自己也被拷贝。所以现在的 a[0]
和 b[0]
是指向两个不同对象的两个不同变量(标签),自然就互不干扰了。
如果要实现自定义对象的深复制,只要实现 __deepcopy__
函数即可。这个概念在几乎所有面向对象的语言中都会存在,就不详细介绍了。
3.1.3 弱引用
Python 内存管理使用垃圾回收的方式,当没有指向对象的引用时,对象就会被回收。然而对象一直被持有也并非什么好事,比如我们要实现一个缓存,预期目标是缓存中的内容随着真正对象的存在而存在,随着真正对象的消失而消失。如果因为缓存的存在,导致被缓存的对象无法释放,就会导致内存泄漏。
Python 提供了语言级别的支持,我们可以使用 weakref
模块,它提供了 weakref.WeakValueDictionary
这个弱引用字典来确保字典中的值不会被引用。如果想要获取某个对象的弱引用,可以使用 weakref.ref(obj)
函数。
3.2 Python 风格的对象
3.2.1 静态函数与类方法
静态函数其实和类的方法没什么关系,它只是恰好定义在类的内部而已,所以这里我用函数(function) 来形容它。它可以没有参数:
- class Person:
- @staticmethod # 用 staticmethod 这个修饰器来表明函数是静态的
- def sayHello():
- print('Hello')
- Person.sayHello() # 输出 'Hello`
静态函数的调用方式是类名加上函数名。类方法的调用方式也是这样,唯一的不同是需要用 @staticmethod
修饰器,而且方法的第一个参数必须是类:
- class Person:
- @classmethod # 用 classmethod 这个修饰器来表明这是一个类方法
- def sayHi(cls):
- print('Hi: ' + cls.__name__)
- Person.sayHi() # 输出 'Hi: Person`
类方法和静态函数的调用方法一致,在定义时除了修饰器不一样,唯一的区别就是类方法需要多声明一个参数。这样看起来比较麻烦,但静态函数无法引用到类对象,自然就无法访问类的任何属性。
于是问题来了,静态函数有何意义呢?有的人说类名可以提供命名空间的概念,但在我看来这种解释并不成立,因为每个 Python 文件都可以作为模块被别的模块引用,把静态函数从类里抽取出来,定义成全局函数,也是有命名空间的:
- # 在 module1.py 文件中:
- def global():
- pass
- class Util:
- @staticmethod
- def helper():
- pass
- # 在 module2.py 文件中:
- import module1
- module1.global() # 调用全局函数
- module1.Util.helper() # 调用静态函数
从这个角度看,定义在类中的静态函数不仅不具备命名空间的优点,甚至调用语法还更加啰嗦。对此,我的理解是:静态函数可以被继承、重写,但全局函数不行,由于 Python 中的函数是一等公民,因此很多时候用函数替代类都会使代码更加简洁,但缺点就是无法继承,后面还会有更多这样的例子。
3.2.2 属性 attribute
Python (等多数动态语言)中的类并不像 C/OC/Java 这些静态语言一样,需要预先定义属性。我们可以直接在初始化函数中创建属性:
- class Person:
- def __init__(self, name):
- self.name = name
- bs = Person('bestswifter')
- bs.name # 值是 'bestswifter'
由于 __init__
函数是运行时调用的,所以我们可以直接给对象添加属性:
- bs.age = 22
- bs.age # 因为刚刚赋值了,所以现在取到的值是 22
如果访问一个不存在的属性,将会抛出异常。从以上特性来看,对象其实和字典非常相似,但这种过于灵活的特性其实蕴含了潜在的风险。比如某个封装好的父类中定义了许多属性, 但是子类的使用者并不一定清楚这一点,他们很可能会不小心就重写了父类的属性。一种隐藏并保护属性的方式是在属性前面加上两个下划线:
- class Person:
- def __init__(self):
- self.__name = 'bestswifter'
- bs = Person()
- bs.__name # 这样是无法获取属性的
- bs._Person__name # 这样还是可以读取属性
这是因为 Python 会自动处理以双下划线开头的属性,把他们重名为 _Classname__attrname
的格式。由于 Python 对象的所有属性都保存在实例的 __dict__
属性中,我们可以验证一下:
- bs = Person()
- bs.__dict__
- # 得到 {'_Person__name': 'bestswifter'}
但很多人并不认可通过名称改写(name mangling) 的方式来存储私有属性,原因很简单,只要知道改写规则,依然很容易的就能读写私有属性。与其自欺欺人,不如采用更简单,更通用的方法,比如给私有属性前面加上单个下划线 _
。
注意,以单个下划线开头的属性不会触发任何操作,完全靠自觉与共识。任何稍有追求的 Python 程序员,都不应该读写这些属性。
3.2.3 特性 property
使用过别的面向对象语言的读者应该都清楚属性的 getter
和 setter
函数的重要性。它们封装了属性的读写操作,可以添加一些额外的逻辑,比如校验新值,返回属性前做一些修饰等等。最简陋的 getter
和 setter
就是两个普通函数:
- class Person:
- def get_name(self):
- return self.name.upper()
- def set_name(self, new_name):
- if isinstance(new_name, str):
- self.name = new_name.lower()
- def __init__(self, name):
- self.name = name
- bs = Person('bestswifter')
- bs.get_name() # 得到大写的名字: 'BESTSWIFTER'
- bs.set_name(1) # 由于新的名字不是字符串,所以无法赋值
- bs.get_name() # 还是老的名字: 'BESTSWIFTER'
工作虽然完成了,但方法并不高明。在 1.2.3 节中我们就见识到了 Python 的一个特点:“内部高度封装,完全对外透明”。这里手动调用 getter
和 setter
方法显得有些愚蠢、啰嗦,比如对比下面的两种写法,在变量名和函数名很长的情况下,差距会更大:
- bs.name += '1995'
- bs.set_name(bs.get_name() + '1995')
Python 提供了 @property
关键字来装饰 getter
和 setter
方法,这样的好处是可以直接使用点语法,了解 Objective-C 的读者对这一特性一定倍感亲切:
- class Person:
- @property # 定义 getter
- def name(self): # 函数名就是点语法访问的属性名
- return self._name.upper() # 现在真正的属性是 _name 了
- @name.setter # 定义 setter
- def name(self, new_name): # 函数名不变
- if isinstance(new_name, str):
- self._name = new_name.lower() # 把值存到私有属性 _name 里
- def __init__(self, name):
- self.name = name
- bs = Person('bestswifter')
- bs.name # 其实调用了 name 函数,得到大写的名字: 'BESTSWIFTER'
- bs.name = 1 # 其实调用了 name 函数,因为类型不符,无法赋值
- bs.name # 还是老的名字: 'BESTSWIFTER'
我们已经在 2.4 节详细学习了装饰器,应该能意识到这里的 @property
和 @xxx.setter
都是装饰器。因此上述写法实际上等价于:
- class Person:
- def get_name(self):
- return self._name.upper()
- def set_name(self, new_name):
- if isinstance(new_name, str):
- self._name = new_name.lower()
- # 以上是老旧的 getter 和 setter 定义
- # 如果不用 @property,可以定义一个 property 类的实例
- name = property(get_name, set_name)
可见,特性的本质是给类创建了一个类属性,它是 property
类的实例,构造方法中需要把 getter
、setter
等函数传入,我们可以打印一下类的 name
属性来证明:
- Person.name # <property object at 0x107c99868>
理解特性的工作原理至关重要。以这里的 name
特性为例,我们访问了对象的 name
属性,但是它并不存在,所以会尝试访问类的 name
属性,这个属性是 property
类的实例,会对读写操作做特殊处理。这也意味着,如果我们重写了类的 name
属性,那么对象的读写方法就不会生效了:
- bs = Person()
- Person.name = 'hello'
- bs.name # 实例并没有 name 属性,因此会访问到类的属性 name,现在的值是 'hello` 了
如果访问不存在的属性,默认会抛出异常,但如果实现了 __getattr__
函数,还有一次挽救的机会:
- class Person:
- def __getattr__(self, attr):
- return 0
- def __init__(self, name):
- self.name = name
- bs = Person('bestswifter')
- bs.name # 直接访问属性
- bs.age # 得到 0,这是 __getattr__ 方法提供的默认值
- bs.age = 1 # 动态给属性赋值
- bs.age # 得到 1,注意!!!这时候就不会再调用 __getattr__ 方法了
由于 __getattr__
只是兜底策略,处理一些异常情况,并非每次都能被调用,所以不能把重要的业务逻辑写在这个方法中。
3.2.4 特性工厂
在上一节中,我们利用特性来封装 getter
和 setter
,对外暴露统一的读写接口。但有些 getter
和 setter
的逻辑其实是可以复用的,比如商品的价格和剩余数量在赋值时,都必须是大于 0 的数字。这时候如果每次都要写一遍 setter
,代码就显得很冗余,所以我们需要一个能批量生产特性的函数。由于我们已经知道了特性是 property
类的实例,而且是类的属性,所以代码可以这样写:
- def quantity(storage_name): # 定义 getter 和 setter
- def qty_getter(instance):
- return instance.__dict__[storage_name]
- def qty_setter(instance, value):
- if value > 0:
- # 把值保存在实例的 __dict__ 字典中
- instance.__dict__[storage_name] = value
- else:
- raise ValueError('value must be > 0')
- return property(qty_getter, qty_setter) # 返回 property 的实例
有了这个特性工厂,我们可以这样来定义特性:
- class Item:
- price = quantity('price')
- number = quantity('number')
- def __init__(self):
- pass
- i = Item()
- i.price = -1
- # Traceback (most recent call last):
- # ...
- # ValueError: value must be > 0
作为追求简洁的程序员,我们不禁会问,在 price = quantity('price')
这行代码中,属性名重复了两次,能不能在 quantity
函数中自动读取左边的属性名呢,这样代码就可以简化成 price = quantity()
了。
答案显然是否定的,因为右边的函数先被调用,然后才能把结果赋值给左边的变量。不过我们可以采用迂回策略,变相的实现上面的需求:
- def quantity():
- try:
- quantity.count += 1
- except AttributeError:
- quantity.count = 0
- storage_name = '_{}:{}'.format('quantity', quantity.count)
- def qty_getter(instance):
- return instance.__dict__[storage_name]
- def qty_setter(instance, value):
- if value > 0:
- instance.__dict__[storage_name] = value
- else:
- raise ValueError('value must be > 0')
- return property(qty_getter, qty_setter)
这段代码中我们利用了两个技巧。首先函数是一等公民, 所以函数也是对象,自然就有属性。所以我们利用 try ... except
很容易的就给函数工厂添加了一个计数器对象 count
,它每次调用都会增加,然后再拼接成存储时用的键 storage_name
,并且可以保证不同 property
实例的存储键名各不相同。
其次,storage_name
在 getter
和 setter
函数中都被引用到,而这两个函数又被 property
的实例引用,所以 storage_name
会因为被持有而延长生命周期。这也正是闭包的一大特性:能够捕获自由变量并延长它的生命周期和作用域。
我们来验证一下:
- class Item:
- price = quantity()
- number = quantity()
- def __init__(self):
- pass
- i = Item()
- i.price = 1
- i.number = 2
- i.price # 得到 1,可以正常访问
- i.number # 得到 2,可以正常访问
- i.__dict__ # {'_quantity:0': 1, '_quantity:1': 2}
可见现在存储的键名可以被正确地自动生成。
3.2.5 属性描述符
文件描述符的作用和特性工厂一样,都是为了批量的应用特性。它的写法也和特性工厂非常类似:
- class Quantity:
- def __init__(self, storage_name):
- self.storage = storage_name
- def __get__(self, instance, owner):
- return instance.__dict__[self.storage]
- def __set__(self, instance, value):
- if value > 0:
- instance.__dict__[self.storage] = value
- else:
- raise ValueError('value must be > 0')
主要有以下几个改动:
- 不用返回
property
类的实例了,因此getter
和setter
方法的名字是固定的,这样才能满足协议。 __get__
方法的第一个参数是描述符类Quantity
的实例,第二个参数self
是要读取属性的实例,比如上面的i
,也被称作托管实例。第三个参数是托管类,也就是Item
。__set__
方法的前两个参数含义类似,第三个则是要读取的属性名,比如price
。
和特性工厂类似,属性描述符也可以实现 storage_name
的自动生成,这里就不重复代码了。看起来属性描述符和特性工厂几乎一样,但由于属性描述符是类,它就可以继承。比如这里的 Quantity
描述符有两个功能:自动存储和值的校验。自动存储是一个非常通用的逻辑,而值的校验是可变的业务逻辑,所以我们可以先定义一个 AutoStorage
描述符来实现自动存储功能,然后留下一个空的 validate
函数交给子类去重写。
而特性工厂作为函数,自然就没有上述功能,这两者的区别类似于 3.2.1 节中介绍的静态函数与全局函数的区别。
3.2.6 实例属性的查找顺序
我们知道类的属性都会存储在 __dict__
字典中,即使没有显式的给属性赋值,但只要字典里面有这个字段,也是可以读取到的:
- class Person:
- pass
- p = Person()
- p.__dict__['name'] = 'bestswifter'
- p.name # 不会报错,而是返回字典中的值,'bestswifter'
但我们在特性工厂和属性描述符的实现中,都是直接把属性的值存储在 __dict__
中,而且键就是属性名。之前我们还介绍过,特性的工作原理是没有直接访问实例的属性,而是读取了 property
的实例。那直接把值存在 __dict__
中,会不会导致特性失效,直接访问到原始内容呢?从之前的实践结果来看,答案是否定的,要解释这个问题,我们需要搞明白访问实例属性的查找顺序。
假设有这么一段代码:
- o = cls() # 假设 o 是 cls 类的实例
- o.attr # 试图访问 o 的属性 attr
再对上一节中的属性描述符做一个简单的分类:
- 覆盖型描述符:定义了
__set__
方法的描述符 - 非覆盖型描述符:没有定义
__set__
方法的描述符
在执行 o.attr
时,查找顺序如下:
- 如果
attr
出现在cls
或父类的__dict__
中,且attr
是覆盖型描述符,那么调用__get__
方法。 - 否则,如果
attr
出现在o
的__dict__
中,返回o.__dict__[attr]
- 否则,如果
attr
出现在cls
或父类的__dict__
中,如果attr
是非覆盖型描述符,那么调用__get__
方法。 - 否则,如果没有非覆盖型描述符,直接返回
cls.__dict__[attr]
- 否则,如果
cls
实现了__getattr__
方法,调用这个方法 - 抛出
AttributeError
所以,在访问类的属性时,覆盖型描述符的优先级是高于直接存储在 __dict__
中的值的。
3.3 多继承
本节内容部分摘自我的这篇文章:从 Swift 的面向协议编程说开去,本节聊的是多继承在 Python 中的知识,如果想阅读关于多继承的讨论,请参考原文。
3.3.1 多继承的必要性
很多语言类的书籍都会介绍,多继承是个危险的行为。诚然,狭义上的多继承在绝大多数情况下都是不合理的。这里所谓的 “狭义”,指的是一个类拥有多个父类。我们要明确一个概念:继承的目的不是代码复用,而是声明一种 is a
的关系,代码复用只是 is a
关系的一种外在表现。
因此,如果你需要狭义上的多继承,还是应该先问问自己,真的存在这么多 is a
的关系么?你是需要声明这种关系,还是为了代码复用。如果是后者,有很多更优雅的解决方案,因为多继承的一个直接问题就是菱形问题(Diamond Problem)。
但是广义上的多继承是必须的,不能因为害怕多继承的问题就忽略多继承的优点。广义多继承指的是通过定义接口(Interface)以及接口方法的默认实现,形成“一个父类,多个接口”的模式,最终实现代码的复用。当然,不是每个语言都有接口的概念,比如 Python 里面叫 Mixin,会在 3.3.3 节中介绍。
广义上的多继承非常常见,有一些教科书式的例子,比如动物可以按照哺乳动物,爬行动物等分类,也可以按照有没有翅膀来分类。某一个具体的动物可能满足上述好几类。在实际的开发中也到处都是广义多继承的使用场景,比如 iOS 或者安卓开发中,系统控件的父类都是固定的,如果想让他们复用别的父类的代码,就会比较麻烦。
Python入门指南(超详细)的更多相关文章
- Python入门教程 超详细1小时学会Python
Python入门教程 超详细1小时学会Python 作者: 字体:[增加 减小] 类型:转载 时间:2006-09-08我要评论 本文适合有经验的程序员尽快进入Python世界.特别地,如果你掌握Ja ...
- Python入门教程 超详细1小时学会Python
Python入门教程 超详细1小时学会Python 本文适合有经验的程序员尽快进入Python世界.特别地,如果你掌握Java和Javascript,不用1小时你就可以用Python快速流畅地写有用的 ...
- Python入门教程 超详细1小时学会Python(转)
假设我们有这么一项任务:简单测试局域网中的电脑是否连通.这些电脑的ip范围从192.168.0.101到192.168.0.200. 思路:用shell编程.(Linux通常是bash而Windows ...
- 清晰易懂!关于PS入门的超详细笔记!
给大家分享一篇关于PS入门的超详细笔记!原理讲解清晰明了,虽不是新版本解析,但都是新手学习PS必掌懂的一些知识点,灰常的实用,转走收藏学习! 编辑:千锋UI设计 来源:PS学堂
- Python 入门指南
Release: 3.4 Date: March 29, 2014 Python 是一门简单易学且功能强大的编程语言. 它拥有高效的高级数据结构,并且能够用简单而又高效的方式进行面向对象编程. Pyt ...
- 25 【python入门指南】如何编写测试代码
python如何编写测试代码 python内置了unittest,使得写应用层的单元测试变得超乎寻常的简单. 1,执行单个测试函数 #!/bin/python import unittest clas ...
- Python该怎么入门?Python入门教程(非常详细)
Python要学多久可以学会,达到精通呢? 任何知识都是基础入门比较快,达到通晓的程序是需求时日的,这是一个逐渐激烈的进程. 通晓任何一门编程语言,都需求通过大量的实践来积累经验,解决遇到的各种疑难问 ...
- 24 【python入门指南】class
一.类 1.1,构造函数,析构函数 #!/bin/python class dog(): def __init__(self, age, name): self.age = age self.name ...
- hadoop入门篇---超详细hadoop服务器环境配置教程
虚拟机以及Linux系统安装在之前的两篇分享中已经详细的介绍了方法,并且每一步的都配图了.如果有朋友还是看不懂,那我也爱莫能助了.本篇主要就hadoop服务器操作系统配置进行详细说明,hadoop安装 ...
随机推荐
- (NO.00003)iOS游戏简单的机器人投射游戏成形记(十八)
在游戏中制作手臂瞄准线,也就是所谓的辅助延长线.玩台球游戏的童鞋应该可以了解. 按道理来说,延长线是一个物理实体,遇到物理刚体应该会发生反弹行为,这个符合实际游戏逻辑. 但是这里为了简单,只是做一条& ...
- 管道模式——pipeline与valve
在一个比较复杂的大型系统中,假如存在某个对象或数据流需要被进行繁杂的逻辑处理的话,我们可以选择在一个大的组件中进行这些繁杂的逻辑处理,这种方式确实达到了目的,但却是简单粗暴的.或许在某些情况这种简单粗 ...
- Chapter 2 User Authentication, Authorization, and Security(4):限制SA帐号的管理权限
原文出处:http://blog.csdn.net/dba_huangzj/article/details/38817915,专题目录:http://blog.csdn.net/dba_huangzj ...
- Ext JS 6正式版的GPL版本下载地址
下面是Ext JS 6正式版的GPL版本下载地址 https://www.sencha.com/legal/gpl/
- C++ Primer 有感(异常处理)(二)
异常就是运行时出现的不正常,例如运行时耗尽了内存或遇到意外的非法输入.异常存在于程序的正常功能之外,并要求程序立即处理.不能不处理异常,异常是足够重要的,使程序不能继续正常执行的事件.如果找不到匹配的 ...
- Java接口interface
Java接口interface 1.多个无关的类可以实现同一个接口. 2.一个类可以实现多个无关的接口. 3.与继承关系类似,接口与实现类之间存在多态性. 接口(interface)是抽象方法和常量值 ...
- Css详解之(伪类选择器)
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/ ...
- SpriteBuilder中关节的Breaking force属性
在SpriteBuilder中三种物理关节都包含Breaking force区域在属性框中. 该属性被设置成关节可以承受的压力临界值.如果关节的压力超出了Breaking force中设置的值,则关节 ...
- 点击table中的某一个td,获得这个tr的所有数据
功能: 点击table中的某一个td,获得这个tr的所有数据 效果图 <html> <head> <script> function getData2(elemen ...
- Android开发中的安全
根据Android四大框架来解说安全机制 代码安全 java不同于C/C++,java是解释性语言,存在代码被反编译的隐患: 默认混淆器为proguard,最新版本为4.7: proguard还可用来 ...