Effective Python -- 第 7 章 协作开发(上)

第 7 章 协作开发(上)

第 49 条:为每个函数、类和模块编写文档字符串

由于 Python 是一门动态语言,所以文档显得极其重要。Python 对文档提供了内置的支持,使得开发者可以把文档与代码块关联起来。与其他许多编程语言不同,Python 程序在运行的时候,能够直接访问源代码中的文档信息。

例如,在为函数编写了 def 语句之后,可以紧接着提供 docstring,以便将一段开发文档与该函数关联起来:

def palindrome(word):
    """Return True if the given word is a palindrome."""
    return word == word[::-1]

在 Python 程序中,可以通过名为 __doc__ 的特殊属性,来访问该函数的文档。

print(repr(palindrome.__doc__))
>>>
'Return True if the given word is a palindrome.'

函数、类和模块,都可以与文档字符串相关联。系统会在编译和运行 Python 程序的过程中,维护这种联系。因为 Python 代码支持文档字符串和 __doc__ 属性,所以产生了下面三个好处:

  • 由于能够访问代码中的文档,所以交互式开发变得更加方便了。可以用内置的 help 函数来查看函数、类和模块的文档,也可以更加方便地采用互动式 Python 解释器(也就是 Python shell、Python “壳”)及 IPython Notebook(http://ipython.org)之类的工具来开发算法、测试 API 并编写代码片段。
  • 这种标准的文档定义方式,使得开发者很容易就能构建出一些工具,把纯文本转换成 HTML 等更为友好的格式。例如,Python 开发者社区就构建了 Sphinx(http://sphinx-doc.org/)等优秀的文档生成工具。此外,还有像 Read the Docs(https://readthedocs.org/)这样,由 Python 开发者社区协力搭建的网站,能够为开源的 Python 项目提供美观的文档及免费的存放空间。
  • 由于 Python 将文档视为第一等级的(first-class)对象,而且可以令开发者在程序中访问格式良好的文档信息,所以很乐意编写更多的文档。Python 社区的开发者都坚信:文档是非常重要的。代码必须要有完备的文档,才能称得上是好代码。由于有了这一信念,所以大部分开源的 Python 库,都应该会带有优雅的文档。

为了融入这种良好的文档撰写氛围,在编制文档字符串时,应该遵循一些规范。对细节问题的详细讨论,可以参阅 PEP 257(http://www.python.org/dev/peps/pep-0257/)。下面这几条规范,是大家都应该遵守的。

1.为模块编写文档

每个模块都应该有顶级的 docstring。这个字符串字面量,应该作为源文件的第一条语句。开发者应该在字符串两端,使用三重双引号(""")把它括起来。之所以要编写 docstring,是为了介绍当前这个模块,以及模块之中的内容。

docsting 的头一行文字,应该是一句话,用以描述本模块的用途。它下面那段话,应该包含一些细节信息,把与本模块的操作有关的内容,告诉模块的使用者。还可以在模块的 docstring 中,强调本模块里面比较重要的类和函数,使得开发者能够据此来了解该模块的用法。

下面列举一个模块级别的 docstring:

# words.py
#!/usr/bin/env python3
"""Library for testing words for various linguistic patterns.

Testing how words relate to each other can be tricky sometimes!
This module provides easy ways to determine when words you've found have special properties.

Available functions:
- palindrome: Determine if a word is a palindrome.
- check_anagram: Determine if two words are anagrams.
...
"""

# ...

如果该模块是个命令行工具,那可以考虑把工具的用法,写在本模块的 docstring 里面,以便告诉用户应该如何从命令行里运行这个工具。

2.为类编写文档

每个类都应该有类级别的 docstring,它的写法与模块级的 docstring 大致相同。头一行,也是用一句话来阐明本类的用途。接下来,用一段话详述该类的操作方式。

类中比较重要的 public 属性及方法,也应该在这个 docstring 里面加以强调。此外,还应该告诉子类的实现者,如何才能正确地与 protected 属性及超类方法相交互。

下面示范一个类级别的 docstring:

class Player(object):
    """ERepresents a player of the game.

    Subclasses may override the 'tick' method to provide custom animations for the player's movement dependingon their power level, etc.

    Public attributes:
    - power: Unused power-ups (float between 0 and 1).
    - coins: Coins found during the level (integer).
    """

    # ...

3.为函数编写文档

每个 public 函数及方法,都应该有 docstring,其写法,与模块和类级别的 docstring 相似。第一行,还是用一句话来描述本函数的功能。接下来,用一段话描述具体的行为和函数的参数。若函数有返回值,则应该在 docstring 中写明。如果函数可能抛出某些调用者必须处理的异常,而这些异常又是函数接口的一部分,那么 docstring 应该对其做出解释。

下面演示函数级别的 docstring:

def find_anagrams(word, dictionary):
    """Find all anagrams for a word.

    This function only runs as fast as the test for membership in the 'dictionary' container. It will be slow if the dictionary is a list and fast if it's a set.

    Args:
        word: String of the target word.
        dictionary: Container with a1l strings that
            are known to be actual words.
    Returns:
        List of anagrams that were found. Empty if none were found.
    """
    # ...

为函数撰写 docstring 时,还有一些特例,大家应该了解:

  • 如果函数没有参数,且仅有一个简单的返回值,那么,只需用一句话来描述该函数,应该就够了。
  • 如果函数根本就没有返回值,那么最好别在 docstring 里面提到它,也就是说,不要在 docstring 里面出现 “returns None” 这样的说法。
  • 如果函数在正常的使用过程中不会抛出异常,那就不要在 docstring 里面提到异常。
  • 如果函数接受数量可变的位置参数或数量可变的关键字参数,那就应该在文档的参数列表中,使用 *args 和 **kwargs 来描述它们的用途。
  • 如果函数的参数有默认值,那么应该指出这些默认值。
  • 如果函数是个生成器,那么应该描述该生成器在迭代时所产生的内容。
  • 如果函数是个协程,那么应该描述协程所产生的值,以及这个协程希望通过 yield 表达式来接纳的值,同时还要说明该协程会于何时停止迭代。

总结

  • 应该通过 docstring,为每个模块、类和函数编写文档。在修改代码的时候,应该更新这些文档。
  • 为模块撰写文档时,应该介绍本模块的内容,并且要把用户应该了解的重要类及重要函数列出来。
  • 为类撰写文档时,应该在 class 语句下面的 docstring 中,介绍本类的行为、重要属性,以及本类的子类应该实现的行为。
  • 为函数及方法撰写文档时,应该在 def 语句下面的 docstring 中,介绍函数的每个参数,函数的返回值,函数在执行过程中可能抛出的异常,以及其他行为。

第 50 条:用包来安排模块,并提供稳固的 API

程序的代码量变大之后,自然就需要重新调整其结构。会把大函数分割成小函数,会把某些数据结构重构为辅助类,也会把功能分散到多个相互依赖的模块之中。

到了一定的阶段,就会发现,模块的数量实在太多了,于是,需要在程序之中引进一种抽象层,使得代码更加便于理解。Python 的包(package)就可以充当这样的抽象层。包,是一种含有其他模块的模块。

在大多数情况下,会给目录中放入名为 __init__.py 的空文件,并以此来定义包。只要目录里有 __init__.py,就可以采用相对于该目录的路径,来引入目录中的其他 Python 文件。例如,某个程序的目录结构如下。

main.py
mypackage/__init__.py
mypackage/models.py
mypackage/uti1s.py

为了以相对的方式引人 utils 模块,需要把上级模块的绝对名称,写在引入语句的 from 部分之中,也就是说,我们要在 from 关键字右侧,写出与 mypackage 包相对应的目录名。

# main.py
from mypackage import utils

如果某个包目录中还嵌套有其他的包(如 mypackage.foo.bar),那么也需要采用上述形式来编写 import 语句。

对于Python程序来说,包所提供的能力,主要有两大用途。

1.名称空间

包的第一种用途,是把模块划分到不同的名称空间之中。这使得开发者可以编写多个文件名相同的模块,并把它们放在不同的绝对路径之下。例如,下面这个程序,能够从两个同名的 utils.py 模块中,分别引入属性。由于这两个模块可以通过绝对路径来区分,所以这种引人方式是可行的。

# main.py
from analysis.utils import log_base2_bucket
from frontend.utils import stringify
bucket = stringify(log_base2_bucket(33))

但是,如果这些包里面定义的函数、类或子模块相互重名,那么上面的做法就失效了。例如,要从 analysis.utils 和 frontend.utils 包中,分别引入名为 inspect 的函数。此时不能像上面那样,直接引人这两个包中的属性,因为第二条 import 语句,会把当前作用域中的 inspect 值覆盖掉。

# main2.py
from analysis.utils import inspect
from frontend.utils import inspect  # Overwrites!

解决办法是:在 import 语句中,通过 as 子句,给引入当前作用域中的属性重新起个名字。

# main3.py
from analysis.uti1s import inspect as analysis_inspect
from frontend.uti1s import inspect as frontend_inspect

value = 33
if analysis_inspect(value) == frontend_inspect(value):
    print('Inspection equal!')

凡是通过 import 语句引入的内容,都可以用 as 子句来改名,即使引入整个模块,也依然能用 as 为其改名。这使得开发者能够轻松地访问位于不同名称空间之中的代码,并在引入该段代码的时候,阐明其身份。

2.稳固的 API

Python 包的第二种用途,是为外部使用者提供严谨而稳固的 API。

如果要编写使用范围较广的 API,如编写开源包,那么,就需要提供一些稳固的功能,并保证它们不会因为版本的变动而受到影响。为此,必须把代码的内部结构对外隐藏起来,以便在不影响现有用户的前提下,通过重构来改善包内的模块。

在 Python 程序中,可以为包或模块编写名为 __all__ 的特殊属性,以减少其曝露给外围 API 使用者的信息量。__all__ 属性的值,是一份列表,其中的每个名称,都将作为本模块的一条公共 API,导出给外部代码。如果外部用户以 from foo import * 的形式来使用 foo 模块,那么只有 foo.__all__ 中列出的那些属性,才会从 foo 中引入。若是 foo 模块没有提供 __all__,则只会引入 public 属性,也就是说,只会引入不以下划线开头的那些属性。

例如,要提供一个包,以计算移动的抛射体(projectile)之间的碰撞。下面定义的这个 models 模块,位于 mypackage 包中,该模块用来表示抛射体:

# models.py
__all__ = ['Projectile']

class Projectile(object):
    def __init__(self, mass, velocity):
        self.mass = mass
        self.velocity = velocity

然后,在这个 mypackage 包中,再定义一个 utils 模块,用来对 Projectile 实例执行某些操作,例如,模拟 Projectile 之间的碰撞。

# utils.py
from . models import Projectile

__all__ = ['simulate_collision']

def _dot_product(a, b):
    # ...

def simulate_co1lision(a, b):
    # ...

现在,想把这套 API 中所有的 public 部分,都作为 mypakcage 模块的属性,提供给外界用户,使他们总是能够直接引入 mypackage,而不用再执行 mypackage.models 和 mypackage.utils 等形式的引入操作。这样一来,即使 mypackage 包的内部结构发生变化(例如,删掉了 models.py 模块),使用这些 API 的外部代码,也依然能照常运行。

为了在 Python 包中实现此功能,需要修改 mypackage 目录下的 __init__.py 文件。当外界代码引入 mypackage 模块的时候,该文件实际上也会成为 mypackage 的一部分内容。因此,可以限定 __init__.py 文件所引入的名称,以此来指明 mypackage 模块所要曝露给外界用户的 API。由于 mypackage 内部的那些模块,都已经提供了 __all__ 属性,所以,只需把内部模块中的所有内容都引入进来,并据此更新 __init__.py__all__ 属性,即可把 mypackage 的 public 接口适当地公布给外界。

# __init_.py
__all__ = []
from . modeis import *
__all__ += models._all__
from . utils import *
__all__ += utils.__all__

经过上述处理之后,API的使用者就可以直接引入 mypackage,而不用再访问具体的内部模块了:

# api_consumer.py
from mypackage import *

a = Projectile(1.5, 3)
b = Projectile(4, 1.7)
after_a, after_b = simulate_collision(a, b)

请注意,像 mypackage.utils._dot_product 这样,专门在内部使用的函数,是不会作为 mypackage 包的 API 提供给使用者的,因为这些函数,没有出现在 __all__ 列表之中。在 __all__ 里省略某个名称,就意味着外部代码不会通过 from mypackage import * 语句导入该名称,这实际上相当于把内部专用的名称给隐藏起来了。

在必须给外界提供严谨而稳固的 API 时,上面这套方案相当合宜。但是,如果只想在自己所拥有的各模块之间构建内部的 API,那恐怕就用不到 __all__ 所提供的功能了,此时应该避免使用它。对于在大型代码库上相互协作的编程团队来说,包本身所提供的命名空间机制,通常来说,就已经足够用了,它可以令开发者在控制这份代码库的同时,保持合理的接口边界。

总结

  • Python 包是一种含有其他模块的模块。可以用包把代码划分成各自独立且互不冲突的名称空间,使得每块代码都能具备独有的绝对模块名称。
  • 只要把 __init__.py 文件放入含有其他源文件的目录里,就可以将该目录定义为包。目录中的文件,都将成为包的子模块。该包的目录下面,也可以含有其他包。
  • 把外界可见的名称,列在名为 __all__ 的特殊属性里,即可为包提供一套明确的 API。
  • 如果想隐藏某个包的内部实现,那么可以在包的 __init__.py 文件中,只把外界可见的那些名称引入进来,或是给仅限内部使用的那些名称添加下划线前缀。如果软件包只在开发团队或代码库内部使用,那可能没有必要通过 __all__ 来明确地导出 API。

第 51 条:为自编的模块定义根异常,以便将调用者与 API 相隔离

在为模块定义其 API 时,该模块所抛出的异常,与模块里定义的函数和类一样,都是接口的一部分。

Python 内置了一套异常体系,以供语言本身及标准库使用。于是,也总想使用这些内置的异常类型来报告错误,而不想自己去定义新的类型。例如,当外界给函数传入了无效的参数时,可能想抛出 ValueError 异常,以指出这一错误。

def determine_weight(volume, density):
    if density <= 0:
        raise valueError('Density must be positive')
    # ...

在某些情况下,使用 ValueError 也许是比较合适的,但是在设计 API 时,还是应该自己来定义一套新的异常体系,这样会令 API 更加强大。可以在模块里面提供一种根异常(root Exception),然后,令该模块所抛出的其他异常,都继承自这个根异常。

# my _module.py
class Error(Exception):
    """Base-class for all exceptions raised by this module."""

class InvalidDensityError(Error):
    """There was a problem with a provided density value."""

模块里有了这种根异常之后,API 的使用者就可以轻松地捕获该模块所抛出的各种异常。例如,API 的使用者在调用模块中的某个函数时,可能就会通过 try/except 语句来捕获这个根异常:

try:
    weight = my _module.determine_weight(1, -1)
except my_module.Error as e:
    logging.error('Unexpected error: %s', e)

API 所抛出的异常,如果向上传播得太远,就会令程序崩溃,而使用 try/except 语句,则可以防止这种情况,因为它会把调用代码与 API 隔开。这种隔离,有三个好处。

首先,通过捕获根异常,调用者可以得知他们在使用你的 API 时,所编写的调用代码是否正确。如果调用者以合理的方式来使用这套 API,那么他们应该会捕获该模块所抛出的各种异常。若是某种异常没有得到处理,那么该异常就会传播到 try/except 语句中负责处理模块根异常的那个 except 块里面,而那个 except 块,则会把该异常告知 API 的使用者,提醒他们应该为这种类型的异常添加适当的处理逻辑。

try:
    weight = my_module.determine_weight(1, -1)
except my_module.InvalidDensityError:
    weight = 0
except my _module.Error as e:
    logging.error('Bug in the calling code: %s', e)

使用根异常的第二个好处,是可以帮助模块的开发者找寻 API 里的 bug。如果在编写模块代码时,只抛出本模块的异常体系中定义过的那些异常,那么,其他类型的异常,就不应该由这个模块抛出。若发现本模块抛出了其他类型的异常,则说明 API 的代码里有 bug。

不过,上面那种 try/except 语句,并不能把 API 使用者与 API 模块代码中的 bug 相隔离。如果要隔离,那么调用者需要再添加一个 except 块,以捕获 Python 的 Exception 基类。这样他们就能够查出:API 模块的实现代码里面是不是留有尚待修复的 bug。

try:
    weight = my _module.determine_weight(1, -1)
except my_module.InvalidDensityError:
    weight = 0
except my_module.Error as e:
    logging.error('Bug in the calling code: %s', e)
except Exception as e:
    logging.error('Bug in the API code: %s', e)
    raise

使用根异常的第三个好处,是便于 API 的后续演化。将来可能会在 API 里面提供更为具体的异常,以便在特定的情况下抛出。例如,可以添加下面这个 Exception 子类,当调用者提供了负的密度值时,就抛出该异常:

# my_module.py
class NegativeDensityError(InvalidDensityError):
    """A provided density value was neaative."""

def determine_weight(volume, density):
    if density < 0:
        raise NegativeDensityError

添加了这个新的 NegativeDensityError 异常之后,原有的调用代码仍然能够继续运作,因为它所捕获的 InvalidDensityError 异常,正是这个新异常的父类。调用者以后可以针对这种新的异常类型,做出特殊的处理,并据此修改程序的行为。

try:
    weight = my_module.determine_weight(1, -1)
except my_moduTe.NegativeDensityError as e:
    raise ValueError('Must supply non-negative density') from e
except my_module.InvalidDensityError:
    weight = 0
except my_module.Error as e:
    logging.error('Bug in the calling code: %s', e)
except Exception as e:
    logging.error('Bug in the API code: %s', e)
    raise

还可以在模块的根异常之下,直接定义一套更为广泛的异常,以便为 API 的后续演化做出更加充分的准备。例如,可以把计算重量时发生的错误归到某一类异常里面,把计算体积时发生的错误归到另—类异常里面,再把计算密度时发生的错误归到第三类异常里面。

# my_module.py
class weightError(Error):
    """Base-class for weiaht calculation errorc."""

class VolumeError(Error):
    """Base-class for volume calculation errors."""

class DensityError(Error) :
    """Base-class for density calculation errors."""

可以从上面这三个通用的异常类之中,继承更为具体的异常子类。上面这三个异常,介于模块的根异常和具体的子异常之间,所以它们本身也可以看作各自门类的根异常。模块的使用者,可以根据这三个大的门类,把调用代码与 API 代码轻松地隔离开,而不用再像原来那样,编写冗长的 catch 块,把每一种具体的 Exception 子类都捕获一遍。

总结

  • 为模块定义根异常,可以把 API 的调用者与模块的 API 相隔离。
  • 调用者在使用API时,可以通过捕获根异常,来发现调用代码中隐藏的 bug。
  • 调用者可以通过捕获 Python 的 Exception 基类,来帮助模块的研发者找寻 API 实现代码中的 bug。
  • 可以从模块的根异常里面,继承一些中间异常,并令 API 的调用者捕获这些中间异常。这样模块开发者将来就能在不破坏原有调用代码的前提下,为这些中间异常分别编写具体的异常子类。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值