《Python 工匠:善用变量来改善代码质量》摘录

在GitHub上看到一个内容很赞的Python读物《Python 工匠:善用变量来改善代码质量》,通读一遍学到不少新知识,因此决定把这些有用的Tricks记下来,方便日后查阅。

所有内容摘自https://github.com/piglei/one-python-craftsman,作者piglei

Table of Contents

1 当Class不包含方法时,可以用NamedTuple代替

1
2
3
4
5
6
7
8
9
10
11
from collections import namedtuple

Address = namedtuple("Address", ['country', 'province', 'city'])

addr = Address(
country='China',
province='Beijing',
city='Beijing'
)

print(addr.country)

2 自定义对象的True/False

在Python内,对象的布尔值由__bool__和__len__返回,如果未定义__bool__则返回__len__ != 0的值。

因此可以通过定义__bool__和__len__来直接定义对象的True/False。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class UserCollection:

def __init__(self, users):
self._users = users

def __len__(self):
return len(self._users)


users = UserCollection([piglei, raymond])

# 定义了 __len__ 方法后,UserCollection 对象本身就可以被用于布尔判断了
if users:
print("There's some users in collection!")

3 在条件判断中使用 all() / any()

all()any() 两个函数非常适合在条件判断中使用。这两个函数接受一个可迭代对象,返回一个布尔值,其中:

  • all(seq):仅当 seq 中所有对象都为布尔真时返回 True,否则返回 False
  • any(seq):只要 seq 中任何一个对象为布尔真就返回 True,否则返回 False

假如我们有下面这段代码:

1
2
3
4
5
6
7
8
9
10
def all_numbers_gt_10(numbers):
"""仅当序列中所有数字大于 10 时,返回 True
"""
if not numbers:
return False

for n in numbers:
if n <= 10:
return False
return True

如果使用 all() 内建函数,再配合一个简单的生成器表达式,上面的代码可以写成这样:

1
2
def all_numbers_gt_10_2(numbers):
return bool(numbers) and all(n > 10 for n in numbers)

4 使用 try/while/for 中 else 分支

让我们看看这个函数:

1
2
3
4
5
6
7
8
9
10
11
12
def do_stuff():
first_thing_successed = False
try:
do_the_first_thing()
first_thing_successed = True
except Exception as e:
print("Error while calling do_some_thing")
return

# 仅当 first_thing 成功完成时,做第二件事
if first_thing_successed:
return do_the_second_thing()

在函数 do_stuff 中,我们希望只有当 do_the_first_thing() 成功调用后(也就是不抛出任何异常),才继续做第二个函数调用。为了做到这一点,我们需要定义一个额外的变量 first_thing_successed 来作为标记。

其实,我们可以用更简单的方法达到同样的效果:

1
2
3
4
5
6
7
8
def do_stuff():
try:
do_the_first_thing()
except Exception as e:
print("Error while calling do_some_thing")
return
else:
return do_the_second_thing()

try 语句块最后追加上 else 分支后,分支下的do_the_second_thing() 便只会在 try 下面的所有语句正常执行(也就是没有异常,没有 return、break 等)完成后执行

类似的,Python 里的 for/while 循环也支持添加 else 分支,它们表示:当循环使用的迭代对象被正常耗尽、或 while 循环使用的条件变量变为 False 后才执行 else 分支下的代码。

5 以r开头的内建字符串函数

Python 的字符串有着非常多实用的内建方法,最常用的有 .strip().split() 等。这些内建方法里的大多数,处理起来的顺序都是从左往右。但是其中也包含了部分以 r 打头的从右至左处理的镜像方法。在处理特定逻辑时,使用它们可以让你事半功倍。

假设我们需要解析一些访问日志,日志格式为:”{user_agent}” {content_length}:

1
>>> log_line = '"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36" 47632'

如果使用 .split() 将日志拆分为 (user_agent, content_length),我们需要这么写:

1
2
3
>>> l = log_line.split()
>>> " ".join(l[:-1]), l[-1]
('"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36"', '47632')

但是如果使用 .rsplit() 的话,处理逻辑就更直接了:

1
2
>>> log_line.rsplit(None, 1)
['"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36"', '47632']

6 无穷大float("inf")和负无穷大float("-inf")

7 当需要频繁在list前端插入元素时,考虑用collections.deque

8 用collcetions.defaultdict统计次数

1
2
3
4
5
6
7
8
from collections import defaultdict


def counter_by_collections(l):
result = defaultdict(int)
for key in l:
result[key] += 1
return result

9 字典操作

  • 如果移除字典成员,不关心是否存在:
    • 调用 pop 函数时设置默认值,比如 dict.pop(key, None)
  • 在字典获取成员时指定默认值:dict.get(key, default_value)
  • 对列表进行不存在的切片访问不会抛出 IndexError 异常:["foo"][100:200]

10 next()函数

next() 是一个非常实用的内建函数,它接收一个迭代器作为参数,然后返回该迭代器的下一个元素。使用它配合生成器表达式,可以高效的实现“从列表中查找第一个满足条件的成员”之类的需求。

1
2
3
4
numbers = [3, 7, 8, 2, 21]
# 获取并立即返回列表里的第一个偶数
print(next(i for i in numbers if i % 2 == 0))
# OUTPUT: 8

11 使用partial构造新函数

假设这么一个场景,在你的代码里有一个参数很多的函数 A,适用性很强。而另一个函数 B 则是完全通过调用 A 来完成工作,是一种类似快捷方式的存在。

比方在这个例子里, double 函数就是完全通过 multiply 来完成计算的:

1
2
3
4
5
6
7
def multiply(x, y):
return x * y


def double(value):
# 返回另一个函数调用结果
return multiply(2, value)

对于上面这种场景,我们可以使用 functools 模块里的 partial() 函数来简化它。

partial(func, *args, **kwargs) 基于传入的函数与可变(位置/关键字)参数来构造一个新函数。所有对新函数的调用,都会在合并了当前调用参数与构造参数后,代理给原始函数处理。

利用 partial 函数,上面的 double 函数定义可以被修改为单行表达式,更简洁也更直接。

1
2
3
import functools

double = functools.partial(multiply, 2)

建议阅读:partial 函数官方文档

12 使用 product扁平化多层嵌套循环

虽然我们都知道“扁平的代码比嵌套的好”。但有时针对某类需求,似乎一定得写多层嵌套循环才行。比如下面这段:

1
2
3
4
5
6
7
8
def find_twelve(num_list1, num_list2, num_list3):
"""从 3 个数字列表中,寻找是否存在和为 12 的 3 个数
"""
for num1 in num_list1:
for num2 in num_list2:
for num3 in num_list3:
if num1 + num2 + num3 == 12:
return num1, num2, num3

对于这种需要嵌套遍历多个对象的多层循环代码,我们可以使用 product() 函数来优化它。product() 可以接收多个可迭代对象,然后根据它们的笛卡尔积不断生成结果。

1
2
3
4
5
6
7
from itertools import product


def find_twelve_v2(num_list1, num_list2, num_list3):
for num1, num2, num3 in product(num_list1, num_list2, num_list3):
if num1 + num2 + num3 == 12:
return num1, num2, num3

相比之前的代码,使用 product() 的函数只用了一层 for 循环就完成了任务,代码变得更精炼了。

13 使用 islice实现循环内隔行处理

有一份包含 Reddit 帖子标题的外部数据文件,里面的内容格式是这样的:

1
2
3
4
5
6
7
python-guide: Python best practices guidebook, written for humans.
---
Python 2 Death Clock
---
Run any Python Script with an Alexa Voice Command
---
<... ...>

可能是为了美观,在这份文件里的每两个标题之间,都有一个 "---" 分隔符。现在,我们需要获取文件里所有的标题列表,所以在遍历文件内容的过程中,必须跳过这些无意义的分隔符。

参考之前对 enumerate() 函数的了解,我们可以通过在循环内加一段基于当前循环序号的 if 判断来做到这一点:

1
2
3
4
5
6
7
8
def parse_titles(filename):
"""从隔行数据文件中读取 reddit 主题名称
"""
with open(filename, 'r') as fp:
for i, line in enumerate(fp):
# 跳过无意义的 '---' 分隔符
if i % 2 == 0:
yield line.strip()

但对于这类在循环内进行隔行处理的需求来说,如果使用 itertools 里的 islice() 函数修饰被循环对象,可以让循环体代码变得更简单直接。

islice(seq, start, end, step) 函数和数组切片操作( list[start:stop:step] )有着几乎一模一样的参数。如果需要在循环内部进行隔行处理的话,只要设置第三个递进步长参数 step 值为 2 即可(默认为 1)

1
2
3
4
5
6
7
from itertools import islice

def parse_titles_v2(filename):
with open(filename, 'r') as fp:
# 设置 step=2,跳过无意义的 '---' 分隔符
for line in islice(fp, 0, None, 2):
yield line.strip()

14 使用 takewhile 替代 break 语句

有时,我们需要在每次循环开始时,判断循环是否需要提前结束。比如下面这样:

1
2
3
4
5
6
for user in users:
# 当第一个不合格的用户出现后,不再进行后面的处理
if not is_qualified(user):
break

# 进行处理 ... ...

对于这类需要提前中断的循环,我们可以使用 takewhile() 函数来简化它。takewhile(predicate, iterable) 会在迭代 iterable 的过程中不断使用当前对象作为参数调用 predicate 函数并测试返回结果,如果函数返回值为真,则生成当前对象,循环继续。否则立即中断当前循环。

使用 takewhile 的代码样例:

1
2
3
4
from itertools import takewhile

for user in takewhile(is_qualified, users):
# 进行处理 ... ...

itertools 里面还有一些其他有意思的工具函数,他们都可以用来和循环搭配使用,比如使用 chain 函数扁平化双层嵌套循环、使用 zip_longest 函数一次同时循环多个对象等等。

itertools — Functions creating iterators for efficient looping

Infinite iterators:

Iterator Arguments Results Example
count() start, [step] start, start+step, start+2*step, … count(10) --> 10 11 12 13 14 ...
cycle() p p0, p1, … plast, p0, p1, … cycle('ABCD') --> A B C D A B C D ...
repeat() elem [,n] elem, elem, elem, … endlessly or up to n times repeat(10, 3) --> 10 10 10

Iterators terminating on the shortest input sequence:

Iterator Arguments Results Example
accumulate() p [,func] p0, p0+p1, p0+p1+p2, … accumulate([1,2,3,4,5]) --> 1 3 6 10 15
chain() p, q, … p0, p1, … plast, q0, q1, … chain('ABC', 'DEF') --> A B C D E F
chain.from_iterable() iterable p0, p1, … plast, q0, q1, … chain.from_iterable(['ABC', 'DEF']) --> A B C D E F
compress() data, selectors (d[0] if s[0]), (d[1] if s[1]), … compress('ABCDEF', [1,0,1,0,1,1]) --> A C E F
dropwhile() pred, seq seq[n], seq[n+1], starting when pred fails dropwhile(lambda x: x<5, [1,4,6,4,1]) --> 6 4 1
filterfalse() pred, seq elements of seq where pred(elem) is false filterfalse(lambda x: x%2, range(10)) --> 0 2 4 6 8
groupby() iterable[, key] sub-iterators grouped by value of key(v)
islice() seq, [start,] stop [, step] elements from seq[start:stop:step] islice('ABCDEFG', 2, None) --> C D E F G
starmap() func, seq func(seq[0]), func(seq[1]), … starmap(pow, [(2,5), (3,2), (10,3)]) --> 32 9 1000
takewhile() pred, seq seq[0], seq[1], until pred fails takewhile(lambda x: x<5, [1,4,6,4,1]) --> 1 4
tee() it, n it1, it2, … itn splits one iterator into n
zip_longest() p, q, … (p[0], q[0]), (p[1], q[1]), … zip_longest('ABCD', 'xy', fillvalue='-') --> Ax By C- D-

Combinatoric iterators:

Iterator Arguments Results
product() p, q, … [repeat=1] cartesian product, equivalent to a nested for-loop
permutations() p[, r] r-length tuples, all possible orderings, no repeated elements
combinations() p, r r-length tuples, in sorted order, no repeated elements
combinations_with_replacement() p, r r-length tuples, in sorted order, with repeated elements
product('ABCD', repeat=2) AA AB AC AD BA BB BC BD CA CB CC CD DA DB DC DD
permutations('ABCD', 2) AB AC AD BA BC BD CA CB CD DA DB DC
combinations('ABCD', 2) AB AC AD BC BD CD
combinations_with_replacement('ABCD', 2) AA AB AC AD BB BC BD CC CD DD

15 尝试用类来实现装饰器

绝大多数装饰器都是基于函数和 闭包) 实现的,但这并非制造装饰器的唯一方式。事实上,Python 对某个对象是否能通过装饰器(@decorator)形式使用只有一个要求:decorator 必须是一个“可被调用(callable)的对象

1
2
3
4
5
6
7
# 使用 callable 可以检测某个对象是否“可被调用”
>>> def foo(): pass
...
>>> type(foo)
<class 'function'>
>>> callable(foo)
True

函数自然是“可被调用”的对象。但除了函数外,我们也可以让任何一个类(class)变得“可被调用”(callable)。办法很简单,只要自定义类的 __call__ 魔法方法即可。

1
2
3
4
5
6
7
8
9
10
11
class Foo:
def __call__(self):
print("Hello, __call___")

foo = Foo()

# OUTPUT: True
print(callable(foo))
# 调用 foo 实例
# OUTPUT: Hello, __call__
foo()

基于这个特性,我们可以很方便的使用类来实现装饰器。

下面这段代码,会定义一个名为 @delay(duration) 的装饰器,使用它装饰过的函数在每次执行前,都会等待额外的 duration 秒。同时,我们也希望为用户提供无需等待马上执行的 eager_call 接口。

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
import time
import functools


class DelayFunc:
def __init__(self, duration, func):
self.duration = duration
self.func = func

def __call__(self, *args, **kwargs):
print(f'Wait for {self.duration} seconds...')
time.sleep(self.duration)
return self.func(*args, **kwargs)

def eager_call(self, *args, **kwargs):
print('Call without delay')
return self.func(*args, **kwargs)


def delay(duration):
"""装饰器:推迟某个函数的执行。同时提供 .eager_call 方法立即执行
"""
# 此处为了避免定义额外函数,直接使用 functools.partial 帮助构造
# DelayFunc 实例
return functools.partial(DelayFunc, duration)

如何使用装饰器的样例代码:

1
2
3
4
5
6
7
8
9
@delay(duration=2)
def add(a, b):
return a + b


# 这次调用将会延迟 2 秒
add(1, 2)
# 这次调用将会立即执行
add.eager_call(1, 2)

@delay(duration) 就是一个基于类来实现的装饰器。当然,如果你非常熟悉 Python 里的函数和闭包,上面的 delay 装饰器其实也完全可以只用函数来实现。所以,为什么我们要用类来做这件事呢?

与纯函数相比,我觉得使用类实现的装饰器在特定场景下有几个优势:

  • 实现有状态的装饰器时,操作类属性比操作闭包内变量更符合直觉、不易出错
  • 实现为函数扩充接口的装饰器时,使用类包装函数,比直接为函数对象追加属性更易于维护
  • 更容易实现一个同时兼容装饰器与上下文管理器协议的对象(参考 unitest.mock.patch

16 使用 wrapt 模块编写更扁平的装饰器

在写装饰器的过程中,你有没有碰到过什么不爽的事情?不管你有没有,反正我有。我经常在写代码的时候,被下面两件事情搞得特别难受:

  1. 实现带参数的装饰器时,层层嵌套的函数代码特别难写、难读
  2. 因为函数和类方法的不同,为前者写的装饰器经常没法直接套用在后者上

比如,在下面的例子里,我实现了一个生成随机数并注入为函数参数的装饰器。

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


def provide_number(min_num, max_num):
"""装饰器:随机生成一个在 [min_num, max_num] 范围的整数,追加为函数的第一个位置参数
"""
def wrapper(func):
def decorated(*args, **kwargs):
num = random.randint(min_num, max_num)
# 将 num 作为第一个参数追加后调用函数
return func(num, *args, **kwargs)
return decorated
return wrapper



@provide_number(1, 100)
def print_random_number(num):
print(num)

# 输出 1-100 的随机整数
# OUTPUT: 72
print_random_number()

@provide_number 装饰器功能看上去很不错,但它有着我在前面提到的两个问题:嵌套层级深、无法在类方法上使用。如果直接用它去装饰类方法,会出现下面的情况:

1
2
3
4
5
6
7
class Foo:
@provide_number(1, 100)
def print_random_number(self, num):
print(num)

# OUTPUT: <__main__.Foo object at 0x104047278>
Foo().print_random_number()

Foo 类实例中的 print_random_number 方法将会输出类实例 self ,而不是我们期望的随机数 num

之所以会出现这个结果,是因为类方法(method)和函数(function)二者在工作机制上有着细微不同。如果要修复这个问题,provider_number 装饰器在修改类方法的位置参数时,必须聪明的跳过藏在 *args 里面的类实例 self 变量,才能正确的将 num 作为第一个参数注入。

这时,就应该是 wrapt 模块闪亮登场的时候了。wrapt 模块是一个专门帮助你编写装饰器的工具库。利用它,我们可以非常方便的改造 provide_number 装饰器,完美解决“嵌套层级深”“无法通用”两个问题,

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
import wrapt

def provide_number(min_num, max_num):
@wrapt.decorator
def wrapper(wrapped, instance, args, kwargs):
# 参数含义:
#
# - wrapped:被装饰的函数或类方法
# - instance:
# - 如果被装饰者为普通类方法,该值为类实例
# - 如果被装饰者为 classmethod 类方法,该值为类
# - 如果被装饰者为类/函数/静态方法,该值为 None
#
# - args:调用时的位置参数(注意没有 * 符号)
# - kwargs:调用时的关键字参数(注意没有 ** 符号)
#
num = random.randint(min_num, max_num)
# 无需关注 wrapped 是类方法或普通函数,直接在头部追加参数
args = (num,) + args
return wrapped(*args, **kwargs)
return wrapper

<... 应用装饰器部分代码省略 ...>

# OUTPUT: 48
Foo().print_random_number()

使用 wrapt 模块编写的装饰器,相比原来拥有下面这些优势:

  • 嵌套层级少:使用 `@wrapt.decorator` 可以将两层嵌套减少为一层
  • 更简单:处理位置与关键字参数时,可以忽略类实例等特殊情况
  • 更灵活:针对 instance 值进行条件判断后,更容易让装饰器变得通用

17 使用 pathlib 模块改写代码

为了让文件处理变得更简单,Python 在 3.4 版本引入了一个新的标准库模块:pathlib。它基于面向对象思想设计,封装了非常多与文件操作相关的功能。如果使用它来改写上面的代码,结果会大不相同。

使用 pathlib 模块后的代码:

1
2
3
4
5
from pathlib import Path

def unify_ext_with_pathlib(path):
for fpath in Path(path).glob('*.txt'):
fpath.rename(fpath.with_suffix('.csv'))

和旧代码相比,新函数只需要两行代码就完成了工作。而这两行代码主要做了这么几件事:

  1. 首先使用 Path(path) 将字符串路径转换为 Path 对象
  2. 调用 .glob(‘*.txt’) 对路径下所有内容进行模式匹配并以生成器方式返回,结果仍然是 Path 对象,所以我们可以接着做后面的操作
  3. 使用 .with_suffix(‘.csv’) 直接获取使用新后缀名的文件全路径
  4. 调用 .rename(target) 完成重命名

相比 osos.path,引入 pathlib 模块后的代码明显更精简,也更有整体统一感。所有文件相关的操作都是一站式完成。

其他用法

除此之外,pathlib 模块还提供了很多有趣的用法。比如使用 / 运算符来组合文件路径:

1
2
3
4
5
6
7
8
9
# 😑 旧朋友:使用 os.path 模块
>>> import os.path
>>> os.path.join('/tmp', 'foo.txt')
'/tmp/foo.txt'

# ✨ 新潮流:使用 / 运算符
>>> from pathlib import Path
>>> Path('/tmp') / 'foo.txt'
PosixPath('/tmp/foo.txt')

或者使用 .read_text() 来快速读取文件内容:

1
2
3
4
5
6
7
8
9
10
# 标准做法,使用 with open(...) 打开文件
>>> with open('foo.txt') as file:
... print(file.read())
...
foo

# 使用 pathlib 可以让这件事情变得更简单
>>> from pathlib import Path
>>> print(Path('foo.txt').read_text())
foo

除了我在文章里介绍的这些,pathlib 模块还提供了非常多有用的方法,强烈建议去 官方文档 详细了解一下。

如果上面这些都不足以让你动心,那么我再多给你一个使用 pathlib 的理由:PEP-519 里定义了一个专门用于“文件路径”的新对象协议,这意味着从该 PEP 生效后的 Python 3.6 版本起,pathlib 里的 Path 对象,可以和以前绝大多数只接受字符串路径的标准库函数兼容使用:

1
2
3
4
>>> p = Path('/tmp')
# 可以直接对 Path 类型对象 p 进行 join
>>> os.path.join(p, 'foo.txt')
'/tmp/foo.txt'

所以,无需犹豫,赶紧把 pathlib 模块用起来吧。

Hint: 如果你使用的是更早的 Python 版本,可以尝试安装 pathlib2 模块 。

# Python

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×