头图

序言

众所周知,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.理念, 我甚至可以花里胡哨地、用关键字参数的语法向string1string2传参

string_equal(string1='Goodbye, world!', string2='ello')  # 返回值为False

但瑜不掩瑕,Python 的关键字参数也有其不足。

Python 的不足

Python 的关键字参数特性的缺点在于,同一个参数无法同时以:

  1. 具有自身的参数名,以及;
  2. 可以从**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中传入什么参数,必须:

  1. 先知道requests.request是如何往requests.Session#request中传参的——将kwargs完全展开传入是最简单的情况;
  2. 再查看requests.Session#request的参数列表中排除掉methodurl的部分剩下哪些参数。

如果想在requests.request的参数列表中使用参数自身的名字(例如paramsdatajson等),那么调用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-Typeapplication/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只能按位置来传参。

阅读原文


用户bPGfS
169 声望3.7k 粉丝