数组语言(未完待续

对于这篇文章,我想从第一性原理在Racket中构建一个数组语言,或者说嵌入式的数组DSL。

这个数组语言的风格接近于APL,其是一种古老的编程语言,并不比Fortran和Lisp晚多少。然而,它的风格却相当特殊,或许可以形容为point-free的组合子式的。

我们并不是要还原APL的编程体验,也不是制作一个嵌入式的APL实现。尽管如此,这个语言在精神上非常接近于APL。

首先我们要思考如何表示一个数组。一个数组可以视为由形状和索引上的函数构成。实际上,虽然这听起来相当fancy,但是历史上第一门编程语言Fortran就采用了这样的观点。(当然了,并不是说Fortran以这种方式实现了数组。)

(struct array (shape indexf) #:transparent)

这是我们对于数组的定义。尽管这个定义相当灵活,留有许多解释的空间,但实际上我们只是利用了很有限的可能性。例如,

(array 100 identity)

代表一个长度为100的数组,这个数组的元素和索引一致,其索引范围是从0到99。

(array '(3 4) +)

代表一个3*4的矩阵,其元素是行与列数之和。

接下来,我们要定义诸多数组上的操作。这些操作充分体现了可复合性。

首先, 我们定义两种操作复合.

(define >>

  (case-lambda

    ((f g) (compose g f))

    ((f g . h*) (apply >> (compose g f) h*))))

这里的>>类似于compose, 但是恰好方向相反.

(define <<

  (case-lambda

    ((x f) (f x))

    ((x f . g*) (apply << (f x) g*))))

这里的<<是将后面的函数按次序应用到第一个参数上.

接着, 我们想要定义Map, 这个非常类似于Racket/Scheme里的map.

(define ((Map f) A)

  (match A

    ((array shape indexf)

     (array shape (compose f indexf)))))

我觉得这个数组语言最为优美的地方在于, 你不是对于逐个元素描述变换, 而是整体通过将函数f与索引函数indexf进行复合来完成了变换. 它可以适用于任意的形状, 但是其实我们什么也没有做.

类似地, 我们可以定义zip-with, 这是来源于那些类型化函数式语言的原语. 虽然我们可以推广Map, 但是暂时我不想这么做, 因为它会使得我们的呈现对于新人而言太过复杂.

(define ((zip-with op) A B)

  (match-define (array shapeA indexfA) A)

  (match-define (array shapeB indexfB) B)

  (unless (equal? shapeA shapeB)

    (error 'zip-with "different shapes: ~s, ~s"

           shapeA shapeB))

  (array shapeA (fork op indexfA indexfB)))

这里的fork也是一种经典的组合子.

(define (fork f g h)

  (λ x (f (apply g x) (apply h x))))

通过这样定义, 可以适应于索引函数接受多个参数的情况.

zip-with要求两个数组A和B具有相同的形状.

接下来, 我们还可以定义Mapi, 其类似于Map, 但是给函数f额外传递了数组元素的位置信息.

(define ((Mapi f) A)

  (match A

    ((array shape indexf)

     (array shape

            (λ indices

              (apply f (apply indexf indices) indices))))))

这样定义的目的仍然是为了适应于多个参数的情况.

接下来我们看Reduce, 这同样是类似于Lisp的reduce (Scheme是Lisp方言, Racket是Scheme方言).

(define ((Reduce f) A)

  (match A

    ((array i indexf)

     (unless (and (integer? i) (> i 0))

       (error 'Reduce "bad shape: ~s" i))

     (let iter ((x 1) (a (indexf 0)))

       (if (= x i)

           a

           (iter (+ x 1) (f a (indexf x))))))))

这个是一维的. 不过, 实际上我们也可以定义适用于任意维度的版本. 只是那仍然对于新人太过复杂, 所以我们只呈现了一维以及后面的二维版本.

(define ((row A) i)

  (match A

    ((array (list _ j) indexf)

     (array j (curry indexf i)))))

(define (rows A)

  (match A

    ((array (list i _) _)

     (array i (row A)))))

(define (Reduce2 f)

  (>> rows

      (Map (Reduce f))

      (Reduce f)))

为了定义二维的版本Reduce2, 我们还定义了row和rows两个函数. row和rows可以推广为对于一般形状降维, 但是我们这里尽可能特殊, 而不考虑一般了. row取一个二维数组的第i行, 这里的定义非常精致, 或许应该揣摩一二. rows也是如此, 其将一个二维数组转换为一个一维数组, 但是每个元素都是二维数组的行. 由此, 通过函数复合, 我们可以优雅地定义Reduce2. 这值得好好品味.

接着, 我们还想定义rho2, 其可以将一维数组转换为二维数组.

(define ((rho2 i j) A)

  (match A

    ((array k indexf)

     (unless (and (integer? k)

                  (= (* i j) k))

       (error 'rho2 "shape mismatch: ~s, ~s, ~s" i j k))

     (array (list i j)

            (λ (x y)

              (indexf (+ (* x j) y)))))))

这只需要元素个数可以对得上即可.

现在我们来思考另外一个问题. 从本质上来说, 我们的数组语言是惰性的 (lazy). 但是, 如果你了解call-by-name和call-by-need的话, 就会知道其实虽然惰性可以避免某些计算, 但是重复的索引会带来重复的计算. 或许我们可以编制一个一般化的备忘化机制, 然而使用某种materialization是最直接的. 其会计算每一个索引处的值, 然后保存固化下来.

(define (materialize1 A)

  (match A

    ((array i indexf)

     (define vec (build-vector i indexf))

     (array i (curry vector-ref vec)))))

(define (build-matrix i j p)

  (build-vector

   i (λ (i)

       (build-vector

        j (λ (j) (p i j))))))

(define (matrix-ref m i j)

  (vector-ref (vector-ref m i) j))

(define (materialize2 A)

  (match A

    ((array (list i j) indexf)

     (define mat (build-matrix i j indexf))

     (array (list i j) (curry matrix-ref mat)))))

接下来, 我们或许还应该定义一些用于构造具体数组的方便原语.

(define (of-array vec)

  (array (vector-length vec)

         (curry vector-ref vec)))

(define (iota n)

  (array n identity))


你的回應