目录

Common Lisp CFFI 详解

目录

前言

我之前有一篇CFFI入门文章:Common Lisp CFFI 入门,相比于那篇文章,本文会以自底向上的方式来讲解,先学底层抽象,再用底层抽象去构建上层抽象。

加载外部库

在调用一个C语言库的函数前,我们首先需要加载该库到内存中,这需要两个步骤:

  1. 定义外部库,通过 define-foreign-library 来进行。
  2. 加载外部库到内存中,通过 load-foreign-library 来进行。

定义外部库

举个定义外部库的例子:

1
2
3
4
(define-foreign-library libcurl
  (:darwin (:or "libcurl.3.dylib" "libcurl.dylib"))
  (:unix (:or "libcurl.so.3" "libcurl.so"))
  (t (:default "libcurl")))

define-foreign-library 的第一个参数是外部库名,为一个符号,CFFI会注册该符号以代表指定的库,之后需要指定外部库的地方,我们都可以通过该符号来指定。

之后便是一系列加载子句(load-clause),用于指定如何找到并加载该外部库,比如上面的例子中,在Mac系统下,会首先尝试加载libcurl.3.dylib,然后再尝试加载libcurl.dylib,UNIX系统同理,最终,如果是其他系统,直接加载不带后缀的libcurl。

第一个参数的完整语法为: name-and-options ::= name | (name &key canary convention search-path) 各部分含义如下:

  1. name:外部库的名字,为一个符号。
  2. canary:一个外部符号字符串,Lisp实现会在Lisp进程映像中搜索该外部符号,如果搜索到了,会认为该库是静态链接的,这会导致之后我们通过 load-foreign-library 加载该库时,Lisp实现直接标记它为加载,而不会实际去加载。
  3. convention:指定该外部库函数的默认调用约定,当定义一个该外部库的函数时,如果没有指定调用约定时,会取该调用约定,默认为:cdecl,另外一个取值是:stdcall。
  4. serach-path:一个路径或者路径列表,当按操作系统的搜索机制搜索失败后,会去这些路径中搜索。

加载子句的完整语法为: load-clause ::= (feature library &key convention search-path) ,各部分含义如下:

  1. feature:可以是一个符号或者列表
    1. 如果是符号:检测该符号是否存在于 common-lisp:*features* 中。
    2. 如果是列表,那么列表的第一个元素决定了列表的含义,分别如下:
      1. (:and features…) features为一系列子feature,子feautre的格式和这里指定的相同,即递归定义,要求所有子feature表达式为真,本表达式才为真。
      2. (:or features) 要求任意一个子feature表达式为真,本表达式就为真。
      3. (:not feature) feature是单独一个子feature表达式,该子表达式为假,本表达式为真。
    3. 如果是t,那么该加载子句会无条件被采用。
  2. library:在加载子句的feature表达式为真的情况下,library会被采用,用于教CFFI如何加载该外部库, library可以是以下几种形式:
    1. 字符串或者pathname,这种形式的library会直接被传递给底层Lisp实现去加载对应的外部库,此时搜索机制(比如搜索目录的顺序,文件后缀名等)一般会按操作系统的来,比如UNIX下可以参考: ld.so(8), Windows下可以参考:Dynamic-Link Library Search Order。当按操作系统的搜索机制搜索失败后,会尝试去 *foreign-library-directories* 记录的多个目录去搜索,如果在其中一个目录中找到了该外部库,该外部库的绝对路径会被传递到底层Lisp实现,再次尝试加载, *foreign-library-directories* 的优先级低于search-path,在指定了seach-path的情况后下,search-path会被优先搜索。
    2. 列表,此时,列表的含义取决于第一个元素:
      1. (:framework framework-name) 此时列表的第二个元素framework-name会被当做Mac系统的Framework名称,该Framework会在 *darwin-framework-directories* 中被搜索。
      2. (:or libraries…) libraries为一系列library表达式,格式和这里指定的相同,即递归定义,每个库会被按顺序尝试加载,直到一个成功。
      3. (:default name) name会按当前操作系统的约定,转换成对应的外部库名称(比如Windows会加上后缀.dll),转换后的名称会按library的字符串形式的情况去尝试加载。
  3. conversion和search-path和第一个参数中的conversion和search-path意义一样,不过加载子句的优先级会高于第一个参数中的。

加载外部库

load-foreign-library 用于加载外部库到内存中,它接受一个library-designator作为参数,会加载该library-designator对应的外部库,返回值为一个 foreign-library 类的实例。

library-designator的形式和 define-foreign-library 中library的形式一样,可以是字符串或者pathname、列表,除了 define-foreign-library 的 library形式外,library-designator还可以是一个符号,此时它会被认为是 define-foreign-library 定义的一个外部库,会按定义中指定的搜索方式去搜索并加载该外部库(实际上 define-foreign-library 的library也可以是一个符号,意义和这里一样,但是我们一般不会这么用)。

举个例子,我们可以通过以下代码加载之前定义的libcurl库:

1
(load-foreign-library 'libcurl)

除了 load-foreign-library ,CFFI还有一个 use-foreign-library 可以用于加载外部库,除了库名不用quote外,其用法和 load-foreign-library 完全一致,实际上也仅仅是展开成 load-foreign-library ,其代码如下:

1
2
(defmacro use-foreign-library (name)
  `(load-foreign-library ',name))

该宏主要作为顶层表达式使用,典型的用法是在 define-foreign-library 后直接调用 use-foreign-library ,CFFI文档中有一句话:“在按常规求值规则(evaluation rule)不高效的Lisp实现中,该外部库会在required time被加载”,我不太清楚上面的宏是怎么达到这个目的的,因为它似乎直接展开成 load-foreign-library 而没有做什么额外的操作,不过我们还是遵循该惯用法。

调用外部函数

foreign-funcall

加载外部库到内存中后,就可以调用该外部库的函数了。

假设我们已经通过 define-foreign-library 定义了osfva库:

1
2
3
4
5
(define-foreign-library (osfva :convention :cdecl)
  ...)
;; 调用约定默认就是:cdecl,所以上面相当于
(define-foreign-library osfva
  ...)

其有一个C语言函数如下:

1
2
3
int osfva_add(int a, int b) {
  return a + b;
}

我们可以通过 foreign-funcall 来调用该函数:

1
(foreign-funcall ("osfva_add" :library osfva :convention :cdecl) :int 3 :int 4 :int)

在第一个参数中,我们指定了外部函数名+该外部函数所属的外部库+调用约定,之后是该外部函数的参数,通过参数类型+参数来指定,最后一个参数是返回值类型。CFFI的外部类型是通过符号来指定的,其中大部分CFFI内置的外部类型都是通过关键字符号来指定的。

返回值类型可以省略,此时返回值会假设为:void,当返回值为:void时(显式指定或者省略返回值类型),foreign-funcall的返回值是未定义的,可能是 (values) 即no-value,也可能是一些奇怪的数值,这里我们不能做任何假定。

当我们没有指定调用约定时,调用约定会取所属外部库的默认调用约定,即 define-foreign-library 定义外部库时指定的调用约定,当一个外部库导出的外部函数有不同的调用约定时,我们可以在这里显示指定调用约定以覆盖 define-foreign-type 指定的默认调用约定。省略调用约定后,上面的代码可以简化成:

1
(foreign-funcall ("osfva_add" :library osfva) :int 3 :int 4 :int)

实际上,如果库函数名是唯一的,我们还可以省略掉:library,此时代码可以简化成:

1
2
3
4
5
6
(foreign-funcall ("osfva_add") :int 3 :int 4 :int)
;; 或者
(foreign-funcall "osfva_add" :int 3 :int 4 :int)
;; 或者等价的,把:library设置成:default也一样,因为这是:library的默认值
;; 不传就相当于:default,即没有指定外部库。
(foreign-funcall ("osfva_add" :library :default) :int 3 :int 4 :int)

取决于操作系统,Lisp实现可能会全局搜索进程映像中所有外部库函数,找到匹配的。一般情况下,一个C语言外部库的导出函数都会带唯一的前缀,比如 libcurl的函数都带 curl_ 前缀,所以命名一般不会有冲突,可以不指定:library,当遇到有多个外部库导出同名函数的情况,再明确指定:library。

需要注意的是,如果不带:library,那CFFI就没办法使用该外部函数对应外部库的默认调用约定了,此时调用约定一律为:cdecl。

foreign-funcall 是具备调用变参函数的能力的,调用方式没有什么特殊的地方,比如调用 printf 的代码如下:

1
2
3
(foreign-funcall "printf" :string (format nil "%s: %d.~%")
                          :string "So long and thanks for all the fish"
                          :int 42 :int)

foreign-funcall 有一点需要注意:如果参数或者返回值的类型是外部结构体(即C语言结构体),且该外部结构体是以传值的形式传入的(而非传指针),那么cffi-libffi系统需要能被成功加载,该系统依赖于libffi外部库,我们得自己安装,不过如果外部结构体是通过传指针形式来的,我们就不需要该系统。那么在成功加载cffi-libffi系统后,我们就可以随便以传值形式把外部结构体作为参数和返回值了吗?还有一个限制,即使加载了cffi-libffi ,变参外部函数仍然不能以传值形式把外部结构体作为参数和返回值。

foreign-funcall-varargs

类似于 foreign-funcall 的还有 foreign-funcall-varargs ,它专门用于调用变参外部函数的情况,虽然 foreign-funcall 已经可以调用变参外部函数了,但是 foreign-funcall-varargs 对变参外部函数有额外的特殊支持。

foreign-funcall-varargs 的完整语法如下:

1
2
3
4
5
Macro: foreign-funcall-varargs name-and-options (fixed-arguments) &rest arguments ⇒ return-value

fixed-arguments ::= { arg-type arg }*
arguments ::= { arg-type arg }* [return-type]
name-and-options ::= name | (name &key library convention)

foreign-funcall 在语法上的最大区别是把固定参数和剩余参数隔离开了,调用示例如下:

1
2
3
4
(with-foreign-pointer-as-string (s 100)
  (foreign-funcall-varargs "sprintf"
                           (:pointer s :string "%.2f")
                           :double (coerce pi 'double-float) :int))

这里 (with-foreign-pointer-as-string (s 100) body) 会开辟一块100个字节的内存,把该内存对应的外部指针绑定到s变量上,并在body中的代码结束后,把该块内存的内容转换成Lisp字符串,这是我们之后会详细讲的内容,这里不用特别深入。

sprintf 的签名如下:

1
int sprintf(char *str, const char *format, ...)

有两个固定参数,我们可以看到,固定参数通过括号和剩余参数隔离开来了,剩余参数的语法和 foreign-funcall 一样,都是一系列参数类型+参数,最后一样是返回值(这里可能会有读者好奇,:pointer和:string类型的区别是什么?这块内容之后会讲,这里不用在意,只要知道它们分别是第1、2个参数的类型即可)。

除了语法上的差别, foreign-funcall-varargs 关键是还能自动帮你处理类型提升,C语言的变参参数是会进行类型提升的,具体的,比int小的整型类型会被提升成int后传入,比double小的浮点数类型会被提升成double后传入,等等,而 foreign-funcall-varargs 会帮你处理类型提升,不用你自己进行。

foreign-funcall-pointer、foreign-funcall-pointer-varargs及foreign-symbol-pointer

foreign-funcall-pointerforeign-funcall-pointer-varargs 分别和 foreign-funcallforeign-funcall-varargs 类似,区别在于第一个参数是外部函数指针而不是外部函数名。

我们可以通过 foreign-symbol-pointer 获取一个外部函数对应的外部函数指针,参数是外部函数名,使用例子如下:

1
(foreign-symbol-pointer "osfva_add")

如果找得到该外部函数,返回值会是对应的外部函数指针,否则返回值会是nil。

我们可以指定:library来限定搜索的外部库,如下:

1
(foreign-symbol-pointer "osfva_add" :library osfva)

获得外部函数指针后,我们就可以使用 foreign-funcall-pointerforeign-funcall-pointer-varargs 来调用它了,以 foreign-funcall-pointer 为例:

1
2
3
(foreign-funcall-pointer (foreign-symbol-pointer "osfva_add")
                         (:convention :cdecl)
                         :int 3 :int 4)

第二个参数用于指定额外的选项,目前仅支持一个选项,即调用约定,不支持指定:library,因为第一个参数函数指针已经唯一定位到一个函数了,没有指定:library的需求,调用约定默认为:cdecl,如果刚好是你要的,可以省略,省略后调用代码如下:

1
2
3
(foreign-funcall-pointer (foreign-symbol-pointer "osfva_add")
                         ()
                         :int 3 :int 4 :int)

这里需要注意,这里的空括号不能省略。

foreign-funcall-pointer-varargs 使用方式类似,这里不再赘述。

简化外部函数的调用

通过 foreign-funcallforeign-funcall-varargs 等调用外部函数每次都要指定各个参数和返回值的类型,非常麻烦,我们希望能不传类型,这可以通过封装函数来达成,比如针对之前的 osfva_add 外部函数,我们可以定义一个包装函数,如下:

1
2
(defun osfva-add (a b)
  (foreign-funcall "osfva_add" :int a :int b :int))

但是针对每个外部函数都这么做,太麻烦了,为了解决这个问题,CFFI提供了宏: defcfun ,使用它,上面的函数的定义可以简化成:

1
2
3
(defcfun "osfva_add" :int
  (a :int)
  (b :int))

第1、2个参数分别是外部函数名和返回值类型,之后的参数用于指定该外部函数的参数以及参数的类型。 defcfun 宏会定义一个Lisp函数,接收指定数量的参数去调用 foreign-funcall ,参数类型会自动帮我们传,不用我们传,该 Lisp函数的函数名是CFFI根据第一个参数,即外部函数名转换得到一个符合Lisp 约定的函数名,比如下划线会被替换成横杠等,如果不满意CFFI生成的函数名,可以自己指定名字,如下:

1
2
3
4
5
6
7
8
(defcfun ("osfva_add" osfva-add) :int
  (a :int)
  (b :int))
;; 或者外部函数名在前,Lisp函数名在后也行,顺序不重要,因为CFFI可以通
;; 过类型来区分。
(defcfun (osfva-add "osfva_add") :int
  (a :int)
  (b :int))

foreign-funcall ,我们也可以指定该外部函数对应的外部库以及调用约定,还可以指定生成的Lisp函数的docstring,如下:

1
2
3
4
(defcfun (osfva-add "osfva_add" :library osfva :convention :cdecl) :int
  "计算a和b的和"
  (a :int)
  (b :int))

从上面的例子来看, defcfun 似乎仅仅是帮我们定义一个外部函数对应的 Lisp函数,避免我们每次都要传参数和返回值的类型而已,实际上 defcfun 还有一个额外的特性:当你在参数列表的结尾放一个 &rest 时, defcfun 会视该外部函数为变参函数,使用例子如下:

1
2
3
4
5
(defcfun ("osfva_add_two_or_manys" :library osfva) :int
  "计算两个或者多个数字的和"
  (a :int)
  (b :int)
  &rest)

此时生成出来的会是Lisp宏,而非函数,底层也会改成调用 foreign-funcall-varargs ,即会自动帮你处理类型提升的问题。

但是这产生了一个额外问题,这个生成出来的Lisp宏,是如何知道多传入的参数的类型的,毕竟我们这里只指定了两个参数的类型,它底层调用的是 foreign-funcall-varargs ,而 foreign-funcall-varargs 是需要知道每个参数的类型的,答案是:它不知道。。。我们得自己指定参数类型,故上面的 osfva-add-two-or-manys 调用方式如下:

1
(osfva-add-two-or-manys 1 2 :int 3 :int 4 :int 5)

定义新的类型及类型转换器

定义新的类型

目前,我们使用的都是CFFI内置的类型,那么,如何定义新类型或者定义一个已有类型的别名呢?CFFI内置了字符串类型:string,这里我们假设没有该字符串类型,从零开始定义一个字符串类型。

首先需要调用 define-foreign-type 来定义一个外部类型,代码如下:

1
2
3
(define-foreign-type my-string-type ()
  ()
  (:actual-type :pointer))

define-foreign-type 仅仅是对 defclass 的一个简单的包装,故语法和 defclass 是一致的,具体的,第一个参数是类名,第二个参数是父类列表,第三个参数是slot列表,最后是类选项,由于仅是对 defclass 的一个简单的包装,故最终该调用也会定义出一个my-string-type类。相比于 defclassdefine-foreign-type 多了一个额外的类选项:actual-type用于告诉CFFI该外部类型底层的实际类型,故上面相当于定义了:pointer类型的一个别名。

光是定义一个类还不能直接在 foreign-funcalldefcfun 等中使用,我们还需要定义该外部类型的解析方法,这需要通过 define-parse-method 来进行,代码如下:

1
2
(define-parse-method my-string ()
  (make-instance 'my-string-type))

define-parse-method 的第一个参数是解析方法名,用法等下再讲,第二个参数是参数列表,参数列表现在为空,不为空的情况我们之后讲,最后是body,解析方法的职责是返回一个外部类型对应的CLOS类实例,比如上面我们创建了 my-string-type类的实例。

定义完解析方法后,我们就可以在 foreign-funcalldefcfun 等函数中使用解析方法名来代表该外部类型了,举个例子:

1
2
3
(defcfun "osfva_test_method" :void
  (a my-string)
  (b :int))

这里定义了外部函数osfva_test_method,返回值类型为:void,接收两个参数,第一个参数类型是my-string,第二个参数类型是:int,注意到,第一个参数的类型我们指定为my-string,即刚才定义的解析方法的名字,而没有使用 my-string-type,CFFI会调用my-string解析方法,而my-string解析方法会创建 my-string-type的实例,CFFI会用该实例来代表该外部类型。

读者到这里可能有疑问了,这不是脱裤子放屁吗?干嘛不直接将参数a的类型指定为my-string-type,然后CFFI直接自己创建my-string-type的实例,而非要解析方法来创建?答案是解析方法让我们的类型能接收额外的参数,用于定制该外部类型。还是接着以my-string为例,字符串的内容是以特定的编码存储的,我们希望my-string-type能让我们记录该字符串的编码,由于 define-foreign-type 仅仅是 defclass 的包装函数,故 defclass 的特性都能用,比如我们可以加一个slot用于存储字符串的编码,代码如下:

1
2
3
(define-foreign-type my-string-type ()
  ((encoding :reader my-string-type-encoding :initarg :encoding))
  (:actual-type :pointer))

定义完slot了,那么怎么传递编码用于创建my-string-type呢?答案是解析方法,它可以接收额外的参数,还记得之前我们解析方法的参数列表是空的吗?现在可以加上额外的参数了,代码如下:

1
2
(define-parse-method my-string (&key (encoding :utf-8))
  (make-instance 'my-string-type :encoding encoding))

注意到,该解析方法接收一个关键字参数:encoding,然后它把该参数传递 make-instance 用于初始化编码slot了。此时,我们可以通过 (my-string :encoding :utf-16le) 来指定编码为UTF-16LE了,如下:

1
2
3
(defcfun "osfva_test_method" :void
  (a (my-string :encoding :utf-16le))
  (b :int))

当然,我们解析方法的:encoding参数默认值为:utf-8,故如果该编码是你想要的,我们可以省略该参数,比如如下两种写法都行:

1
2
3
4
5
6
7
(defcfun "osfva_test_method" :void
  (a (my-string))
  (b :int))
;; 或者
(defcfun "osfva_test_method" :void
  (a my-string)
  (b :int))

现在,我们来实际调用 osfva-test-method ,代码如下:

1
(osfva-test-method 一个指针 100)

注意到,由于指定:actual-type为指针,故第一个参数得是一个外部指针对象,读者可能会想传递Lisp字符串作为第一个参数,毕竟类名/解析方法名叫 my-string-type/my-string,但是不行,到目前为止, my-string/my-string-type还仅仅是:pointer的别名,没什么特别的地方,传的参数还得是一个外部指针对象。还有一个读者可能会奇怪的地方,这个编码只是记录着,好像没什么用啊?记它干嘛?接下来我们会慢慢解决这些疑问。

类型转换器

我们的 osfva-test-method 的第一个参数仍然得是外部指针对象,但我们希望能直接传递Lisp字符串作为参数,那么怎么达到这个目的?答案是CFFI的类型转换器特性,思考下,不能直接传递Lisp字符串的关键问题在于CFFI不知道怎么在Lisp字符串和my-string-type的实际类型即:pointer之间来回进行转换,那我们可以告诉CFFI怎么进行转换啊,而类型转换器就起到了这个作用,类型转换器负责Lisp类型到外部类型以及外部类型到Lisp类型的转换。

首先我们需要告诉CFFI,怎么转换Lisp字符串为指针,即把内部类型转换成外部类型,为此,我们需要特化 translate-to-foreign ,代码如下:

1
2
(defmethod translate-to-foreign (string (type my-string-type))
  (foreign-string-alloc string :encoding (my-string-type-encoding type)))

translate-to-foreign 的第一个参数为调用者实际传递的参数,这里我们预期它是一个Lisp字符串,第二个参数是解析方法返回的外部类型实例,这里我们特化在my-string-type上,我们的任务就是将Lisp字符串转换成指针,这通过 foreign-string-alloc 来进行,该函数负责分配足够的内存用来存储Lisp字符串,并返回分配的内存对应的指针,该方法需要知道Lisp字符串的编码,这通过:encoding关键字参数来指定,这里my-string-type记录在encoding slot中的编码就派上用场了,通过它,我们在这里可以取出该Lisp字符串对应的编码。

完整过一遍流程,假设 osfva-test-method 的定义为:

1
2
3
(defcfun "osfva_test_method" :void
  (a (my-string :encoding :utf-8))
  (b :int))

上面我们指定第一个参数的类型/解析方法为 my-string ,故my-string解析方法会被调用,它会创建一个 my-string-type的实例,并传递编码:utf-8用于初始化encoding slot,我们称该实例为my-string-type-instance。接下来,我们调用 osfva-test-method ,代码为:

1
(osfva-test-method "Lisp字符串" 100)

在调用对应的外部函数前,CFFI会调用 translate-to-foreign 来转换第一个Lisp字符串参数为外部类型,代码大概是这样的:

1
(translate-to-foreign "Lisp字符串" my-string-type-instance)

这刚好会分派到我们刚才定义的、第二个参数特化在my-string-type上的方法,该方法会把这个Lisp字符串转换成外部指针。针对第二个Lisp数字参数 100 也是同样的转换过程,当所有参数都转换成外部类型后,再用转换后的各个外部类型参数去调用对应的外部函数。

好了,到此,我们可以传递Lisp字符串作为第一个参数了,不用再传递指针。

现在,再考虑下my-string/my-string-type作为返回值类型的情况,此时CFFI需要知道如何把外部指针转换回Lisp字符串,我们定义一个测试外部函数作为例子:

1
2
3
(defcfun "osfva_test_method2" (my-string :encoding :utf-8)
  (a :int)
  (b :int))

类似于 translate-to-foreign ,我们这次需要特化方法 translate-from-foreign ,代码如下:

1
2
(defmethod translate-from-foreign (pointer (type my-string-type))
  (foreign-string-to-lisp pointer :encoding (my-string-type-encoding type)))

第一个参数是外部函数的实际返回值,这里我们预期是一个外部指针,第二个参数是解析方法返回的外部类型实例,这里我们特化在my-string-type上,我们的任务就是将外部指针转换为Lisp字符串,这通过 foreign-string-to-lisp 来进行,该函数的使用非常直接,这里不再赘述。

完整过一遍流程,由于使用 defcfun 定义 osfva-test-method 时,我们指定返回值的类型/解析方法为 my-string ,因此my-string解析方法会被调用,并传递编码:utf-8用于初始化encoding slot,我们称该实例为my-string-type-instance (注意,是在 defcfun 时就创建了my-string-type,只创建一次,而不是每次调用 osfva-test-method2 时都创建)。接下来,我们调用 osfva-test-method2 ,代码为:

1
(osfva-test-method2 100 100)

当外部函数调用完成后,返回时,CFFI会调用 translate-from-foreign 来转换返回值,代码大概是这样的:

1
(translate-from-foreign (osfva-test-method2 100 100) my-string-type-instance)

这刚好会分派到我们刚才第二个参数特化在my-string-type上的方法,该方法会把这个返回值(指针)转换为Lisp字符串。

到此,我们的my-string/my-string-type类型可以在作为外部函数的参数时,接收Lisp字符串作为参数,并转换成外部指针传给外部函数,也可以在作为返回值类型时,将指针转换回Lisp字符串值了,但是还存在一个问题,我们 translate-to-foreign 把Lisp字符串转换成外部指针,用的是 foreign-string-alloc ,它分配的内存不属于垃圾回收器的管辖范围,需要我们手动通过 foreign-string-free 去释放,这意味着,每次把 my-string/my-string-type作为外部函数参数时(即会调用 translate-to-foreign 的情况),我们都需要在外部函数返回后,调用 foreign-string-free 去释放内存,不仅是麻烦的问题,问题是我们还没办法拿到 foreign-string-alloc 分配的内存,根本没办法释放。这里的解决方案是特化 free-translated-object 方法,CFFI会调用它去释放对应的内存,代码如下:

1
2
3
(defmethod free-translated-object (pointer (type my-string-type) param)
  (declare (ignore param))
  (foreign-string-free pointer))

(注意:CFFI仅会对转换后的参数调用 free-translated-object ,针对返回值,CFFI会调用 translate-from-foreign 进行类型转换,但是不会调用 free-translated-object ,去释放外部类型值,其原因见后面的字符串章节,该结论不仅适用于字符串,也适用于其他类型)。

这里,第一个参数是 translate-to-foreign 转换后的对象,第二个参数是解析方法返回的外部类型实例,第三个参数我们暂时用不上,之后再讲。在方法体内,我们调用了 foreign-string-free 去释放掉了内存。

完整过一遍流程,调用 osfva-test-method ,代码为:

1
(osfva-test-method "Lisp字符串" 100)

在调用外部函数前,CFFI会调用 translate-to-foreign"Lisp字符串" 转换成外部类型,然后在外部函数执行完,返回后,会调用 free-translated-object 将该外部类型释放,完整代码大概是这样的:

1
2
3
(let ((foreign-value (translate-to-foreign "Lisp字符串" my-string-type-instance)))
  (unwind-protect (osfva-test-method foreign-value 100)
    (free-translated-object foreign-value my-string-type-instance 我们没用的参数)))

OK,到此,my-string/my-string-type算是完整了。

现在,讲下 free-translated-object 的第三个参数,它实际上是 translate-to-foreign 返回的第二个值(如果有的话),我们前面 translate-to-foreign 只返回了一个值,故 free-translated-object 的第三个参数会为nil。举个第三个参数可以派上用场的例子,假设一个分配内存的函数在释放时需要指定其大小,我们可以这样写:

1
2
(defmethod translate-to-foreign (string (type my-string-type))
  (my-string-alloc string :encoding (my-string-type-encoding type)))

这里,假设my-string-alloc会返回两个值,第一个值是分配的内存对应的指针,第二个值是该块内存的大小,即返回值是 (values pointer size) ,这时,我们的 free-translated-object 的第三个参数的值会是第二个返回值,即 size ,代码如下:

1
2
3
(defmethod free-translated-object (pointer (type my-string-type) size)
  (declare (ignore param))
  (my-string-free pointer size))

当然,translate-to-foreign返回的第二个值不一定得是像size这样的简单的整数类型,而可以是任意复杂的类型,记录你所需要的各种信息。

附加一个额外的信息:CFFI底层是通过babel这个库来对字符串编码进行处理的。 babel支持什么编码,它就支持什么编码。

:simple-parser类选项

当通过 define-foreign-type 定义一个外部类型时,每次都还要额外通过 define-parse-method 来定义解析方法是比较麻烦的,特别是解析方法的代码都比较固定,基本上就是接收参数并把参数转发给 make-instance ,属于重复性工作,比如定义一个boolean类型,代码如下:

1
2
3
4
5
6
(define-foreign-type my-boolean-type ()
  ()
  (:actual-type :int))

(define-parse-method my-boolean ()
  (make-instance 'my-boolean-type))

通过:simple-parser类选项,上面的代码可以简化成:

1
2
3
4
(define-foreign-type my-boolean-type ()
  ()
  (:actual-type :int)
  (:simple-parser my-boolean))

这里只要给:simple-parser类选项指定你要生成的解析方法的方法名,它就会帮你生成对应的解析方法,比如上面的例子生成的解析方法代码如下:

1
2
(define-parse-method my-boolean (&rest args)
  (apply #'make-instance 'my-boolean-type args))

注意,生成的解析方法可以接收不限量的参数,它会直接无条件把所有参数转发给 make-instace ,也就是说我们的my-string-type可以简化成:

1
2
3
4
(define-foreign-type my-string-type ()
  ((encoding :reader my-string-type-encoding :initarg :encoding :initform :utf-8))
  (:actual-type :pointer)
  (:simple-parser my-string))

注意: make-instance 也会做一定的参数检查,比如哪些initarg是合法的,等等,所以生成的解析方法还是带一定保护的。

优化类型转换器

通过 translate-xx 进行类型转换存在一个问题,每次调用都得涉及通用函数的分派,虽然在优秀的Lisp实现上,通用函数的分派是非常快的,代价非常小,但是对于调用频率非常非常高的函数,用户可能会连这点代价都承受不起,此时用户会想要不涉及通用函数的分派。

CFFI提供了这样的优化能力,它的优化方法不是省略通用函数的分派,而是把分派放到宏展开期去了。

为了避免在运行期涉及通用函数的分派,我们不能特化 translate-xx 通用函数,而需要特化其他的通用函数,具体为 expand-xx 通用函数。

我们宏展开一下某个使用我们my-string/my-string-type的外部函数的定义:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
CFFI> (macroexpand-1 '(defcfun foo my-string (x my-string)))
;; 简化后的宏展开如下:
(defun foo (x)
  (multiple-value-bind (#:G2019 #:PARAM3149)
      (translate-to-foreign x #<MY-STRING-TYPE {11ED5A79}>)
    (unwind-protect
         (translate-from-foreign
          (foreign-funcall "foo" :pointer #:G2019 :pointer)
          #<MY-STRING-TYPE {11ED5659}>)
      (free-translated-object #:G2019 #<MY-STRING-TYPE {11ED51A79}>
                              #:PARAM3149))))

可以看到,会在运行时调用 translate-xx 通用函数,也就是每次调用都会进行通用函数分派。我们可以转而特化 expand-xx 通用函数,这样可以做到不在运行时进行分派。

以定义一个boolean类型为例,定义如下:

1
2
3
4
(define-foreign-type my-boolean-type ()
  ()
  (:actual-type :int)
  (:simple-parser my-boolean))

我们希望该boolean类型在被传递给外部函数前,被转换成数字0或者1,然后在转换回Lisp类型时,转换成t或者nil,按之前的 translate-xx 来写的话,代码如下:

1
2
3
4
5
(defmethod translate-to-foreign (boolean (type my-boolean-type))
  (if boolean 1 0))

(defmethod translate-from-foreign (boolean-number (type my-boolean-type))
  (not (zerop boolean-number)))

改写成 expand-xx 的话,我们需要改特化在 expand-to-foreignexpand-from-foreign 上,它们分别对应于 translate-to-foreigntranslate-from-foreignexpand-xx 不同于 translate-xxtranslate-xx 接收待转换值,返回值直接是转换后的值,而 expand-xx 则不同,它们接收的参数和 translate-xx 一样,但是要求返回的是用于转换待转换值的代码(form),而不是直接返回转换后的值,代码如下:

1
2
3
4
5
(defmethod expand-to-foreign (value (type my-boolean-type))
  `(if ,value 1 0))

(defmethod expand-from-foreign (value (type my-boolean-type))
  `(not (zerop ,value)))

返回的转换代码会直接在宏展开期被内嵌到相应的位置中去,比如定义了上面的 expand-xx 后,我们再次宏展开之前的foo外部函数,结果如下:

1
2
3
4
5
CFFI> (macroexpand-1 '(defcfun bar my-boolean (x my-boolean)))
;; 简化后的宏展开如下:
(defun bar (x)
  (let ((#:G3182 (if x 1 0)))
    (not (zerop (foreign-funcall "bar" :int #:G3182 :int)))))

可以看到,转换代码直接在宏展开时就被嵌到定义的函数里去了,运行时不需要经过通用函数分派。

我们之前的my-string-type也可以改用 expand-xx ,如下:

1
2
3
4
5
(defmethod expand-to-foreign (value (type my-string-type))
  `(foreign-string-alloc ,value :encoding (my-string-type-encoding type)))

(defmethod expand-from-foreign (value (type my-string-type))
  `(foreign-string-to-lisp ,value :encoding (my-string-type-encoding type)))

但是这样写会有内存泄漏的问题, foreign-string-alloc 分配的内存没有被释放,我们可以把 expand-to-foreign 换成 expand-to-foreign-dyn ,代码如下:

1
2
3
4
(defmethod expand-to-foreign-dyn (value var body (type my-string-type))
  `(let ((,var (foreign-string-alloc ,value :encoding ,(my-string-type-encoding type))))
     (unwind-protect (progn ,@body)
       (foreign-string-free ,var))))

expand-to-foreign-dyn 接收四个参数,第一个参数是实参的表达式(未求值),运行时求值该表达式会得到对应的实参值,第二个参数是一个变量名(符号),第三个参数是CFFI实际调用外部函数的代码,最后一个参数没什么好说的, expand-to-foreign-dyn 的职责是生成一段代码,该代码要求:

  1. 转换实参值为对应的外部类型,并绑定转换后的值到第二个参数指定的变量名上。
  2. 执行body去调用外部函数。

这就是为什么上面的 expand-to-foreign-dyn 是这么写的。

上面的代码中,我们通过 unwind-protect 以确保内存得到释放,使用CFFI内置的 with-foreign-string 宏,上面的代码可以简化成:

1
2
3
4
(defmethod expand-to-foreign-dyn (value var body (type my-string-type))
  (let ((encoding (my-string-type-encoding type)))
    `(with-foreign-string (,var ,value :encoding ',encoding)
       ,@body)))

with-foreign-string 会在进入body前,调用 foreign-string-alloc 去分配内存,然后把分配的内存绑定到指定的变量上,并会通过 unwind-protect 在body结束后,调用 foreign-string-free 去释放内存,这正是我们想要的。

我们再次看下一个示例外部函数的宏展开,如下:

1
2
3
4
5
6
CFFI> (macroexpand-1 '(defcfun foo my-string (x my-string)))
;; 简化后的宏展开如下:
(defun foo (x)
  (with-foreign-string (#:G2021 X :encoding ':utf-8)
    (foreign-string-to-lisp
     (foreign-funcall "foo" :pointer #:G2021 :pointer))))

expand-xx和translate-xx的对比

expand-xx 的优势是:转换代码在宏展开期就嵌入到生成的Lisp函数/宏中去了,在运行时不需要进行通用函数分派,运行效率更高。缺点是:整个过程在宏展开期执行,故没办法利用运行时才有的一些信息。

translate-xx 的优势是:转换是在运行时进行的,可以利用运行时的一些信息。缺点是:运行时每次调用都要进行通用函数分派。

故推荐是:如果不需要运行时的信息,那么就用 expand-xx ,运行效率更高,否则,使用 translate-xx

expand-xx使用时的注意点

expand-xx 通用函数要求特化它们的方法在宏展开期就要存在,典型的方法是把 (defmethod expand-xx ...) 定义方法的代码包裹在合适的 eval-when 里面,或者把这些 (defmethod expand-xx ...) 编译并存在分开的FASL文件中,然后在编译前加载该FASL文件,再进行编译。

如果直接调用foreign-funcall和foreign-funcall-varargs等底层接口,expand-xx能起作用吗?

根据上面的描述,特别是宏展开的例子,读者可能会认为 expand-xx 仅仅作用于 defcfun 定义的外部函数中,然而实际上即使你直接调用 foreign-funcallforeign-funcall-varargs 等底层函数, expand-xx 还是会起作用。 foreign-funcallforeign-funcall-varargs 都是宏,其内部会调用 translate-objects ,而 translate-objects 会进而调用 expand-xx 来辅助自己生成转换代码,故直接调用这些底层函数还是会起作用的。

expand-xx和translate-xx的优先级

当同时定义了 expand-xxtranslate-xx 时, expand-xx 的优先级会高于对应的 translate-xx ,比如 expand-to-foreign 的优先级会高于 translate-to-foreign 。除此之外, expand-to-foreign-dyn 的优先级是高于 expand-to-foreign 的。

利用这点,我们在使用交互式编程(Interactive Programming)风格进行开发时,一开始如果先用了 translate-xx ,后面可以改用 expand-xx 而不用重启整个Lisp进程(实际上即使顺序反过来,我们也能通过CLOS自身提供的接口来取消不要的方法定义)。

类型转换器的内部实现细节

CFFI是怎么做到优先使用 expand-xx 然后再尝试使用 translate-xx 的呢?前面说过, foreign-funcallforeign-funcall-varargs 等底层函数会调用 translate-objects 来生成转换代码,而 translate-objects 会进而调用 expand-xx 来辅助自己生成转换代码,CFFI有多个特化在 expand-xx 上的代码,这里取两个出来作为例子:

1
2
3
4
5
6
7
(defmethod expand-to-foreign :around (value (type translatable-foreign-type))
  (let ((*runtime-translator-form* `(translate-to-foreign ,value ,type)))
    (call-next-method)))

(defmethod expand-to-foreign (value (type translatable-foreign-type))
  (declare (ignore value))
  (values *runtime-translator-form* t))

也就是默认 expand-to-foreign 生成出来的转换代码会调用 translate-to-foreign 来执行转换,由于转换代码是嵌入在调用代码中的,运行时才执行的,故默认情况下,运行时会执行 translate-to-foreign 来转换,即运行时会涉及通用函数分派。

当你通过 define-foreign-type 定义一个外部类型时,该外部类型会是 translatable-foreign-type 的子类,特化在它上面的方法会比CFFI的特化方法优先级高,而我们的 expand-xx 代码正是特化在我们定义的子类上,故在宏展开期,会优先分派到我们的 expand-xx ,进而我们 expand-xx 生成的转换代码会被采用,我们生成的转换代码中如果没有用到 translate-xx ,那么在运行时就不会涉及通用函数的分派,同理,如果我们没有特化 expand-xx ,直接特化的 translate-xx ,那么默认的转换代码(即 *runtime-translator-form* 的值)会调用 translate-xx ,进而在运行时分派到我们的 translate-xx

expand-xx可以根据条件选择是否fallback到translate-xx

前面说了类型转换器的内部实现细节,根据该内部实现细节,我们的 expand-xx 方法,可以根据条件,选择生成不同的转换代码,甚至可以直接调用 call-next-method ,让低优先级的 expand-xx 去生成代码,如果下一个优先级的 expand-xx 方法会生成调用 translate-xx 的转换代码,那此时运行时就会涉及 translate-xx 了,非常灵活。

手动调用类型转换器进行转换

正常情况下我们不会手动去调用类型转换器进行转换,而是依赖CFFI去自动在调用外部函数时以及从外部函数返回时调用类型转换器。不过如果你想快速测试下自己定义的类型转换器的效果,或者优化一些特殊情况下的外部函数调用,可以使用 convert-to-foreign 以及 convert-from-foreign 来手动调用类型转换器。

convert-to-foreign

convert-to-foreign 用于把Lisp类型转换成外部类型,使用方法如下:

1
2
3
4
(convert-to-foreign "a boat" my-string) ; -> #.(SB-SYS:INT-SAP #X05EDDDF0)
;; 内置的:string类型也可以自动在指针和Lisp字符串之间进行转换,
;; 故上面等价于:
(convert-to-foreign "a boat" :string) ; -> #.(SB-SYS:INT-SAP #X05EDDFF0)

第一个参数是Lisp类型的实例,第二个参数是类型说明符,下面举几个典型的类型说明符:

  1. 解析方法名: my-string
  2. 带参数的解析方法: (my-string :encoding :utf-8)
  3. 类型别名。
  4. 结构体的 (:struct 结构体类型) ,联合体的 (:union 结构体类型)

上面例子中,有些类型是还没讲过的,之后会讲。

convert-from-foreign

convert-from-foreign 用于把外部类型转换成Lisp类型,使用方法如下:

1
2
(convert-to-foreign "a boat" :string) ; -> #.(SB-SYS:INT-SAP #X05EDDDF0)
(convert-from-foreign * :string) ; -> "a boat"

参数和 convert-to-foreign 一样,仅仅是转换方向反过来而已。

free-converted-object

free-converted-object 用于把转换得到的外部类型值释放掉,对应于 free-translated-object ,第一个参数是待释放的外部类型值,第二个参数是类型说明符,最后一个参数对应于 free-translated-object 的第三个参数,之前讲过,正常情况下,第三个参数会接收 translate-to-foreign 的第二个返回值,这里你得手动传,如下:

1
2
(multiple-value-bind (foreign-value param) (convert-to-foreign "a boat" :string)
  (free-converted-object foreign-value :string param))

注意:该函数的返回值是未定义的,不要做任何假设。

内置的定义新类型的接口

CFFI内置了一些定义新类型的接口,其大部分是类型转换器的语法糖,或者是简单的映射(比如哈希表)。

定义类型别名

我们可以通过 defctype 来定义类型别名,语法如下:

1
Macro: defctype name base-type &optional documentation

例子如下:

1
2
3
4
5
(defctype my-string :string
  "我们自己的字符串类型")

(defctype long-bools (:boolean :long)
  "底层类型为long型的boolean")

第一个参数是别名,第二个参数是该别名的实际类型,最后是可选的docstring。

该别名会继承其实际类型的类型转换器,比如CFFI的内置:string类型能自动在外部指针和Lisp字符串之间进行转换,而上面定义的别名类型my-string也可以做到。

需要注意的是: defctype 不会生成一个CLOS类,故无法通过特化 expand-xx 或者 translate-xx 以定义额外的类型转换器。

第二点特别特别注意的地方是:类型转换器的继承是非传递的,考虑下以下代码:

1
2
3
4
5
(defctype lpstr (:string :encoding :ascii)
  "Windows下的窄字符类型")

(defctype lpcstr lpstr
  "Windows下的cosnt窄字符类型")

这里lpstr为:string的别名,lpcstr为lpstr的别名,lpstr会继承:string的类型转换器,这点没问题。读者可能还会预期lpcstr也会继承:string的类型转换器,即类型转换器的继承是传递的,然而实际上是非传递的,lpcstr不会继承:string的类型转换器,故你如果定义一个外部函数,其参数类型为lpcstr,那么该参数类型只能传指针,不能转Lisp字符串。为了解决这个问题,我们需要把上面的代码改写成:

1
2
3
4
5
(defctype lpstr (:string :encoding :ascii)
  "Windows下的窄字符类型")

(defctype lpcstr (:string :encoding :ascii)
  "Windows下的cosnt窄字符类型")

规范化(canonicalized)类型

这里引入一个概念,即规范化类型,即一个类型A最终解析为什么CFFI内置类型,该内置类型为类型A的规范化类型,举个例子:

1
2
(defctype integer :int)
(defctype int_number integer)

这里integer是:int的别名,故integer的规范化类型是:int,int_number是 integer的别名,但是integer不是int_number的规范化类型,因为integer不是内置类型,:int才是int_number的规范化类型。

定义枚举类型

同C语言一样,CFFI可以定义枚举类型来替代常量,起到提高代码可读性、减少重复的作用。

定义枚举类型通过 defcenum 来进行,最简单的例子:

1
2
3
4
(defcenum boolean
  "一个简单的枚举类型"
  :no
  :yes)

定义了一个名为boolean的枚举类型,该枚举类型有两个枚举常量,分别是:no和:yes,枚举值分别是0和1。

需要注意:定义的枚举常量必须是关键字符号,不能是普通符号。

我们可以通过 foreign-enum-value 得到对应的枚举值,如下:

1
2
(foreign-enum-value 'boolean :no) ; -> 0
(foreign-enum-value 'boolean :yes) ; -> 1

反过来,我们也可以通过枚举值得到对应枚举常量的关键字符号,如下:

1
2
(foreign-enum-keyword 'boolean 0) ; -> :no
(foreign-enum-keyword 'boolean 1) ; -> :yes

调用 foreign-enum-keyword 时,如果指定了未定义的数值,会抛出错误,除非你将errorp关键字参数指定为nil,此时会转而返回nil,如下:

1
2
(foreign-enum-keyword 'boolean 100) ; -> 抛出错误
(foreign-enum-keyword 'boolean 100 :errorp nil) ; -> nil

定义枚举类型时,我们可以手动指定一个枚举常量的枚举值,没指定枚举值的枚举常量,如果是第一个枚举常量,其值会是0,否则其值会接着上一个枚举常量的枚举值+1,即规则和C语言一样,如下:

1
2
3
4
(defcenum numbers
  (:one 1)
  :two
  (:four 4))

这里:one、:two、:four的枚举值分别是1、2、4。

CFFI为枚举类型定义了类型转换器,在传递给外部函数时,会自动转换成该枚举类型对应的整数类型,默认为:int,作为返回值返回时,会自动转换回关键字符号。

我们可以指定底层的整数类型,下面的代码将其指定为:long:

1
2
3
4
(defcenum (numbers :long)
  (:one 1)
  :two
  (:four 4))

CFFI为枚举类型定义的类型转换器有一个特性:在将整数转换回关键字符号时,如果发现该整数是没有定义的,会抛出错误,你可以通过指定 allow-undeclared-values 为non-nil,使其不进行转换,直接返回该整数,如下:

1
2
3
4
(defcenum (numbers :int :allow-undeclared-values t)
  (:one 1)
  :two
  (:four 4))

定义位域

位域类似于枚举,不过不同于枚举,每个位域都独立占一位,示例如下:

1
2
3
4
5
6
7
8
(defbitfield open-flags
  "一个示例位域类型"
  (:rdonly 0)
  :wronly                               ; 值为#b0001
  :rdwr                                 ; 值为#b0010
  :nonblock                             ; 值为#b0100
  :append                               ; 值为#b1000
  (:creat #b1000000000))

类似于枚举类型,第一个位域的值默认为0,其他位域的值会在前一个位域的值基础上左移一位。

同枚举类型,我们也可以指定其底层的基础整数类型,如下:

1
2
3
4
5
6
7
(defbitfield (open-flags :long)
  (:rdonly 0)
  :wronly                               ; 值为#b0001
  :rdwr                                 ; 值为#b0010
  :nonblock                             ; 值为#b0100
  :append                               ; 值为#b1000
  (:creat #b1000000000))

不同于枚举的是,我们的位域符号可以是普通符号,不一定要是关键字符号。

我们可以通过 foreign-bitfield-symbols 获得一个数字设置了多少个位域,如下:

1
(foreign-bitfield-symbols 'open-flags #b1101) ; -> (:WRONLY :NONBLOCK :APPEND)

反过来,我们也可以调用 foreign-bitfield-value 获取一个位域列表对应的数字,如下:

1
(foreign-bitfield-value 'open-flags '(:rdwr :creat)) ; -> #b1000000010

位域和枚举最大的不同在于:位域传递参数时,传递的不是数字,而是位域列表,同理,返回值也会从整数被转换成位域列表,如下:

1
2
3
4
5
6
(defcfun ("open" unix-open) :int
  (path :string)
  (flags open-flags)
  (mode :uint16))

(unix-open "/tmp/foo" '(:wronly :creat) #o644)

定义结构体

通过 defcstruct 可以定义外部结构体,如下:

1
2
3
4
(defcstruct point
  "点结构体"
  (x :int)
  (y :int))

上面定义了一个point结构体,有两个域(后面我们会改叫slot),类型均为:int。

同C语言结构体一样, defcstruct 定义的结构体,每个slot在内存中是按顺序存储了,有不同的偏移,第一个slot的偏移是0,其他slot的偏移一般是紧接着上一个slot的偏移+sizeof(上一个slot的大小),但是还得考虑操作系统所指定的C ABI,CFFI会按C ABI插入一些padding进行对齐。比如上面的结构体,一般情况下,x的偏移会是0,y的偏移会是4。

CFFI的slot分两种类型:

  1. 简单slot - 即slot的类型对应的规范化类型为标量类型,比如:int、:pointer等,而非数组、结构体等聚合类型。
  2. 聚合slot - 即slot的类型为数组、结构体、联合体等聚合类型。

我们称仅包含简单slot的结构体为简单结构体,包含至少一个聚合slot的为聚合结构体。

在讲完一些术语的定义后,我们接着讲 defcstruct 的使用。 defcstruct 可以自定义结构体的大小以及各个slot的偏移,如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
(defcstruct (foo :size 32)
  "32个字节的结构体"
                                        ; 由于第一个slot的偏移从0开始,开头会有16个没用的字节
  (x :int :offset 16)                   ; x的偏移是16
  (y :int)                              ; y的偏移是16+sizeof(:int)
                                        ; 同理,这里也有一些没用的字节
  (z :char :offset 24)                  ; z的偏移是24
                                        ; 由于我们指定整个结构体的大小为32,故尾部还会有7个没用的字节
  )
(foreign-type-size 'foo)                ; -> 32

使用非常直接,一看就知道意思了,这里不进行讲解。

上面 foreign-type-size 用于获取外部类型的大小,不仅仅适用于结构体,其他外部类型都能用。

每个slot还可以指定:count参数,当:count参数>1时,该slot会变成数组,即一个聚合slot,例子如下:

1
2
(defcstruct person
  (name :char :count 32))

这里name为拥有32个元素的char数组。

:count参数默认为1,为1就是标量,也就是简单slot,故可以认为不存在数组长度为1的数组,当然,数组长度为1的数组在二进制级别和标量是完全一样的,不考虑类型的话没什么区别,故这不是什么限制。

访问slot

我们可以用 foreign-slot-value 来访问结构体的某个slot,如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
(defcstruct point
  (x :int)
  (y :int))

(with-foreign-object (ptr '(:struct point))
  (setf (foreign-slot-value ptr '(:struct point) 'x) 42
        (foreign-slot-value ptr '(:struct point) 'y) 42)
  (list (foreign-slot-value ptr '(:struct point) 'y)
        (foreign-slot-value ptr '(:struct point) 'y)))
;; -> (42 42)

with-foreign-object 用于分配结构体对应的内存,该块内存仅在body中生效,之后会自动释放,这是我们后面会详细讲的内容。注意到,我们指定结构体类型的方式是 (:struct point) 而不是简单的 point ,后者在大部分情况下也可以用,但是已经是废弃的用法,当变量存的是结构体指针时(如上面的ptr),则类型指定为 (:struct point) 或者 (:pointer (:struct point)) 都行。

除了 foreign-slot-value ,我们还可以用 with-foreign-slots 来访问结构体的slot,它类似于 with-slots ,一样创建的是符号宏,故也可以使用 setf 或者 setq (注意,对符号宏使用 setq 会被转换成 setf ) ,例子如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
(defcstruct point
  (x :int)
  (y :int))

(with-foreign-object (ptr '(:struct point))
  (with-foreign-slots ((x y) ptr (:struct point))
    (setf x 42)
    (setq y 42)
    (list x y)))
;; -> (42 42)

上面都是访问简单slot的情况,直接返回slot的值,用C语言的语法来讲,就是返回 ptr->slot ,那么如果访问聚合slot,返回的值会是什么?直接返回整个聚合slot的值,即复制整个数组或者结构体等?不是的,如果访问的是聚合 slot,那 foreign-slot-value 会返回指向该聚合slot的指针,用C语言的语法来讲,就是返回 &ptr->slot ,例子如下:

1
2
3
4
5
6
(defcstruct person
  (name :char :count 32))

(with-foreign-object (ptr '(:struct person))
  (foreign-slot-value ptr '(:struct person) 'name))
;; -> #.(SB-SYS:INT-SAP #X05DCFFD8)

返回的是指向聚合slot的指针,故聚合slot是什么类型,就用对应的访问方法,比如上面name是数组,那么可以用 mem-aref 去访问,如下:

1
2
3
4
5
(with-foreign-object (ptr '(:struct person))
  (let ((name-ptr (foreign-slot-value ptr '(:struct person) 'name)))
    (dotimes (i 10)
      (setf (mem-aref name-ptr :char i) (random 128)))
    (loop for i below 10 collect (mem-aref name-ptr :char i))))

同理,如果聚合slot是一个结构体,那么就用结构体的访问方法。

with-foreign-slots 有一个特性,如果绑定的变量写成形如 (:pointer slot-name) 这样的列表,那么slot-name符号宏绑定的会是指向该slot的指针,而不是值,如下:

1
2
3
4
5
6
7
8
9
(defcstruct point
  (x :int)
  (y :int))

(with-foreign-object (ptr '(:struct point))
  (with-foreign-slots ((x (:pointer y)) ptr (:struct point))
    (setf x 1)
    (list x y)))
;; -> (1 #.(SB-SYS:INT-SAP #X05DCFFF4))

获取指向slot的指针

如果我们想获取某个slot的指针,除了可以用 with-foreign-slots 把变量写成 (:pointer slot-name) 之外,还可以用 foreign-slot-pointer ,如下:

1
2
3
4
5
6
7
(defcstruct point
  (x :int)
  (y :int))

(with-foreign-object (ptr '(:struct point))
  (foreign-slot-pointer ptr '(:struct point) 'x))
;; -> #.(SB-SYS:INT-SAP #X05DCFFF0)

针对聚合slot, foreign-slot-pointer 的返回值和 foreign-slot-value 一样,都是指向slot的指针,不会变成二级指针。

获取slot的偏移

通过 foreign-slot-offset 可以获取一个slot的偏移,如下:

1
2
3
4
5
6
(defcstruct point
  (x :int)
  (y :int))

(foreign-slot-offset '(:struct point) 'x) ; -> 0
(foreign-slot-offset '(:struct point) 'y) ; -> 4

获取结构体所有slot的名字

通过 foreign-slot-names 可以获取结构体的所有slot的名字(符号),如下:

1
2
3
4
5
(defcstruct point
  (x :int)
  (y :int))

(foreign-slot-names '(:struct point)) ; -> (X Y)

需要注意几点:

  1. 返回的列表可能是和其他列表有着共享内存(有个共同部分)的列表,即shared列表,故调用者不能去修改该列表。
  2. 返回的名字是没有特定顺序的。

结构体的类型转换器

CFFI为结构体定义了类型转换器,使得能在plist和外部结构体指针之间进行转换,其中,结构体的slot名作为plist的关键字,我们可以用 convert-to-foreignconvert-from-foreign 以及 free-converted-object 快速测试下效果,如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
(defcstruct person
  (age :int)
  (name :string))

(multiple-value-bind (foreign-struct-ptr param)
    (convert-to-foreign '(age 10 name "osfva") '(:struct person))
  (prog1
      (list
       (with-foreign-slots ((age name) foreign-struct-ptr (:struct person))
         (list age name))
       (convert-from-foreign foreign-struct-ptr '(:struct person)))
    (free-converted-object foreign-struct-ptr '(:struct person) param)))
;; -> ((10 "osfva") (NAME "osfva" AGE 10))

需要注意:plist的每个value也会通过 convert-to-foreign 转换成外部值。

定义联合体

定义联合体用 defcunion ,用法和 defcstruct 基本上一样,但是几点不一样:

  1. 你不能像结构体一样指定联合体的大小,即不能用:size。
  2. 每个slot不能手动指定偏移,即不能用:offset。

用法如下:

1
2
3
(defcunion uint32-bytes
  (int-value :unsigned-int)
  (bytes :unsigned-char :count 4))

访问slot

访问slot一样用 foreign-slot-valuewith-foreign-slots ,例子如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
(with-foreign-object (ptr '(:union uint32-bytes))
  (setf (foreign-slot-value ptr '(:union uint32-bytes) 'int-value) (1- (expt 2 32)))
  (list (foreign-slot-value ptr '(:union uint32-bytes) 'int-value)
        (let ((bytes-ptr (foreign-slot-value ptr '(:union uint32-bytes) 'bytes)))
          (loop for i below 4
                collect (mem-aref bytes-ptr :unsigned-char i)))))
;; -> (4294967295 (255 255 255 255))

(with-foreign-object (ptr '(:union uint32-bytes))
  (with-foreign-slots ((int-value bytes) ptr (:union uint32-bytes))
    (setf int-value (1- (expt 2 32)))
    (list int-value
          (loop for i below 4
                collect (mem-aref bytes :unsigned-char i)))))
;; -> (4294967295 (255 255 255 255))

访问slot偏移

由于联合体的各个slot的偏移都是0,故针对它们获取偏移没什么意义,但是如果联合体的某个slot是一个聚合slot,比如结构体,那么获取该结构体slot的子 slot的偏移就是有意义的了,不过此时操作的是结构体,用结构体的接口就行,跟联合体没什么特别的关系。

联合体类型继承了结构体类型

联合体内部对应CLOS类foreign-union-type,该类是foreign-struct-type的子类,而foreign-struct-type是结构体对应的CLOS类,故适用于结构体的东西大部分也适用于联合体(比如大部分特化在结构体上的类型转换器)。

全局变量

我们可以通过 defcvar 来访问一个外部库的全局变量。

举个例子,假设osfva库,有一个名为osfva_error_code的全局变量,类型为int,那么我们可以调用 defcvar 定义一个符号宏来访问该全局变量,如下:

1
2
3
(define-foreign-library osfva ...)
(defcvar ("osfva_error_code" *osfva-error-code* :library osfva) :int
  "错误代码")

这里, defcvar 会定义一个符号宏 error-code ,访问该符号宏能得到该全局变量的值,如下:

1
*osfva-error-code* ; -> 0

并且该全局变量是可以通过 setf 去改变它的值的,如下:

1
(setf *osfva-error-code* -1)

由于对符号宏使用 setq 会被转换成 setf ,故上面也可以写成:

1
(setq *osfva-error-code* -1)

不同于 defcfun 等转换外部名称为Lisp名称的规则, defcvar 会额外地在转换的Lisp名称两侧加上 * 以符合Common Lisp惯用的动态变量命名规则,这刚好符合我们的要求,故实际上我们的代码可以简化成如下:

1
2
(defcvar ("osfva_error_code" :library osfva) :int
  "错误代码")

再进一步,如果该全局变量的命名不会和其他外部库的全局变量冲突(比如命名带有唯一前缀,就像这里的osfva_),那么可以把:library指定为:default,或者直接省略,因为:library的默认值就是:default,如下:

1
2
3
4
5
(defcvar ("osfva_error_code") :int
  "错误代码")
;; 进一步,可以简写成如下:
(defcvar "osfva_error_code" :int
  "错误代码")

如果该C语言全局变量是只读的,你想Lisp这边也是只读的,那可以传递:read-only选项为non-nil,此时修改该变量会抛出错误,如下:

1
2
3
(defcvar ("osfva_error_code" +osfva-error-code+ :read-only t) :int
  "错误代码")
(setf +osfva-error-code+ 100) ; -> 抛出错误

获取全局变量的指针

可以通过 get-var-pointer 获取一个全局变量对应的指针,指针可以通过 mem-ref 解引用,如下:

1
2
3
(defcvar "osfva_error_code" :int)
(get-var-pointer '*osfva-error-code*) ; -> #.(SB-SYS:INT-SAP #X05DBFFE8)
(mem-ref (get-var-pointer '*osfva-error-code*) :int) ; -> 0

额外的类型转换器接口

translate-into-foreign-memory和convert-into-foreign-memory

除了之前讲的 expand-xxtranslate-xx 外,类型转换器还有一个额外的接口,即通用函数 translate-into-foreign-memory ,它接收三个参数,分别是:待转换Lisp值、CFFI类型说明符、一块未初始化的内存,要求 translate-into-foreign-memory 以这块未初始化的内存为存储,将Lisp值转换成外部值,这就是为什么这个函数命名的后面部分是into foreign memory了,因为转换值得存到目标内存中,当然,该块内存要足够容纳外部值。

以结构体为例,结构体支持plist到外部结构体指针的转换,这就是通过 translate-into-foreign-memory 来实现的,对应的代码如下:

1
2
3
4
5
6
7
8
(defmethod translate-into-foreign-memory ((object list)
                                          (type foreign-struct-type)
                                          p)
  (unless (bare-struct-type-p type)
    (loop for (name value) on object by #'cddr
          do (setf (foreign-slot-value p (unparse-type type) name)
                   (let ((slot (gethash name (structure-slots type))))
                     (convert-to-foreign value (slot-type slot)))))))

第一个参数特化在list类型上,第二个参数特化在foreign-struct-type上,方法体中, bare-struct-type-p 判断CFFI类型说明符是否是废弃的结构体类型说明符,即纯结构体名作为类型说明符,比如你 (defcstruct person ...) 定义了一个person结构体,此时如果类型说明符指定为 person ,那么 bare-struct-type-p 会返回真,不执行方法体,如果类型说明符指定为 (:struct person) ,那么 bare-struct-type-p 会返回假,执行方法体,也就是说,只有用新的结构体类型说明符才支持plist到外部结构体指针的转换(等下我们可以写代码验证下这个说法)。剩下的方法体内容就是按plist方式遍历列表,得到一个个slot-name和value,然后把value通过 convert-to-foreign 转换成外部类型,最终设置到结构体中对应的slot中去,其中用到了内部函数 structure-slots 和 ~slot-type~分别用于获取一个结构体的所有slot以及每个slot的类型。

类似于 convert-to-foreigntranslate-to-foreign ,我们也可以用 convert-into-foreign-memory 来手动调用 translate-into-foreign-memory ,其接收的参数和 translate-into-foreign-memory 是一样的,调用例子如下:

1
2
3
4
5
6
7
8
9
(defcstruct person
  (age :int)
  (name :string))

(with-foreign-object (ptr '(:struct person))
  (convert-into-foreign-memory
   '(age 3 name "abc") '(:struct person) ptr)
  (foreign-slot-value ptr '(:struct person) 'age))
;; -> 3

translate-into-foreign-memory 一样, convert-into-foreign-memory 的返回值是未定义的。

注: convert-into-foreign-memory 是未文档函数,但是它是有被CFFI导出的。

验证只有新的结构体类型说明符才支持plist到外部结构体指针的转换

前面看到结构体的 translate-into-foreign-memory 实现,只有 (bare-struct-type-p type) 为假,才会执行转换,否则直接返回,这意味着只有新的结构体类型说明符才支持plist到外部结构体指针的转换,我们写代码来验证这点,代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
(defcstruct person
  (age :int)
  (name :string))

(multiple-value-bind (ptr param) (convert-to-foreign '(age 3 name "abc") '(:struct person))
  (unwind-protect (foreign-slot-value ptr '(:struct person) 'age)
    (free-translated-object '(:struct person) ptr param)))
;; -> 3
(multiple-value-bind (ptr param) (convert-to-foreign '(age 3 name "abc") 'person)
  (unwind-protect (foreign-slot-value ptr '(:struct person) 'age)
    (free-translated-object '(:struct person) ptr param)))
; in: MULTIPLE-VALUE-BIND (PTR PARAM)
;     (CFFI:CONVERT-TO-FOREIGN '(LEARN-CFFI::AGE 3 LEARN-CFFI::NAME "abc")
;                              'LEARN-CFFI::PERSON)
;
; caught STYLE-WARNING:
;   bare references to struct types are deprecated. Please use (:POINTER
;                                                               (:STRUCT PERSON)) or (:STRUCT
;                                                                                     PERSON) instead.
;
; compilation unit finished
;   caught 1 STYLE-WARNING condition
;; -> 6952784

可以看到,如果指定旧的结构体类型说明符,首先会报警告,且slot也没有被初始化,值为随机值6952784。

结构体的类型转换器默认转换的外部类型为外部结构体指针而非外部结构体

类似:int,:short,枚举、位域等,从Lisp类型转换到外部类型时,都是转换成值的形式(比如整数,而非整数指针),那我们自然会想,针对特化在结构体上的 translate-to-foreign , 它从plist转换成外部类型,是不是应该转换成外部结构体而非外部结构体指针啊?然而,实际结果是 translate-to-foreign 把plist直接转换成外部结构体指针了,实际上,它内部直接转而调用 translate-into-foreign-memory 了,代码如下:

1
2
3
4
(defmethod translate-to-foreign (value (type foreign-struct-type))
  (let ((ptr (foreign-alloc type)))
    (translate-into-foreign-memory value type ptr)
    ptr))

当然,这意味着,CFFI得记得在返回前释放掉这块内存,实际上CFFI是有特化 free-translated-object 来释放这块内存的,代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
(defmethod free-translated-object (ptr (type foreign-struct-type) freep)
  (unless (bare-struct-type-p type)
    ;; Look for any pointer slots and free them first
    (loop for slot being the hash-value of (structure-slots type)
          when (and (listp (slot-type slot)) (eq (first (slot-type slot)) :pointer))
            do
               ;; Free if the pointer is to a specific type, not generic :pointer
               (free-translated-object
                (foreign-slot-value ptr type (slot-name slot))
                (rest (slot-type slot))
                freep))
    (foreign-free ptr)))

释放代码中,每个slot都要检查其类型是否是指向具体类型的指针(非通用指针,即类似void*这种指针),如果是的话,那value也得释放。

由于 translate-to-foreign 会直接调用 translate-into-foreign-memory ,这意味着,当一个外部函数某个参数的类型说明符为结构体时,我们传递 plist,这个plist会被转换成外部结构体指针,也就是默认情况下(只要不覆盖默认实现)我们都只能以传指针形式传递结构体。

那按照这个情况来看, translate-from-foreign 是不是也会把结构体返回值当指针来看?答案是:是的,代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
(defmethod translate-from-foreign (p (type foreign-struct-type))
  ;; Iterate over slots, make plist
  (if (bare-struct-type-p type)
      p
      (let ((plist (list)))
        (loop for slot being the hash-value of (structure-slots type)
              for name = (slot-name slot)
              do (setf (getf plist name)
                       (foreign-struct-slot-value p slot)))
        plist)))

将外部函数参数/返回值的类型说明符指定为(:struct 结构体名)以及(:pointer (:struct 结构体名))的差别

注意,这里限定是将 (:struct 结构体名)(:pointer (:struct 结构体名)) 这两个类型说明符用于指定参数或者返回值的类型上,而不包括用在 foreign-slot-value 等结构体结构,这些结构体接口,接收的参数都是外部结构体指针,type参数指定为 (:struct 结构体名)(:pointer (:struct 结构体名)) 都是等价的,没有区别。

第一个区别,前者涉及的类型转换器是foreign-struct-type的类型转换器,可以把plist转换成外部结构体指针,参数可以传递plist,而后者涉及的类型转换器是foreign-pointer-type,不能以plist作为转换对象,即参数不能传递plist。

第二个区别,前者结构体以传值形式传递,后者结构体以传指针形式传递(你从一开始就得传 with-foreign-object 等返回的指针作为参数,而不能传 plist),到这里,读者会有疑问,前面刚说,结构体的类型转换器会把plist转换成外部结构体指针而不是外部结构体值,那怎么做到以传值形式传递外部结构体?答案是,外部结构体在Lisp侧一直都是以外部结构体指针的形式存在的,不会以外部结构体值的形式存在,直到真正传递给外部函数时,才会根据类型说明符进行不同的操作,如果是 (:struct 结构体名) ,则按值传递,此时会调用 cffi-libffi系统提供的接口去处理按值传递结构体,如果是 (:pointer (:struct 结构体名)) ,则按指针传递,不需要cffi-libffi系统。外部结构体在Lisp侧一律以指针的形式存在也解释了为什么所有结构体接口接收结构体都是以指针作为参数的。

如果调用按值传递结构体的外部函数时,cffi-libffi系统没有加载,那么会报错,测试代码如下(记得不要加载cffi-libffi系统):

1
(osf-test-method '(age 100 name "abc"))

报错如下:

自定义结构体的类型转换器

如何定义结构体的类型转换器呢?读者可能回想,应该还是用 expand-xx 或者 translate-xx ,那前提是 defcstruct 底层有定义一个CLOS类,这样我们才能通过特化在 expand-xx 或者 translate-xx 上实现类型转换。

我们可以宏展开一个 defcstruct 的定义看看,如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
(defcstruct person
  (age :int)
  (name :string))
;; 宏展开结果如下:
(EVAL-WHEN (:COMPILE-TOPLEVEL :LOAD-TOPLEVEL :EXECUTE)
  (DEFCLASS PERSON-TCLASS
            (CFFI::FOREIGN-STRUCT-TYPE CFFI::TRANSLATABLE-FOREIGN-TYPE) NIL)
  (CFFI::NOTICE-FOREIGN-STRUCT-DEFINITION 'PERSON '(:CLASS PERSON-TCLASS)
                                          '((AGE :INT) (NAME :STRING)))
  (DEFINE-PARSE-METHOD PERSON
      NIL
    (CFFI::PARSE-DEPRECATED-STRUCT-TYPE 'PERSON :STRUCT))
  '(:STRUCT PERSON))

可以看到, defcstruct 定义一个 person 结构体,底层会定义一个 person-tclass 类,该类有两个父类,分别是: foreign-struct-typetranslatable-foreign-type ,故我们可以通过特化 expand-xx 或者 translate-xxperson-tclass 上以自定义类型转换器,但是不推荐这么做,因为CFFI并没有在文档中说明 defcstruct 底层定义的CLOS类的名字,如果想明确 defcstruct 底层定义的CLOS类的名字,可以用:class选项直接指定 defcstruct 底层定义的CLOS类的名字,例子如下:

1
2
3
(defcstruct (person :class person-type)
  (age :int)
  (name :string))

我们这里可以明确CLOS类名为person-type,但解析方法名仍为person,也就是对应的CFFI类型说明符还是 (:struct person) 而不是 (:struct c-person) 从而便于我们在类型转换器的通用函数上特化,比如,我们可以定义 association list到外部结构体指针的类型转换器(只做前向的转换),如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
(defun assoc-list? (value)
  (and (listp value)
       (listp (car value))))

(defmethod translate-into-foreign-memory ((value list) (type person-type) ptr)
  (if (assoc-list? value)
      (loop for (slot-name value) in value
            do (setf (foreign-slot-value ptr '(:struct person) slot-name)
                     (convert-to-foreign value (foreign-slot-type '(:struct person) slot-name))))
      (call-next-method)))

注意几点:

  1. 我们特化的 translate-into-foreign-memory ,而没有特化 translate-to-foreign ,这是因为:
    1. 针对结构体, translate-to-foreign 会转而调用 translate-into-foreign-memory
    2. translate-into-foreign-memory 意义更明确,因为我们确实是将Lisp类型association list转换成外部内存(指针)。
    3. 读者可以使用 convert-into-foreign-memory 等明确把Lisp类型转换成外部内存。
  2. 我们对association list的各个value也调用 convert-to-foreign 进行了转换。
  3. 当检测到不是association list,就调用 call-next-method fallback到下一个方法,也就是CFFI内置的plist的转换还是起作用的。
  4. 我们没有特化 free-translated-object ,因为结构体默认的释放方法刚好能帮我们进行释放,不需要特殊的操作。

测试代码如下:

1
2
3
4
5
6
7
8
9
(convert-to-foreign '((name "osfva") (age 200)) '(:struct person))
;; -> #.(SB-SYS:INT-SAP #X00F41CB0)
(multiple-value-bind (c-struct param)
    (convert-to-foreign '((name "osfva") (age 200)) '(:struct person))
  (unwind-protect
       (with-foreign-slots ((age name) c-struct (:struct person))
         (list age name))
    (free-translated-object c-struct '(:struct person) param)))
;; -> (200 "osfva")

优化结构体的类型转换器

前面讲过,可以特化 expand-xx ,直接返回转换代码,以在宏展开期直接嵌入到代码中去,避免在运行时每次都调用 translate-xx 导致的通用函数分派开销。类似的, translate-into-foreign-memory 有对应的 expand-into-foreign-memory ,我们可以利用这个来优化结构体的转换,下面举一个例子。

定义一个person的C结构体,代码如下:

1
2
3
(defcstruct (person :class c-person)
  (number :int)
  (reason :string))

接着,定义个Lisp结构体lisp-person以代替plist,代码如下:

1
2
3
(defstruct lisp-person
  (number 0 :type integer)
  (reason "" :type string))

下面,按常规的方式定义Lisp结构体到外部结构体指针的类型转换器,代码如下:

1
2
3
4
5
6
7
8
9
(defmethod translate-from-foreign (ptr (type c-person))
  (with-foreign-slots ((number reason) ptr (:struct person))
    (make-lisp-person :number number :reason reason)))

(defmethod translate-into-foreign-memory (value (type c-person) ptr)
  (with-foreign-slots ((number reason) ptr (:struct person))
    ;; defstruct默认就会定义“结构体名-slot名”的访问函数,不用我们声明。
    (setf number (lisp-person-number value)
          reason (lisp-person-reason value))))

不过按上面的实现来,运行时每次都会调用 translate-from-foreign 以及 translate-into-foreign-memory 进行转换,涉及到通用函数的分派,为了优化,我们可以改特化 expand-from-foreign 以及 expand-into-foreign-memory ,直接返回转换代码,如下:

1
2
3
4
5
6
7
8
(defmethod expand-from-foreign (ptr (type c-person))
  `(with-foreign-slots ((number reason) ,ptr (:struct person))
     (make-lisp-person :number number :reason reason)))

(defmethod expand-into-foreign-memory (value (type c-person) ptr)
  `(with-foreign-slots ((number reason) ,ptr (:struct person))
     (setf number (lisp-person-number ,value)
           reason (lisp-person-reason ,value))))

translation-forms-for-class

优化结构体的类型转换器中,我们手动定义了Lisp结构体到外部结构体指针之间的类型转换器,CFFI提供了一个 translation-forms-for-class 宏,不过它的服务对象不是Lisp结构体,而是CLOS类,它能自动帮你定义一个CLOS类到外部结构体指针的转换函数,具体的,它会自动帮你生成特化在目标CLOS类上的 translate-from-foreign 以及 translate-into-foreign-memory 方法,如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
(defclass clos-person ()
  ;; 注意这里:initarg不是一般情况下我们用的关键字符号,理由见下文。
  ((age :initarg age)
   (name :initarg name)))

(defcstruct c-perosn
  (age :int)
  (name :string))

(translation-forms-for-class clos-person c-person)
;; 宏展开结果如下:
(PROGN
 (DEFMETHOD TRANSLATE-FROM-FOREIGN (CFFI::POINTER (TYPE C-PERSON))
   ;; Make the instance from the plist
   (APPLY 'MAKE-INSTANCE 'CLOS-PERSON (CALL-NEXT-METHOD)))
 (DEFMETHOD TRANSLATE-INTO-FOREIGN-MEMORY
            ((CFFI::OBJECT CLOS-PERSON) (TYPE C-PERSON) CFFI::POINTER)
   (CALL-NEXT-METHOD
    ;; Translate into a plist and call the general method
    (LOOP CFFI::FOR CFFI::SLOT CFFI::BEING THE CFFI::HASH-VALUE CFFI::OF (CFFI::STRUCTURE-SLOTS
                                                                          TYPE)
          CFFI::FOR CFFI::NAME = (CFFI::SLOT-NAME CFFI::SLOT)
          APPEND (LIST CFFI::SLOT-NAME
                       (SLOT-VALUE CFFI::OBJECT CFFI::SLOT-NAME)))
    TYPE CFFI::POINTER)))

translate-into-foreign-memory 构造出一个plist,其中key为外部结构体的slot,value从CLOS实例中同名的slot中去取,接着便以构造的 plist去调用 call-next-method 了,从而可以利用plist到外部结构体指针的转换代码,简化自己的实现。

translate-from-foreign 则直接以 call-next-method 调用默认实现得到 plist作为 make-instance 的参数,这意味:initarg得是slot-name,这就是我们为什么clos-person各个slot的:initarg取和slot同名,而不是常规的关键字符号的原因。

可以看到,虽然 translation-forms-for-class 挺方便的,但是非常不灵活,对目标CLOS类的假定太多了,有以下的限制:

  1. CLOS类和结构体的slot得同名,不能配置slot间的映射关系。
  2. 直接用的slot-value去访问CLOS实例的slot值,不能指定访问函数。
  3. :initarg用的slot名,而不是常规的关键字符号,且我们不能进行配置。

如果对 translation-forms-for-class 的以上限制不满意,可以自己定义一个更灵活的宏。

指针及内存管理

分配、释放、访问内存

我们可以通过 foreign-alloc 来分配内存,其类似于C语言的 malloc ,不过前者能识别CFFI的类型说明符,且拥有更多的特性。

foreign-alloc 的第一个参数是CFFI类型说明符, foreign-alloc 会分配一块足够存放目标类型的内存,并返回该块内存首地址的指针,类型说明符会被求值,故注意如果是列表、普通符号得记得quote,如下:

1
2
3
4
5
6
7
8
(foreign-alloc :int)
;; -> #.(SB-SYS:INT-SAP #X00F71790)

(defcstruct person
  (age :int)
  (name :string))
(foreign-alloc '(:struct person))
;; -> #.(SB-SYS:INT-SAP #X028E0070)

foreign-alloc 分配的内存需要手动释放,释放通过 foreign-free 来进行,如下:

1
2
3
(foreign-alloc :int)
;; -> #.(SB-SYS:INT-SAP #X00F71790)
(foreign-free *)

需要注意: foreign-free 的返回值是未定义的,不要做任何假设。

foreign-alloc 可以一次性分配能存储指定数量目标类型的内存,即能按数组形式分配内存,类似C++的 new[] 操作符,我们通过:count关键字参数来指定数量,如下:

1
2
3
(foreign-alloc :int :count 100)
;; -> #.(SB-SYS:INT-SAP #X00F71790)
(foreign-free *)

我们可以通过 mem-aref 以及 (setf (mem-aref ...) ...) 来读取/设置数组内存中的某个元素,第一个参数是指针,第二个参数是数组元素类型(会被求值,记得必要时quote),第三个可选参数指定下标,默认为0,如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
(let* ((count 100)
       (ptr (foreign-alloc :int :count count)))
  (unwind-protect
       (progn
         (loop for i below count
               do (setf (mem-aref ptr :int i) (* i count)))
         (loop for i below count
               collect (mem-aref ptr :int i)))
    (foreign-free ptr)))
;; ->
;; (0 100 200 300 400 500 600 700 800 900 1000 1100 1200 1300 1400 1500 1600 1700
;;  1800 1900 2000 2100 2200 2300 2400 2500 2600 2700 2800 2900 3000 3100 3200
;;  3300 3400 3500 3600 3700 3800 3900 4000 4100 4200 4300 4400 4500 4600 4700
;;  4800 4900 5000 5100 5200 5300 5400 5500 5600 5700 5800 5900 6000 6100 6200
;;  6300 6400 6500 6600 6700 6800 6900 7000 7100 7200 7300 7400 7500 7600 7700
;;  7800 7900 8000 8100 8200 8300 8400 8500 8600 8700 8800 8900 9000 9100 9200
;;  9300 9400 9500 9600 9700 9800 9900)

foreign-alloc 还可以用:initial-element关键字参数指定初始元素,此时数组内存中的每个元素,都会被初始化成初始元素,如下:

1
2
3
4
5
6
7
(let* ((count 10)
       (ptr (foreign-alloc :int :count count :initial-element 100)))
  (unwind-protect
       (loop for i below count
             collect (mem-aref ptr :int i))
    (foreign-free ptr)))
;; -> (100 100 100 100 100 100 100 100 100 100)

故申请一块全0的数组内存,既可以循环通过 (setf (mem-aref ...) 0) 来,也可以直接指定:initial-element为0,后者应该会快很多,因为少了很多次函数调用,如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
(let* ((count 10)
       (ptr (foreign-alloc :int :count count)))
  (unwind-protect
       (progn
         (loop for i below count
               do (setf (mem-aref ptr :int i) 0))
         (loop for i below count
               collect (mem-aref ptr :int i)))
    (foreign-free ptr)))
;; -> (0 0 0 0 0 0 0 0 0 0)

(let* ((count 10)
       (ptr (foreign-alloc :int :count count :initial-element 0)))
  (unwind-protect
       (loop for i below count
             collect (mem-aref ptr :int i))
    (foreign-free ptr)))
;; -> (0 0 0 0 0 0 0 0 0 0)

需要注意的是,如果没有指定:initial-element,虽然关键字参数的默认值会是 nil,但是数组元素的值会是随机值,并不会被初始成nil/false,除非你显式指定:initial-element为nil,如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
(let* ((count 10)
       (ptr (foreign-alloc :boolean :count count)))
  (unwind-protect
       (loop for i below count
             collect (mem-aref ptr :boolean i))
    (foreign-free ptr)))
;; -> (T NIL T NIL T T T T T T)
;; 可以看到,值是一些随机值(更精确的说,应该是之前残留下来的值)

(let* ((count 10)
       (ptr (foreign-alloc :boolean :count count :initial-element nil)))
  (unwind-protect
       (loop for i below count
             collect (mem-aref ptr :boolean i))
    (foreign-free ptr)))
;; -> (NIL NIL NIL NIL NIL NIL NIL NIL NIL NIL)

类似:initial-element的还有一个:initial-contents,用于具体指定数组中每个元素的值,:initial-contents参数的类型需要是序列,比如列表、Lisp数组等,如下:

1
2
3
4
5
6
7
(let* ((count 10)
       (ptr (foreign-alloc :int :count count :initial-contents '(1 2 3 4 5 6 7 8 9 10))))
  (unwind-protect
       (loop for i below count
             collect (mem-aref ptr :int i))
    (foreign-free ptr)))
;; -> (1 2 3 4 5 6 7 8 9 10)

:initial-contents的数量可以必须小于或者等于:count,如果大于的话,会报错,如果小于的话,剩余元素会处于未初始化的状态,如下:

1
2
3
4
5
6
7
8
(let* ((count 10)
       (ptr (foreign-alloc :int :count count :initial-contents '(1 2 3 4 5 6 7 8))))
  (unwind-protect
       (loop for i below count
             collect (mem-aref ptr :int i))
    (foreign-free ptr)))
;; -> (1 2 3 4 5 6 7 8 419 832741327)
;; 注:你有可能会看到(1 2 3 4 5 6 7 8 9 10),后面的9和10是残留值。

:count参数一般默认值为1,但是如果指定:initial-contents并省略:count,那么:count会取:initial-contents的长度,即如果你指定了:initial-contents,那么可以不指定:count,如下:

1
2
3
4
5
6
(let ((ptr (foreign-alloc :int :initial-contents '(1 2 3 4 5 6 7 8 9 10))))
  (unwind-protect
       (loop for i below 10
             collect (mem-aref ptr :int i))
    (foreign-free ptr)))
;; -> (1 2 3 4 5 6 7 8 9 10)

CFFI要求不能同时指定:initial-element和:initial-contents,否则会报错。

最后, foreign-alloc 支持一个叫:null-terminated-p的关键字参数,如果指定为真,那么CFFI会多分配一个元素,具体的,CFFI会分配 (1+ (max count (length initial-contents))) 个元素,并把最后一个元素初始化成NULL,此时,CFFI要求分配的元素类型必须是一种指针类型(不管是通用指针类型:pointer还是其他具体的指针类型),否则会报错。

1
2
3
4
5
6
7
8
(let ((ptr (foreign-alloc :string
                          :initial-contents '("foo" "bar")
                          :null-terminated-p t)))
  (unwind-protect
       (loop for i below 3
             collect (mem-aref ptr :string i))
    (foreign-free ptr)))
;; -> ("foo" "bar" NIL)

有些读者可能猜测:null-terminated-p和C语言字符串最后的 '\0' 有关,但是由于要求元素类型是指针,所以实际上是无关的。

其他内存访问函数

mem-ref

mem-ref 用于解引用一个指针,具体的,它可以把一块内存指定偏移处的值当目标类型来读取/设置,类似于 *((目标类型 *)(((uint8_t *)ptr) + offset)) 以及 *((目标类型 *)(((uint8_t *)ptr) + offset)) = 值 ,第一个参数是指针,第二个参数是目标类型,第三个可选参数是偏移,以字节为单位,默认为0,如下分配一块内存,按顺序分别存储:int、:double、:uint类型:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
(let ((ptr (foreign-alloc :uint8 :count (+ (foreign-type-size :int)
                                           (foreign-type-size :double)
                                           (foreign-type-size :uint)))))
  (unwind-protect
       (progn
         (setf (mem-ref ptr :int 0) (1- (expt 2 31)))
         (setf (mem-ref ptr :double (foreign-type-size :int)) pi)
         (loop for i below (foreign-type-size :uint)
               do (setf (mem-ref ptr :uint8 (+ (foreign-type-size :int)
                                               (foreign-type-size :double)
                                               i))
                        (+ 160 i)))

         (list :int (mem-ref ptr :int 0)
               :double (mem-ref ptr :double (foreign-type-size :int))
               :uint-parts (loop for i below (foreign-type-size :uint)
                                 collect (write-to-string
                                          (mem-ref ptr :uint8
                                                   (+ (foreign-type-size :int)
                                                      (foreign-type-size :double)
                                                      i))
                                          :base 16))
               :uint-whole (write-to-string
                                          (mem-ref ptr :uint
                                                   (+ (foreign-type-size :int)
                                                      (foreign-type-size :double)))
                                          :base 16)))
    (foreign-free ptr)))
;; ->
;; (:INT 2147483647 :DOUBLE 3.141592653589793d0 :UINT-PARTS ("A0" "A1" "A2" "A3")
;;  :UINT-WHOLE "A3A2A1A0")

注意我是如何把第三个域:uint拆成4个部分去访问的。

注意到,如果offset为0,那 (mem-ref ptr type) 以及 (setf (mem-ref ptr type) 值) 就相当于 *((目标类型 *)(ptr))*((目标类型 *)(ptr)) = 值 ,即普通的指针解引用,也等价于偏移为0的 mem-aref ,即 (mem-aref ptr type)(setf (mem-aref ptr type) 值)

mem-aptr

mem-aptr 类似于 mem-aref ,不过返回的不是元素值,而是指向目标元素的指针,如下相当于 &ptr[4]

1
2
3
4
5
(let* ((count 10)
       (ptr (foreign-alloc :int :count count)))
  (unwind-protect (mem-aptr ptr :int 4)
    (foreign-free ptr)))
;; -> #.(SB-SYS:INT-SAP #X00DF20F0)

当然,既然它返回的是指针,我们可以用 mem-aref 去解引用,不要指定下标,即下标为0就行,如下:

1
2
3
4
5
(let* ((count 10)
       (ptr (foreign-alloc :int :count count :initial-contents '(1 2 3 4 5 6 7 8 9 10))))
  (unwind-protect (mem-aref (mem-aptr ptr :int 4) :int)
    (foreign-free ptr)))
;; -> 5

同理,也可以用 mem-ref 去解引用,如下:

1
2
3
4
5
(let* ((count 10)
       (ptr (foreign-alloc :int :count count :initial-contents '(1 2 3 4 5 6 7 8 9 10))))
  (unwind-protect (mem-ref (mem-aptr ptr :int 4) :int)
    (foreign-free ptr)))
;; -> 5

推荐用后者,意义比较明确,毕竟这里指针不指向数组。

确保内存释放的宏

上一节,我们为了确保内存释放,都手动用了 unwind-protect + foreign-free ,这种模式比较常见,故CFFI提供了宏来简化这一操作,这些宏接收一个body,在body执行前,它会为你分配内存,在body结束后,它会确保内存被释放(通过 unwind-protect 保护)。

不仅如此,如果Lisp实现支持在栈上分配的情况下(得看Lisp实现、分配的类型、分配的数量等),会直接在栈上分配 ,之前用的 foreign-alloc + foreign-free ,Lisp实现只能在堆上分配内存,因为它不确定你什么时候会释放这个内存,但是用这些宏就不同了,Lisp实现知道你仅在body内使用这块内存,可以选择在栈上分配。

with-foreign-object

with-foreign-object 用于分配指定数量目标类型的内存,绑定内存首地址到指定变量,执行body,在body结束后会保证释放掉分配的内存,如下:

1
2
3
4
5
6
7
(let ((count 10))
  (with-foreign-object (ptr :int count)
    (loop for i below count
          do (setf (mem-aref ptr :int i) (* i 10)))
    (loop for i below count
          collect (mem-aref ptr :int i))))
;; -> (0 10 20 30 40 50 60 70 80 90)

需要注意的是,此时count参数不是通过关键字参数给出的,而是通过位置参数给出的。count是可选的,默认为1。

foreign-alloc 一样,类型说明符部分是会被求值的,如果是列表、普通符号得记得quote,除此之外,结构体类型还得特别注意用 mem-aptr 去拿到外部结构体指针,如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
(let ((count 2)

      (type '(:struct person)))
  (with-foreign-object (ptr type count)
    (loop for i below count
          do
             (setf (foreign-slot-value (mem-aptr ptr type i) type 'age) (+ i 10))
             (setf (foreign-slot-value (mem-aptr ptr type i) type 'name)
                   (concatenate 'string "osfva-" (prin1-to-string i))))
    (loop for i below count
          collect
          (list :age
                (foreign-slot-value (mem-aptr ptr type i) type 'age)
                :name
                (foreign-slot-value (mem-aptr ptr type i) type 'name)))))
;; -> ((:AGE 10 :NAME "osfva-0") (:AGE 11 :NAME "osfva-1"))

with-foreign-objects

with-foreign-objectswith-foreign-object 一样,只不过可以一次性分配多种类型的对象,语法也很直观,就是第一个参数binding那边从一个原子列表变成列表的列表而已,如下:

1
2
3
4
5
(let ((count 10))
  (with-foreign-objects ((ptr1 :int count) (ptr2 :double count))
    ;; 注意:出body后,这两个指针都失效了,不能再使用了。
    (list ptr1 ptr2)))
;; -> (#.(SB-SYS:INT-SAP #X02968EA0) #.(SB-SYS:INT-SAP #X00DF1C50))

with-foreign-pointer

with-foreign-pointer 类似 with-foreign-object ,但是它不接收类型作为参数,它是直接以字节为单位分配内存的,跟类型无关,你说分配多少个字节就分配多少个字节,如下:

1
2
3
4
(with-foreign-pointer (ptr 100)
  ;; 注意:出body后,这个指针就失效了,不能再使用了。
  ptr)
;; -> #.(SB-SYS:INT-SAP #X05DCFF90)

with-foreign-pointer 还接收一个可选的变量名参数,该变量会绑定到分配内存的字节数量上,如下:

1
2
3
4
(with-foreign-pointer (ptr 100 size)
  ;; 注意:出body后,这个指针就失效了,不能再使用了。
  (list ptr size))
;; -> (#.(SB-SYS:INT-SAP #X05DCFF90) 100)

指针操作函数

foreign-symbol-pointer

foreign-symbol-pointer 返回指定外部符号(一个字符串,而不是Lisp符号)对应的外部变量或者外部函数的指针,如果找不到,会返回nil,如下:

1
2
3
4
5
6
7
8
(foreign-symbol-pointer "errno")
;; -> #.(SB-SYS:INT-SAP #X04BCEB50)

(foreign-symbol-pointer "strerror")
;; -> #.(SB-SYS:INT-SAP #X7FFB877D0220)

(foreign-symbol-pointer "unknonw_symbol_abc")
;; -> NIL

如果有多个外部库导出同名的符号,可以通过指定:library来消除歧义,如下:

1
(foreign-symbol-pointer "osfva_test_method" :library osfva)

之前有介绍过了, foreign-funcall-pointer 可以用于调用外部函数指针,如果是变量的指针,那么则可以用 mem-refmem-aref 等来访问。

inc-pointer

inc-pointer 返回目标指针递增指定字节的指针,注意,该函数以字节为单位递增,跟类型无关,如下:

1
2
3
4
5
(with-foreign-object (ptr :int 2)
  (setf (mem-ref ptr :int) 100)
  (setf (mem-ref (inc-pointer ptr (foreign-type-size :int)) :int) 200)
  (list (mem-aref ptr :int 0) (mem-aref ptr :int 1)))
;; -> (100 200)

incf-pointer

incf-pointerinc-pointer 但关系就类似于 incf+ 的关系,后者仅会递增指针并返回新指针,前者会递增指针并把新指针的值设置到指定 place,最终返回新指针,如下:

1
2
3
4
5
6
(with-foreign-object (ptr :int 2)
  (let ((cur-ptr ptr))
    (setf (mem-ref cur-ptr :int) 100)
    (incf-pointer cur-ptr (foreign-type-size :int))
    (setf (mem-ref cur-ptr :int) 200)
    (list (mem-aref ptr :int 0) (mem-aref ptr :int 1))))

注意到,上面代码创建了一个新的变量 cur-ptr ,目的是不让 incf-pointer 修改到 with-foreign-object 绑定的 ptr ,因为 with-foreign-object 会直接拿 ptr 作为参数去释放内存,它没有自己保存一份旧的值,如果我们修改了 ptr ,会导致内存释放失败。

make-pointer、pointerp

make-pointer 返回指定地址对应的指针, pointerp 用于判断目标对象是否是一个指针,如下:

1
2
3
4
(make-pointer #x401000)
;; -> #.(SB-SYS:INT-SAP #X00401000)
(pointerp *)
;; -> T

注意,在Allegro CL中,由于指针是通过整数实现的,故在该Lisp实现中,参数为整数时,pointerp也会返回真。

null-pointer、null-pointer-p

null-pointer 返回一个NULL指针,即地址为0的指针, null-pointer-p 用于判断一个指针是否是NULL指针,即其地址是否为0,如下:

1
2
3
4
(null-pointer)
;; -> #.(SB-SYS:INT-SAP #X00000000)
(null-pointer-p *)
;; -> T

(null-pointer) 等价于 (make-pointer 0) ,如下:

1
2
(null-pointer-p (make-pointer 0))
;; -> T

pointer-eq

pointer-eq 用于判断两个指针是否指向同一个地址,如下:

1
2
3
4
5
6
(pointer-eq (null-pointer) (null-pointer))
;; -> T
(pointer-eq (make-pointer #x401000) (make-pointer #x401000))
;; -> T
(pointer-eq (make-pointer #x401000) (make-pointer #x401001))
;; -> NIL

注意,在某些Lisp实现中(如SBCL),指针是通过Lisp对象表示的,故简单的eql并不一定能判断两个指针是否指向同一个地址,如下:

1
2
(eql (make-pointer #x401000) (make-pointer #x401000))
;; -> SBCL下为NIL,Allegro CL为T

pointer-address

pointer-address 返回指定指针的地址,即一个整数,如下:

1
2
3
4
(make-pointer #x401000)
;; -> #.(SB-SYS:INT-SAP #X00401000)
(pointer-address *)
;; -> 4198400

字符串

前面讲过,CFFI内置的:string类型的类型转换器支持Lisp字符串到外部字符串之间的转换,而且还能自动帮我们处理编码问题,下面举一个例子:

假设我们有一个外部函数,签名如下:

1
void osfva_test_method(const char *str);

它接收一个字符串参数,返回值为void,故我们 defcfun 定义如下:

1
2
(defcfun "osfva_test_method" :void
  (str :string))

这里由于我们没有指定编码,故编码取默认编码,即 *default-foreign-encoding* 的值,该变量的默认值为:utf-8,故默认编码是:utf-8。当然,我们也可以显式传递参数了:string解析方法,明确指定编码,如下:

1
2
(defcfun "osfva_test_method" :void
  (str (:string :encoding :utf-16le)))

当我们调用该外部函数时,可以直接传递Lisp字符串,如下:

1
(osfva-test-method "Hello, World")

Lisp字符串"Hello, World"会由:string的类型转换器转换成外部字符串, 外部字符串的编码为:encoding参数指定的编码,或者默认值:utf-8 ,即CFFI会自动帮你把Lisp字符串由内部编码转换成指定的外部编码,不用你自己去处理编码问题(前面说过了,底层用的babel库)。当外部函数返回时,该外部字符串也会被自动释放掉(即调用 free-translated-object )。

从上面的例子来看,使用:string是非常方便的,简化了我们很多工作,我们不需要自己分配、释放外部字符串,不需要自己处理编码问题。但是有些事情仍然是需要我们自己处理的,比如考虑:string作为返回值的情况,如下:

假设另一个外部函数 osfva_test_method2 的签名如下:

1
char *osfva_test_method(int num);

该函数接收一个int参数,返回字符串char*,该C语言字符串的编码我们假设为:utf-16ble,自然的,我们可能会用 defcfun 进行类似如下的定义:

1
2
(defcfun "osfva_test_method2" (:string :encoding :utf-16le)
  (num :int))

接着,我们就可以调用该函数了,如下:

1
2
(let ((str (osf-test-method2 100)))
  (format t "~&str: ~A~%" str))

CFFI在该外部函数调用返回时,会将返回的外部字符串按指定编码转换成Lisp字符串,前面我们声明了返回值字符串的编码为:utf-16le,故CFFI会认为返回的外部字符串的编码是:utf-16le,会将其转换成内部编码。转换成Lisp字符串会得到一个全新的Lisp字符串,该Lisp字符串是受GC管理的,不用担心释放问题,但是外部函数返回的外部字符串怎么处理?释放它?这里我们考虑两种情况:

  1. 返回的C语言字符串位于静态内存区域。
  2. 返回的C语言字符串是 osfva_test_method2 通过堆分配的。

如果是第一种情况,那么不需要释放,如果是第二种情况,CFFI也不敢释放,如果释放,在如下情况会出问题:

  1. 返回的C语言字符串虽然是堆分配的,但是分配都是有记录的,之后会由该外部库在合适的时机统一释放,此时如果CFFI释放了该内存,会导致二次释放。
  2. 返回的C语言字符串是通过非标准的内存分配机制来分配的,此时CFFI根本不知道该用什么方法去释放该内存,根本无法释放。

综上,在返回值类型为:string时,CFFI不会调用 free-translated-object 去释放内存, 这点不仅仅适用于:string,也适用于其他类型,因为CFFI无法确保能安全地释放,该块内存的来源是外部库,CFFI没有足够的信息以做决定

那针对:string,如果我们就是想在返回时,自动释放掉返回的外部字符串呢(当然,是在转换成Lisp字符串后再去释放)?我们可以指定:free-from-foreign给:string解析方法,如下:

1
2
(defcfun "osfva_test_method2" (:string :encoding :utf-16le :free-from-foreign t)
  (num :int))

此时,类型转换器会在转换完成后,直接调用 foreign-free 去释放外部字符串,其原理如下(看注释):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
;; :string解析方法创建的外部类型实例是foreign-string-type
;; :free-from-foreign参数初始化的是free-from-foreign slot
;; 其reader是fst-free-from-foreign-p。
(define-foreign-type foreign-string-type ()
  (;; CFFI encoding of this string.
   (encoding :initform nil :initarg :encoding :reader encoding)
   ;; Should we free after translating from foreign?
   (free-from-foreign :initarg :free-from-foreign
                      :reader fst-free-from-foreign-p
                      :initform nil :type boolean)
   ;; Should we free after translating to foreign?
   (free-to-foreign :initarg :free-to-foreign
                    :reader fst-free-to-foreign-p
                    :initform t :type boolean))
  (:actual-type :pointer)
  (:simple-parser :string))

;; 在translate-from-foreign转换结束后,它会判断free-from-foreign slot是否为真,
;; 如果为真,那就释放掉外部值。
(defmethod translate-from-foreign (ptr (type foreign-string-type))
  (unwind-protect
       (values (foreign-string-to-lisp ptr :encoding (fst-encoding type)))
    (when (fst-free-from-foreign-p type)
      (foreign-free ptr))))

于是,如果我们指定返回值类型为 (:string :encoding :utf-16le :free-from-foreign t) ,在外部函数返回后,由于 free-from-foreign slot为真,会直接将C语言字符串通过 foreign-free 释放掉。

上面方法能不能奏效取决于该C语言字符串是否直接或者间接通过 foreign-alloc 来分配内存的,如果不是,就不能用。

看完上面,读者可能会有疑问, free-from-foreign 这个slot的:initform是 nil啊,前面说过如果:string作为参数类型而非返回值类型时,那CFFI是会自动在外部函数返回时释放的,那此时 free-from-foreign 为nil,那不是不会释放?答案是::string作为参数类型时的释放机制是不同的,而且作为参数类型也不会涉及 translate-from-foreign ,要涉及也是涉及 translate-to-foreign ,但是 translate-to-foreign 返回时,外部函数还没返回,故不能由 translate-to-foreign 来释放。参数类型的释放机制如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
(defmethod expand-to-foreign-dyn :around
    (value var body (type enhanced-foreign-type))
  (let ((*runtime-translator-form*
         (with-unique-names (param)
           `(multiple-value-bind (,var ,param)
                (translate-to-foreign ,value ,type)
              (unwind-protect
                   (progn ,@body)
                (free-translated-object ,var ,type ,param))))))
    (call-next-method)))

expand-to-foreign-dyn 生成的默认转换代码会首先调用 translate-to-foreign ,并在调用外部函数完毕后,即body执行完后,调用 free-translated-object

如果我们定义的类型也想做到在作为返回值类型时,可以指定是否自动释放,可以模仿下:string的做法,提供一个slot,让用户自己选择是否释放,然后在 translate-from-foreign 中判断并进行释放,释放的函数可以自己写死,也可以再提供一个slot让用户自己指定。

外部字符串的分配和释放

按前面的例子来看,正常情况下,我们是不需要自己去分配外部字符串的内存的,:string的类型转换器已经能帮我们处理大部分情况了,但是仍然有部分情况是需要我们自己去分配、释放字符串的,那怎么去分配和释放字符串呢?该调用什么接口?

我们可以试着通过之前的 foreign-allocwith-foreign-object 等函数去分配外部字符串,如下:

1
2
3
4
5
(with-foreign-object (str :string)
  ...)
;; 或者
(with-foreign-object (str '(:string :encoding :utf-16le))
  ...)

但是仍然存在一个问题, with-foreign-object 是怎么知道我外部字符串缓冲区要多大的?具体的,这个str指针指向的内存可以存放多少个字符?

实际上,:string解析方法并没有提供参数让我们指定缓冲区的大小,那怎么办?有好几种解决办法,比如你分配:char型数组,自己指定:count等,但问题是,用这种办法,你得自己处理编码问题,比如自己调用babel的接口,把Lisp字符串转换成指定编码,最后复制到:char数组中去,比较麻烦。

为了解决以上问题,CFFI提供了字符串专用的分配、释放函数,分别是: foreign-string-alloc 以及 foreign-string-free

foreign-string-alloc和foreign-string-free

foreign-string-alloc 用于按指定编码把Lisp字符串转换成外部字符串(当然,得分配足够大的内存来存外部字符串),释放内存就用 foreign-string-free ,示例如下:

1
2
3
4
5
6
7
8
;; 假设有一个外部函数,签名如下:
;; void osfva_test_method(const char* str);
(defcfun "osfva_test_method" :void
  (str :pointer))

(let ((ptr (foreign-string-alloc "Hello, world")))
  (unwind-protect (osfva-test-method ptr)
    (foreign-string-free ptr)))

你还可以指定外部字符串的编码,通过关键字参数:encoding来,如下:

1
2
3
(let ((ptr (foreign-string-alloc "Hello, world" :encoding :utf-16le)))
  (unwind-protect (osfva-test-method ptr)
    (foreign-string-free ptr)))

默认没有指定编码的时候,取 *default-foreign-encoding* 作为编码。

分配的字符串默认是以NULL( '\0' )结尾的,你可以通过:null-terminated-p控制该行为,如下:

1
2
3
(let ((ptr (foreign-string-alloc "Hello, world" :null-terminated-p nil)))
  (unwind-protect ...
    (foreign-string-free ptr)))

你还可以指定只转换Lisp字符串的一部分,即子串,指定方法是通过:start和:end指定边界,它们的默认值分别是0和nil,此时,只有[:start, :end)部分的Lisp字符串会被转换,:end为nil表示字符串结尾,如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
;; 只转换"world"
(let ((ptr (foreign-string-alloc "Hello, world" :start 7 :end 12)))
  (unwind-protect (osfva-test-method ptr)
    (foreign-string-free ptr)))
;; 或者:end为nil
(let ((ptr (foreign-string-alloc "Hello, world" :start 7 :end nil)))
  (unwind-protect (osfva-test-method ptr)
    (foreign-string-free ptr)))
;; 或者直接省略:end,因为默认值就是nil
(let ((ptr (foreign-string-alloc "Hello, world" :start 7)))
  (unwind-protect (osfva-test-method ptr)
    (foreign-string-free ptr)))

手动在Lisp字符串和外部字符串之间进行转换

lisp-string-to-foreign

lisp-string-to-foreign 用于手动将Lisp字符串转换成外部字符串,不过你需要自己提供外部缓冲区,如下:

1
2
3
(with-foreign-pointer (ptr 4 buffer-size)
  (lisp-string-to-foreign "abc" ptr buffer-size)
  ...到这里ptr里面存储了外部字符串的内容可以传给外部函数用了)

上面例子中, with-foreign-pointer 分配了一个4个字节大的缓冲区, lisp-string-to-foreign 将Lisp字符串"abc"转换成外部字符串存在ptr绑定的缓冲区内,由于 lisp-string-to-foreign 转换得到的外部字符串是以 '\0' 结尾的,故缓冲区需要4个字节才能容纳。除此之外, lisp-string-to-foreign 的第三个参数指定了缓冲区的大小, lisp-string-to-foreign 最多只会写入buffer-size-1个字节,剩下一个留给 '\0' ,不会有缓冲区溢出的问题。

with-foreign-pointer 还支持通过:encoding关键字参数指定转换得到的外部字符串的编码,默认取 *default-foreign-encoding* 的值,也就是:utf-8,如下:

1
2
3
(with-foreign-pointer (ptr 8 buffer-size)
  (lisp-string-to-foreign "abc" ptr buffer-size :encoding :utf-16le)
  ...到这里ptr里面存储了外部字符串的内容可以传给外部函数用了)

注意,上面缓冲区大小需要至少8个字节,因为 'a''b''c''\0' 每个都占两个字节。

除了:encoding关键字参数外,还支持:start、:end和:offset。

:start和:end指定要转换的Lisp字符串的边界,默认是0和nil,代表开头和结尾,即转换整个 Lisp字符串,你可以指定它们以只转换Lisp字符串的一部分。

:offset则指定了缓冲区偏移,即从缓冲区的哪个字节开始写起,默认是第0个字节。

foreign-string-to-lisp

lisp-string-to-foreign 用于手动将外部字符串转换成Lisp字符串,如下:

1
2
3
4
(with-foreign-pointer (ptr 4 buffer-size)
  (lisp-string-to-foreign "abc" ptr buffer-size)
  (foreign-string-to-lisp ptr))
;; -> "abc"

默认是将整个外部字符串都转换成Lisp字符串(即转换到 '\0' 为止),你可以通过:max-chars关键字限制最多转换多少个字符,:max-chars的默认值是 (1- array-total-size-limit) ,如下:

1
2
3
4
(with-foreign-pointer (ptr 4 buffer-size)
  (lisp-string-to-foreign "abc" ptr buffer-size)
  (foreign-string-to-lisp ptr :max-chars 2))
;; -> "ab"

foreign-string-to-lisp 默认会假设外部字符串的编码为 *default-foreign-encoding* 的值,即:utf-8,如果外部字符串不是该编码,可以通过:encoding指定,如下:

1
2
3
4
(with-foreign-pointer (ptr 8 buffer-size)
  (lisp-string-to-foreign "abc" ptr buffer-size :encoding :utf-16le)
  (foreign-string-to-lisp ptr :max-chars 2 :encoding :utf-16le))
;; -> "ab"

同样,注意上面,每个字符和 '\0' 都占2个字节,所以缓冲区要至少有8个字节。

foreign-string-to-lisp 还支持:count关键字参数,不同于:max-chars,:count指定了最大转换多少个字节,注意,单位不是字符数量,而是字节数量,如果指定的话,一般我们会指定为缓冲区大小。默认值为nil,即没有限制转换多少个字节,此时转换到达到:max-chars或者遇到 '\0' 为止。

最后,还有一个:offset参数,用于指定从缓冲区偏移,即从缓冲区的哪个字节开始转换。

确保外部字符串内存释放的宏

with-foreign-string和with-foreign-strings

前面的例子中,我们一直使用 unwind-protect 来确保 foreign-string-alloc 分配的外部字符串得到释放,CFFI提供了 with-foreign-string 来达到这一目的,其内部会调用 foreign-string-alloc 来按指定编码把Lisp字符串转换成外部字符串,把外部字符串绑定到指定变量上,然后执行body,并确保在body结束后会释放外部字符串(通过 unwind-protect ),如下:

1
2
3
4
5
6
7
;; 假设有一个外部函数,签名如下:
;; void osfva_test_method(const char* str);
(defcfun "osfva_test_method" :void
  (str :pointer))

(with-foreign-string (ptr "Hello, world")
  (osfva-test-method ptr))

第一个括号内的参数,比如上面的 (ptr "Hello, world") 实际上会原封不动传给 foreign-string-alloc ,故 foreign-string-alloc 的参数它都支持,比如你可以指定:encoding、:start和:end等,如下:

1
2
(with-foreign-string (ptr "Hello, world" :encoding :utf-16le :start 7 :end 12)
  (osfva-test-method ptr))

除此之外,还有一个 with-foreign-strings ,语法和 with-foreign-string 一样,不过支持一次性转换多个Lisp字符串,一样会保证在body结束后,所有外部字符串得到释放,如下:

1
2
3
4
(with-foreign-strings ((ptr1 "Hello, world" :encoding :utf-16le :start 7 :end 12)
                       (ptr2 "Hello, world2" :start 2 :end nil)
                       (ptr3 "Hello, world3" :null-terminated-p nil))
  ...)

with-foreign-pointer-as-string

with-foreign-pointer-as-string 最好是通过一个例子来讲,如下:

1
2
(with-foreign-pointer-as-string (ptr 6 buffer-size :encoding :utf16-le)
  ...调用一个外部函数将外部字符串的内容写到ptr指向的缓冲区中)

上面代码具体执行流程是这样的,首先, with-foreign-pointer-as-string 分配一块指定大小的外部内存,这里指定的大小为6,故分配一个6个字节的外部内存;然后,将该块外部内存绑定到括号内第一个参数指定的变量上,这里我们指定的变量是ptr,所以将该外部内存绑定到ptr变量上;以及,将外部内存的大小(这里是6)绑定到括号内第三个参数指定的变量上,这里我们指定的变量是 buffer-size;接着,执行body(先不用管 :encoding :utf16-le ,这个关键字参数在body执行结束后才会用上),以上面的例子来说,在body中,ptr绑定到6个字节的外部内存上,buffer-size绑定到6上,此时我们调用一个外部函数,将外部字符串的内容写到ptr指向的缓冲区中,在body结束后, with-foreign-pointer-as-string 会用上关键字参数 :encoding :utf16-le 将ptr绑定的外部缓冲区里面存储的外部字符串内容转换成Lisp字符串,最后,还会确保ptr绑定的外部缓冲区被释放。

with-foreign-stringwith-foreign-strings 一样,括号内第三个参数后的所有参数会传递给 foreign-string-to-lisp ,故 foreign-string-to-lisp 支持的参数它都支持。

综上, with-foreign-pointer-as-string 基本上就是 with-foreign-pointerforeign-string-to-lisp 的结合体。

常用的内存管理策略

有些情况下,我们能通过 unwind-protect 或者各种 with-xx 宏来保证外部内存的释放,但是两种方法都仅限于外部内存可以立马用立马释放的情况。如果一块外部内存需要长期保留,此时就得考虑何时释放该外部内存,下面介绍几种常用的内存管理策略。

将内存和某一个对象关联,在该对象被销毁时统一销毁所有内存

一个比较常用的内存管理策略就是:将内存和某一个对象关联,在该对象被销毁时统一销毁所有内存,这里的对象指的是广义上的对象,而不是面向对象中的对象,比如可以是:句柄、内存池、窗口对象等等。

这些对象往往会有创建和销毁的过程,比如内存池会有类似 create_memory_pooldestroy_memory_pool 的接口,窗口对象会有类似 create_windowdestroy_window 的接口,等等。我们可以将该对象所属的内存与该对象绑定,即当有所属于该对象的外部内存被分配的时候,将该外部内存关联到该对象上(可以通过哈希表、CLOS实例的slot等等方式来进行关联),然后在该对象被销毁的时候,统一进行销毁(该策略不只适用于CFFI,实际上C 语言很多库也用了这一内存管理策略,其他语言同理)。

举个例子,假设我们有创建窗口和销毁窗口的外部函数,签名如下:

1
2
window *create_window(const char *title, int width, int height, int flags);
void destroy_window(window *window);

对应的 defcfun 定义如下:

1
2
3
4
5
6
7
8
(defcfun "create_window" :pointer
  (title :string)
  (width :int)
  (height :int)
  (flags :int))

(defcfun "destroy_window" :void
  (window :pointer))

现在,我们可以通过如下代码创建窗口,并保存到动态变量 *cur-window* 上,如下:

1
(defvar *cur-window* (create-window "Title A" 400 400 0))

假设有外部函数 set_window_content 用于设置窗口显示的文本内容(只读),签名如下:

1
void set_window_content(window *window, const char *content);

对应的 defcfun 定义如下:

1
2
3
(defcfun "set_window_content" :void
  (window :pointer)
  (content :string))

这块内容在窗口显示过程中都可能会被使用,故我们 不能 写如下的代码:

1
2
(with-foreign-string (ptr "Content A")
  (set-window-content *my-window* ptr))

因为ptr绑定的外部内存会在 with-foreign-string 的body执行完后被释放,之后窗口还是处于显示状态,仍然可能会用到 set-window-content 设置的内存,而这块内存却被释放了。

我们可以通过 foreign-string-alloc 手动分配且不释放外部字符串,如下:

1
2
(let ((ptr (foreign-string-alloc "Content A")))
  (set-window-content *my-window* ptr))

但问题是,什么时候调用 foreign-string-free 去释放ptr绑定的内存?答案是:窗口销毁的时候,也就是说,我们得记得在窗口销毁的时候去释放这块内存,想象下,如果这样待释放的外部内存很多且各自有不同的释放方法,再加上销毁窗口的地方很多,那这将是一个非常易错的过程。

为了解决这个问题,我们将所属于某个窗口对象的内存与该对象关联,在该对象的销毁时,统一销毁。

接着刚才的例子,首先,每个对象都要有一个记录它所拥有内存的地方,为了做到这点,我们可以在 create_window 的基础上,引入自己的构造函数,在构造函数中,创建好记录内存的结构,如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
(defvar *window-strings-hash* (make-hash-table))

(defcfun ("create_window" create-window%) :pointer
  (title :string)
  (width :int)
  (height :int)
  (flags :int))

(defun create-window (title width height flags)
  (let ((window (create-window% title width height flags)))
    (setf (gethash window *window-strings-hash*) nil)))

(defun window-foreign-string-alloc (window &rest args)
  (let ((ptr (apply #'foreign-string-alloc args)))
    (push ptr (gethash window *window-strings-hash*))
    ptr))

(defcfun ("set_window_content" set-window-content%) :void
  (window :pointer)
  (content :string))

(defun set-window-content (window content)
  (let ((ptr (window-foreign-string-alloc window content)))
    (set-window-content% window ptr)))

注:这里每个window对象的空列表可以在 window-foreign-string-alloc 中按需创建,不一定要在构造函数中创建,这里只是举个常见的情况

接下来,引入自己的销毁窗口函数,在销毁窗口的时候,释放窗口对象所拥有的内存,如下:

1
2
3
4
5
6
7
8
(defcfun ("destroy_window" destroy-window%) :void
  (window :pointer))

(defun destroy-window (window)
  (let ((window-strings (gethash window *window-strings-hash*)))
    (destroy-window% window)
    (dolist (ptr window-strings)
      (foreign-string-free ptr))))

然后让用户调用我们封装函数,即 create-windowdestroy-windowset-window-content ,而不要直接调用底层的以%为结尾的外部函数,这样,用户就只需要记得创建和销毁窗口就行了。

同理,如果有其他类型的外部内存,可以仿照该模式,在构造函数中,创建其他记录结构,然后在销毁函数中逐一销毁。

优缺点

该内存管理策略的优缺点如下:

优点:

  1. 用户只需要记录创建、销毁对象即可,对象所拥有的其他内存会在销毁对象时自动销毁。

缺点:

  1. 只适用于外部内存有所属权、且只属于一个对象的情况。
  2. 所有内存仅在所属对象被销毁的时候才会销毁,这包括已经不需要用的内存,可能会导致内存占用比较大的情况。

针对缺点2,如果有些情况下,我们知道某块内存已经不会再用了,可以在特定的点检查并提前销毁。

使用CLOS实例的slot来存储对象所拥有外部内存

利用类型转换器,我们可以很容易做到使用CLOS实例的slot来存储对象所拥有的外部内存,做法就是:在Lisp侧使用CLOS实例来代表目标对象,然后定义类型转换器,达到在传递给外部函数前,转换成对应的外部类型。

还是以前面的窗口对象为例,首先,我们定义代表窗口的CLOS类,定义slot以存储它所拥有的外部字符串列表以及外部窗口对象指针,如下:

1
2
3
4
5
(defclass window ()
  ((window-ptr :reader window-ptr
               :initarg :window-ptr
               :initform (error "Must supply a foreign window pointer."))
   (strings :accessor window-strings :initform nil)))

接着,定义类型转换器,以在window类和外部指针进行转换,如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
(define-foreign-type window-type ()
  (:actual-type :pointer)
  (:simple-parser window))

(defmethod translate-to-foreign (window (type window-type))
  "把window CLOS类的实例转换成外部窗口对象指针"
  (window-ptr window))

(defmethod translate-from-foreign (window-ptr (type window-type))
  "把外部窗口对象指针转换成window CLOS类的实例"
  (make-instance 'window :window-ptr window-ptr))

其他函数就比较简单了,主要是参数和返回值类型改成window-type的解析方法 window了,代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
(defcfun ("create_window" create-window) window
  (title :string)
  (width :int)
  (height :int)
  (flags :int))

(defun window-foreign-string-alloc (window &rest args)
  (let ((ptr (apply #'foreign-string-alloc args)))
    (push ptr (window-strings window))
    ptr))

(defcfun ("set_window_content" set-window-content%) :void
  (window window)
  (content :string))

(defun set-window-content (window content)
  (let ((ptr (window-foreign-string-alloc window content)))
    (set-window-content% window ptr)))

(defcfun ("destroy_window" destroy-window%) :void
  (window window))

(defun destroy-window (window)
  (destroy-window% window)
  (dolist (ptr (window-strings window))
    (foreign-string-free ptr)))

这个例子用CLOS类没有特别大的优势,但是考虑如果外部内存类型多起来了(不只外部字符串)或者其他复杂的情况,用slot存相关数据就会很方便。加上由于用了CLOS,很多函数改成通用函数能比较轻松地提高可拓展性。

TODO 增加其他内存管理策略

回调函数

defcallback和callback

CFFI支持在Lisp中写C语言的回调函数供外部函数调用,举个例子,假设有个下载URL的外部函数,如下:

1
2
typedef int (*download_url_callback)(/* const char *url, */ size_t total_bytes, size_t bytes_transmitted);
int download_url(const char *url, char *buffer, size_t buffer_size, download_url_callback callback);

download_url 会下载指定的URL,buffer指定存储页面内存的缓冲区, buffer_size指定缓冲区的大小,download_url_callback则用于接收下载进度,返回值是错误码,类型是int,正常开始下载会返回0,如果URL对应的host不存在等错误,会返回非0,这里不关注具体错误码的值。

注:正常情况下,download_url_callback回调应该还会接收一个参数url,但是这会涉及URL生命周期的问题,所以为了简化例子,省略了该参数。

download_url 在下载过程中,会调用download_url_callback以汇报下载进度, total_bytes是页面的总字节大小,bytes_transmitted是目前已传输的字节数量。 download_url_callback的返回值类型是int,其中返回某个特殊的整数可以用于中断下载,返回0则不会进行特殊操作。

首先,我们使用 defcfun 定义下download_url,如下:

1
2
3
4
5
(defcfun "download_url" :int
  (url :string)
  (buffer :pointer)
  (buffer-size :size)
  (callback :pointer))

接下来,我们需要定义回调函数了,使用 defcallback 来定义,如下:

1
2
3
(defcallback my-download-url-callback :int ((total-bytes :size) (bytes-transmitted :size))
  (format t "~&Download progress - total-bytes: ~A bytes-transmitted: ~A~%"
          total-bytes bytes-transmitted))

第一个参数是回调函数的名字,第二个参数是返回值,第三个参数是回调函数的参数列表,每个参数由 (参数名 参数类型) 来指定,最后是回调函数体了,在这里,我们可以用Lisp写回调函数的内容。 defcallback 会根据给予的参数,生成一个C语言函数,没错,直接在运行中生成一个C语言函数,相当于JIT,由于这不是一个简单的操作,部分Lisp实现可能不支持。

defcallback 生成的C语言回调函数会在被调用时,将接收到的外部参数通过类型转换器,从外部类型转换成Lisp类型,接下来才执行我们用Lisp写的回调函数体,故函数体里面引用到的都是Lisp类型的参数。同理,在回调函数返回时,返回值会从Lisp类型通过类型转换器转换成外部类型。

注:有些Lisp实现下,要求 defcallback 是顶层表达式,所以我们最好在顶层定义回调函数

好了,定义完回调函数,接下来问题是怎么传给外部函数了,这通过 callback 来, callback 接收回调函数名(一个符号)并返回对应回调函数的函数指针,如下:

1
2
3
4
5
6
7
8
(defcallback my-download-url-callback :int ((total-bytes :size) (bytes-transmitted :size))
  (format t "~&Download progress - total-bytes: ~A bytes-transmitted: ~A~%"
          total-bytes bytes-transmitted)
  ;; 返回0,确保不会进行取消下载等特殊操作
  0)

(callback my-download-url-callback) ; 注意, ~callback~ 的参数不用quote
;; -> #.(SB-SYS:INT-SAP #X20220850)

现在,我们可以调用 download-url 了,如下:

1
2
3
4
5
6
(defvar +download-buffer-size+ 65536)
(defvar *download-buffer*
  (foreign-alloc :char :count +download-buffer-size+))

(download-url "http://www.baidu.com/" *download-buffer* +download-buffer-size+
              (callback my-download-url-callback))

之后 *download-buffer* 不用了,还得记得使用 foreign-free 去释放该缓冲区。

get-callback

callback 存在一个问题,它的参数是不会被求值的,这意味我们难以动态传回调函数(传不同的符号,调用不同的回调函数),为了解决这个问题,CFFI提供了 get-callback ,不同于 callback ,它的参数是会被求值的,这意味着,如果它的参数是符号,你得记得quote,如下:

1
(get-callback 'my-download-url-callback)

如何解决defcallback定义在顶层,无法访问周围环境的问题

前面提到,有些Lisp实现下,要求 defcallback 是顶层表达式,所以我们最好在顶层定义回调函数。将回调函数定义在顶层有一个问题:顶层的词法环境是 nil,周围没有词法变量让我们通过词法闭包以引用,这意味着,所有信息要么通过回调函数参数传入,要么通过动态变量传入,为了解决这个问题,我们可以在回调函数的函数体中,调用动态变量绑定的Lisp函数。

举个例子,假设 download_url 是一个同步函数,只有到下载完成或者下载失败才会返回,那么可以通过如下方法使得我们可以在非顶层定义回调函数内容:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
(defvar *download-url-callback*
  (lambda (total-bytes bytes-transmitted)
    (declare (ignore total-bytes bytes-transmitted))
    0))

(defcallback my-download-url-callback :int ((total-bytes :size) (bytes-transmitted :size))
  (handler-case
      (funcall *download-url-callback* total-bytes bytes-transmitted)
    (error () 0)))

(let ((*download-url-callback*
        (lambda (total-bytes bytes-transmitted)
          (format t "~&Download progress - total-bytes: ~A bytes-transmitted: ~A~%"
                  total-bytes bytes-transmitted)
          ;; 返回0,确保不会进行取消下载等特殊操作
          0)))
  (download-url "http://www.baidu.com/" *download-buffer* +download-buffer-size+
                (callback my-download-url-callback)))

my-download-url-callback中调用动态变量 *download-url-callback* 绑定的Lisp函数,而我们在调用 download-url 前,绑定 *download-url-callback* 到一个Lisp函数,供 my-download-url-callback 调用,由于绑定 *download-url-callback* 的地方不是顶层,故周围可以有词法变量供引用。

上面的方法仅适用于调用回调函数的外部函数是同步函数的情况,假设 download_url 是异步函数,那上面的方法就不可行了,这种情况下,我们可以定义一个动态变量,其值为某种关联数据结构(比如哈希表、alist、 plist等),用它来存储额外的信息,举个例子,我们可以给每次下载分配不同的UUID,以UUID作为键去索引对应的额外信息,当然,不一定要用UUID作为键,任意“唯一”标识都可以作为键(注:这里“唯一”加引号是因为,我们不一定要标识是真正唯一的,我们只需要该标识能区分开需要区分开的对象即可),比如 download_url 的回调可以用URL作为键(注:注意到,前面的例子中, download_url 的回调函数没有接收URL作为参数,我在前面也说了,正常情况下是会传URL或者其他能让你获得URL的参数(比如句柄),上面仅仅是为了简化例子才省略该参数的。除此之外,注意到,如果同一个URL下载多次话,所有回调将使用同一块存储,你得确定这是你想要的)。

参考文章

  1. CFFI Manual
  2. investigate whether CFFI frees C-allocated memory for :string return types