编写高质量python代码
(持续更新中......)

概述

摘自《Effective Python:编写高质量Python代码的90个有效方法(第二版)》。该篇为个人读书笔记,更多知识点请参考原书。本书源码见effective python

培养Pythonic思维


  1. 原书第2条:遵循PEP8 风格指南
  • 与表达式和语句有关的建议
    • 采用行内否定,即把否定词直接写在要否定的内容前面,而不要放在整个表达式的前面
    if a is not b  # 推荐
    if not a is b  # 不推荐
    
    • 多行表达式应该用括号括起来,而不要用\符号续行
  • 与引入有关的建议
    • import 语句按顺序划分为三部分分别是:标准库模块,第三方模块,自己的模块。同一部分的import语句按字母顺序排列
  1. 原书第5条:用辅助函数取代复杂的表达式
    如下的代码,想实现缺失或者空白的值都当成0,否则用int表示
colors = {'red': '5', 'green': ['0'], 'blue': ['']}

print("red:", colors.get('red'))
print("green:", colors.get('green'))
print("blue:", colors.get('blue'))
print("gray:", colors.get('gray'))

解决方案:使用辅助函数

colors = {'red': '5', 'green': ['0'], 'blue': ['']}

print("red:", colors.get('red'))
print("green:", colors.get('green'))
print("blue:", colors.get('blue'))
print("gray:", colors.get('gray'))

print('='*30)

def get_first_int(values, key, default=0):
    found = values.get(key, [''])
    if found[0]:
        return int(found[0])
    return default

print("red:", get_first_int(colors, 'red'))
print("green:", get_first_int(colors, 'green'))
print("blue:", get_first_int(colors, 'blue'))
print("gray:", get_first_int(colors, 'gray'))
  1. 原书第7条:尽量用enumerate取代range
    如下代码,用enumerate来改写
flavor_list = ['vanilla', 'chocolate', 'pecan', 'strawberry']
for i in range(len(flavor_list)):
    flavor = flavor_list[i]
    print(f'{i+1}: {flavor}')

使用enumerate改写:

for i, flavor in enumerate(flavor_list):
    print(f'{i+1}: {flavor}')

# 设置enumerate第2参数指定起始序号
for i, flavor in enumerate(flavor_list, 1):
    print(f'{i}: {flavor}')
  1. 原书第8条:用zip函数同时遍历两个迭代器
names = ['cecilia', 'lise', 'marie']
counts = [len(n) for n in names]

longest_name = None
max_count = 0
for name, count in zip(names, counts):
    # print(name, count)
    if count > max_count:
        max_count = count
        longest_name = name

print(longest_name)

扩展知识:如果提供的迭代器长度不一致,只要其中任何一个迭代完毕,zip就会停止
如果想按最长的来遍历,可使用itertools.zip_longest函数

  1. 原书第9条:不要在for与while循环后面写else块
  2. 原书第10条:使用赋值表达式减少重复代码
  • 赋值表达式(Assignment Expressions)是py3.8新引入的语法,如a := b(a walrus b)就是赋值表达式,其中:=称为海象操作符
  • 赋值表达式通过海象操作符给变量赋值,并让这个值成为这条表达式的结果
  • 可以用在普通赋值语句无法应用的场合,例如用在条件表达式的的if语句,赋值表达式的值就是:=操作符左侧的标识符的值
# 赋值表达式示例
def make_cider(count):
    pass

# 缺货
def out_of_stock():
    pass

if __name__ == '__main__':
    fresh_fruit = {
        'apple': 5,
        'banana': 10,
        'lemon': 5
    }
    if (count := fresh_fruit.get("apple", 0)) > 4:
        make_cider(count)
    else:
        out_of_stock()

还可以使用赋值表达式实现swith...case来减少if...else的嵌套深度和缩进层次;还可以使用赋值表达式来简化while循环等

列表与字典


  1. 原书第14条:用sort方法的key参数来表示复杂的排序逻辑
class Tool:
    def __init__(self, name, weight):
        self.name = name
        self.weight = weight

    def __repr__(self):
        return f"Tool({self.name}, {self.weight})"

tools = [
    Tool('drill', 4),
    Tool('circular saw', 5),
    Tool('jackhammer', 40),
    Tool('sander', 4)
]
# tools按照名称忽略大小写排列
tools.sort(key=lambda x: x.name.lower())
print(tools)

# tools按照重量排序
tools.sort(key=lambda x: x.weight)
print(tools)

# tools重量为首要指标来排序,重量相同的情况下再按name来排序
tools.sort(key=lambda x: (x.weight, x.name))
print(tools)

# tools重量为首要指标来排序(降序),重量相同的情况下再按name来排序(升序)
tools.sort(key=lambda x: (-x.weight, x.name))
print(tools)

# tools重量为首要指标来排序(升序),重量相同的情况下再按name来排序(降序)
tools.sort(key=lambda x: x.name, reverse=True)
# print(tools)
tools.sort(key=lambda x: x.weight)
print(tools)

结论:

  • 如果排序时依据的指标有多项,可以把他们放在一个元组中,让key函数返回这样的元组
  • 对于支持一元减操作符的类型来说,可以单独给这项指标取反,让排序算法在这项指标上按照想法的方向处理
  • 对于不支持一元减操作符的指标,可以多次调用sort方法,并在每次调用是分别指定key函数与reverse参数。最次要的指标放在第一轮处理,然后逐步处理更为重要的指标,首要指标放在最后一轮处理
  1. 原书第17条:用defaultdict处理内部状态中缺失的元素,而不要用setdefault
  • setdefault:如果键不存在于字典中,将会添加键并将值设为默认值
class Visits:
    def __init__(self):
        self.data = {}

    def add(self, country, city):
        self.data.setdefault(country, set()).add(city)

if __name__ == '__main__':
    visits = Visits()
    visits.add("guangzhou", "shenzhen")
    visits.add("anhui", "hefei")
    print(visits.data)
    visits.add("anhui", "anqing")
    print(visits.data)
#####################输出结果##################
{'guangzhou': {'shenzhen'}, 'anhui': {'hefei'}}
{'guangzhou': {'shenzhen'}, 'anhui': {'hefei', 'anqing'}}

在字典里存在这个键的情况下,仍然分配set

  • 当字典需要添加任意键的时候,可考虑能否使用collections.defaultdict实例来解决
from collections import defaultdict

class Visits:
    def __init__(self):
        self.data = defaultdict(set)

    def add(self, country, city):
        self.data[country].add(city)

if __name__ == '__main__':
    visits = Visits()
    visits.add("guangzhou", "shenzhen")
    visits.add("anhui", "hefei")
    print(visits.data)
    visits.add("anhui", "anqing")
    print(visits.data)
###############输出结果################
defaultdict(<class 'set'>, {'guangzhou': {'shenzhen'}, 'anhui': {'hefei'}})
defaultdict(<class 'set'>, {'guangzhou': {'shenzhen'}, 'anhui': {'hefei', 'anqing'}})
  1. 原书第18条:学会利用__missing__构造依赖键的默认值

魔术方法回顾可参看魔术方法-容器化

  • 如果构造的默认值必须根据键名来确定,则可以定义自己的dict子类并实现__missing__方法
class A(dict):
    def __missing__(self, key):
        value = f'{str(key)}_1'
        self[key] = value
        return value

a = A()
print(a['abc'])

函数


  1. 原书第20条:遇到意外状况时应该抛出异常,不要返回None
  • 不推荐的写法
def careful_divide(a, b):
    try:
        return a/b
    except ZeroDivisionError:
        return None

# 函数的返回值可能会用在if条件语句中
if __name__ == '__main__':
    if not careful_divide(2, 0):
        print("invalid input~~~~")
    if not careful_divide(0, 2):
        print("invaild input")
  • 解决方案
    • 方案1:利用二元组把计算结果分成两个部分返回,首页元素表示操作是否成功,第二个元素表示计算的实际值
    • 方案2:不采用None返回,向调用方抛出异常,让其自己处理
# 方案1:
def careful_divide(a, b):
    try:
        return True, a/b
    except ZeroDivisionError:
        return False, None

if __name__ == '__main__':
    success, result = careful_divide(0, 2)
    if not success:
        print("invalid input")
    else:
        print(result)

但有时候调用方会忽略返回元组的第一个部分,如_, result = careful_divide(0, 2)

# 方案2:
def careful_divide(a, b):
    try:
        return a / b
    except ZeroDivisionError:
        raise ValueError("invalid input")

if __name__ == '__main__':
    try:
        result = careful_divide(2, 0)
    except ValueError:
        print("invalid input")
    else:
        print(result)
  1. 原书第21条:了解如何在闭包里面使用外围作用域中的变量

    什么是闭包,可参看装饰器-闭包

  • 闭包函数可以引用定义他们的那个外围作用域中的变量
  • 在闭包里面给变量赋值并不会改变外围作用域中的同名变量
  • 可使用nonlocal语句说明,然后赋值来修改外围作用域中的变量
  • 除特别简单的函数外,尽量少用nonlocal语句
  1. 原书第24条:用None和docstring来描述默认值会变的参数
  • 函数的参数默认值只会计算一次,也就是在系统把定义函数的那个模块加载进来的时候
  • 若关键字参数在默认值是这种会发生变化的值,那就应该设置成None,并在在docstring里描述此时的默认行为
  • 默认值为None的关键字参数,可添加类型注解
# 模块加载时计算一次
from datetime import datetime
import time


def log(message, when=datetime.now()):
    print(f'{when}: {message}')


if __name__ == '__main__':
    log('hi there')
    time.sleep(1)
    log('hello again')
###########输出结果##################
2021-01-14 10:25:48.867682: hi there
2021-01-14 10:25:48.867682: hello again
from datetime import datetime
import time
from typing import Optional

def log(message: str, when: Optional[datetime] = None) -> None:
    """log a message with a timestamp.
    Args:
    :param message: message to print
    :param when: datetime of when the message occurred.
                defaults to the present time.
    :return:
    """
    if when is None:
        when = datetime.now()
    print(f'{when}: {message}')

if __name__ == '__main__':
    log('hi there')
    time.sleep(1)
    log('hello again')
###################输出结果###################
2021-01-14 10:34:09.822429: hi there
2021-01-14 10:34:10.827249: hello again
  1. 原书第25条:用只能以关键字指定和只能按位置传入的参数来设计清晰的参数列表
def safe_division(number, divisor, *,
                  ignore_overflow=False,
                  ignore_zero_division=False
                  ):
    try:
        return number / divisor
    except OverflowError:
        if ignore_overflow:
            return 0
        else:
            raise
    except ZeroDivisionError:
        if ignore_zero_division:
            return float('inf')
        else:
            raise

keyword-only argument 是一种通过关键字指定而不能通过位置指定的参数,调用者必须指定这个值必须传给哪个参数,这种参数位于*符号的右侧

  • py3.8引入新特性:position-only argument,不允许调用者通过关键字来指定,必须按照位置传递,这样可以降低调用代码与参数名称之间的耦合程度。在函数列表中,这些参数位于/符号的左侧
def safe_division(number, divisor, /, ndigits=10, *,
                  ignore_overflow=False,
                  ignore_zero_division=False
                  ):
    try:
        fraction = number / divisor
        return round(fraction, ndigits)
    except OverflowError:
        if ignore_overflow:
            return 0
        else:
            raise
    except ZeroDivisionError:
        if ignore_zero_division:
            return float('inf')
        else:
            raise

if __name__ == '__main__':
    print(safe_division(22, 7))
    print(safe_division(22, 7, 5))
    print(safe_division(22, 7, ndigits=2))
  1. 原书第26条:用functools.wraps定义函数装饰器

推导与生成


  1. 原书第29条:用赋值表达式消除推导中的重复代码
  2. 原书第30条:不要让函数直接返回列表,应该让他逐个生成列表里的值
  • 例:返回字符串里每个单词的首字母所对应的下标
# 常规解法
def index_words(text):
    result = []
    if text:
        result.append(0)
    for index, letter in enumerate(text):
        # print(index, letter)
        if letter == " ":
            result.append(index+1)
    return result

if __name__ == '__main__':
    text = 'i am jerry'
    print(index_words(text))
  • 改用生成器来实现
def index_words(text):
    if text:
        yield 0
    for index, letter in enumerate(text):
        # print(index, letter)
        if letter == " ":
            yield index + 1


if __name__ == '__main__':
    text = 'i am jerry'
    result = index_words(text)
    # print(next(result))
    print(list(result))

不管输入的数据量有多大,生成器函数每次都只需要根据其中的一小部分来计算当前这次的输出值,不用把整个输入值全部读取出来,也不用一次把所有的输出值全都算好

  1. 原书第31条:谨慎地迭代函数所收到的参数
  • 例:从文件读取数据,并计算百分比
def read_data(data_path):
    with open(data_path, 'r') as f:
        for line in f:
            yield int(line)

def normalize(numbers):
    result = []
    numbers_copy = list(numbers)  # 复制迭代器,这样read_data函数也没啥用处
    total = sum(numbers_copy)
    for value in numbers_copy:
        percent = 100 * value / total
        result.append(percent)
    return result

if __name__ == '__main__':
    data_path = '1.txt'
    data = read_data(data_path)
    print(normalize(data))

显然,如果数据量很大的情况下,可能导致程序在复制迭代器时因耗尽内存而崩溃

  • 使可迭代的容器类来改写
from collections.abc import Iterator
class ReadData:
    def __init__(self, data_path):
        self.data_path = data_path

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

def normalize(numbers):
    if isinstance(numbers, Iterator):
        raise TypeError('must supply a container')
    result = []
    total = sum(numbers)
    for value in numbers:
        percent = 100 * value / total
        result.append(percent)
    return result

if __name__ == '__main__':
    data_path = '1.txt'
    data = ReadData(data_path)
    # print(data)
    # for i in data:
    #     print(i)
    print(normalize(data))
  1. 原书第32条:考虑用生成器表达式改写数据量较大的列表推导
  • 例:读取一份文件并返回每行的字符数
# 数据量大的情况下程序可能因为内存耗尽而崩溃
value = [len(i) for i in open('1.txt')]
print(value)

# 使用生成器表达式改写
it = (len(i) for i in open('1.txt'))
print(it)
print(next(it))
print(next(it))
  • 生成器表达式所形成的迭代器可以当成for语句的子表达式出现在另一个生成器表达式里
# 组合起来使用,编写一条新的生成器表达式
roots = ((x, x * 0.5) for x in it)
print(roots)
print(next(roots))
  1. 原书第33条:通过yield from 把多个生成器连起来用
  2. 原书第36条:考虑用itertools拼装迭代器与生成器
  • itertools包里面有三套函数可以拼接迭代器和生成器,他们分别能够连接多个迭代器,过滤源迭代器中的元素,以及用源迭代器中的元素合成新元素

更多关于itertools的使用,可进一步参考python标准库:itertools

类与接口


  1. 原书第37条:用组合起来的类来实现多层结构,不要用嵌套的内置类型
  • 如这样的数据结构:字典里面出现小字典,小字典又出现了列表这种超过两层的嵌套结构,就应该及时把这些代码拆分到多个类的,这样可以定义良好的接口,并且能够合理的封装数据,这种写法可以在接口与具体实现之间创建一层抽象
  • namedtuple能够实现出轻量级的容器,以存放不可变的数据,而且将来可以灵活的转化成普通的类
    • namedtuple的局限:1. namedtuple类无法指定默认的参数类型,在属性比较多的情况下,应该改用内置的dataclasses模块来实现;2. namedtuple实例的属性可以通过下标与迭代来访问,会有人用这种方式来访问,将来就不容易把他转化成普通的类,这种情况下,最好还是明确定义一个新的类
  • 如果发现字典来维护内部状态的代码越写越复杂,就应该考虑用多个类来实现

具体实例可参考原书

  1. 原书第38条:让简单的接口接受函数,而不是类的实例
  2. 原书第39条:通过@classmethod多态来构造同一体系中的各类对象
  3. 原书第40条:通过super初始化超类
  4. 原书第41条:考虑用mix-in类来表示可组合的功能
  5. 原书第42条:优先考虑用public属性表示应受保护的数据,不要用private属性表示
  6. 原书第43条:自定义的容器类型应该从collections.abc继承

元类与属性


并发与并行


稳定与性能


测试与调试


协作开发