【Python开发】EffectivePython:编写高质量Python代码

1,070 阅读16分钟

如下为《Effective Python:编写高质量Python代码的90个有效方法(原书第2版)》的读书笔记,

不保证包含书中提及的所有内容,请读者知悉~

培养 Pythonic 思维

使用f'{var}实现格式化字符串输出

  • 采用%操作符把值填充到C风格的格式字符串时会遇到许多问题,而且这种写法比较烦琐。
  • str.format方法专门用一套迷你语言来定义它的格式说明符,这套语言给我们提供了一些有用的概念,但是在其他方面,这个方法还是存在与C风格的格式字符串一样的多种缺点,所以我们也应该避免使用它。
  • f-string采用新的写法,将值填充到字符串之中,解决了C风格的格式字符串所带来的最大问题。f-string是个简洁而强大的机制,可以直接在格式说明符里嵌入任意Python表达式。

详见:weread.qq.com/web/reader/…

scores = {'Andy': 100, 'Bob': 90, 'Cici': 80}
name = 'Andy'

# 使用%格式化操作符
print('name=%s, score=%d' % (name, scores[name]))

# 使用format函数: 不设置指定位置,按默认顺序
print("name={}, score={}".format(name, scores[name]))

# 使用format函数: 设置指定位置
print("name={0}, score={1}".format(name, scores[name]))

# 使用format函数: 通过字典设置参数
print("name={name}, score={score}".format(**{'name': name, 'score': scores[name]}))

# 【推荐】使用插值格式字符串
print(f"name={name}, score={scores[name]}")


# name=Andy, score=100
# name=Andy, score=100
# name=Andy, score=100
# name=Andy, score=100
# name=Andy, score=100

列表与字典

数据拆分

  • unpacking是一种特殊的Python语法,只需要一行代码,就能把数据结构里面的多个值分别赋给相应的变量。
  • unpacking在Python中应用广泛,凡是可迭代的对象都能拆分,无论它里面还有多少层迭代结构。
  • 尽量通过unpacking来拆解序列之中的数据,而不要通过下标访问,这样可以让代码更简洁、更清晰。

详见:weread.qq.com/web/reader/…

  • 拆分数据结构并把其中的数据赋给变量时,可以用带星号的表达式,将结构中无法与普通变量相匹配的内容捕获到一份列表里。
  • 这种带星号的表达式可以出现在赋值符号左侧的任意位置,它总是会形成一份含有零个或多个值的列表。
  • 在把列表拆解成互相不重叠的多个部分时,这种带星号的unpacking方式比较清晰,而通过下标与切片来实现的方式则很容易出错。

详见:weread.qq.com/web/reader/…

  • 在返回多个值的时候,可以用带星号的表达式接收那些没有被普通变量捕获到的值
  • 函数可以把多个值合起来通过一个元组返回给调用者,以便利用Python的unpacking机制去拆分。
  • 对于函数返回的多个值,可以把普通变量没有捕获到的那些值全都捕获到一个带星号的变量里。
  • 把返回的值拆分到四个或四个以上的变量是很容易出错的,所以最好不要那么写,而是应该通过小类或namedtuple实例完成。

详见:weread.qq.com/web/reader/…

names = ('Andy', 'Bob', 'Cici', 'Daniel')
name1, name2, *others = names
print(f'names = {names}')
print(f'|- name1 = {name1}')
print(f'|- name2 = {name2}')
print(f'|- others = {others}')

# names = ('Andy', 'Bob', 'Cici', 'Daniel')
# |- name1 = Andy
# |- name2 = Bob
# |- others = ['Cici', 'Daniel']

在Python代码里,可以用下划线 来表示用不到的变量

数据拆分 - 实现交换

a = 111; b = 222
print(f'交换前:a={a}, b={b}')
a, b = b, a
print(f'交换后:a={a}, b={b}')

# 交换前:a=111, b=222
# 交换后:a=222, b=111

尽量用enumerate

  • enumerate函数可以用简洁的代码迭代iterator,而且可以指出当前这轮循环的序号。
  • 不要先通过range指定下标的取值范围,然后用下标去访问序列,而是应该直接用enumerate函数迭代。
  • 可以通过enumerate的第二个参数指定起始序号(默认为0)。

详见:weread.qq.com/web/reader/…

列表:

tuple_list = [('AAA', '111'), ('BBB', '222'), ('CCC', '333')]
for index, (key, value) in enumerate(tuple_list):
    print(f'#{index}: {key} = {value}')

# #0: AAA = 111
# #1: BBB = 222
# #2: CCC = 333

字典:

dict = {'AAA': '111', 'BBB': '222', 'CCC': '333'}

print('\nenumerate(dict):')
for index, key in enumerate(dict):
    print(f'#{index}: {key} = {dict.get(key)}')

print('\nenumerate(dict.items()):')
for index, (key, value) in enumerate(dict.items()):
    print(f'#{index}: {key} = {value}')

# enumerate(dict):
# #0: AAA = 111
# #1: BBB = 222
# #2: CCC = 333
# 
# enumerate(dict.items()):
# #0: AAA = 111
# #1: BBB = 222
# #2: CCC = 333

zip 同时遍历多个集合

  • 内置的zip函数可以同时遍历多个迭代器。
  • zip会创建惰性生成器,让它每次只生成一个元组,所以无论输入的数据有多长,它都是一个一个处理的。
  • 如果提供的迭代器的长度不一致,那么只要其中任何一个迭代完毕,zip就会停止。
  • 如果想按最长的那个迭代器来遍历,那就改用内置的itertools模块中的zip_longest函数。

详见:weread.qq.com/web/reader/…

names = ['Andy', 'Bob', 'Cici', 'Daniel']
scores = [100, 90, 80]

print('# 若2个集合 长度不一样,则当较短的集合遍历完,就会结束整个遍历:')
for name, score in zip(names, scores):
    print(f'{name}: {score}')

import itertools
print('\n# 或是使用 itertools.zip_longest() 实现较长集合的遍历:')
for name, score in itertools.zip_longest(names, scores):
    print(f'{name}: {score}')

# # 若2个集合 长度不一样,则当较短的集合遍历完,就会结束整个遍历:
# Andy: 100
# Bob: 90
# Cici: 80
#
# # 或是使用 itertools.zip_longest() 实现较长集合的遍历:
# Andy: 100
# Bob: 90
# Cici: 80
# Daniel: None

自定义排序

  • 列表的sort方法可以根据自然顺序给其中的字符串、整数、元组等内置类型的元素进行排序。
  • 普通对象如果通过特殊方法定义了自然顺序,那么也可以用sort方法来排列,但这样的对象并不多见。
  • 可以把辅助函数传给sort方法的key参数,让sort根据这个函数所返回的值来排列元素顺序,而不是根据元素本身来排列。
  • 如果排序时要依据的指标有很多项,可以把它们放在一个元组中,让key函数返回这样的元组。
  • 对于支持一元减操作符的类型来说,可以单独给这项指标取反,让排序算法在这项指标上按照相反的方向处理。
  • 如果这些指标不支持一元减操作符,可以多次调用sort方法,并在每次调用时分别指定key函数与reverse参数。
  • 最次要的指标放在第一轮处理,然后逐步处理更为重要的指标,首要指标放在最后一轮处理。

详见:weread.qq.com/web/reader/…

class Student:
    name = ''
    score = 0


    def __init__(self, name, score):
        self.name = name
        self.score = score


    def __repr__(self):
        return f'Student({self.name}, {self.score})'


print(f'排序前:')
students = [Student('Bob', 60), Student('Andy', 100), Student('Cici', 80), Student('Bob', 80)]
for stu in students: print(f'|- {stu.name}: {stu.score}')

print(f'\n以名称升序排序后:')
students.sort(key=lambda stu: stu.name)
for stu in students: print(f'|- {stu.name}: {stu.score}')

print(f'\n以名称到序排序后:')
students.sort(key=lambda stu: stu.name, reverse=True)
for stu in students: print(f'|- {stu.name}: {stu.score}')

print(f'\n以名称倒序排序,且 名称相同时 分数高的排在前面:')
students.sort(key=lambda stu: (stu.name, stu.score), reverse=True)
for stu in students: print(f'|- {stu.name}: {stu.score}')

print(f'\n以分数降序排序后:')
students.sort(key=lambda stu: stu.score, reverse=True)
for stu in students: print(f'|- {stu.name}: {stu.score}')

# 排序前:
# |- Bob: 60
# |- Andy: 100
# |- Cici: 80
# |- Bob: 80
# 
# 以名称升序排序后:
# |- Andy: 100
# |- Bob: 60
# |- Bob: 80
# |- Cici: 80
# 
# 以名称到序排序后:
# |- Cici: 80
# |- Bob: 60
# |- Bob: 80
# |- Andy: 100
# 
# 以名称倒序排序,且 名称相同时 分数高的排在前面:
# |- Cici: 80
# |- Bob: 80
# |- Bob: 60
# |- Andy: 100
# 
# 以分数降序排序后:
# |- Andy: 100
# |- Cici: 80
# |- Bob: 80
# |- Bob: 60

使用字典 获取类的属性键值对

  • 在Python 3.5 与之前的版本中,迭代字典(dict)时所看到的顺序好像是任意的

  • 从Python 3.6开始,字典会保留这些键值对在添加时所用的顺序,而且Python 3.7版的语言规范正式确立了这条规则。

    于是,在新版的Python里,总是能够按照当初创建字典时的那套顺序来遍历这些键值对。

  • 类也会利用字典来保存这个类的实例所具备的一些数据,在新版的Python中,我们就可以认为这些字段在__dict__中出现的顺序应该与当初赋值时的顺序一样。

  • 现在的Python语言规范已经要求,字典必须保留添加键值对时所依照的顺序。所以,我们可以利用这样的特征来实现一些功能,而且可以把它融入自己给类和函数所设计的API中。

class Student:
    name = ''
    score = 0
    id = '001'
    _sex = None
    __id_num = None

    def __init__(self, name, score):
        self._sex = 'M'
        self.name = name
        self.score = score

    def __repr__(self):
        return f'Student({self.name}, {self.score})'


students = [Student('Bob', 60), Student('Andy', 100), Student('Cici', 80), Student('Bob', 80)]
print('# 遍历 students')
for stu in students: print(f'|- {stu.name}: {stu.score}')

print('\n# 遍历 students 属性键值对')
for stu in students: print(f'|- {stu.__dict__}')

# # 遍历 students
# |- Bob: 60
# |- Andy: 100
# |- Cici: 80
# |- Bob: 80
# 
# # 遍历 students 属性键值对
# |- {'_sex': 'M', 'name': 'Bob', 'score': 60}
# |- {'_sex': 'M', 'name': 'Andy', 'score': 100}
# |- {'_sex': 'M', 'name': 'Cici', 'score': 80}
# |- {'_sex': 'M', 'name': 'Bob', 'score': 80}

无序字典 & 有序字典

  • 从Python 3.7版开始,我们就可以确信迭代标准的字典时所看到的顺序跟这些键值对插入字典时的顺序一致。

  • 在Python代码中,我们很容易就能定义跟标准的字典很像但本身并不是dict实例的对象。

  • 对于这种类型的对象,不能假设迭代时看到的顺序必定与插入时的顺序相同。

  • 如果不想把这种跟标准字典很相似的类型也当成标准字典来处理,那么可以考虑这样三种办法。

  • 第一,不要依赖插入时的顺序编写代码;第二,在程序运行时明确判断它是不是标准的字典;第三,给代码添加类型注解并做静态分析。

  • 其实,内置的collections模块早就提供了这种能够保留插入顺序的字典,叫作OrderedDict。

  • 它的行为跟(Python 3.7以来的)标准dict类型很像,但性能上有很大区别。

  • 如果要频繁插入或弹出键值对(例如要实现least-recently-used缓存),那么OrderedDict可能比标准的Pythondict类型更合适

详见:weread.qq.com/web/reader/…

import collections
import os

print(f'# python env:', end='')
os.system("python3 --version")

scores = dict()
scores['Bob'] = 60
scores['Andy'] = 100
scores['Cici'] = 80
print('\n# 遍历 scores')
for name, score in scores.items(): print(f'|- {name}: {score}')

ordered_scores = collections.OrderedDict()
ordered_scores['Bob'] = 60
ordered_scores['Andy'] = 100
ordered_scores['Cici'] = 80
print('\n# 遍历 ordered_scores')
for name, score in ordered_scores.items(): print(f'|- {name}: {score}')

# # python env:Python 3.8.9
# 
# # 遍历 scores
# |- Bob: 60
# |- Andy: 100
# |- Cici: 80
# 
# # 遍历 ordered_scores
# |- Bob: 60
# |- Andy: 100
# |- Cici: 80

字典的 取值、赋值、默认值

详见:weread.qq.com/web/reader/…

另外 defaultdict可见:weread.qq.com/web/reader/…

scores = {'Andy': 100, 'Bob': 90, 'Cici': 80}
name = 'Daniel'
score = None

# 传统方法
if name in scores:
    score = scores[name]
print(f"{name}'s score is {score}")

# 推荐方法
score2 = scores.get(name, 0)
print(f"{name}'s score is {score2}")

# 设置默认值:
# 这个方法会查询字典里有没有这个键,如果有 就返回对应的值;如果没有 就先把用户提供的默认值跟这个键关联起来并插入字典,然后返回这个值。
scores.setdefault(name, 60)
print(f"{name}'s score is {scores.get(name)}")

# Daniel's score is None
# Daniel's score is 0
# Daniel's score is 60

字典取值 - 自定义 __missing__

  • 如果要构造的默认值必须根据键名来确定,那么可以定义自己的dict子类并实现__missing__方法。

详见:weread.qq.com/web/reader/…

class Scores(dict):

    def __missing__(self, key):
        self[key] = None


# 传统方法
scores = dict()
try:
    print(f"xxx's score is {scores['xxx']}")  # KeyError: 'xxx'
except KeyError as err:
    print(f'KeyError: {err}')

# 通过继承dict类型并实现__missing__特殊方法来解决这个问题
scores2 = Scores()
print(f"xxx's score is {scores2['xxx']}")

# KeyError: 'xxx'
# xxx's score is None

一个支持自定义__missing__的字典:

class safedict(dict):
    missing = None

    def __missing__(self, key):
        if self.missing:
            return self.missing(key)
        return None


empty_dict = safedict()
empty_dict.missing = lambda key: f'key:{key} not define.'
print(f'{empty_dict["qqq"]}')

# key:qqq not define.

函数

使用作用域外的变量

  • 闭包函数可以引用定义它们的那个外围作用域之中的变量。
  • 按照默认的写法,在闭包里面给变量赋值并不会改变外围作用域中的同名变量。
  • 先用nonlocal语句说明,然后赋值,可以修改外围作用域中的变量。
  • 除特别简单的函数外,尽量少用nonlocal语句。

详见:weread.qq.com/web/reader/…

先来看一个示例:

def fun_outer():
    outer_var = None

    def func_inner():
        outer_var = 'new value'
        return outer_var

    func_inner()
    return outer_var


print(f'value = {fun_outer()}')
# value = None

如上代码,最后 value = None 而不是 value = new value

使用 nonlocal 可以实现该效果,即「把闭包里面的数据赋给闭包外面的变量」:

def fun_outer():
    outer_var = None

    def func_inner():
        nonlocal outer_var  # 重点
        outer_var = 'new value'
        return outer_var

    func_inner()
    return outer_var


print(f'value = {fun_outer()}')
# value = new value

可变参数

  • def定义函数时,可以通过*args的写法让函数接受数量可变的位置参数。
  • 调用函数时,可以在序列左边加上*操作符,把其中的元素当成位置参数传给*args所表示的这一部分。
  • 如果*操作符加在生成器前,那么传递参数时,程序有可能因为耗尽内存而崩溃。
  • 给接受*args的函数添加新位置参数,可能导致难以排查的bug。

详见:weread.qq.com/web/reader/…

def func(label, *args):
    if not args:
        print(f'{label}')
    else:
        print(f'{label}: {", ".join(args)}')


func('hello')
func('hello', 'Andy', 'Bob', 'Cici', 'Daniel')

# hello
# hello: Andy, Bob, Cici, Daniel

关键字参数

  • 函数的参数可以按位置指定,也可以用关键字的形式指定。
  • 关键字可以让每个参数的作用更加明了,因为在调用函数时只按位置指定参数,可能导致这些参数的含义不够明确。
  • 应该通过带默认值的关键字参数来扩展函数的行为,因为这不会影响原有的函数调用代码。
  • 可选的关键字参数总是应该通过参数名来传递,而不应按位置传递。

详见:weread.qq.com/web/reader/…

def get_url(host='http://baidu.com', path=None, **kwargs):
    return f"{host}/{path or ''}{'?' if kwargs else ''}{'&'.join([f'{key}={value}' for key, value in kwargs.items()])}"


print(get_url())
print(get_url(path='aaa/bbb'))
print(get_url(path='aaa/bbb', k1='v1', k2='v2'))
print(get_url('http://google.com'))
print(get_url('http://google.com', path='aaa/bbb'))
print(get_url('http://google.com', path='aaa/bbb', k1='v1', k2='v2'))

# http://baidu.com/
# http://baidu.com/aaa/bbb
# http://baidu.com/aaa/bbb?k1=v1&k2=v2
# http://google.com/
# http://google.com/aaa/bbb
# http://google.com/aaa/bbb?k1=v1&k2=v2

位置参数、可变参数、关键字参数 的混合使用

def func(a, b, c='c_default', *args, **kwargs):
    print(f'a={a}, b={b}, c={c}, ', end='')
    for i, v in enumerate(args): print(f'args[{i}]={v}, ', end='')
    for k, v in kwargs.items(): print(f'{k}={v}, ', end='')
    print()


func('1', '2')
func('1', '2', '3')
func('1', '2', c='3')
func('1', '2', '3', '4', '5', '6')
func('1', '2', '3', '4', '5', '6', x='7', y='8', z='9')
func('1', '2', x='7', y='8', z='9')

# a=1, b=2, c=c_default, 
# a=1, b=2, c=3, 
# a=1, b=2, c=3, 
# a=1, b=2, c=3, args[0]=4, args[1]=5, args[2]=6, 
# a=1, b=2, c=3, args[0]=4, args[1]=5, args[2]=6, x=7, y=8, z=9, 
# a=1, b=2, c=c_default, x=7, y=8, z=9, 

合理设置参数的默认值

  • 参数的默认值只会计算一次,也就是在系统把定义函数的那个模块加载进来的时候。
  • 所以,如果默认值将来可能由调用方修改(例如{}、[])或者要随着调用时的情况变化(例如datetime.now()),那么程序就会出现奇怪的效果。
  • 如果关键字参数的默认值属于这种会发生变化的值,那就应该写成None,并且要在docstring里面描述函数此时的默认行为。
  • 默认值为None的关键字参数,也可以添加类型注解。

详见:weread.qq.com/web/reader/…

from datetime import datetime
import time
from typing import Optional


# ========================================================= 错误案例 ============================================================
def log_1(message, time=datetime.now()):
    print(f'[{time}] {message}')


log_1('AAAAA')
time.sleep(0.5)
log_1('BBBBB')


# 两次log的时间是一样的,这是不符合预期的
# [2022-04-14 11:28:17.523949] AAAAA
# [2022-04-14 11:28:17.523949] BBBBB

# ========================================================= 修复问题 ============================================================
def log_2(message, time=None):
    print(f'[{time or datetime.now()}] {message}')


log_2('CCCCC')
time.sleep(0.5)
log_2('DDDDD')


# 符合预期了
# [2022-04-14 11:30:25.636013] CCCCC
# [2022-04-14 11:30:26.139351] DDDDD

# ========================================================= 优化 ============================================================
# 优化:指定参数类型
def log_3(message: str, time: Optional[datetime] = None):
    print(f'[{time or datetime.now()}] {message}')


log_3('EEEEE')
time.sleep(0.5)
log_3('FFFFF')

# [2022-04-14 11:34:15.135794] EEEEE
# [2022-04-14 11:34:15.638360] FFFFF

合理使用位置参数与关键字参数

  • Keyword-only argument是一种只能通过关键字指定而不能通过位置指定的参数。
    • 这迫使调用者必须指明,这个值是传给哪一个参数的。
    • 在函数的参数列表中,这种参数位于*符号的右侧。
  • Positional-only argument是这样一种参数,它不允许调用者通过关键字来指定,而是要求必须按位置传递。
    • 这可以降低调用代码与参数名称之间的耦合程度。
    • 在函数的参数列表中,这些参数位于/符号的左侧。
  • 在参数列表中,位于/*之间的参数,可以按位置指定,也可以用关键字来指定。
    • 这也是Python普通参数的默认指定方式。

详见:weread.qq.com/web/reader/…

# 既可以作为位置参数传参,也可以作为关键字参数传参
# 这就使得,参数名的修改 会影响调用者
def func_1(a, b, c, d):
    print(f'a={a}, b={b}, c={c}, d={d}')


func_1(1, 2, 3, 4)
func_1(1, 2, c=3, d=4)
func_1(a=1, b=2, c=3, d=4)


# a=1, b=2, c=3, d=4
# a=1, b=2, c=3, d=4
# a=1, b=2, c=3, d=4

# =====================================================================================================================

# 插入 '/'、'*' 号,来指定"只能以 某种方式传参",即 在函数的参数列表之中:
# /符号左侧的参数是只能按位置指定的参数,*符号右侧的参数则是只能按关键字形式指定的参数。
# 这两个符号之间的参数,既可以按位置提供,又可以用关键字形式指定
def func_2(a, b, /, c, *, d):
    print(f'a={a}, b={b}, c={c}, d={d}')


func_2(1, 2, 3, d=4)
func_2(1, 2, c=3, d=4)

# a=1, b=2, c=3, d=4
# a=1, b=2, c=3, d=4

函数修饰器

  • 修饰器是Python中的一种写法,能够把一个函数封装在另一个函数里面,这样程序在执行原函数之前与执行完毕之后,就有机会执行其他一些逻辑了。
  • 修饰器可能会让那些利用introspection机制运作的工具(例如调试器)产生奇怪的行为。
  • Python内置的functools模块里有个叫作wraps的修饰器,可以帮助我们正确定义自己的修饰器,从而避开相关的问题。

详见:weread.qq.com/web/reader/…

# 未使用函数修饰器
def log_1(*args):
    print(f'{", ".join(args)}')


log_1('hello')
log_1('hello', 'world')
log_1('hello', 'world', '~~')

# hello
# hello, world
# hello, world, ~~

# =====================================================================================================================

from functools import wraps
from datetime import datetime


# 定义函数修饰器
def with_time(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(f'[{datetime.now()}]', end=' ')
        result = func(*args, **kwargs)
        return result
    return wrapper


# 用@符号把修饰器运用在想要调试的函数上面
@with_time
def log_2(*args):
    print(f'{", ".join(args)}')


print('\n')
log_2('hello')
log_2('hello', 'world')
log_2('hello', 'world', '~~')

# [2022-04-14 17:35:16.344146] hello
# [2022-04-14 17:35:16.344173] hello, world
# [2022-04-14 17:35:16.344186] hello, world, ~~