Python函数接口的一些设计心得

不止一次听到熟悉Java的程序员抱怨说Python项目难管理、难重构了,我认为这个可能跟设计理念有关系。对Python这种动态语言来说,许多从Java时代延续下来的设计方法是不合适的,但我们回头来看这些设计方法的时候,会发现也许是这些设计方法本身就有问题。

这篇文章我们着重讲一下,如何设计一个函数的参数列表,这是个很小的问题,却可以引申到整个设计理念差异上面。

Java的许多函数参数是使用自己专有的对象类型的。这个习惯可能从C的时代就沿袭下来了,C中一部分参数复杂的函数也会使用一个结构体,主要的原因是:

  1. 参数太多,写完整的参数列表太长了
  2. 没有合适的方式表达复杂的参数格式(比如变长的数组)
  3. 大部分参数可以用默认的0,这样可以用memset初始化结构体,然后只赋值比较少的几个
  4. 需要反复使用一组参数的时候,传递一个指针(或者C++的引用)比重新在栈里复制一次开销小一些
  5. 二进制连接,而且不支持重载,希望通过结构体头部字段(比如Windows下通常有个字段表示结构体大小)来支持扩展

Java当中也经常使用这种接口形式,理由跟上面其实有类似的地方:

  1. 参数太多,完整的参数列表太长,尤其是要把类型全都写上的时候(很多泛型类型名字很长)
  2. 没有特别有效的方式表达复杂的参数格式,虽然支持List、Array、Map等基础数据类型,但构造包含数据的这些类型很困难
  3. 可能的性能损耗
  4. 不支持默认参数
  5. 希望能通过传递接口的方式支持多态

这些理由当中第二条和第四条直接跟Java语言的缺陷有关,第五条看上去很OOP,实际上我认为完全是个设计错误。我们回到一个比较本质的问题:

怎样的参数列表是清晰的?

我们想一下,当我们拿到一个参数列表的时候,什么情况下我们可以在最短时间内知道如何调用它?

  1. 所有的参数都是你熟悉的类型
  2. 所有的参数是单一职责的,你很清楚传入的这个参数起什么作用
  3. 所有的参数不会在传入之后产生副作用

如果传入的参数是个对象呢?

  1. 我们很可能不熟悉这个对象的类型。尤其对于动态类型语言来说,这个参数可能没有明确的类型
  2. 我们无法知道这个对象是用来干什么的,究竟其中的哪些属性被用到了,哪些方法会被调用,如果我们希望在动态类型语言中造一个duck type的对象,应当最少提供哪些成员
  3. 我们不知道这个函数是否会修改这个对象,是否会在内部保存这个对象,这个对象的所有权究竟是移交了还是仍然保留在我手里还是由我跟其他人共享,我是否能够再次复用这个对象等等

尤其是如果你希望通过传递一个接口类型来实现多态,我们会遇到怎样的困境呢?

  1. 当我需要添加新的功能的时候,我很可能需要修改这个接口。这意味着所有调用这个函数的地方都要做相应的修改,要为它们的实现添加这个新的接口。
  2. 接口中用到的各个方法和属性是紧密耦合在一起的。即使是最严格的实现规范,也很难保证这些实现不同的接口在各种调用方式下都保持一致的特性。某个版本可能会调换了接口中两个方法的调用顺序,仅仅是这样就可能会带来新的bug。

对Python来说

我们回到之前Java选择传递对象作为参数的理由,我们对比一下Python

  1. 动态类型语言不需要写类型名, 所以参数很多的情况下看起来也不会特别长
  2. 非常容易构造复杂的结构化数据,比如元组、字典、列表等
  3. 性能。性能??
  4. 不仅支持默认参数,而且支持使用keyword-argument来传递参数,因此可以在很长的参数列表中,有选择地传递少数几个,还支持*args,**kwargs这样的可扩展参数,没有必要依赖对象来实现扩展性
  5. 可以传递函数、boundmethod等callable作为参数

前面应该都比较好理解,我们着重说一下最后一点。我们有的时候需要调用方为我们提供一些用于回调的方法,Java当中通常需要传递一个接口。如果说需要传递的回调方法不止一个,就可能会合并在同一个接口中。

而Python直接就可以传递一个callable,这带来了很多的好处:

  1. 单一职责。 你很清楚传进来的这个函数是需要被调用的,以及如何被调用;它不需要跟其他传入的参数有联系。这也是最重要的好处。
  2. 灵活性。Python的callable可以是一个普通函数,可以是一个闭包,也可以是实例的boundmethod,还可以是一个lambda表达式,还可以是一个类,还可以是一个实现了__call__的类的实例,调用方可以很容易进行自由选择,自由实现
  3. 多个callable完全不需要有联系,可以分开实现,分开复用
  4. 可以有缺省值,这样调用方如果只需要默认的行为,可以不传

Python函数参数设计的准则

基于以上理由,我个人总结的函数参数设计的准则是这样的:

一:参数必须是某个基础类型

这包括内置类型如字符串、整数、元组、列表、字典等,也包括一组贯穿你整个项目的基础的类的实例,这些实例不管是开发你项目的哪一个部分都是必须知晓的,比如说异步编程的接口需要传入一个loop对象(或者调度器对象),这没有什么问题,如果调用方不知道什么是调度器那他大概也不用往下写了。不应该使用某个专门为了这个功能而设计的类的实例来作为参数。

某种callable也算是基础类型,可以在文档中说清这个callable的参数列表以及类型,这个callable的参数也应当符合这里讨论的准则。这个callable应该接受所有它可能需要的参数,而不是默认它在内部实现机制上知道一起调用时传入的其他参数。如果有特殊的要求也应该声明。

二:对于需要比较复杂的数据的情况,可以使用嵌套的元组、字典等进行传递

在Python中,构造一个对象的代价远远比构造一个嵌套的元组(列表)或字典要高,尤其是列表、字典可以通过生成器表达式来构造的情况下。而且这些数据类型永远可以通过deepcopy复制,不会担心有副作用。

除非必要,sequence类型(包括元组、列表、set、迭代器乃至所有可以迭代的对象)都应当支持,视作同一种类型,可以在传入的时候通过list()或者tuple()转换成确定的类型。这是因为你比较难在文档中描述清楚你需要的究竟是一个列表套列表还是元组套元组。

注意只有当有必要这么做的时候才用这种方法,正常情况下应当尽量使用参数列表。

三:永远不要修改或保存传入的数据

某些内置类型也是引用类型,如列表、字典、set等,永远不要默认调用方将所有权转移给了你,如果你需要修改或者永久保存这个数据,一定要使用list、dict等构造函数或者copy/deepcopy来复制一个副本。

四:重要的参数在前,次要的参数在后且有默认值

这个一般人都应该能理解,虽然可以用keyword-argument,但毕竟比较长,参数很多的时候,大家都会默认后面的参数不看也没什么大不了的。

参数列表设计带来的解耦

这样设计并不是说大家不要用OOP,不要设计类了,而是相反。这种设计是让大家尽量不要把一个类的实例当作另一个类的参数来使用,尤其当它们属于不同的模块的时候。这就带来了模块之间的解耦,从:

类 <=> 类

变成了

类 <=> 最小数据集合 <=> 类

的关系。这个最小的数据集合也可以理解为一种protocol,它很清晰地描述了我们在这个问题中,需要传递哪些信息。这个集合很容易扩展,在Python当中,只要在参数列表末位添加新的带有缺省值的参数就可以了。

这样的设计还可以很容易直接将函数定义映射到WebService上,我们可以发现如果没有复杂的callable之类的参数,这些参数通常都可以用JSON来表示,那么我们就可以直接将接口映射到WebService上而不需要过多的修改,也许添加一个注解就足够了。

发布于 2017-01-25 12:01