Elisp 11:动态模块

garfileo

前言:不知多久能学会 Elisp

上一章:

Emacs 从版本 25 开始支持动态模块。所谓动态模块,即 C 语言编写的共享库 1 。Emacs 的动态模块,就是 Elisp 的动态模块。因此,倘若 Elisp 语言编写的程序某些环节存在性能瓶颈,可借 C 语言之力予以缓解。对于其他编程语言,只要能够调用 C 程序库,皆能用于编写 Emacs 的动态模块。本章仅讲述如何使用 C 语言完成此事,所用的 C 编译器为 gcc。

又一个 Hello world!

Hello world 程序总是能够帮助我们忽略大量的细节,而掌握一个程序的基本相貌,这一经验对于如何编写 Emacs 动态模块依然适用。

我建议使用 Emacs 但是并不禁止使用其他文本编辑器创建 C 程序源文件 foo.c,在其中郑重其事地写下

#include <emacs-module.h>
int plugin_is_GPL_compatible;

使用 C 语言为 Emacs 编写的任何一个动态模块皆以上述代码作为开头。

接下来应该写 main 函数了。每个 C 程序皆以 main 函数作为程序的入口和出口。但是,Emacs 动态模块的入口和出口不是 main,而是

int emacs_module_init (struct emacs_runtime *ert)
{
        return 0;
}

跟 C 程序的 main 函数相似,返回 0 表示成功,返回其他整型数值意味着失败。

还记得 C 程序的 Hello world 吗?

#include <stdio.h>

int main(void)
{
        printf("Hello world!\n");
        return 0;
}

Emacs 的动态模块在以上述的代码为基础,也能写出类似的 Hello world 程序。下面给出 foo.c 的全部内容:

#include <stdio.h>
#include <emacs-module.h>
int plugin_is_GPL_compatible;

int emacs_module_init (struct emacs_runtime *ert)
{
        printf("Hello world!\n");
        return 0;
}

执行以下命令

$ gcc -I /usr/include/emacs-27 -fPIC -shared foo.c -o foo.so

便可将 foo.c 编译为共享库 foo.so。注意,上述命令里,/usr/include/emacs-27 是我机器上的 Linux 系统里 emacs-module.h 文件所在路径,不同的 Emacs 版本或不同的操作系统,需要因地制宜。

将 foo.so 放到系统变量 EMACSLOADPATH 定义的目录或 Elisp 的 load-path 列表里定义的目录里。完成上述工作,便可在 Elisp 程序里载入 foo.so,例如创建 Elisp 程序 foo.el,令其内容为

(load "foo" nil t)

然后执行

$ emacs -Q --script foo.el

可得到以下输出:

Hello world!

这就是 Emacs 动态模块的 Hello world。成功加载这个模块后,心里不禁有些小激动呢。

创建可在 Elisp 程序里调用的 C 函数

现在考虑用 C 写一个可以计算宇宙的终极答案的函数,然后在 Elisp 里调用。这样的函数称为模块函数。

在动态模块的 C 代码里,可在 Elisp 程序调用的 C 函数,其格式必须像下面这样

emacs_value func (emacs_env *env,
                  ptrdiff_t nargs,
                  emacs_value *args,
                  void *data)
{
        
}

上述代码仅仅是一个空壳函数,因为现在我还不知道 emacs_value 这个类型的返回值该如何构造。由于宇宙的终极答案是 42,经过认真阅读 Elisp 手册,我找到了一个办法。emacs_env 里有一个函数 make_integer,用它可以构造 emacs_value 类型的实例,例如

emacs_value foo_answer (emacs_env *env,
                        ptrdiff_t nargs,
                        emacs_value *args,
                        void *data)
{
        return env->make_integer(env, 42);
}

在尚未搞清楚 envnargsargs 以及 data 等参数的含义的情况下,我已经写出 foo_answer。学习的过程,要学会临时放弃一些东西。接下来要考虑的问题是,如何让 foo_answer 这个 C 函数变成 Elisp 体制内的函数。

Elisp 手册里提供了示例代码,我针对 foo_answer 对其略作修改并置入 emacs_module_init 函数里,如下

int emacs_module_init (struct emacs_runtime *ert)
{
        emacs_env *env = ert->get_environment(ert);
        emacs_value func = env->make_function(env, 0, 0, foo_answer, "", NULL);
        emacs_value symbol = env->intern(env, "foo-anwser");
        emacs_value args[] = {symbol, func};
        env->funcall(env, env->intern(env, "defalias"), 2, args);
        return 0;
}

为了方便代码复制,在本地机器上算出宇宙的终极答案,在此我不厌其烦,给出 foo.c 全部的代码:

#include <emacs-module.h>
int plugin_is_GPL_compatible;

emacs_value foo_answer (emacs_env *env,
                        ptrdiff_t nargs,
                        emacs_value *args,
                        void *data)
{
        return env->make_integer(env, 42);
}

int emacs_module_init (struct emacs_runtime *ert)
{
        emacs_env *env = ert->get_environment(ert);
        emacs_value symbol = env->intern(env, "foo-anwser");
        emacs_value func = env->make_function(env, 0, 0, foo_answer, "", NULL);
        emacs_value args[] = {symbol, func};
        env->funcall(env, env->intern(env, "defalias"), 2, args);
        return 0;
}

重新编译 foo.c,并将编译所得 foo.so 放到它应该在的目录里。然后,在 Elisp 程序 foo.el 里,载入 foo.so,并调用 foo-anwser 函数,即

(load "newbie" nil t)
(load "foo" nil t)
(princ\n (foo-anwser))

执行 foo.el 程序,

$ emacs -Q --script foo.el

可输出

42

城乡结合部

上一节的示例代码,大多数都是莫名其妙的。尽管如此,大致上它们的举动无法是将一个 Elisp 里一个体制内的符号 foo-anwser 绑定到模块函数 foo_anwser,而真正完成此事的代码是

env->funcall(env, env->intern(env, "defalias"), 2, args);

首先看 env,它是怎么来的?来自 Emacs 运行时 emacs_runtime,即

emacs_env *env = ert->get_environment(ert);

emacs_runtime 怎么来的呢?是 Emacs 传给 emacs_module_init 函数的。问题追溯至此,便可以结束了。身处城乡结合部,就不必再问城市是怎么来的了。

可以再问的是,env->intern(env, "defalias") 是什么意思?是让 Elisp 解释器派遣一个符号 defalias 过来。如果 Elisp 解释器所维护的符号表里有没有这个符号,如果没有就创建一个,然后以 emacs_value 的形式封装这个符号,将其作为 env->intern 的返回值。简而言之,env->intern 返回一个符号。

由于 env->intern(env, "defalias")env->funcall 的参数,那么后者拿到前者返回的符号,要做什么呢?如果前者返回的符号绑定了一个 Elisp 函数,那么 env->funcall 便可以通过这个符号调用它绑定的函数。那么,Elisp 符号 defalias 绑定的是一个 Elisp 函数吗?是的。Elisp 函数 defalias 可以用于定义一个函数,类似于 defun,二者的区别是,defalias 是函数,而 defun 实际上是宏。env->funcall 可以调用函数,但不可以调用宏。

env->funcall 要调用 defalias 函数,就需要给它传递两个参数,一个是符号,一个是函数的定义,以下代码便是为 defalias 函数准备参数:

emacs_value symbol = env->intern(env, "foo-anwser");
emacs_value func = env->make_function(env, 0, 0, foo_answer, "", NULL);
emacs_value args[] = {symbol, func};

基于上述解释,

env->funcall(env, env->intern(env, "defalias"), 2, args);

的含义就基本上清晰了。env->funcall 调用了 Elisp 函数 defalias,将 symbolfunc 这两个参数传递给了 defalias,由 defalias 在 Elisp 环境里,亦即上述代码里几乎无处不在的 env,将一个符号 foo-anwser 绑定了一个函数 func

func 是怎么来的呢?它实际上是一个匿名函数,是 env->make_function 的返回值。这不奇怪,Elisp 语言可以将函数像数据一样传来传去。env->make_function 创建并返回的,实际上是一个匿名函数。

匿名函数

匿名函数也叫 Lambda 表达式。在 Elisp 语言里,几乎所有的函数本质上都是匿名函数,它们之所以有名字,是因为有符号绑定了它们。defalias 的用处就是将一个符号绑定到一个 Lambda 表达式。例如

(defalias 'foo
  (lambda ()
    (princ\n "Hello world!")))

defalias 将符号 foo 绑定了 Lambda 表达式

(lambda ()
  (princ\n "Hello world!"))

这个 Lambda 表达式就是一个函数,可在终端里输出 Hello world!

如果使用 Elisp 函数 funcall,可以调用 defalias,将 foo 绑定到上述的 Lambda 表达式,例如

(funcall 'defalias
         'foo
         (lambda ()
           (princ\n "Hello world")))

在 Emacs 的动态模块里,用 env->make_function 创建并返回的匿名函数,其定义就是符合格式要求的 C 函数。因此,上述 Elisp 代码完全可以用 Emacs 动态模块的代码予以模拟,即

#include <stdio.h>
#include <emacs-module.h>
int plugin_is_GPL_compatible;

emacs_value lambda_func (emacs_env *env,
                         ptrdiff_t nargs,
                         emacs_value *args,
                         void *data)
{
        printf("Hello world\n");
        return env->make_integer(env, 0);
}

int emacs_module_init (struct emacs_runtime *ert)
{
        emacs_env *env = ert->get_environment(ert);
        emacs_value symbol = env->intern(env, "foo");
        emacs_value lambda = env->make_function(env, 0, 0, lambda_func, "", NULL);
        emacs_value args[] = {symbol, lambda};
        env->funcall(env, env->intern(env, "defalias"), 2, args);
        return 0;
}

emacs_env

emacs_module_init 函数里,一旦从 emacs_runtime 里获得 emacs_env,即

emacs_env *env = ert->get_environment(ert);

便相当于在 C 程序里得到了一个 Emacs 的全部功能,同时这也意味着,Emacs 可以从 C 程序里得到它想要的东西。所以,前文中我用了一个隐喻「城乡结合部」形容 emacs_env,它的作用就是沟通 Emacs 和 C 程序,通过它,C 程序里的数据和函数可以传送到 Elisp 的世界里,反过来,Elisp 世界里的的一切也可以通过它传送到 C 程序的世界里。

在计算宇宙终极答案的 C 代码里,已经见识了使用 env->make_integer 函数将 C 程序里的数据 42 封装为 Elisp 世界里的整型数,即

env->make_integer(env, 42);

反过来,使用 env->extract_integer 函数可以从 Elisp 世界里的整型数里取出 C 程序需要的数据,例如

emacs_value foo = env->make_integer(env, 42);
int bar = env->extract_inter(env, foo);

与整型数据的转换类似,emacs_env 提供的浮点数据交换函数是 make_floatextract_float

字符串交换

从 Elisp 程序向动态模块里的 C 函数传递字符串,后者需要用 copy_string_contentsemacs_value 对象里提取。例如

emacs_value foo (emacs_env *env,
                 ptrdiff_t nargs,
                 emacs_value *args,
                 void *data)
{
        char *buf;
        ptrdiff_t len;
        
        env->copy_string_contents(env, args[0], NULL, &len);
        printf(">>> %ld\n", len);
        
        buf = malloc(len);
        env->copy_string_contents(env, args[0], buf, &len);
        printf("%s\n", buf);
        free(buf);

        return env->make_integer(env, 0);
}

需要调用两次 copy_string_contents,第一次调用,用于获取 Elisp 字符串的长度,以便于在 C 程序里为存放字符串内容而分配足够的内存。第二次调用方是获取 Elisp 字符串内容,存入 buf 指向的空间。倘若能保证 buf 的空间足够大,copy_string_contents 的第一次调用可免。

由于上述的模块函数 foo 可以接受 1 个参数,因此在 emacs_module_init 函数里关于它与 Elisp 符号的绑定代码需要指定它的参数的最小和最大个数,即

emacs_value symbol = env->intern(env, "foo");
emacs_value func = env->make_function(env, 1, 1, foo, "", NULL);

那么,如何将 C 程序里的字符串传递到 Elisp 程序呢?使用 make_string 函数,例如

emacs_value result = env->make_string(env, buf, strlen(buf));

下面给出完整的 C 代码以供参考:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <emacs-module.h>
int plugin_is_GPL_compatible;

emacs_value foo (emacs_env *env,
                 ptrdiff_t nargs,
                 emacs_value *args,
                 void *data)
{
        ptrdiff_t len;       
        env->copy_string_contents(env, args[0], NULL, &len);
        printf(">>> %ld\n", len);
        
        char *buf = malloc(len);
        env->copy_string_contents(env, args[0], buf, &len);
        printf("%s\n", buf);
        
        emacs_value result = env->make_string(env, buf, strlen(buf));
        free(buf);

        return result;
}

int emacs_module_init (struct emacs_runtime *ert)
{
        emacs_env *env = ert->get_environment(ert);
        emacs_value symbol = env->intern(env, "foo");
        emacs_value func = env->make_function(env, 1, 1, foo, "", NULL);
        emacs_value args[] = {symbol, func};
        env->funcall(env, env->intern(env, "defalias"), 2, args);
        return 0;
}

用户指针

从 Elisp 程序向动态模块传递 C 指针对象,似乎没有应用场景,因为 Elisp 里没有指针类型。同理,将动态模块里的 C 指针传递给 Elisp 程序,也没有应用场景,但是可以让 Elisp 解释器自动回收动态模块里为 C 指针分配的内存空间,从而使得为 Elisp 写动态模块不需要关心内存回收问题。

假设在动态模块里,有以下内存分配:

char *buf = malloc(10 * sizeof(char));

以往,在 C 程序里,需要使用 free 函数回收为 buf 分配的内存:

free(buf);

在动态模块里,倘若使用 make_user_ptr 函数创建一个用户指针对象,

emacs_value bar = env->make_user_ptr(env, free, buf);

那么 Elisp 解释器便具有了回收 buf 所指向的内存空间的能力,它会在适当时机调用 free 函数,释放 buf 指向的空间。

为了验证这一点,可以自行定义一个能够在终端输出确认 free 函数被 Elisp 解释器调用的函数,例如

void my_free(void *buf)
{
        free(buf);
        printf("内存释放!\n");
}

然后将用户指针对象的创建代码修改为

emacs_value bar = env->make_user_ptr(env, my_free, buf);

下面给出完整的动态模块代码:

#include <stdio.h>
#include <stdlib.h>
#include <emacs-module.h>
int plugin_is_GPL_compatible;

void my_free(void *buf)
{
        free(buf);
        printf("内存释放!\n");
}

emacs_value foo (emacs_env *env,
                 ptrdiff_t nargs,
                 emacs_value *args,
                 void *data)
{
        char *buf = malloc(10 * sizeof(char));
        emacs_value bar = env->make_user_ptr(env, my_free, buf);
        return bar;
}

int emacs_module_init (struct emacs_runtime *ert)
{
        emacs_env *env = ert->get_environment(ert);
        emacs_value symbol = env->intern(env, "foo");
        emacs_value func = env->make_function(env, 0, 0, foo, "", NULL);
        emacs_value args[] = {symbol, func};
        env->funcall(env, env->intern(env, "defalias"), 2, args);
        return 0;
}

为了确认 buf 指向的空间确实被 Elisp 解释器调用 my_free 函数释放了,需要在 Elisp 程序里强制运行内存回收函数 garbage-collect,否则在示例程序里不容易等到 Elisp 解释器自动执行内存回收的时机。以下 Elisp 程序

(load "foo")
(foo)
(garbage-collect)

在我的机器上可输出:

Loading foo (module)...
内存释放!

数组交换

Elisp 语言提供了数组类型,但是这份教程一直没有用过它。现在到了用它的时候了,因为 C 语言也有数组类型。在动态模块里,这两种类型需要交换,从而实现一组数据在动态模块和 Elisp 程序之间的传递。

在 Elisp 程序里,通常可以将向量类型视为数组类型。与创建列表类似,创建向量,有两种办法:

(setq a '[0 1 2 3])
(setq b (vector 0 1 2 3))

但是,也许是由于方括号形式的向量构造语法在 Elisp 语言里不像列表符号那样会导致 Elisp 解释器存在误节,因此可以去掉引号,即

(setq a [0 1 2 3])

使用 eltaref 可以根据下标访问数组元素,下标从 0 开始。例如,访问上述代码构造的向量 a 的第 3 个元素:

(elt a 2)
(aref a 2)

使用 aset 函数可以修改数组里某个下表对应的元素的值,例如,将 a 的第 2 个元素修改为 0:

(aset a 1 0)

至此,Elisp 的数组类型就基本介绍完了。

将 Elisp 程序里的向量传递给动态模块,需要用到 emacs_env 类型里的三个函数,vec_getvec_set 以及 vec_size。假设在 Elisp 程序里向动态模块里的一个函数 foo 传递一个向量:

(foo [0 1 2 3 4])

那么,在动态模块里,可以像下面这样实现 foo

emacs_value foo (emacs_env *env,
                 ptrdiff_t nargs,
                 emacs_value *args,
                 void *data)
{
        emacs_value bar = args[0];
        size_t n = env->vec_size(env, bar);
        env->vec_set(env, bar, 2, env->make_integer(env, 0));
        for (size_t i = 0; i < n; i++) {
                emacs_value j = env->vec_get(env, bar, i);
                printf("%ld ", env->extract_integer(env, j));
        }
        return env->make_integer(env, 0);
}

C 函数 foo 将 Elisp 传递给它的向量的第 2 个元素修改为 0,因此 Elisp 解释器调用了它之后,在终端里输出的向量的值为

0 1 0 3

C 函数对向量元素的修改,本质上是对 Elisp 里向量对象的修改,亦即以下 Elisp 程序

(let ((a [0 1 2 3]))
  (foo a)
  a)

的求值结果是 [0 1 0 3],而非 [0 1 2 3]

如何将动态模块里的 C 数组传递给 Elisp 程序呢?Elisp 在 emacs_env 类型里并未提供 make_vector 之类的函数,但是,可以通过 emacs_env 类型里的 internfuncall 函数直接调用 Elisp 函数创建列表、向量、Hash 表等复合数据类型的对象,会有些繁琐,但是利用这种方法可在动态模块里调用 Elisp 的一切功能,只需综合利用上文给出的知识便可实现该方法,在此不作赘述了……懒人总是有高明的借口,那就作为一个习题吧。

结语

Elisp 程序能够通过动态模块调用一个能够计算宇宙终极答案的 C 函数,这意味着……这个教程可能需要结束了。

下一章:兔子洞


  1. 在 Windows 系统中,共享库即动态连接库。
阅读 2.1k

5.9k 声望
1.9k 粉丝
0 条评论
5.9k 声望
1.9k 粉丝
宣传栏