编写 docstring

优雅的代码,应该为每个模块、类、方法与函数编写 docstring。

不同风格的 docstring:Python Docstring 风格和写法学习 - 飞鸟_Asuka - 博客园

平时开发时可以通过 VsCode 或者 PyCharm 的插件功能来实现自动提示 docstring。

Python 包管理

当模块变多后,可以通过包(package)来管理模块。大多数情况下,把名为 __init__.py 的空白文件放在某个目录中,即可令该目录成为一个包。一旦有了 __init__.py 文件,我们就可以使用相对于该目录的路径引入包中的其他 py 文件了。例如,现在目录结构是这样的:

main.py
views/__init__.py
views/user.py
views/item.py
views/action.py

通过用包来管理模块,即便两个模块所在的文件同名,只要处于不同的包中,也依然能够加以区分。

from h1.utils import hello_h1
from h2.utils import hello_h2

但需要注意的是,如果两个包里有同名的模块,或者两个模块里有同名的函数或类,那么后引入的那个会把先引入的覆盖掉。

解决的方式是通过 as 关键词来进行重命名。

from h1 import utils
from h2 import utils  # Overwrites!
 
from h1.utils import hello
from h2.utils import hello  # Overwrites!
 
from h1.utils import hello as hello_h1
from h2.utils import hello as hello_h2

除了可以更方便的管理模块,还可以通过包来构建稳固的 API。如果这套 API 要提供给很多人使用,那么可能想要隐藏软件包内部的部分代码结构,避免使用者使用不必要或者可能容易更新的方法和函数。

Python 允许我们通过 __all__ 这个特殊的属性,决定模块或包里面有哪些内容应该当作 API 公布给外界。__all__ 的值是一份列表,用来描述可作为 public API 导出的所有内容名称。如果执行 from foo import * 这样的语句,那么只有 __all__ 所列出名称的属性才会引入进来。若是 foo 里面没有 __all__,那么就只会引入 public 属性,也就是只会引入不以下划线开头的那些属性。

例如下面的效果:

# MyPackage/model.py
__all__ = ["Project"]
 
class Project:
	def __init__(self):
		self.num = 0
 
# MyPackage/utils.py
__all__ = ["hello"]
 
from .model import Project
 
def _hi():
	print("hi")
 
def hello():
	print("hello")
 
# MyPackage/__init__.py
__all__ = []
from .model import *
__all__ += model.__all__
from .utils import *
__all__ += utils.__all__

通过类似于上述的代码,就可以隐藏不必要的代码逻辑。

用模块级别的代码来配置不同的环境

代码开发时,往往会至少有两个使用环境,一个是生产环境,一个是测试环境。如果在不同环境时手动修改内部的配置,如数据库连接操作,过于麻烦。比较优雅的方式是通过模块级别的代码来配置不同的环境。

方案一:Python 程序启动时会默认进入名为 __main__ 的模块,于是我们创建这样两份不同的文件,让它们都可以作为主文件在 __main__ 里面运行,其中 dev_main.py 针对生产环境,prod_main.py 针对开发环境。这两份文件只有一个地方不同,也就是 TESTING 常量的取值。程序中的其他模块可以引入 __main__ 模块,并根据 TESTING 的值决定如何配置自己的某些属性。

# dev_main.py
TEST = True
import db_connection
 
db = db_connection.DataBase()
 
# prod_main.py
TEST = False
import db_connection
 
db = db_connection.DataBase()
 
# db_connection.py
import __main__
 
class TestDataBase:
	...
 
class RealDataBase:
	...
 
if __main__.TEST:
	DataBase = TestDataBase
else:
	DataBase = RealDataBase

如果环境配置起来特别复杂,那就不要使用 TEST 这样单纯的 Python 常量,而是可以考虑构建专门的配置文件,并通过 Python 内置的 configparser 模块等解析工具来处理这种文件,把它们与程序代码分开维护。在与运维团队合作的时候,这一点尤其重要。

方案二:配置运行的环境变量。在代码中根据环境变量动态的使用不同的环境配置。

# config.py
import os
 
env = os.getenv("env", "dev")
 
try:
	exec(f"from config_{env} import Config")
except:
	print("不存在该环境配置")
 
# config_prod.py
class Config:
	pass
 
# config_dev.py
class Config:
	pass

为自己的模块定义根异常

如果自己的模块需要开放给其他人使用时,那么最好给自己的模块定义根异常,这样使用者在使用这个 API 的时候可以专项处理该 API 内部的异常。

# error.py
class Error(Exception):
	...
 
class VideoError(Exception):
	...
 
class PictureError(Exception):
	...
 
try:
	func()
except error.VideoError:
	pass
except error.Error:
	pass
except Exception:
	pass

打破模块间的循环依赖关系

方法一:把模块划分成引入-配置-运行这样三个环节。

循环引入问题的一个解决办法是,尽量缩减引入时所要执行的操作。我们可以让模块只把函数、类与常量定义出来,而不真的去执行操作,这样的话,Python 程序在引入本模块的时候,就不会由于操作其他模块而出错了。我们可以把本模块里面,需要用到其他模块的那种操作放在 configure 函数中,等到本模块彻底引入完毕后,再去调用。configure 函数会访问其他模块中的相关属性,以便将本模块的状态配置好。这个函数是在该模块与它所要使用的那个模块都已经彻底引入后才调用的,因此,其中涉及的所有属性全都定义过了。

# dialog.py
import app
class Dialog:
	pass
 
save_dialog = Dialog()
 
def show():
	pass
 
def configure():
	save_dialog.save_dir = app.prefs.get('save_dir')
 
 
# app.py
class Prefs:
	pass
 
prefs = Prefs()
 
def configure():
	pass
 
# configure.py
import app
import dialog
app.configure()
dialog.configure()
	dialog.show()

方法二:动态引入。

把 import 语句从模块级别下移到函数或方法里面,这样就可以解除循环依赖关系了。这种 import 语句并不会在程序启动并初始化本模块时执行,而是等到相关函数真正运行的时候才得以触发,因此又叫作动态引入(dynamic import)。

一般来说,还是应该尽量避免动态引入,因为 import 语句毕竟是有开销的,如果它出现在需要频繁执行的循环体里面,那么这种开销会更大。

基于 typing 进行代码静态分析

我们可以通过内置的 typing 模块给变量、类中的字段、函数及方法添加类型信息。给 Python 程序的代码添加类型信息之后,就可以运行静态分析(static analysis)工具,分析这些代码里面是否存在极有可能出现 bug 的地方。

# example.py
def sub(a: int, b: int):
	return a - b
 
sub(1, 's')

与 typing 模块相搭配的 Python 静态分析工具,也有很多方案。笔者编写本书的时候,比较流行的是 mypy( https://github.com/python/mypy )、pytype( https://github.com/google/pytype )、pyright( https://github.com/microsoft/pyright )与 pyre( https://pyre-check.org )。本书中的 typing 范例,笔者打算用 mypy 来验证,而且验证时会带上—strict 标志,以便将该工具所能判断的各种问题全都显示出来。

python3 -m mypy --strict example.py

当注解中需要使用类型注解的时候,可能需要用到当前还没有定义出来的类型,这叫作提前引用。提前引用并不会被 mypy 发现出现,但是在实际 Python 程序运行时会触发错误。

class FirstClass:
	def __init__(self, value: SecondClass):
		self.value = value
 
class SecondClass:
	def __init__(self, value: int):
		self.value = value
 
s = SecondClass(1)
f = FirstClass(s)

解决的办法是,通过 from __future__ import annotations 来引入类型注解功能。这样写,会让 Python 系统在运行程序的时候,完全忽略类型注解里面提到的值,于是就解决了提前引用的问题,而且程序在启动时的性能也会提升。

from __future__ import annotations
 
class FirstClass:
	def __init__(self, value: SecondClass):
		self.value = value
 
class SecondClass:
	def __init__(self, value: int):
		self.value = value
 
s = SecondClass(1)
f = FirstClass(s)

重构时通过 warnings 提醒使用者 API 已经发生了变化

若开发的库的某个函数发生了变动,扩展了该函数的功能,调用者可以通过可选的关键字参数来更好的使用该函数。新的函数依旧可以兼容旧的调用方式,但是希望调用者尽快使用新写法来使用。

解决方法是通过 Python 内置的 warnings 模块解决。该模块让我们以编程的手段提醒其他开发者注意:代码所依赖的底层库已经发生变化,请尽快做出相应修改。

import warnings
 
def require(name, value, default):
    if value is not None:
        return value
    warnings.warn(
        f'{name} will be required soon, update your code',
        DeprecationWarning,
        stacklevel=3)
    return default
 
def print_distance(speed, duration, *,
                   speed_units=None,
                   time_units=None,
                   distance_units=None):
    speed_units = require('speed_units', speed_units, 'mph')
    time_units = require('time_units', time_units, 'hours')
    distance_units = require(
        'distance_units', distance_units, 'miles')
 
    norm_speed = convert(speed, speed_units)
    norm_duration = convert(duration, time_units)
    norm_distance = norm_speed * norm_duration
    distance = localize(norm_distance, distance_units)
    print(f'{distance} {distance_units}')