分享:让 Common Lisp 代码更简洁可读性高一些~

平时看Lisp源代码(和自己写的代码)很多都是函数调用很多层,看起来比较吃力。。。
所以想实践下自己学到的伎俩,让很多函数调用可读性强一些, 有点像我以前用的 Thenjs,现在变为 ES6+ await/async,但是貌似更灵活:

(<< (fn1 a ~ b c)
    'fn2  ;;  #'fn2 is OK as well
    'fn3
    (fn4 xx)
    (+ 1 2 3))
;;or reverse:
(=> (+ 1 2 3)
    (fn4 xx)
    'fn3
    'fn2
    (fn1 a ~ b c))
;; Equals:
(fn1 a (fn2 (fn3 (fn4 xx (+ 1 2 3)))) b c)

大家有没有觉得这样可读性更友好呢?(又不会损失半点性能,波浪线 ~ 是我定义的一个表示占位符的符号)
我也尝试过用 curry+pipe/compose,但是那样增加了好几个函数调用,性能损失可能不小,心理不舒服。。。

实现也并不复杂:

(defmacro => (init &rest codes)
  "~ for arg and can be recursive/embed"
  (unless codes
    (return-from => init))
  (destructuring-bind (next . others) codes
    (when (atom? next) ; a function variable
      (setf next `(call ,next ~)))
    (when (find (car next) '(quote function))	;; => 'func #'func or function variable are both OK
      (setf next `(,(second next) ~)))
    (unless (find ~ next)
      (insert* ~ next))
    `(=> ,(substitute init ~ next) ,@others)))    ;; Can't use subst, should not deep replace

(defmacro << (&rest codes)
  `(=> ,@(nreverse codes)))

你有沒有发现只要折了行就是差不多的么⋯⋯连額外的 macro 都不用写。

(fn1 a 
    (fn2 
    (fn3 
    (fn4 xx 
    (+ 1 2 3)))) 
  b c)

重新发明threading macro?

https://clojure.org/guides/threading_macros

1赞

https://quickref.common-lisp.net/arrows.html

你猜猜为什么 threading macro 沒在 CL 火起來?

threading一般当作JavaScript等语言的dot chain来使用,比如比如某个对象的一个方法返回和原对象有类似性质的对象,然后可以无限chain下去(例子:builder pattern)

(thread-first (make-xxx-struct)
  (set-xxx-struct-field-a ...)
  (set-xxx-struct-field-b ...)
  (set-xxx-struct-field-c ...))

写成Javascript大概是

(new XXX()).setFieldA(...).setFieldB(...).setFieldC(...)

Lisp里的threading macro,除了用来连接method,还可以用来连接不同的函数。然而对于方法来说,总有一个主要参数(self)。对于一般的函数,参数之间不一定有这个主次之分,反而是互相平行的关系(比如list)

对于上面的例子,假如我修改一下需求,最后返回的东西变成一个cdr是一个tag的cons cell,我可以直接改写成

(thread-first (make-xxx-struct)
  (set-xxx-struct-field-a ...)
  (set-xxx-struct-field-b ...)
  (set-xxx-struct-field-c ...)
  (cons 'tag))

但这样看起来就会十分的怪异,cons就接受了一个参数,与常识不符。另外(cons 'tag)给人感觉是car部分是'tag,实际上'tag是cdr。给阅读源码带来的无谓的麻烦。


-> thread-first这种在头部插入的宏后,还会造成编辑器参数提示不友好(比如eldoc)的问题,由于忽略了头参数,剩下的参数提示都会错位显示。对于->> thread-last从尾部插入某种意义上可以规避这个问题,但是这样就会丢失了使用&rest &key这些复杂参数列表的可能性。总之是两面不讨好。


不过说到底,threading macro算是半个人畜无害的特性,为什么火不起来,可能只是单纯的不喜欢用罢了。但绝不可能是因为可以花式缩进的原因,当threading中间几个forms也带上参数,用缩进和用threading是完全不同样的。

感觉很高级的样子。个人还挺习惯缩进的,写成一行总是想起恐怖的压缩后 css 和 js 代码 :joy: :joy: :joy:

你用elisp就可以用subr-x里的thread-first thread-last或者dash里的-> ->>来体验

1赞

缩进太多的时候我偶尔用。

太整齐的代码,总会让我有产生一种顺序无关的错觉,觉得还可以排整齐一些。有一次为了对齐调整了 or 语句内部的顺序,结果炸了半天才找到原因。

请用数据说话

1赞

看起来老铁也有强迫症哪

Sexp 什么都好,就是缩进太难受…尤其是多层嵌套的时候,或者一个很长的参数后面跟一个很短的参数,难受啊。

1赞

写代码有时候考验的是「排版」技术。

1赞

我觉得 threading 挺好的。楼主设计的占位符就可以解决上面的一些疑问吧。

一般情况下链式调用的情况,换上 threading 应该是无缝衔接。而对于前面的运算结果不是作为第一个参数的情况,确实有点难受,这时候占位符就挺好。我平常用的 Racket 里的 threading,也是有这么一个设计,不过是用 _ 作为占位符,那么就可以写成这样:

(~> (make-foo ...)
    (do-something-on-foo _ arg ...)
    ...
    (cons _ some-tag))

这样下来还是很容易看清楚实际的参数位置的,而且还可以解决并不想在第一个参数位置插入前面的计算结果的情况。就我个人的习惯,(即使会自动把前面的计算结果隐式地作为第一个参数,)我还是会显式地标记一下参数的位置。

而且 threading 主要不是在于缩进的问题吧,这样的写法可能更贴近计算流程,在不少情况下也符合编码时候的思路。


Clojure 的一些小发明我觉得还挺好,比如这里的 threading,比如对匿名函数的 reader,#(+ % %2) 都可以解决一些特定情况下的痛点。尤其是后面一个 % 的设计,我很喜欢。

2赞

应该是不会影响运行时性能的吧,宏展开时就还原成普通的调用了。说影响应该会对编译时期产生影响吧,毕竟多用了一个宏,需要多进行一次/组宏展开了。

Clojure里面的threading macro好用,写elisp的时候就从来没用过threading。

感觉没有啥缩进问题。 一个函数别用两种threading,一个函数别有两个condition branching,一个函数别有两层let,就能搞的比较好看。

(defn fetch [{:keys [params] :as ctx}]
  (-> {:url api-url, :params params}
    (http/get)
    (ensure-success!)
    (:body)
    (json/parse-string {:key-fn keyword})))

(defn parse [items]
  (->> items
    (map ->some-item)
    (reduce summary (init-result))
    (dump datasource)))

(def api-request (comp parse fetch))

(api-request {:params {:x 1}})

Emacs 默认缩进都会把这个变成

(fn1 a 
     (fn2 
      (fn3 
       (fn4 xx 
            (+ 1 2 3)))) 
     b c)

啊~~ 你关闭了默认缩进? 另外如果 fn2 fn3 也有额外的参数可能不那么直观
我也考虑连 => 都不用 用大括号:

{ 'fn1  
  'fn2  
  (fn3 a ~ c)
  (fn4 5 6 ~)
  (+ 1 2 3)
...}

但好像也没有好看到哪里去。。。

我没有了解这方面的库,只是在去年初看JavaScript 熟悉函数式编程后觉得很有用,然后写了自己的 Common Lisp 版 curry、pipe…
《Ansi Common Lisp》 有简化版的 curry/compose/pipe,我写了加强版。。。 . https://github.com/Soul-Clinic/cl-celwk/blob/master/functional.lisp

另外我也参考以前用的node.js 的 Koa/Express (他好像是参考Ruby on Rails 的??),写了更灵活的 Hunchentoot 版本(smart-server)~~,觉得有用的就拿去。。。

(smart=> "/$name/#id/%money/:type/$others*" fn1 fn2 @next)  
;; @next is optional, means go on next route parser, otherwise return the last function value

(smart+ fn1 (id name type money others)  
;; name the same with the route path, not necessary same order
  "$name => string  
  #id => integer  
  :type => symbol 
  %money => float  
  $others* => rest (an optional list)
  $others? => an optional string"
  codes...)

自认为比 Hunchentoot 的 easy-handler 好用灵活…

因为我这是个 macro,解析后就是函数调用,例如获取IP地址的物理地址:

我是隐式地作为最后一个参数。。。
我也参考RamdaJS 写了个可以加任意的待输入参数的curry:

(call (call (curry~ 'fn1 ~ a b ~ ~) d) e f) => (fn1 d a b e f)

另外没想到 Clojure 也是用这样风格的符号,我是自己编的~~

另外我这个没涉及多线程, 暂时还没用到。。。
只写了个简单的 race 和时间限制函数,用来限制获取IP地理位置的最长时间(用到SBCL的 gate 函数,好用么?)

这不就是clojure中流行的东西吗?

而且emacs的dash.el库就有这个功能: https://github.com/magnars/dash.el.

dash中的介绍:

Threading macros:
-> (x &optional form &rest more)
->> (x &optional form &rest more)
--> (x &rest forms)
-as-> (value variable &rest forms)
-some-> (x &optional form &rest more)
-some->> (x &optional form &rest more)
-some--> (x &optional form &rest more)

但我在写程序的时候没怎么用过这个特性, 因为抽象层次太高, 不是很好调试…

我没用过 Clojure 啊,买了本书觉得不好看。。