《Effective Python》 读书笔记

《Effective Python 59》 是非常值得一读的Python进阶书籍,它阐述了Python语言中一些鲜为人知的微妙特性,并给出了能够改善代码功能及运行效率的习惯用法。相比其他Python书籍,这本书有以下几个特点:

  1. 从工程实践出发、以场景为主导阐述如何编写高质量、可维护的代码,并配套的代码范例。
  2. 内容不拖沓,省去很多基础语法。
  3. 不厚,只有二百多页(这一点至关重要)。

这是我看完之后做的一份读书笔记,列出来以供以后不时查阅。(还没写完,持续更新…)

用Pythonic方式来思考

bytes、str与unicode的区别

很多时候项目从Python2.x迁移到Python3.x会遇到字符编码的问题,原因是Pyhton2.x和Python3.x的字符编码不统一,具体如下:
Python3:有两种表示字符序列的类型:bytesstr

  1. bytes:8个二进制位
  2. strunicode字符

Python2:有两种表示字符序列的类型:strunicode

  1. str:8个二进制位
  2. unicodeunicode字符

unicode$\rightarrow$ 二进制: 常见的编码方式是utf-8,使用encode方法

二进制$\rightarrow$unicode: 使用decode方法

在编程的时候,编解码放在接口外面来做。程序核心使用unicode字符,且不要对字符编码做任何假设。

1
2
3
4
5
6
7
8
9
10
11
12
13
def to_str(bytes_or_str):
if isinstance(bytes_or_str, bytes):
value = bytes_or_str.decoce('utf-8')
else:
value = bytes_or_str
return value

def to_bytes(bytes_or_str):
if isinstance(bytes_or_str, str):
value = bytes_or_str.encode('utf-8')
else:
value = bytes_or_str
return value

在Python3中,涉及到文件处理的操作(使用内置的open函数)会默认的以UTF-8进行编码。而在Python2中默认采用二进制形式来编码。这也是导致很多意外事故发生的根源,特别是对于那些更习惯使用Python2的程序员而言。

比方说,将几个随机的二进制数据写入到一个文件中。在Python2中,下面的这段代码可以正常的工作,但是在Python3中却会报错并退出。

1
2
3
import os
with open('random.bin', 'w') as f:
f.write(os.urandom(10))
---------------------------------------------------------------------------

TypeError                                 Traceback (most recent call last)

<ipython-input-4-71b20f96b6df> in <module>()
      1 import os
      2 with open('random.bin', 'w') as f:
----> 3     f.write(os.urandom(10))


TypeError: write() argument must be str, not bytes

导致这个异常发生的原因是在Python3中对于open函数又新增了一个名为encoding的参数。此参数默认为UTF-8。这样在文件句柄上进行read和write操作时,必须传入Unicode字符串的str实例,而不是包含了二进制数据的bytes实例。

用生成器表达式来改写数据量较大的列表推导

  1. 当输入的数据量较大时,列表推导可能因为占用太多内存而出问题。
  2. 使用圆括号构成生成器表达式,由生成器表达式所返回的迭代器,可以逐次产生输出值,从而避免内存占用问题。
  3. 把某个生成器表达式所返回的迭代器,放在另一个生成器表达式的for子表达式中,即可将二者组合起来。
  4. 串在一起的生成器表达式执行速度很快,果要把多种手法组合起来,以操作大批量数据,最好是用生成器表达式实现。
1
2
3
4
5
6
7
8
9
10
11
12
13
# 这种类表推导只适合处理少量的输入值,对于大量数据,最好考虑生成器表达式而不是列表生成式
value = [len(x) for x in open('my_file.txt')]

# 生成器表达
value = (len(x) for x in open('my_file.txt'))
print('value: ', value)
print('next value: ',next(value))
print('next value: ', next(value))

# 使用生成器表达式的另一个好处是可以互相组合
# 这种连锁生成器表达式,可以迅速在python中执行
roots = ((x, x*10) for x in value)
print('next roots: ', next(roots))
value:  <generator object <genexpr> at 0x000001A6AC0D1308>
next value:  7
next value:  1
next roots:  (6, 60)

尽量用enumerate代替range

  1. enumerate提供了一种精简的写法,可以在遍历迭代器时获知每个元素的索引。
  2. 尽量用enumerate来改写那种将range与下标访问相结合的序列遍历代码。
  3. 可以个enumerate提供第二个参数,以指定开始计数时所用的值(默认值为0)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 第一种写法。不推荐
print('range写法,不推荐')
flavor_list = ['vanilla', 'chocolate', 'pecan', 'strawberry']
for i in range(len(flavor_list)):
print('%d: %s' % (i+1, flavor_list[i]))

# enumerate
print('\nenumerate方法')
for i, flavor in enumerate(flavor_list):
print('%d: %s' % (i+1, flavor))

# 还可以直接指定enumerate函数开始计数时所用的值
print('\n指定enumerate函数开始计数时所用的值')
for i, flavor in enumerate(flavor_list, 1):
print('%d: %s' % (i, flavor))
range写法,不推荐
1: vanilla
2: chocolate
3: pecan
4: strawberry

enumerate方法
1: vanilla
2: chocolate
3: pecan
4: strawberry

指定enumerate函数开始计数时所用的值
1: vanilla
2: chocolate
3: pecan
4: strawberry

用zip函数同时遍历两个迭代器

  1. 内置的zip可以平行地遍历多个迭代器。
  2. Python3中的zip相当于生成器,会在遍历过程中逐次产生元组,而Python2中直接把这些元组完全生成好,并一次性返回整份列表。
  3. 如果提供的迭代器长度不等,zip会提前终止。
1
2
3
4
5
6
7
8
9
10
11
12
names = ['LiMing', 'LiLi', 'ZhangMei']
letters = [len(n) for n in names]
longest_name = None
max_letters = 0

for i in range(len(names)):
count = letters[i]
if count > max_letters:
max_letters = count
longest_name = names[i]

print(longest_name, max_letters)
ZhangMei 8
1
2
3
4
5
6
7
8
longest_name = None
max_letters = 0
for name, count in zip(names, letters):
if count > max_letters:
longest_name = name
max_letters = count

print(longest_name, max_letters)
ZhangMei 8

不要在for和while循环后面写else块

  1. Python有种特殊写法,可在forwhile循环的内部语句块之后紧跟一个else块。
  2. 但这种写法既不直观,又容易让人误解,应该避免这种写法。

合理利用 try/except/else/finally 结构中的每个代码块

  1. try...finally...
    这种结构简单的说是在try下的全部操作如果某项失败的话就终止并执行finally下定义的语句。如果全部操作都没有报错,那么最后也执行finally下定义的语句,经常用于既要向上传播异常,又要在异常发生时执行某些清理操作。

  2. try...except...else...

  3. 可以混合使用。
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
# try...finally...
handle = open('my_file.txt', 'w')
try:
handle.write('111')
handle.write('222')
finally:
print('close handle')
handle.close()

# try...except...else...
import json
def load_json_key(data, key):
try:
result_dict = json.load_json_key(data)
except ValueError as e:
raise KeyError from e
else:
return result_dict[key]

# 混合使用
UNDEFINED = object()
def divide_json(path):
handle = open(path, 'r+')
try:
data = handle.read()
op = json.loads(data)
value = (op['numerator'], op['denominator'])
except ZeroDivisionError as e:
return UNDEFINED
else:
op['result'] = value
result = json.dumps(op)
handle.seek(0)
handle.write(result)
return value
finally:
handle.close()
close handle

函数

尽量用异常来表示特殊情况,而不要返回None

  1. 返回None的来作为特殊的含义很出错,因为None和其他的变量(例如 zero,空字符串)在条件表达式的判断下是等价的。
  2. 函数在遇到特殊情况时,应该抛出异常,而不是返回None。这样调用者就能够合理地按照函数中的说明文档来处理由此而引发的异常了。

None表示特殊情况如下:

1
2
3
4
5
6
7
8
def divide(a, b):
try:
return True, a/b
except ZeroDivisionError:
return False, None

sucess, result = divide(3,3)
result
1.0

用异常表示特殊情况如下:

1
2
3
4
5
6
7
8
9
10
11
12
def divide(a, b):
try:
return a / b
except ZeroDivisionError as e:
raise ValueError('Invalid inputs') from e

try:
result = divide(3, 0)
except ValueError:
print('Invlid inputs')
else:
print('Result is: %f' % result)
Invlid inputs

第一种方法问题在于,Python程序员习惯用-表示用不到的变量,那很有可能调用者会轻易的跳过元组的第一部。第二种方法让程序员不得不处理异常情况。

了解如何在闭包里使用外围作用域中的变量

假如有一份数字列表,要对其排序,但在排序时,要把出现在某个组群中的数字,放在组群外的那些数字之前,简单的实现如下:

1
2
3
4
5
6
7
8
9
10
11
def sort_priority(values, group):
def helper(x):
if x in group:
return (0, x)
return (1, x)
values.sort(key=helper)

numbers = [8, 3, 1, 2, 5, 4, 7, 6, -1]
group = {2, 3, 5, 7}
sort_priority(numbers, group)
numbers
[2, 3, 5, 7, -1, 1, 4, 6, 8]

上面程序能正常工作的原因是以下三个方面:

  • Python支持闭包:闭包是一种定义在某个作用域中的函数,这种函数引用了那个作用域中的变量
  • Python的函数是一级对象,也就是说,我们可以直接引用函数、把函数赋给变量、把函数当成参数
  • Python使用特殊规则比较两个元祖,首先比较下标为0的对应元素,如果相等,再比较下标为1的对应元素,以此类推

若果增加一个功能,如果在数字出现在了组群中,返回一个标志,先试试下面这种写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
def sort_priority2(numbers, group):
found = False
def helper(x):
if x in group:
found = True
return (0, x)
return (1, x)
numbers.sort(key=helper)
return found

found = sort_priority2(numbers, group)
print(found)
print(numbers)
False
[2, 3, 5, 7, -1, 1, 4, 6, 8]

排序的结果是对的,但标志不对。解释如下:

当在表达式中引用变量的时候,Python解释器会按如下顺序遍历各作用域:

  • 当前函数的作用域。
  • 任何外围作用域(比如其他的包含着的函数)。
  • 包含当前代码的模块域(也称之为全局作用域)。
  • 内置域(包含了像len,str等函数的域)。

如果上述地方都没找到,就抛出异常。

当给变量赋值时,如果变量在当前作用域内已经被定义过,那么该变量会具备新值,如果当前作用域没有这个变量,Python会把这次的赋值行为视为对变量的定义

在Python3中有一种特殊的写法,能够获取闭包内的数据,我们可以用nonlocal语句表明这样的意图。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def sort_priority2(numbers, group):
found = False
def helper(x):
nonlocal found
if x in group:
found = True
return (0, x)
return (1, x)
numbers.sort(key=helper)
return found

found = sort_priority2(numbers, group)
print(found)
print(numbers)
True
[2, 3, 5, 7, -1, 1, 4, 6, 8]
  • nonlocal清楚的表明:如果在闭包内给该变量赋值,那么修改的其实是闭包外的那个作用域中的变量,nonlocal不能延伸到模块级别
  • Python2中不支持nonlocal
  • nonlocal可能也会像全局变量一样遭到滥用,建议只在及其简单的函数中使用这种机制
  • 如果使用nonlocal的那些代码,已经写的越来越复杂了,那就应该将相关的状态封装成辅助类

下面定义的类与nonlocal的功能相同

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Sorter(object):
def __init__(self, group):
self.group = group
self.found = False

def __call__(self, x):
if x in self.group:
self.found = True
return (0, x)
return (1, x)

numbers = [8, 3, 1, 2, 5, 4, 7, 6, -1]
group = {2, 3, 5, 7}
sorter = Sorter(group)
numbers.sort(key=sorter)
print(sorter.found)
print(numbers)
True
[2, 3, 5, 7, -1, 1, 4, 6, 8]

考虑用生成器来改写直接返回列表的函数

如果函数要产生一系列结果,最简单的做法是返回一份列表。例如,想知道一个字符串中每个单词的首字母在句子中的位置。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
def index_words(text):
result = []
if text:
result.append(0)

for index, letter in enumerate(text):
if letter == ' ':
result.append(index + 1)
return result

address = 'Four score and seven years ago...'
result = index_words(address)
result
[0, 5, 11, 15, 21, 27]

这段代码的问题如下:

  • 代码杂乱拥挤。每次都要调用append方法,而且要初始化result,返回result
  • 在返回前,要把所有的结果放在列表中,在输入量大的情况下,有内存崩溃的风险

下面这个生成器函数,返回一个迭代器,与之前的代码功能相同。

1
2
3
4
5
6
7
8
9
10
11
def index_words(text):
if text:
yield 0

for index, letter in enumerate(text):
if letter == ' ':
yield index+1
address = 'Four score and seven years ago...'
result = index_words(address)
print(result)
list(result)
<generator object index_words at 0x000001A6AC0FA9E8>





[0, 5, 11, 15, 21, 27]

在参数上迭代时,要多加小心

  1. Python的迭代器协议,描述了容器和迭代器应该如何与iternext内置函数、for循环函数及相关表达式相互配合。
  2. __iter__方法实现为生成器,即可定义自己的容器类型。
  3. 想判断某个值是迭代器还是容器,可以拿该值为参数,两次调用iter函数,若结果相同,则是迭代器。

当一个函数接收的参数是一个对象列表,那么很有可能要在这个列表上迭代。如下代码计算个体占总体的百分比。

1
2
3
4
5
6
7
8
9
def normalize(numbers):
total = sum(numbers)
result = []
for num in numbers:
result.append(100 * num / total)
return result

visits = [15, 35, 80]
normalize(visits)
[11.538461538461538, 26.923076923076923, 61.53846153846154]

如果将个体数据放在一份文件里,然后从文件中读取,我们定义read_vists函数,然后定义一个生成器以便将数据应用到更大数据集上。

1
2
3
4
5
6
7
8
def read_vists(path):
with open(path) as f:
for line in f:
yield int(line)

it = read_vists('data.txt')
percentages = normalize(it)
percentages
[]

奇怪的是,以生成器返回的迭代器为参数,来调用normalize,没有产生任何结果。原因是迭代器只能产生一轮结果,在sum函数那里已经用完了。但之后在已经用完的迭代器上继续迭代时,没有报错。
一种解决方法是通过参数来接受另外一个函数,那个函数每次调用后,都能返回新的迭代器。代码如下。

1
2
3
4
5
6
7
def normalize_func(numbers):
total = sum(get_iter()) # 一个新的迭代器
result = []
for value in get_iter(): # 又一个新的迭代器
percent = 100 * value / total
result.append(percent)
return result

这种方法显得生硬,很不Pythnic。另一种解决方法是新编一种实现迭代器协议的容器类。

实际上,当Python执行类似for x in foo这样的表达式的时候,它就会调用iter(foo)。内置的iter函数然后会调用foo.__iter__方法。该方法返回一个迭代器对象,而那个迭代器对象,则实现了__next__方法。然后循环语句会在迭代器对象上反复调用next方法,直到产生StopIteration异常。

只需自己的类把__iter__方法实现为生成器就满足上述要求。

1
2
3
4
5
6
7
8
9
10
11
class ReadVisits(object):
def __init__(self, data_path):
self.data_path = data_path

def __iter__(self):
with open(self.data_path) as f:
for line in f:
yield int(line)

visits = ReadVisits('data.txt')
normalize(visits)
<generator object ReadVisits.__iter__ at 0x000001A6AC0D1C50>

normalizesum方法会调用ReadVisits.__iter__方法,之后的for循环也会调用ReadVisits.__iter__方法。

迭代器协议有这样的规定:如果把迭代器对象传给内置的iter函数,那么此函数会把该迭代器返回,反之,如果传给iter的是个容器类型的对象,那么iter函数则每次回返回新的迭代器对象,我们可以根据这种行为来判断输入值是不是迭代器对象本身,如果是,就抛出错误

下面是对第一个函数的完善。

1
2
3
4
5
6
7
8
9
10
11
12
def normalize_defensive(numbers):
if iter(numbers) == iter(numbers):
raise TypeError('Must supply a container')
total = sum(numbers)
result = []
for value in numbers:
percent = 100 * value / total
result.append(percent)
return result

visits = ReadVisits('data.txt')
normalize_defensive(visits)
[7.6923076923076925,
 28.205128205128204,
 33.97435897435897,
 9.615384615384615,
 20.512820512820515]
1
2
it = read_vists('data.txt')
normalize_defensive(it)
---------------------------------------------------------------------------

TypeError                                 Traceback (most recent call last)

<ipython-input-32-df5cf9d8e72a> in <module>()
      1 it = read_vists('data.txt')
----> 2 normalize_defensive(it)


<ipython-input-29-ce5cccbce26d> in normalize_defensive(numbers)
      1 def normalize_defensive(numbers):
      2     if iter(numbers) == iter(numbers):
----> 3         raise TypeError('Must supply a container')
      4     total = sum(numbers)
      5     result = []


TypeError: Must supply a container

用数量可变的位置参数减少视觉杂讯

令函数接受可选的位置参数(*args),能够使代码更加清晰,并能减少视觉杂讯。

例如: 你想打印一些调试信息。假如该函数的参数个数固定不变,那它就必须接受一段信息及一份含有待打印值的列表。

1
2
3
4
5
6
7
def log(message, values):
if not values:
print(message)
else:
value_str = ','.join(str(x) for x in values)
print('%s: %s' % (message, value_str))
log('My Number are', [11, 22, 33])
My Number are: 11,22,33

但是如果没有values要打印的时候也必须传递一个空列表,这样使得代码既麻烦又杂乱。

我们在位置参数前加一个*来实现可变参数

1
2
3
4
5
6
7
8
9
10
11
12
def log(message, *values):
if not values:
print(message)
else:
value_str = ','.join(str(x) for x in values)
print('%s: %s' % (message, value_str))
log('My Number are', 12, 33 ,44)

numbers = [12, 33, 44]
log('My Number are', *numbers)

log('My Number are')
My Number are: 12,33,44
My Number are: 12,33,44
My Number are

有两个应该注意的问题:

  1. 第一个是可变参数在被传递给函数的时候要转变成元组。这意味着如果调用者对生成器使用了*操作符,来调用这种函数,程序将会先把生成器迭代一轮,并把生成器锁生成的每一个值,都放在元组中,如果数据量巨大,可能消耗大量内存,并导致程序崩溃。
  2. 使用*args参数的话,如果以后再新增其他的位置参数,就必须修改原来调用该函数的那些旧代码,如果不更新调用代码,则会产生难以调试的错误。

用关键字参数表达可选的行为

  1. Python函数中的所有的位置参数都可以通过关键字来传值,关键字参数的顺序不限,只要把函数所要求的全部位置参数都指定好即可。
  2. 还可以混合使用关键字参数和位置参数来调用函数。
  3. 位置参数必须出现在关键字参数之前。
  4. 只使用位置参数来调用函数,可能导致这些参数值的含义不够明确,而关键字参数则能够阐明每个参数的意图。
  5. 给函数添加新的行为时,可以使用带默认值的关键字参数,以便于原有的调用代码保持兼容,关键字参数提供了一种扩充函数参数的有效方式。
  6. 可先的关键字参数,总是应该以关键字形式来指定,而不应该以位置参数的形式来指定。
1
2
3
4
5
6
7
def remainder(number, divisor):
return number % divisor

remainder(20, 7)
remainder(20, divisor=7)
remainder(number=20, divisor=7)
remainder(divisor=7, number=20)
6
1
remainder(number=20, 7)
  File "<ipython-input-15-fa871e527313>", line 1
    remainder(number=20, 7)
                        ^
SyntaxError: positional argument follows keyword argument

用None和文档字符串来描述具有动态默认值的参数

  1. 参数的默认值,只会在程序加载模块并读到本函数的定义时评估一次,对于{}[]等动态的值,者可能导致奇怪的行为。
  2. 对于以动态值作为实际默认值的关键字参数来说,应该把形式上的默认值写为None,并在函数的文档字符串里面描述该默认值所对应的实际行为。

例如打印日志时想要加上打印的时间。
先看如下的写法:

1
2
3
4
from datetime import datetime
import time
def log(message, when=datetime.now()):
print('%s: %s' % (when, message))
1
2
3
log('Hi there!')
time.sleep(1)
log('Hi again!')
2018-05-30 11:37:23.481681: Hi there!
2018-05-30 11:37:23.481681: Hi again!

从上面打印的信息来看,时间戳是相同的,这是因为datetime.now仅仅被执行了一次,也及时它只在函数定义的时候执行了一次。

参数的默认值,仅仅在模块被加载进来的时候执行一次,而这通常发生在程序开始运行的时候。当模块已经加载完这段代码后,参数的默认值就不会被改变了。

在Python中如果想真正实现动态默认值,习惯上把默认值设为None,并且在文档字符串中记录详细的行为和使用方法。当代码发现一个值为None的参数的时候,就可以为其分配默认值了。

修改打印日志的函数,产生不同的时间戳,代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def log(message, when=None):
"""
Log a message with a timestamp.

Args:
message: Message to print
when: datetime of when the message occurred.
Default to the present time.
"""
when = datetime.now() if when is None else when
print("%s: %s" %(when, message))

log("hi there!")
time.sleep(1)
log('hi again!')
2018-05-30 11:43:57.224250: hi there!
2018-05-30 11:43:58.224661: hi again!

如果参数的实际默认值是可变类型(mutable),那就一定要记得用None作为形式上的默认值。

例如实现一个功能:加载一个被编码为JSON的数据值,如果解码的时候失败了,你想默认返回一个空字典。代码如下:

1
2
3
4
5
6
import json
def decode(data, default={}):
try:
return json.loads(data)
except ValueError:
return default
1
2
3
4
5
6
foo = decode('bad data')
foo['stuff'] = 5
bar = decode('also bad')
bar['meep'] = 1
print(foo)
print(bar)
{'stuff': 5, 'meep': 1}
{'stuff': 5, 'meep': 1}

我们本以为foobar会表示两份不同的字典,每份字典都有一个键值对,但以上代码的结果却是,修改一个的话很明显也会改变另一个。错误的根本原因是:foobar其实都等同于卸载default参数默认值中的那个字典,它们表示的都是同一个字典对象。

由于default参数的默认值只在模块加载时执行一次,所以凡是以默认的空字典调用这个函数的代码,都将共享一份字典

1
assert foo is bar

解决办法就是对关键字参数设置默认值None并且记录在该函数的说明文档中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def decode(data, default=None):
"""Load JSON data from string.

Args:
data: JSON data to be decoded.
default: Value to return if decoding fails.
Defaults to an empty dictionary.
"""

if default is None:
default = {}
try:
return json.loads(data)
except ValueError:
return default

foo = decode('bad data')
foo['stuff'] = 5
bar = decode('also bad')
bar['meep'] = 1
print('Foo:', foo)
print('Bar:', bar)
Foo: {'stuff': 5}
Bar: {'meep': 1}

用只能以关键字形式指定的参数来确保代码明确清晰

对于函数的某些关键变量,希望调用者明确改参数的值,使代码清晰,减少逻辑错误。此时,可以使用命名关键字参数。示例如下:

1
2
3
def safe_division_c(number, division, *, ignore_overflow=False, 
c=False):
pass

ignore_overflowignore_overflow只能通过关键字参数赋值的形式被使用,而不是通过位置参数赋值的方式,也不能像默然参数那样省略。

类与继承

尽量用辅助类来维护程序的状态,而不是字典和元组

  1. 不要使用包含其他字典的字典,也不要使用过长的元组(元组里的元素超过两个,就应该考虑用其他办法实现)。
  2. 如果容器中包含简单又不可变的数据,那么可以先使用namedtuple来表示,待稍后有需要时,再修改为完整的类。
  3. 保存内部状态的字典如果变得复杂,那就应该吧这些代码拆解为多个辅助类。

namedtuple是继承自tuple的子类。namedtuple创建一个和tuple类似的对象,而且对象拥有可访问的属性。由于这种具名元组的属性都带有名称,所以当需求发生变化,以致要给简单的数据容器添加新的行为时,很容易就能从namedtuple迁移到自己定义的类。

尽管namedtuple在很多场合很有用,但它在有些场合使用反而不好。

  • namedtuple类无法指定各参数的默认值。对于可选属性比较多的数据来说,namedtuple用起来很不方便。
  • namedtuple实例的各项属性,依然可以通过下标及迭代访问。这可能导致其他人以不符合设计者意图的方式使用这些元组,从而使以后很难迁移成真正的类。
1
2
3
4
5
6
7
from collections import namedtuple

User = namedtuple('User', ['name', 'sex', 'age'])
user1 = User(name='kongxx', sex='male', age=21)
print('age:', user1.age)
print('sex:', user1.sex)
print('user1[0]:',user1[0])
age: 21
sex: male
user1[0]: kongxx

简单的接口应该接受函数,而不是类的实例

  1. 对于连接各种Python组件的简单接口来说,通常应该给其直接传入函数,而不是先定义某个类,然后再传入该类的实例。
  2. Python中的函数和方法都可以像一级对象那样引用,因此,它们和其他类型的对象一样,也能够放在表达式里。
  3. 通过名为__call__的特殊方法,可以使类的实例能够像普通的Python函数那样得到调用。
  4. 如果要用函数来保存状态,那就应该定义新的类,并令其实现__call__方法,而不要定义带状态的闭包。__call__方法强烈地暗示了该类的用途,它告诉我们,这个类的功能就相当于一个带有状态的闭包。

例如:Python内置的defaultdict类,这个数据结构允许调用者提供一个函数,在查询本字典时,如果字典中没有待查询的键时,此函数返回一个默认值。而且为字典中的这个缺省键来返回一个默认值。提供像这样的函数接口,可以使得API更容易被构建和测试,因为它能够把附带的效果和确定的行为分开。

例如:现在我们要给defaultdict传入一个产生默认值的挂钩函数,并令其统计出该字典一共遇到了多少个缺失的键。

第一种方式:带状态的闭包。

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
from collections import defaultdict

def increment_with_report(current, increments):
added_count = 0

def missing():
nonlocal added_count # 状态闭包
added_count += 1
return 0

result = defaultdict(missing, current)
for key, amount in increments:
result[key] += amount

return result, added_count

current = {'green': 12, 'blue': 3}
increments = [
('red', 5),
('blue', 17),
('orange', 9)
]

result, count = increment_with_report(current, increments)
assert count == 2

尽管defaultdict并不知道missing挂钩函数里保存了状态,但是运行上面的代码,依旧会产生预期的结果。

但上述方法读起来不够直观。

第二种方法:定义一个小的类,把想追踪的状态信息封装起来。

1
2
3
4
5
6
7
8
9
10
11
12
13
class CountMissing(object):
def __init__(self):
self.added = 0

def missing(self):
self.added += 1
return 0

counter = CountMissing()
result = defaultdict(counter.missing, current)
for key, amount in increments:
result[key] += amount
assert counter.added == 2

这种方法却是比increment_with_report函数更加清晰,但是,单看这个类,依然不太容易理解CounMissing的意图,CountMissing对象由谁构造?missing方法谁来调用?该类以后是否需要添加新的方法?直到你看到了使用它的defaultdict函数,你才会明白这些问题。

为了厘清这些问题,我们可以在Python代码中定义__call__这个特殊的方法。该方法使对象能够像函数一样被调用。此外,如果把这样的实例传给内置的callable函数,那么callable会返回True

第三种方法:类中实现__call__方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class BetterCountMissing(object):
def __init__(self):
self.added = 0

def __call__(self):
self.added += 1
return 0

counter = BetterCountMissing()
assert callable(counter)
result = defaultdict(counter, current)
for key, amount in increments:
result[key] += amount
assert counter.added == 2

用@classmethod形式的多态去通用地构建对象

  1. Python的每个类只能有一个构造器,也就是一个__init__方法。
  2. 使用@classmethod可以用一种与构造器相仿的方式构造类的对象。

参考链接:
https://stackoverflow.com/questions/12179271/meaning-of-classmethod-and-staticmethod-for-beginner
例子如下。首先我们有一个处理时间的类:

1
2
3
4
5
class Date(object):
def __init__(self, day=0, month=0, year=0):
self.day = day
self.month = month
self.year = year

如果我们要通过字符串创建Date实例,此时我们要做如下操作:

  1. str的日期转为int
  2. 通过int的日期构建Date
1
2
day, month, year = map(int, '11-09-2012'.split('-'))  
date1 = Date(day, month, year)

如果我们经常要使用字符串创建Date实例,很显然如果能实现重载就更加方便,C++有重载的方法,但Python没有重载,每个类只有一个构造器,只有一个__init__方法,因此@classmethod方法应运而生。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Date(object):
def __init__(self, day=0, month=0, year=0):
self.day = day
self.month = month
self.year = year

@classmethod
def from_string(cls, date_as_string):
day, month, year = map(int, date_as_string.split('-'))
date1 = cls(day, month, year)
return date1

date2 = Date.from_string('11-09-2012')

cls表示这个类本身

@staticmethod@classmethod的区别是不构建类的实例,也不访问、依赖和改变类,只是一个函数。和普通的非class的method作用是一样的,只不过是命名空间是在类里面。一般使用场景就是和类相关的操作。

加入我们要验证日期的合法性,我们可以使用@staticmethod,如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Date(object):

def __init__(self, day=0, month=0, year=0):
self.day = day
self.month = month
self.year = year

@classmethod
def from_string(cls, date_as_string):
day, month, year = map(int, date_as_string.split('-'))
date1 = cls(day, month, year)
return date1

@staticmethod
def is_date_valid(date_as_string):
day, month, year = map(int, date_as_string.split('-'))
return day <= 31 and month <= 12 and year <= 3999

date2 = Date.from_string('11-09-2012')
is_date = Date.is_date_valid('11-09-2012')

super初始化父类

  1. Python采用MRO解决超类初始化次序以及菱形继承问题。
  2. 总是应该使用super来初始化父类。

初始化父类的传统方式是在子类中直接调用父类的__init__方法。

1
2
3
4
5
6
7
class MyBaseClass(object):
def __init__(self, value):
self.value = value

class MyChildClass(MyBaseClass):
def __init__(self):
MyBaseClass.__init__(self, 5)

这种方法对于单继承体系是可行的,但是如果子类收到多继承的影响,由于调用父类初始化方法的顺序并不固定,可能产生无法预知的行为(尤其在菱形继承体系下)。

super函数定义了方法解析顺序(MRO)以解决这一问题,MRO以标准的流程来安排超类之间的初始化顺序:深度优先、从左至右,也保证了菱形继承中超类的初始化方法只执行一次。这个MRO的顺序可以通过名为mro的类方法类查询。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from pprint import pprint

# Python 2
class TimesFiveCorrect(MyBaseClass):
def __init__(self, value):
super(TimesFiveCorrect, self).__init__(value)
self.value *= 5


class PlusTwoCorrect(MyBaseClass):
def __init__(self, value):
super(PlusTwoCorrect, self).__init__(value)
self.value += 2


class GoodWay(TimesFiveCorrect, PlusTwoCorrect):
def __init__(self, value):
super(GoodWay, self).__init__(value)

foo = GoodWay(5)
print("Should be 5 * (5 + 2) = 35 and is " , foo.value)
pprint(GoodWay.mro())
Should be 5 * (5 + 2) = 35 and is  35
[<class '__main__.GoodWay'>,
 <class '__main__.TimesFiveCorrect'>,
 <class '__main__.PlusTwoCorrect'>,
 <class '__main__.MyBaseClass'>,
 <class 'object'>]

调用GoodWay(5)的时候,它会调用TimesFiveCorrect.__init__,而TimesFiveCorrect.__init__又会调用PlusTwoCorrect.__init__PlusTwoCorrect.__init__会调用MyBaseClass.__init__。到达钻石顶部之后,所有的初始化过程会按照相反的顺序进行。

在Python3中将super方法写法简化了,具体示例如下。

1
2
3
class Implicit(MyBaseClass):
def __init__(self, value):
super().__init__(value * 2)

只在使用Mix-in组件制作工具类时进行多重继承

  1. 尽量避免多继承,能用mix-in组件实现的效果,就不要用多继承来做。
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
class ToDictMixin(object):
def to_dict(self):
return self._traverse_dict(self.__dict__)

def _traverse_dict(self, instance_dict):
output = {}
for key, value in instance_dict.items():
output[key] = self._traverse(key, value)
return output

def _traverse(self, key, value):
if isinstance(value, ToDictMixin):
return value.to_dict()
elif isinstance(value, dict):
return self._traverse_dict(value)
elif isinstance(value, list):
return [self._traverse(key, i) for i in value]
elif hasattr(value, '__dict__'):
return self._traverse_dict(value.__dict__)
else:
return value


class BinaryTree(ToDictMixin):
def __init__(self, value, left=None, right=None):
self.value = value
self.left = left
self.right = right

# 这下把大量的Python对象转换到一个字典中变得容易多了。
tree = BinaryTree(10, left=BinaryTree(7, right=BinaryTree(9)),
right=BinaryTree(13, left=BinaryTree(11)))
print(tree.to_dict())
{'value': 10, 'left': {'value': 7, 'left': None, 'right': {'value': 9, 'left': None, 'right': None}}, 'right': {'value': 13, 'left': {'value': 11, 'left': None, 'right': None}, 'right': None}}

多用public属性,少用private属性

  1. 对Python来说,其属性的可见度只有两种,publicprivate。以两个下划线__开头的属性,是private字段,本类的方法可以访问它们,类外直接访问会引发异常。
  2. 子类无法访问父类的private字段。
  3. Python会对私有属性做一些简单的变换,以保证private字段的私密性,例如:在MyObject中定义self.__privated_file,Python会以变换后的_MyObject__privated_file保存,以保证私密性。所以如果我们以这种方式在类外访问私有属性,也可以访问到。Python为什么不从语法上对于私有属性严格保证呢?最简单的原因就是”We are all consenting adults here”。Python程序员相信开放要比封闭好。
  4. 为了尽量减少无意间访问内部属性所带来的意外,Python程序员应该遵守《Python风格指南》中建议,用一种习惯性的命名方式来表示这种字段:以单下划线开头的字段应该视为protected字段,本类之外的那些代码在使用这种字段的时候要多加小心。
  5. 由于在开发中,以后的代码可能需要从这个类上继承子类,并在子类中添加新的行为,假设超类使用了private属性,那么在覆写子类的时候就会遇到麻烦。虽然此时仍然可以通过第3条中所述的方式访问超类私有属性,但如果继承体系发生变化,private字段很可能失效,导致子类出现错误。
  6. 一般来说,宁可叫子类更多地访问超类的protected属性,也别把这些属性设为private,我们应该在文档中说明每个protected字段的含义,解释哪些字段是可供子类使用的内部api,哪些是不应该完全触碰的数据。

继承collections.abc以实现自定义的容器类

  1. 如果要定制一个实现list等功能的简单子类,那就可以直接从Python的容器类型(如listdict)继承。
  2. 想正确实现自定义的容器类型,可能需要编写大量的特殊方法。
  3. 编写自制的容器类型时,可以从collections.abc模块的抽象基类中继承,那些基类能够确保我们的子类具备适当的接口及行为。

如果要设计比较简单的序列,我们自然会想到继承Python的内置List类型,如下我们要定义一种自定义的列表类型,并提供统计各元素出现频率的功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class FrequencyList(list):
def __init__(self, members):
super().__init__(members)

def frequency(self):
counts = {}
for item in self:
counts.setdefault(item, 0)
counts[item] += 1
return counts

foo = FrequencyList(['a', 'b', 'c', 'b', 'c', 'f'])
print('Length is: ', len(foo))
foo.pop()
print('after pop: ', repr(foo))
print(foo.frequency())
Length is:  6
after pop:  ['a', 'b', 'c', 'b', 'c']
{'a': 1, 'b': 2, 'c': 2}

现在要编写一个本身不属于list子类,但是可以像list一样通过下标来访问,并且可以使用len来获取长度。

  1. Python用下标访问序列中的元素时,会把访问代码转译为:xx.__getitem__(index),所以我们要想一个类可以用下标访问,只需在类中实现特殊方法__getitem__方法即可。
  2. 想要是内置的len函数正常工作,就必须在自己定制的序列类型中实现一个名叫__len__的特殊方法……

例如要令表示二叉树节点的类,也能通过下标访问节点,并且用len能够得到长度信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class BinaryNode(object):
def __init__(self, value, left=None, right=None):
self.value = value
self.left = left
self.right = right

class IndexableNode(BinaryNode):
def _search(self, count, index):
pass

def __getitem__(self, index):
found, _ = self._search(0, index)
if not found:
raise IndentationError('Index out of range')
return found.value

def __len__(self):
_, count = self._search(0, None)
return count

如果还要实现其他功能,那会是一件很麻烦的事。为了避免这些麻烦,我们可以使用内置的collections.abc模块,该模块定义了一系列的抽象基类,它们提供了每一种容器所应当具备的常用方法。从这样的基类继承子类之后,如果忘记实现某个方法,那么collections.abc模块就会指出这个错误。如果子类实现了抽象基类所要求的每个方法,那么基类就会自动提供剩下的那些方法。

1
2
3
4
5
6
from collections.abc import Sequence

class BadType(Sequence):
pass

foo = BadType()
---------------------------------------------------------------------------

TypeError                                 Traceback (most recent call last)

<ipython-input-10-997f61651b50> in <module>()
      4     pass
      5 
----> 6 foo = BadType()


TypeError: Can't instantiate abstract class BadType with abstract methods __getitem__, __len__
1
2
3
4
5
6
7
8
9
10
11
12
13
class IndexableNode(BinaryNode, Sequence):
def _search(self, count, index):
pass

def __getitem__(self, index):
found, _ = self._search(0, index)
if not found:
raise IndentationError('Index out of range')
return found.value

def __len__(self):
_, count = self._search(0, None)
return count
持续技术分享,您的支持将鼓励我继续创作!