序言
众所周知,Python 支持向函数传递关键字参数。比如 Python 的内置函数max
就接受名为key
的关键字参数,以决定如何获取比较两个参数时的依据
max({'v': 1}, {'v': 3}, {'v': 2}, key=lambda o: o['v']) # 返回值为{'v': 3}
自定义一个运用了关键字参数特性的函数当然也不在话下。例如模仿一下 Common Lisp 中的函数string-equal
def string_equal(string1, string2, *, start1=None, end1=None, start2=None, end2=None):
if not start1:
start1 = 0
if not end1:
end1 = len(string1) - 1
if not start2:
start2 = 0
if not end2:
end2 = len(string2) - 1
return string1[start1:end1 + 1] == string2[start2:end2 + 1]
再以关键字参数的形式向它传参
string_equal("Hello, world!", "ello", start1=1, end1=4) # 返回值为True
秉承 Python 之禅中的 我甚至可以花里胡哨地、用关键字参数的语法向There should be one-- and preferably only one --obvious way to do it.
理念,string1
和string2
传参
string_equal(string1='Goodbye, world!', string2='ello') # 返回值为False
但瑜不掩瑕,Python 的关键字参数也有其不足。
Python 的不足
Python 的关键字参数特性的缺点在于,同一个参数无法同时以:
- 具有自身的参数名,以及;
- 可以从
**kwargs
中取得,
两种形态存在于参数列表中。
举个例子,我们都知道 Python 有一个知名的第三方库叫做 requests,提供了用于开发爬虫牢底坐穿的发起 HTTP 请求的功能。它的类requests.Session
的实例方法request
有着让人忍不住运用 Long Parameter List 对其重构的、长达 16 个参数的参数列表。(你可以移步request
方法的文档观摩)
为了便于使用,requests 的作者贴心地提供了requests.request
,这样只需要一次简单的函数调用即可
requests.request('GET', 'http://example.com')
requests.request
函数支持与requests.Session#request
(请允许我借用 Ruby 对于实例方法的写法)相同的参数列表,这一切都是通过在参数列表中声明**kwargs
变量,并在函数体中用相同的语法向后者传参来实现的。(你可以移步request 函数的源代码观摩)
这样的缺陷在于,requests.request
函数的参数列表丢失了大量的信息。要想知道使用者能往kwargs
中传入什么参数,必须:
- 先知道
requests.request
是如何往requests.Session#request
中传参的——将kwargs
完全展开传入是最简单的情况; - 再查看
requests.Session#request
的参数列表中排除掉method
和url
的部分剩下哪些参数。
如果想在requests.request
的参数列表中使用参数自身的名字(例如params
、data
、json
等),那么调用requests.Session#request
则变得繁琐起来,不得不写成
with sessions.Session() as session:
return session.request(method=method, url=url, params=params, data=data, json=data, **kwargs)
的形式——果然人类的本质是复读机。
一个优雅的解决方案,可以参考隔壁的 Common Lisp。
Common Lisp 的优越性
Common Lisp 第一次面世是在1984年,比 Python 的1991年要足足早了7年。但据悉,Python 的关键字参数特性借鉴自 Modula-3,而不是万物起源的 Lisp。Common Lisp 中的关键字参数特性与 Python 有诸多不同。例如,根据 Python 官方手册中的说法,**kwargs
中只有多出来的关键字参数
If the form “**identifier” is present, it is initialized to a new ordered mapping receiving any excess keyword arguments
而在 Common Lisp 中,与**kwargs
对应的是&rest args
,它必须放置在关键字参数之前(即左边),并且根据 CLHS 中《A specifier for a rest parameter》的说法,args
中含有所有未经处理的参数——也包含了位于其后的关键字参数
(defun foobar (&rest args &key k1 k2)
(list args k1 k2))
(foobar :k1 1 :k2 3) ;; 返回值为((:K1 1 :K2 3) 1 3)
如果我还有另一个函数与foobar
有着相似的参数列表,那么也可以轻松将所有参数传递给它
(defun foobaz (a &rest args &key k1 k2)
(declare (ignorable k1 k2))
(cons a
(apply #'foobar args)))
(foobaz 1 :k1 2 :k2 3) ;; 返回值为(1 (:K1 2 :K2 3) 2 3)
甚至于,即使在foobaz
中支持的关键字参数比foobar
要多,也能轻松地处理,因为 Common Lisp 支持向被调用的函数传入一个特殊的关键字参数:allow-other-keys
即可
(defun foobaz (a &rest args &key k1 k2 my-key)
(declare (ignorable k1 k2))
(format t "my-key is ~S~%" my-key)
(cons a
(apply #'foobar :allow-other-keys t args)))
(foobaz 1 :k1 2 :k2 3 :my-key 4) ;; 打印my-key is 4,并返回(1 (:ALLOW-OTHER-KEYS T :K1 2 :K2 3 :MY-KEY 4) 2 3)
回到 HTTP 客户端的例子。在 Common Lisp 中我一般用drakma这个第三方库来发起 HTTP 请求,它导出了一个http-request
函数,用法与requests.request
差不多
(drakma:http-request "http://example.com" :method :get)
如果我想要基于它来封装一个便捷地发出 GET 请求的函数http-get
的话,可以这样写
(defun http-get (uri &rest args)
(apply #'drakma:http-request uri :method :get args))
如果我希望在http-get
的参数列表中直接暴露出一部分http-request
支持的关键字参数的话,可以这样写
(defun http-get (uri &rest args &key content)
(declare (ignorable content))
(apply #'drakma:http-request uri :method :get args))
更进一步,如果我想在http-get
中支持解析Content-Type
为application/json
的响应结果的话,还可以这样写
(ql:quickload 'jonathan)
(ql:quickload 'str)
(defun http-get (uri &rest args &key content (decode-json t))
;; http-request并不支持decode-json这个参数,但依然可以将整个args传给它。
(declare (ignorable content))
(multiple-value-bind (bytes code headers)
(apply #'drakma:http-request uri
:allow-other-keys t
:method :get
args)
(declare (ignorable code))
(let ((content-type (cdr (assoc :content-type headers)))
(text (flexi-streams:octets-to-string bytes)))
(if (and decode-json
(str:starts-with-p "application/json" content-type))
(jonathan:parse text)
text))))
不愧是Dio Common Lisp,轻易就做到了我们做不到的事情。
题外话
曾几何时,Python 程序员还会津津乐道于 Python 之禅中的There should be one-- and preferably only one --obvious way to do it.
,但其实 Python 光是在定义一个函数的参数方面就有五花八门的写法了。甚至在写这篇文章的过程中,我才知道原来 Python 的参数列表中可以通过写上/
来使其左侧的参数都成为 positional-only 的参数。
def foo1(a, b): pass
def foo2(a, /, b): pass
foo1(a=1, b=2)
foo2(a=1, b=2) # 会抛出异常,因为a只能按位置来传参。