位元詩人 [Common Lisp] 網頁程式設計教學:Parenscript 入門

Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Email

前言

Parenscript 是一個基於 Common Lisp 的轉換器 (translator) 和領域專用語言 (domain-specific language),可將 Common Lisp 程式碼 (註) 轉為等效的 JavaScript 程式碼。Parenscript 的目的是為了簡化 Common Lisp 程式設計者撰寫網頁程式的過程。

(註) 僅可轉換 Common Lisp 的子集。

由於 Parenscript 在網路上的學習資料較少,本文會介紹 Parenscript 的語法和使用方式。

cl2js 執行 Parenscript 命令稿

典型的 Parenscript 使用情境是搭配網頁伺服器軟體,以 server-side scripting 的方式生成 JavaScript 程式碼後將 JavaScript 程式碼傳送到網頁客戶端。像是 Parenscript 的官方教程就是以這種模式撰寫 Parenscript。

但對學習 Parenscript 來說,這樣的方式所需要的樣板程式碼 (boilerplate code) 過多,而且不易觀察生成的 JavaScript 程式碼。既然現在已經有 Node.js 這類獨立在瀏覽器外的 JavaScript 運行環境,大可以把 Parenscript 原始碼轉成 JavaScript 命令稿後以 Node.js 執行轉出的程式碼。

Parenscript 官方團隊沒有製作這樣的工具,所以筆者寫了 cl2js。這是一個小型的命令列工具,可將 Parenscript 命令稿轉為等效的 JavaScript 命令稿。

cl2js 可以用 SBCL 或 Clozure CL 編譯成執行檔 (native executable)。也可以用 ABCL 直接執行。但用 ABCL 執行的啟動速度偏慢,不是很實用。

cl2js 的使用方式如下:

$ cl2js source.lisp > output.js
$ node output.js

在這個模式下,把 output.js 當成是產出,不手動編輯該命令稿,僅觀看產出的 JavaScript 程式碼是否符合預期。實際執行程式的是 Node.js。

如果用 ABCL 則改用 jcl2js 來執行:

$ jcl2js source.lisp > output.js

待 Parenscript 寫熟後,也可以把 Node.js 當成運行環境來使用,直接把 Parenscript 的輸出重導到 Node.js 來執行:

$ cl2js source.lisp | node

Parenscript 當初在設計時,其工作模式是將 Common Lisp 程式碼一對一轉換成等效的 JavaScript 程式碼。所以,沒有為 Parenscript 實作函式庫的特性。cl2js 也遵循這樣的原則,僅將原始碼做一對一的轉換。

撰寫第一個程式

Parescript 在本質上是領域專用語言而非轉譯器,因為 Parenscript 只負責將 Common Lisp 程式碼進行一對一的轉換,而沒有引入額外的執行期函式庫 (runtime library)。

我們會很想用 Parenscript 寫 Hello World 程式:

(write-line "Hello World")

但 Parenscript 只會做字面上的 (literal) 轉換,結果會轉為以下的程式碼:

writeLine('Hello World');

由於瀏覽器或 Node.js 不會有 writeLine 函式,這樣的轉換是無效的。

Node.js 中用來輸出資料的函式是 console.log。我們得用 Parenscript 將 console.log 函式按照字面寫出來:

((getprop console 'log) "Hello World")

(getprop console 'log) 是 Parenscript 特有的寫法,主要是為了因應 JavaScript 程式碼而設計的。getprop 可記做 get property 。在本範例中記成取得 console 物件的 log 性質。

這段程式碼的轉換過程以虛擬碼展示如下:

((getprop console 'log) "Hello World")
-> (console.log "Hello World")
-> console.log("Hello World")

這時候就會成功地轉換成 Node.js 版本的 Hello World 程式:

console.log('Hello World');

讀者可能會很想寫這樣的程式:

(console.log "Hello World")

但這種寫法已經棄用了 (deprecated)。不建議這樣寫:

WARNING: Parenscript symbol CONSOLE.LOG contains one or more dot operators.
This compound naming convention is deprecated and will be removed!
Use GETPROP, @, or CHAIN instead.

我們之後不會再用這種寫法。請讀者儘早習慣 Parenscript 的新式寫法。

由於 Parenscript 的設計方式,有些讀者可能會認定 Parenscript 不適合用來移植 Common Lisp 程式碼。我們在下文有提到因應的方式。

大小寫敏感性 (Case Sensitivity)

Common Lisp 不區分大小寫,但 JavaScript 會區分大小寫,故撰寫 Parenscript 程式碼時仍需注意大小寫的差別。

在 Parenscript 中執行 Common Lisp 程式碼

在預設情形下,Parenscript 語境內所有的 Common Lisp 程式碼都視為 Parenscript 程式碼。但 Parenscript 語境內可用 lisp 指令執行原生 Common Lisp 程式碼。在 lisp 指令內的 Common Lisp 程式碼會在運行完後回傳到 Parenscript 語境中。

參考以下例子:

; Custom-made assertion.
(defun assert (condition &optional message)
  ((getprop console 'assert) condition message))

; Run an assertion at runtime.
(assert (= 4 (lisp (1+ 3))) "It should be 4")

(lisp (1+ 3)) 的部分會運行後回傳 4 給 Parenscript 語境。所以這個斷言可成功通過。

(getprop console 'assert) 會轉成 console.assert,過程如同前一節所示。

上述例子轉成等效的 JavaScript 程式碼如下:

function assert(condition, message) {
    __PS_MV_REG = [];
    return console.assert(condition, message);
};

assert(4 === 4, 'It should be 4');

由於 lisp 指令內的 Lisp 程式碼已經預先轉換完了。所以原本的程式碼不會出現在輸出的 JavaScript 程式碼中,而是以轉換後的結果來呈現。

撰寫純量資料

以下範例展示如何在 Parenscript 中撰寫純量資料 (scalar data):

;; Special characters.
; # is buggy in solo.
; ['bang', 'whathash', 'at', 'percent', 'slash', 'star', 'plus'];
'(! ?# @ % / * +)

;; Camel case word.
; 'fooBarBaz';
'foo-bar-baz

;; Pascal case word.
; 'FooBarBaz';
'*Foo-bar-baz
; 'FooBarBaz';
'-foo-bar-baz

;; All uppercase word.
; 'FOO_BAR_BAZ';
'*foo_bar_baz*

;; Word as-is.
; 'foo-bar';
:foo-bar

;; Number.
; 3.1415927
3.1415927

整理成規則如下:

  • 數字:直接對轉成數字
  • 字串:直接對轉成字串
  • Keyword:轉換成字串,保持大小寫不變
  • foo-bar-baz:轉成 fooBarBaz- 表示下一個字母轉大寫
  • -foo-bar-baz:轉成 FooBarBaz。規則同上
  • *foo_bar_baz*:轉成 FOO_BAR_BAZ。一對 * 包起來的字轉成全大寫
  • 非文字符號:轉成相對應的文字

由於 Common Lisp 允許用符號當成函式名稱的一部分,但 JavaScript 不允許這項的特性。故 Parenscript 會將非文字符號轉文字。

Parenscript 的資料型態 (Data Type)

Parenscript 實際的目標環境是 JavaScript 運行環境 (瀏覽器、Node.js)。所以,Parenscript 沿用 JavaScript 的資料型態,未引入新的資料型態。原本的 Common Lisp 資料型態會自動轉換至最合適的 JavaScript 資料型態。

此外,Parenscript 加入一些和資料型態相關的 predicate,可在程式中檢查資料型態:

  • defined:資料是否有定義
  • null:資料是否為空
  • numberp:是否為數字型態
  • stringp:是否為字串型態
  • objectp:是否為物件型態
  • functionp:是否為函式型態

我們寫了個實際的範例來觀察 JavaScript 的型態系統:

(defun write-line (text)
  ((chain console log) text))

(write-line (+ "null == undefined is " (null undefined)))
(write-line (+ "null == null is " (null nil)))
(write-line (+ "null === undefined is " (= nil undefined)))
(write-line (+ "null === null is " (= nil nil)))

(write-line "")  ; Separator

(write-line (+ "typeof 123 === 'object' is " (objectp 123)))
(write-line (+ "typeof 'str' === 'object' is " (objectp "str")))
(write-line (+ "typeof {} === 'object' is " (objectp (create))))
(write-line (+ "typeof [] === 'object' is " (objectp '())))
(write-line (+ "'function is object' is " (objectp write-line)))

本例轉為等效的 JavaScript 程式碼如下:

function writeLine(text) {
    __PS_MV_REG = [];
    return console.log(text);
};
writeLine('null == undefined is ' + (undefined == null));
writeLine('null == null is ' + (null == null));
writeLine('null === undefined is ' + (null === undefined));
writeLine('null === null is ' + (null === null));
writeLine('');
writeLine('typeof 123 === \'object\' is ' + (typeof 123 === 'object'));
writeLine('typeof \'str\' === \'object\' is ' + (typeof 'str' === 'object'));
writeLine('typeof {} === \'object\' is ' + (typeof {  } === 'object'));
writeLine('typeof [] === \'object\' is ' + (typeof [] === 'object'));
writeLine('\'function is object\' is ' + (typeof writeLine === 'object'));

但結果可能會出乎意料:

null == undefined is true
null == null is true
null === undefined is false
null === null is true

typeof 123 === 'object' is false
typeof 'str' === 'object' is false
typeof {} === 'object' is true
typeof [] === 'object' is true
'function is object' is false

這是由於 ===== 之間的差異所造成。因 == 有複雜且難以理解的轉換規則,建議一律用 === 檢查兩者相等。

使用運算子 (Operator)

Common Lisp 不區分運算字和函式,但這兩者在 JavaScript 中是有別的。在 JavaScript 中,運算子有可能用中序排列或後序排列,但函式呼叫是前序排列。

以下是使用運算子的範例:

; Custom-made assertion.
(defun assert (condition &optional message)
  ((getprop console 'assert) condition message))

; Elementary arithmetic operations.
(assert (= 7 (+ 3 4)))
(assert (= -1 (- 3 4)))
(assert (= 12 (* 3 4)))
(assert (= 0.75 (/ 3 4)))

; Logic AND operations.
(assert (= true (and true true)))
(assert (= false (and true false)))
(assert (= false (and false true)))
(assert (= false (and false false)))

原本 Common Lisp 是以 tnil 表示真和偽。但 Parenscript 因應 JavaScript 運行環境,使用以下數個常值:

  • true:等同於 JavaScript 的 true
  • false:等同於 JavaScript 的 false
  • t:會轉為 JavaScript 的 true
  • nil:在 JavaScript 中轉為 null
  • undefined:Parenscript 中特有的常值,在 JavaScript 中等同於 undefined

本例轉換出來的 JavaScript 程式碼如下:

function assert(condition, message) {
    __PS_MV_REG = [];
    return console.assert(condition, message);
};

assert(7 === 3 + 4);
assert(-1 === 3 - 4);
assert(12 === 3 * 4);
assert(0.75 === 3 / 4);

assert(true === (true && true));
assert(false === (true && false));
assert(false === (false && true));
assert(false === (false && false));

在這個例子中,Parenscript 自動將 Common Lisp 中的函式轉成 JavaScript 中相對應的運算子,並自動將前序排列轉中序排列。

使用控制結構 (Control Structure)

除了 Common Lisp 原有的控制結構外,Parenscript 還引入了幾個新的控制結構,更適合 JavaScript 程式。本節展示三種控制結構,其他的控制結構請自行翻閱 Parenscript 的指引。

使用 if

Parenscript 的 if 是沿用原本 Common Lisp 的,使用起來不會太困難。以下實例用 if 結構檢查 num 是奇數還是偶數:

(用 Parenscript 的 random 函式重寫)

(defun random-integer (small large)
  ((getprop *Math 'floor)
     (+ small
        (* ((getprop *Math 'random)) (1+ (- large small))))))

(defun write-line (message)
  ((getprop console 'log) message))

(defvar num (random-integer 1 100))

(if (oddp num)
    (write-line "odd")
    (write-line "even"))

可以注意一下 random-integer 函式的部分。我們使用 JavaScript 的 Math 物件來求隨機整數,而不是用 Common Lisp 的隨機函式。

本例轉為等效的 JavaScript 程式碼如下:

function randomInteger(small, large) {
    __PS_MV_REG = [];
    return Math.floor(small + Math.random() * ((large - small) + 1));
};

function writeLine(message) {
    __PS_MV_REG = [];
    return console.log(message);
};

if ('undefined' === typeof num) {
    var num = randomInteger(1, 100);
};

if (num % 2) {
    writeLine('odd');
} else {
    writeLine('even');
};

使用 switch

switch 是 Parenscript 新加入的控制結構,對應於 JavaScript 的同名結構。以下範例用 switch 結構來檢查日期 (day of week):

(defun write-line (text)
  ((getprop console 'log) text))

(switch ((getprop (new (-Date)) 'get-day))
  (0 nil)  ; Fallthrough
  (6 (write-line "Weekend")
       break)
  (5 (write-line "Thank God. It's Friday!")
       break)
  (3 (write-line "Hump day")
       break)
  (default
     (write-line "Week")))

Parenscript 本質上仍然是在寫 JavaScript,所以我們用 Date 物件來求日期。

本例轉為等效的 JavaScript 程式碼如下:

function writeLine(text) {
    __PS_MV_REG = [];
    return console.log(text);
};

switch ((new Date()).getDay()) {
case 0:
    null;
case 6:
    writeLine('Weekend');
    break;
case 5:
    writeLine('Thank God. It\'s Friday!');
    break;
case 3:
    writeLine('Hump day');
    break;
default:
    writeLine('Week');
};

使用 for

do 是原本 Common Lisp 就有的控制結構,經 Parenscript 會轉為等效的 for 迴圈。參考以下例子:

(defun write-line (s)
  ((getprop console 'log) s))

(do ((i 1 (1+ i)))
    ((< 10 i) i)
  (if (oddp i)
      (write-line "odd")
      (write-line "even")))

本例會轉為以下 JavaScript 程式碼:

function writeLine(s) {
    __PS_MV_REG = [];
    return console.log(s);
};

(function () {
    var i = 1;
    for (; 10 >= i; ) {
        if (i % 2) {
            writeLine('odd');
        } else {
            writeLine('even');
        };
        var _js1 = i + 1;
        i = _js1;
    };
    __PS_MV_REG = [];
    return i;
})();

讀者可能會疑惑為什麼轉出來的 for 迴圈比較複雜?這是因為 Common Lisp 的 do 控制結構本身就比較複雜,所以轉出來的 for 迴圈會比手寫的 for 迴圈來得複雜一些。

使用內建物件 (Built-in Object)

Parenscript 的目標環境是 JavaScript (瀏覽器或 Node.js),當然要有使用 JavaScript 內建物件的能力。本節展示兩個例子,來看如何在 Parenscript 中使用 JavaScript 的內建物件。

使用物件實字 (Object Literal)

物件實字在 JavaScript 中有許多用途,可以當成雜湊表、模擬命名空間等。以下範例用物件實字做為雜湊表:

; Custom-made assertion.
(defun assert (condition &optional message)
  ((getprop console 'assert) condition message))

; Create an object literal.
(defvar de2en (create :eins :one
                      :zwei :two
                      :drei :three))

; Validate data at runtime.
(assert (= :one (getprop de2en :eins)))
(assert (= :two (getprop de2en :zwei)))
(assert (= :three (getprop de2en :drei)))
(assert (not (defined (getprop de2en :vier))))

注意一下建立物件實字的 Parenscript 指令為 create

這個例子轉為等效的 JavaScript 程式碼如下:

function assert(condition, message) {
    __PS_MV_REG = [];
    return console.assert(condition, message);
};

if ('undefined' === typeof de2en) {
    var de2en = { 'eins' : 'one',
                  'zwei' : 'two',
                  'drei' : 'three'
                };
};

assert('one' === de2en['eins']);
assert('two' === de2en['zwei']);
assert('three' === de2en['drei']);
assert('undefined' === typeof de2en['vier']);

使用陣列 (Array)

陣列是另一個常見的內建物件,可以透過索引快速存取其元素。以下範例用陣列物件的 forEach 函式將陣列內的元素逐一印出:

((getprop (array "foo" "bar" "baz" "qux")
          'for-each)
   (lambda (elem) ((getprop console 'log) elem)))

Parenscript 中建立陣列的方式較多,可使用列表實字或是 array 指令。另外可以注意一下 Common Lisp 的 lambda 函式剛好對應至 JavaScript 的匿名函式。

本例轉為等效的 JavaScript 如下:

['foo', 'bar', 'baz', 'qux'].forEach(function (elem) {
    __PS_MV_REG = [];
    return console.log(elem);
});

撰寫 ECMAScript 5 風格的物件

Parenscript 所提供的 JavaScript 特性,約略在 ECMAScript 5 左右。所以,ECMAScript 6 的便捷語法還無法使用。這也是 Parenscript 不受歡迎的成因之一。

在本例中,我們以 Parenscript 撰寫平面上的 (2-dimensional) 點:

; ES5-style class.
(defvar *Point 
  (lambda (x y)
   (let ((_x 0.0)
         (_y 0.0))
     ; Getter and setter of `x`
     ((getprop *Object 'define-property) this 'x 
        (create :get (lambda () _x)
                :set (lambda (v) (setq _x v))))
     ; Getter and setter of `y`
     ((getprop *Object 'define-property) this 'y
        (create :get (lambda () _y)
                :set (lambda (v) (setq _y v))))
     (setq _x x)
     (setq _y y)
     this)))

; Function binding to `Point` class.
(setf (getprop *Point 'prototype 'distance-to)
      (lambda (p) 
        (sqrt (+ (expt (- (getprop this 'x) (getprop p 'x)) 2)
                 (expt (- (getprop this 'y) (getprop p 'y)) 2)))))

; Custom-made assertion.
(defun assert (condition &optional message)
  ((getprop console 'assert) condition message))

; Main function.
(let ((p (new (*Point 0.0 0.0)))
      (q (new (*Point 3.0 4.0))))
  (assert (= 5.0 ((getprop p 'distance-to) q)))

  ; Mutate `q`
  (setf (getprop q 'x) 5.0)
  (setf (getprop q 'y) 12.0)

  (assert (= 13.0 ((getprop p 'distance-to) q))))

這個範例稍微長一點,請讀者和轉出的 JavaScript 程式碼對照著看。幸好 Parenscript 轉出來的 JavaScript 程式碼沒用太多的魔幻語法,還算容易閱讀。

在 ECMAScript 5 的物件導向程式中,使用函式物件 (function object) 當成物件是主流的做法。所以我們也用這種手法來寫物件。

我們希望控制 xy 的存取,所以我們用 property 的特性來寫 xy,不直接暴露這兩個屬性。日後要修改時會比較方便。

由於 distanceTo 不會用到私有屬性,故將其寫到 Point 物件的 property 上,可以節省一點運算資源。

本例轉成等效的 JavaScript 程式碼如下:

if ('undefined' === typeof Point) {
    var Point = function (x, y) {
        var _x = 0.0;
        var _y = 0.0;
        Object.defineProperty(this, 'x', { 'get' : function () {
            return _x;
        }, 'set' : function (v) {
            return _x = v;
        } });
        Object.defineProperty(this, 'y', { 'get' : function () {
            return _y;
        }, 'set' : function (v) {
            return _y = v;
        } });
        _x = x;
        _y = y;
        __PS_MV_REG = [];
        return this;
    };
};

Point.prototype.distanceTo = function (p) {
    __PS_MV_REG = [];
    return Math.sqrt(Math.pow(this.x - p.x, 2) + Math.pow(this.y - p.y, 2));
};

function assert(condition, message) {
    __PS_MV_REG = [];
    return console.assert(condition, message);
};

(function () {
    var p = new Point(0.0, 0.0);
    var q = new Point(3.0, 4.0);
    assert(5.0 === p.distanceTo(q));
    q.x = 5.0;
    q.y = 12.0;
    __PS_MV_REG = [];
    return assert(13.0 === p.distanceTo(q));
})();

使用巨集

原生的 JavaScript 並沒有巨集。Parenscript 是基於 Common Lisp 的語言,所以也有巨集可用。可以使用巨集這件事可算是使用 Parenscript 最大的益處。

本範例用巨集做為 predicate,檢查 obj 為特定的 type-stringnull,這種型態稱為 nullable type:

(defmacro nullable (type-string obj)
  `(or (= null ,obj) (= ,type-string (typeof ,obj))))

(defun assert (condition &optional message)
  ((@ console 'assert) condition message))

(assert (nullable 'number 3))
(assert (nullable 'number nil))
(assert (not (nullable 'number "3")))

本例轉為等效的 JavaScript 程式碼如下:

function assert(condition, message) {
    __PS_MV_REG = [];
    return console.assert(condition, message);
};

assert(null === 3 || 'number' === typeof 3);
assert(null === null || 'number' === typeof null);
assert(!(null === '3' || 'number' === typeof '3'));

請注意原本巨集的部分消失了。因為巨集在本質上是一種字串代換的過程,在編譯的過程中會自動轉換為沒有巨集的等效程式碼。此外,原生 JavaScript 沒有巨集可用,故 Parenscript 會採用這樣的設計。

載入外部程式

既然 Parenscript 的目標環境是 JavaScript 運行環境,就不能自絕於 JavaScript 生態圈外,應有使用第三方 JavaScript 函式庫的能力。由於瀏覽器和 Node.js 中載入函式庫的方式相異,故我們分開來討論。

在網頁中使用第三方 JavaScript 函式庫

瀏覽器的 JavaScript 並沒有真正的函式庫的概念。所有的 JavaScript 命令稿都是以 <script> 標籤載入,在載入後會自動進入全域命名空間 (global namespace)。所以,在用 Parenscript 寫 JavaScript 程式碼時,只要寫出符合 API 呼叫的程式碼即可。

以下範例摘錄自 jQuery 官方網站:

; Sample from jQuery official site.
((chain $ ajax)
   (create :accepts
           (create :mycustomtype
                   "application/x-some-cstom-type")
           :converters
           (create "text mycustomtype"
                   (lambda (result) newresult))
           'date-type
           "mycustomtype"))

一如預期,這段程式碼會轉回原本的 jQuery 程式碼:

$.ajax({
  'accepts' : {
    'mycustomtype' : 'application/x-some-cstom-type'
  },
  'converters' : {
    'text mycustomtype' : function (result) {
      return newresult;
    }
  },
  'dateType' : 'mycustomtype'
});

這時候,使用 Parenscript 不會比用 JavaScript 簡單。因為網路上大部分的範例都是用 JavaScript 實作。當我們用 Parenscript 程式呼叫第三方 JavaScript 函式庫時,還得想辦法寫成等效的 Parenscript 程式碼,撰碼過程反而變複雜了。

在 Node.js 中使用第三方 JavaScript 函式庫

Node.js 有自己的模組系統 (註) 。在寫 Parenscript 程式碼時,得加入等效的 require 指令以引入函式庫。此外,也要撰寫符合 API 呼叫的 Parenscript 程式碼。

(註) 即 CommonJS。

以下簡短的範例使用到 underscore

(defvar _ (require "underscore"))

((chain console log)
   ((chain _ map) '(1 2 3) (lambda (elem) (expt elem 2))))

由於 Parenscript 沒有定義 defconstant 指令,故我們退而求其次,使用 defvar 來宣告函式庫的識別字。

本例會轉為以下等效的 JavaScript 程式碼:

if ('undefined' === typeof _) {
    var _ = require('underscore');
};

console.log(_.map([1, 2, 3], function (elem) {
    __PS_MV_REG = [];
    return Math.pow(elem, 2);
}));

載入外部 Parenscript 程式碼

Parenscript 的目的是提供 Common Lisp 轉 JavaScript 的轉譯器,故沒有實作模組相關的功能。

移植 Common Lisp 程式碼

在大部分情形下,Parenscript 僅將 Common Lisp 程式碼做字面上的轉換,實際上會自動轉換成等效 JavaScript 程式碼的部分甚少。

如果我們手頭上有一隻 Common Lisp 程式,想到移植到 JavaScript 運行環境上,但我們只想做最少量的修改,否則還不如直接用原生 JavaScript 重寫。其實 Parenscript 還是可以用的,只是要自己補上小型運行期函式庫。

我們以簡短的範例來看這個過程。建立 ps-runtime.lisp 命令稿,撰寫以下內容:

;; Only declare a new function when it doesn's exist.
(defmacro declare (symbol &body body)
  `(when (not (defined ,symbol))
      ,@body))

;; Here we implement a variant of UMD pattern
;;  to load our runtime in either AMD, CommonJS and a browser.
(flet ((browserp ()
         (if (defined window) true false))
       (nodejsp ()
         (if (and (defined module)
                  (defined (chain module exports)))
             true
             false))
       (amdp ()
         (and (functionp define)
              (defined (chain define amd))))
       ; UMD variant.
       (umd (glob fact)
         (cond ((amdp)
                (define '() fact))
               ; Globally require in Node.js
               ;  to simulate builtin :cl package.
               ((nodejsp)
                (let ((obj (fact)))
                  (for-in (key obj)
                    (setf (getprop glob key) (getprop obj key)))))
               ; Globally load function(s) in a browser.
               (t (let ((obj (fact)))
                    (for-in (key obj)
                      (setf (getprop glob key) (getprop obj key))))))))
      (umd 
        (if (browserp) window global)
        (lambda ()
          (create 'write-line
                  (lambda (text) ((chain console log) text))
                  'assert
                  (lambda (condition &optional message)
                    ((chain console assert) condition message))))))

在這個微型運行環境中,我們想引入的是 write-lineassert 兩個指令。所以,我們在 (儘量) 符合 Common Lisp 的同名函式的前提下,用 Parenscript 重寫這兩個函式,並在函式內部呼叫等效的 JavaScript 程式碼。

我們無法預知 Parenscript 會不會在未來加上運行期函式庫。所以,我們用 declare 巨集做為防衛性程式設計的手法。只有在 Parenscript 或其他外部程式未宣告這些函式時,我們的運行環境才會加入這些函式。

由於這個運行環境有可能拿到瀏覽器或 Node.js 中使用,所以我們要採用不同的函式輸出方式。為了處理這個議題,我們實作了 UMD 模式的變體。原本輸出到 Node.js 時,函式會集中在套件識別字中,但我們為了模式 Common Lisp 的預設 :cl 套件,所以改成輸出到全域空間。

由於我們的目標是寫運行環境,而不是製作通用函式庫。所以,我們把所有函式輸出到全域命名空間中。在一般的情形下,這是一種反模式。但在這裡,這種手法是可接受的。除了移植 Common Lisp 程式碼外,不會有程式設計者把這個運行環境當成函式庫來用。

上述運行環境的程式碼轉為等效的 JavaScript 程式碼如下:


(function () {
    var browserp = function () {
        return 'undefined' !== typeof window ? true : false;
    };
    var nodejsp = function () {
        return 'undefined' !== typeof module && 'undefined' !== typeof module.exports ? true : false;
    };
    var amdp = function () {
        return typeof define === 'function' && 'undefined' !== typeof define.amd;
    };
    var umd = function (glob, fact) {
        if (amdp()) {
            __PS_MV_REG = [];
            return define([], fact);
        } else if (nodejsp()) {
            var obj = fact();
            for (var key in obj) {
                glob[key] = obj[key];
            };
        } else {
            var obj1 = fact();
            for (var key in obj1) {
                glob[key] = obj1[key];
            };
        };
    };
    __PS_MV_REG = [];
    return umd(browserp() ? window : global, function () {
        return { 'writeLine' : function (text) {
            __PS_MV_REG = [];
            return console.log(text);
        }, 'assert' : function (condition, message) {
            __PS_MV_REG = [];
            return console.assert(condition, message);
        } };
    });
})();

使用以下指令將 Lisp 命令稿輸出成等效的 JavaScript 命令稿,待會就會用到:

$ cl2js ps-runtime.lisp > ps-runtime.js

接著,開始處理應用程式的部分。我們假定在 Node.js 上執行程式,所以得寫額外的 require 敘述:

; Globally require to simulate builtin :cl package.
(require "./ps-runtime")

(write-line "Hello World")

讀者可以注意到我們沒有寫 console.log 函式的等效指令,而是直接以 Common Lisp 的 write-line 指令來寫程式。

另外,這個運行環境的函式會輸出到全域命名空間中,不需要在 require 指令加上額外的識別字。

此應用程式會轉為等效的 JavaScript 程式碼如下:

require('./ps-runtime');

writeLine('Hello World');

原本 writeLine 函式是無效的,但在引入自製的運行環境後,這個函式就可以正常使用了。

編譯此應用程式的指令如下:

$ cl2js main.lisp > main.js

實際上會用 Node.js 來執行此應用程式:

$ node main.js
Hello World

為什麼 Parenscript 不直接為使用者實作運行期函式庫呢?雖然 Parenscript 官網未明確提到原因,可能的原因是 Common Lisp 的標準函式庫比較大,如果整個函式庫都移植,可能會做出一個肥大的運行期函式庫,也會消耗過多的開發時間。所以,Parenscript 官方團隊就不這麼做了。

使用 Parenscript 的議題

Parenscript 為使用 Common Lisp 撰寫網頁程式的程式設計者帶來便利的工具。但 Parenscript 也不是完美無缺的,本節討論一些使用 Parenscript 時可能會碰到的議題。

學習資源稀少

Common Lisp 本來就是利基語言,會使用 Parenscript 的程式設計者也不多,網路上可見的 Parenscript 範例程式自然就少了。讀者還是要試著直接閱讀 Parenscript 的指引手冊 (reference manual) 來學這個語言。直接閱讀指令手冊是枯燥、無聊的,但對冷門的軟體工具來說,仍然是必要的過程。

不會省去學習 JavaScript 的時間

在撰寫 Parenscript 程式碼的過程中,時常要和轉出來的 JavaScript 程式碼相互比較。一方面是要熟悉 Parenscript 的轉換規則,一方面是要確認 Parenscript 的產出符合我們的預期。讀者若完全不會 JavaScript,往往也無法順利地修 Parenscript 程式碼的 bug。

學習 Parenscript 並不是跳過 JavaScript 不學的藉口。由於 Parenscript 的範例很少,程式設計者時常要自行將 JavaScript 程式碼轉為等效的 Parenscript 程式碼。若要能順利地使用 Parenscript,最好 Common Lisp 和 JavaScript 都有一些基礎才是。

不易移植 Common Lisp 程式

由於 Parenscript 未實作運行期函式庫,很難直接將 Common Lisp 程式碼移植到 JavaScript 運行環境上。我們在前文有介紹自行實作小型運行期函式庫的方式,有需要的讀者可以參考一下。

無法使用 ECMAScript 6 之後的特性

Parenscript 是小型專案,很難追上 JavaScript 快速的進展。到目前 (西元 2020 年四月中旬) 仍然無法直接使用 ECMAScript 6 特性的 JavaScript 轉譯器,很難在競爭激烈的 JavaScript 生態圈中出頭。所以,Parenscript 較適合本來就有在寫 Common Lisp 的程式設計者,不易吸引到新的程式設計者來寫 Parenscript 程式。

無法保留註解

Parenscript 會將程式碼中的註解直接抹去,而且沒有選項來改變這個行為。因此,有些利用註解撰寫程式碼的 JavaScript 套件就無法在 Parenscript 上使用,像是 Flow Type。

繼續深入

由於 Parenscript 在網路上的資訊甚少,還是得透過其官網來學習這套軟體,尤其是參考手冊的部分,務必要來回讀個幾次,以熟悉 Parenscript 的撰寫方式。

關於作者

身為資訊領域碩士,位元詩人 (ByteBard) 認為開發應用程式的目的是為社會帶來價值。如果在這個過程中該軟體能成為永續經營的項目,那就是開發者和使用者雙贏的局面。

位元詩人喜歡用開源技術來解決各式各樣的問題,但必要時對專有技術也不排斥。閒暇之餘,位元詩人將所學寫成文章,放在這個網站上和大家分享。