博客 / 詳情

返回

你會寫 Emacs 命令嗎?

=> 上一篇:Emacs 的一些本能

前言

計算機上古時代,大概是上個世紀 70 年代中期,有一種計算機,名曰 Lisp 機,其 CPU 可作為 Lisp 語言的解釋器,亦即在這種計算機裏,Lisp 程序可以直接運行。譬如,你所寫的每個 Lisp 表達式,CPU 可對其求值,於是單個表達式即可為程序,就像地球上最早的生命體——單細胞生物以及後來的多細胞生物。

時間到了 80 年代初期,Lisp 機在市場上敗給了運行着 Unix 操作系統的計算機。之後年輕的黑客 Richard Stallman 在 Unix 系統裏復活了 Lisp 機。這個重生的 Lisp 機便是 Emacs,是 Richard Stallman 領導的自由軟件革命事業的一部分。Stallman 的理想是創造一個可供人類自由使用的 Unix 系統。他和一些志同道合的黑客奮鬥數年,創造了可與 Unix 系統適配的軟件生態,萬事俱備,唯缺內核,相當於他們創造出來一輛尚不具備引擎的汽車。

90 年代初,又一位年輕的黑客,Linus Torvalds 創造了 Linux 內核。在當時的自由軟件精神的感召下,他將 Linux 內核也以自由軟件的形式公諸於眾,於是 GNU 項目夙願得償,一個完整的 Unix 系統的替代品 GNU/Linux 就此誕生。不過,為 GNU 偉大勝利歡呼的餘音尚在繞樑之時,自由軟件陣營發生了嚴重分裂。以 Eric Raymond 為首的一部分自由軟件開發者認為貧窮不是社會主義,必須擁抱市場,於是創建了開源軟件理念,與 GNU 項目分道揚鑣。

無論風雲如何變幻,時至今日,Emacs 依然是一個非常重要的軟件,且頑強進化着。它活在 Linux 裏,活在 macOS 裏,活在 Windows 裏,活在 Android 手機裏。Lisp 機不復存在,而我們可以通過 Elisp 語言感受其靈魂。你依然可以像當初在 Lisp 機器上的黑客那樣,對一個表達式求值,相當於運行了一個程序。

可交互函數

在 Unix 以及後來的 Linux 系統中,一個程序通常意味着是一個可以在 Shell 中運行的命令。實際上,Emacs 的微緩衝區也可以視為 Shell,只是它運行的並非 Unix 或 Linux 中的命令,而是一種特殊的 Elisp 函數,即可交互函數。

你可以在 init.el 添加一個 hello 函數,其定義如下:

(defun hello ()
    (interactive)
    (princ "Hello world!"))

將光標移動到上述函數定義的末尾,執行 C-x C-e,然後執行 M-x hello RET,便可在微緩衝區裏看到「Hello world!」字樣。或者,保存上述對 init.el 的內容,重新打開 Emacs,執行 M-x hello RET

若一個函數的定義裏,第一個表達式是 (interactive),便意味着這個函數可以作為命令使用,甚至可以通過 C-h f 查看其説明。Elisp 函數定義中,若第一個表達式是字符串,該字符串便是函數的説明。例如,重新定義上述 hello 函數:

(defun hello ()
    "這是一個沒什麼用處的函數"
    (interactive)
    (princ "Hello world!"))

重新使用 C-x C-e 讓這個函數的定義生效,然後執行 C-h f hello RET,便可獲得如下圖所示的幫助信息:

hello 函數的文檔

參數

(interactive) 並非僅僅是讓函數變成可在 M-x 中執行的命令,它更重要的功能是從微緩衝區接收命令執行者提供的參數併為參數提供提示信息,參數的類型只有兩種形式——字符串和數字。

以下代碼定義了可接收兩個參數的函數 foo,令其定義生效後,倘若你執行 M-x foo RET hello RET 3 RET,結果可在緩衝區看到「hello 3」字樣。當輸入 foo 命令並回車後,Emacs 會在微緩衝區顯示「輸入文字:」。在輸入「hello」並回車後,Emacs 會在微緩衝區顯示「輸入數字:」。在輸入「3」並回車後,foo 便獲得了參數 ab 的值。

(defun foo (a b)
    "這是一個沒什麼用處的函數。"
    (interactive (list
                     (read-string "輸入文字:")
                     (read-number "輸入數字:")))
    (princ (format "%s %d" a b)))

上述函數定義中的 interactive 表達式也可寫為更為直接的形式:

(interactive
"s輸入文字:
n輸入數字:")

s 表示字符串,n 表示數字。這種直接獲取參數的方式有些醜陋,切不可為了美觀將其寫為

(interactive
    "s輸入文字:
    n輸入數字:")

不過,寫成以下形式是可行的,其中的 \n 是換行符,而不是數字示意符,其後的 n 才是數字示意符。

(interactive "s輸入文字:\nn輸入數字:")

長纓小試

倘若你經常編寫 C 程序,應該知道 C 程序的頭文件通常要加入三條預處理指令,以保證該文件在 C 程序編譯過程中不會被重複載入。例如,在 foo.h 文件,其前兩行通常要寫為

#ifndef FOO_H
#define FOO_H

最後一行要寫為

#endif

我們可以定義一個 Emacs 命令 c-header,它接受一個字符串參數,自動生成上述的預處理執行。

(defun c-header (name)
    "初始化 C 程序頭文件。"
    (interactive "s頭文件名: ")
    (insert (format "#ifndef %s_H\n" (upcase name)))
    (insert (format "#define %s_H\n\n" (upcase name)))
    (insert "#endif"))

上述代碼中使用的 upcase 函數,可將字符串中的小寫字符轉換為大寫。

c-header 定義生效後,執行 M-x c-header RET foo_bar RET 便可在緩衝區內自動插入以下內容:

#ifndef FOO_BAR_H
#define FOO_BAR_H

#endif

變量

善於編程的你,想必已經敏鋭的覺察到上一節定義的 c-header 函數是低效的,它對 upcase 表達式進行了重複求值。我們可以用一個變量保存 upcase 的結果,然後重複使用該變量,便可提高 c-header 的性能。想必你還沒有忘記 setq

(defun c-header (name)
    "初始化 C 程序頭文件。"
    (interactive "s頭文件名: ")
    (setq name (upcase name))
    (insert (format "#ifndef %s_H\n" name))
    (insert (format "#define %s_H\n\n" name))
    (insert "#endif"))

let 表達式

在 Elisp 語言中,除了函數的參數,其他變量默認是全局變量。這一點也許與你所熟悉的那些編程語言不同,而且想必你也清楚全局變量的危險,它會給程序帶來不確定性。例如

(defun foo ()
    (setq bar 3)
    (message (format "%d" bar)))

(foo) ;; 顯示 “3”
(message "%d" bar) ;; 顯示 "3"

上述代碼定義了函數 foo,然後對 foo 求值,繼而在微緩衝區打印在 foo 內部定義的變量 bar 的值 3。你應該發現了詭異之處,即在函數內部定義的變量,竟然可以在函數外部訪問,原因在於 bar 是全局變量。

另外,需要注意的是,message 是一個可以在微緩衝區顯示內容的函數,它比之前多次用過的 princ 更適合做這件事,因為用後者在微緩衝區顯示信息時,Emacs 會將 princ 自身的求值結果也顯示在微緩衝區,導致信息重複顯示。

除了將變量作為函數的參數外,有一種辦法可以定義局部變量,即 let 表達式。例如,可將上述函數 foo 重新定義為

(defun foo ()
    (let ((n 3))
    (message "%d" n)))

若試圖在 foo 外部訪問變量 n 的值,Emacs 便會抱怨 n 是無效變量。

let 表達式可以定義多個局部變量,但是需要注意,訪問局部變量的代碼必須在 let 表達式內。例如

(let ((a "Hello")
      (b 3.1415926))
    (message "%s %f" a b))

定位光標

goto-char 函數可將光標定位在指定位置。point 函數可以獲取當前的光標位置。基於這兩個函數,我們可以讓 c-header 更好用一些,可以讓它在完成第三條預處理指令 #endif 的插入之後,將光標定位到該預處理指令之前,即將光標定位在以下代碼的 所示位置。

#ifndef FOO_BAR_H
#define FOO_BAR_H
▌
#endif

以下代碼重新定義 c-header

(defun c-header (name)
    "初始化 C 程序頭文件。"
    (interactive "s頭文件名: ")
    (setq name (upcase name))
    (insert (format "#ifndef %s_H\n" name))
    (insert (format "#define %s_H\n" name))
    (let ((a "\n#endif"))
        (insert a)
        (goto-char (- (point) (length a)))))

上述代碼中所用的 length 函數,用於計算字符串長度。表達式 (- (point) (length a)),是用 (point) 減去 (length a)。在 Elisp 裏,像 +-*/ 這些數值運算符,它們都是函數,必須像 Elisp 函數那樣使用。例如

(+ 1 2) ;; 結果為 3
(- 1 2) ;; 結果為 -1
(* 3 4) ;; 結果為 12
(+ 1 (/ 6 3)) ;; 結果為 3

也許你不習慣上述的前綴形式的數值運算表達式,更習慣傳統的中綴表達式,例如 (1 + 3 * 4)。然而,凡事有弊必有利。我們之所以能夠在函數名中使用 -,例如 foo-bar,正是因為 Emacs 並不會將這樣的函數名理解為 foo 減去 bar。你可以在函數的名字中使用很多特殊符號,除了 +-*/,你也可以使用 ?^&*$@ 等符號。

goto-char 基於絕對位置定位光標,例如若緩衝區共有 100 個字符,而 goto-char 的參數是 95,則 goto-char 便將光標定位在第 95 個字符所在的位置。Emacs 也提供了基於相對位置定位光標的函數,即 forward-charbackward-char,分別用於向前和向後移動光標。這裏所謂的向前移動光標,含義是向緩衝區尾部移動光標,而向後移動光標,含義是向緩衝區首部移動光標。我們可以用 backward-char 將上述的 c-header 函數定義中的 let 表達式簡化為

(let ((a "\n#endif"))
    (insert a)
    (backward-char (length a)))

總結

通過親手編寫一個可作為命令使用的 Elisp 函數,你也許已經感受到了,在 Emacs 樸素的表象背後隱藏着一股神秘且巨大的力量,猶如可縛蒼龍的長纓。

Emacs 的神秘力量由 C 語言實現的文本編輯器核心以及同樣以 C 語言實現 Elisp 語言的解釋器構成。Emacs 的外圍部分,是我們最為常用的部分,可稱為應用層,主要由 Elisp 語言實現。Emacs 用户可以繼續使用 Elisp 語言在 Emacs 應用層面編寫程序,通過它們完成複雜的文字編輯工作,亦可將這些程序與他人共享,亦即 Emacs 不僅是一台計算機,也是一個完備的操作系統,如同它的先祖 Lisp 機,而你可以是它的用户,也可以是它的開發者。

在 Emacs 裏,我經常能感受到這樣一幅畫面,一個人出走了半生,歸來時仍是少年。

=> 下一篇:Emacs:我曾為你留下退路……
user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.