最近在工作中的一些场景,让我想到了「编译期计算」这个概念,然而一直没有时间整理、复习下相关概念。

什么是编译期计算

「编译期计算」是指: 可以在编译期间执行的的运算或者这种能力

这并不是什么新的概念,最古老的语言之一的 C 就具有很弱的编译期计算能力,而之后的编译语言中,凡是支持 元编程的,多多少少都能支持编译期间预算。

而另外一方面,编译期计算也是一种常见的编译优化手段,高优化的编译器会尽可能的,对在编译期间可以求值的计算过程进行 求值,然后内联其计算结果,比如:如果一个循环内所有变量都是常量,在 -O2 和 -O3 的情况下,GCC 生成的代码多半只有 循环结果,而没有循环的过程。

但是,相对而言,大部分编程语言所支持的 编译期计算 和 Lisp 比起来,都很弱

做为最古老的编程语言之一,Lisp 发明了很多 超时代 的特性,最出名的莫过于 垃圾回收(GC) 了,但是 Lisp 对 计算的控制粒度和灵活度,目前也鲜有对手。

Common Lisp 和 编译期计算

C++11 引入了一个关键字 constexpr,用来声明一个函数或者变量,在编译期是可能进行求值的,相比 模板 , 这是一个更加强大的编译期计算的能力。

不过,这种能力却是 30 多年前的 Common Lisp 里面的内建功能,这里用一个求和函数进行演示

(defpackage ct
  (:use :cl)
  (:export :test1 :test2))

(in-package ct)

;;; 声明一个求和函数 sum
;;; 设定函数 sum 具有在编译期和执行时进行运算的能力
;;; 默认情况下函数只能在执行时(execute)才能进行计算
(eval-when (:compile-toplevel :execute)
           (defun sum (&rest args)
             (apply #'+ args)))

;;; 声明函数 test1,使用求和函数求值
(defun test1 ()
  (let ((x (sum 1 2 3 )))
    x))

;;; 声明函数 test2,使用语言自带的加法运算求值
(defun test2 ()
  (let ((x (+ 1 2 3)))
    x))

然后在 REPL 中执行编译和加载

(compile-file "ct.lisp")
(load "ct.fasl")

先输出 test2 的汇编代码

(disassemble #'ct:test2)
; disassembly for CT:TEST2
; Size: 21 bytes. Origin: #x534C531C                          ; CT:TEST2
; 1C:       498B4510         MOV RAX, [R13+16]                ; thread.binding-stack-pointer
; 20:       488945F8         MOV [RBP-8], RAX
; 24:       BA0C000000       MOV EDX, 12
; 上面的 12 就是 1 + 2 + 3 = 6 在 SBCL 中的表示, SBCL 中的 fixnum 最低位应该是 0
; 所以值相当于左移了一位
; 29:       488BE5           MOV RSP, RBP
; 2C:       F8               CLC
; 2D:       5D               POP RBP
; 2E:       C3               RET
; 2F:       CC10             INT3 16                          ; Invalid argument count trap

可以看到 SBCL 编译器实现的加法运算在编译期对运算进行了求值,最后生成的代码中只剩下了求值结果,而没求值过程

再来输出 test1 中的汇编代码

(disassemble #'ct:test1)
; disassembly for CT:TEST1
; Size: 56 bytes. Origin: #x535C05CC                          ; CT:TEST1
; 5CC:       498B4510         MOV RAX, [R13+16]               ; thread.binding-stack-pointer
; 5D0:       488945F8         MOV [RBP-8], RAX
; 5D4:       4883EC10         SUB RSP, 16
; 5D8:       BA02000000       MOV EDX, 2
; 5DD:       BF04000000       MOV EDI, 4
; 5E2:       BE06000000       MOV ESI, 6
; 上面三行代码分别将 1 2 3 拷贝到寄存器中
; 5E7:       B906000000       MOV ECX, 6
; 5EC:       48892C24         MOV [RSP], RBP
; 5F0:       488BEC           MOV RBP, RSP
; 5F3:       E80AC1E2FC       CALL #x503EC702                 ; #<FDEFN CT::SUM>
; 调用 ct::sum 求值
; 5F8:       480F42E3         CMOVB RSP, RBX
; 5FC:       488BE5           MOV RSP, RBP
; 5FF:       F8               CLC
; 600:       5D               POP RBP
; 601:       C3               RET
; 602:       CC10             INT3 16                         ; Invalid argument count trap

可以看到 sum 函数没有在编译期被求值,汇编代码忠实的还原了函数调用的过程

但是,在文件开通, sum 被声明可以在编译期进行计算,那为什么这里没有被计算呢?

这里, sum 之所以没有被计算,是因为它是在一个函数(test1)的声明中,在编译期间,编译器只是生成了一个函数, 并没有任何地方告诉编译器,在这个函数内部的某个表达式中,有一个 sum 是可以被求值的, 所以只要能在编译器进 行编译时,触发对 sum 的求值就行

(defmacro compile-time-sum (&rest args)
  "定义一个宏,让其在编译时调用sum,而不是返回一个表达式"
  (apply #'sum args))

(defun test3 ()
  "函数 test3 使用上面的宏替换掉 sum"
  (let ((x (compile-time-sum 1 2 3)))
    x))

对文件重新编译、加载后,查看 test3 的汇编代码

; disassembly for CT::TEST3
; Size: 21 bytes. Origin: #x5358324C                          ; CT::TEST3
; 4C:       498B4510         MOV RAX, [R13+16]                ; thread.binding-stack-pointer
; 50:       488945F8         MOV [RBP-8], RAX
; 54:       BA0C000000       MOV EDX, 12
; 59:       488BE5           MOV RSP, RBP
; 5C:       F8               CLC
; 5D:       5D               POP RBP
; 5E:       C3               RET
; 5F:       CC10             INT3 16                          ; Invalid argument count trap

可以看到,test3 生成的代码,和编译器自己实现的加法运算的代码完全一样,计算过程被优化掉了,只剩下计算结果

但是这样就会产生另外一个问题,对于每个编译期函数,如果都需要进行一层包装,岂不是很麻烦?实际上,并不是,Common Lisp 中有专门定义 编译器宏 的宏

define-compiler-macro

先看代码,

(defpackage ct2
  (:use :cl)
  (:export :test1 :test2))

(in-package ct2)

;;; 和之前一样,先声明求和函数可以在编译期进行计算
(eval-when (:compile-toplevel :load-toplevel  :execute)
  (defun sum (&rest args)
    (apply #'+ args)))

;;; 定义一个编译器宏 sum, 这个宏的逻辑如下:
;;; 如果所有参数在编译期间都是常量,则调用 求和函数sum 进行求值
;;; 如果不是,则返回原表达式
;;; 这里实际上就是相当于在编译时,对语法树进行重写
(define-compiler-macro sum (&whole from &rest args)
  (if (every #'constantp args)
      (apply #'sum args)
      from))

;;; test1 里面 sum 的参数全是常量
(defun test1 (x)
  (let ((y (sum 1 2 3 4)))
    y))

;;; test2 里面 sum 的参数含有变量
(defun test2 (x)
  (let ((y (sum 1 2 3 x)))
    y))

test1 的汇编代码:

; disassembly for CT2:TEST1
; Size: 21 bytes. Origin: #x535C178D                          ; CT2:TEST1
; 8D:       498B4510         MOV RAX, [R13+16]                ; thread.binding-stack-pointer
; 91:       488945F8         MOV [RBP-8], RAX
; 95:       BA14000000       MOV EDX, 20
; 9A:       488BE5           MOV RSP, RBP
; 9D:       F8               CLC
; 9E:       5D               POP RBP
; 9F:       C3               RET
; A0:       CC10             INT3 16                          ; Invalid argument count trap

test2 的汇编代码:

; disassembly for CT2:TEST2
; Size: 92 bytes. Origin: #x533F0D54                          ; CT2:TEST2
; 54:       498B4510         MOV RAX, [R13+16]                ; thread.binding-stack-pointer
; 58:       488945F8         MOV [RBP-8], RAX
; 5C:       4C8D4424F0       LEA R8, [RSP-16]
; 61:       4883EC20         SUB RSP, 32
; 65:       BA02000000       MOV EDX, 2
; 6A:       BF04000000       MOV EDI, 4
; 6F:       BE06000000       MOV ESI, 6
; 74:       4D8948F0         MOV [R8-16], R9
; 78:       B908000000       MOV ECX, 8
; 7D:       498928           MOV [R8], RBP
; 80:       498BE8           MOV RBP, R8
; 83:       E85ABAFFFC       CALL #x503EC7E2                  ; #<FDEFN CT2::SUM>
; 88:       480F42E3         CMOVB RSP, RBX
; 8C:       4C8B4DF0         MOV R9, [RBP-16]
; 90:       488D42F1         LEA RAX, [RDX-15]
; 94:       A801             TEST AL, 1
; 96:       750D             JNE L0
; 98:       3C0A             CMP AL, 10
; 9A:       7409             JEQ L0
; 9C:       A80F             TEST AL, 15
; 9E:       750B             JNE L1
; A0:       803829           CMP BYTE PTR [RAX], 41
; A3:       7706             JNBE L1
; A5: L0:   488BE5           MOV RSP, RBP
; A8:       F8               CLC
; A9:       5D               POP RBP
; AA:       C3               RET
; AB: L1:   CC57             INT3 87                          ; OBJECT-NOT-NUMBER-ERROR
; AD:       08               BYTE #X08                        ; RDX(d)
; AE:       CC10             INT3 16                          ; Invalid argument count trap

可以看见,test1 因为符合 编译器宏 sum 里面的运算规则,所以在编译期间被求值了,而 test2 因为含有变量,无法被求值, 所以最终生成的代码并没有优化,而是忠实的还原了函数调用过程。(实际上只要变量也是可以在编译期求值的,test2 也是可以 进行优化的,不过那样的例子太复杂了)

编译期求值可以做什么

因为真实世界存在大量 IO 副作用,不存在真正的的纯函数,所以编译期求值的适用范围比运行时求值要小很多。

最常见的还是数值计算、文本处理、序列化/反序列化、类型转换等的优化。