HD Blog

用FFI导出haskell函数

希望在python写的web服务器中调用haskell函数,需要用到FFI。具体来说实际上是要将haskell函数编译为共享库,再用pythonctypes包来调用。

接触到haskell这门传说中的论文语言是大概6、7年前的事情了,当时写了些toy程序,但是只是觉得好玩,并没有找到能用到哪里。这两天才终于有机会用它做了一个模型。

用法早已经忘得差不多,难免掉坑。其中一个就是FFI导出haskell函数到其他语言(Python)的问题。

之所以要用这个功能,是出于两个原因:

  • 模型有保密性质需要能够编译分发,要有较好的性能,对内需要有较好的可读性
  • 会做成简单的web api,不会花过多精力

前者适合优雅的haskell,后者适合敏捷的python

导出Haskell函数可用的方法

这个问题在stackoverflow上有讨论,基本上有四种方法:

  • FFI:导出到C,再用ctypes或者swig导入到Python,直接调用
  • haskell写一个服务: 比如说用http进行通讯,通过网络IO传递函数的参数和结果
  • vanilla+shell:就是和shell进行交互,通过命令行传递函数的参数和结果
  • haskellpython的包,回答中提到过几个,但没有太成熟的

前三种都能用,其中第三种最对口味。但是如果真的想追求性能的话,可能也要直接用FFI导入的函数才更快。因此这里选用了第一种。

使用FFI导出haskell函数到Python

这个工作主要有两个部分:把导出函数编译为shared lib .so和用ctypesPython里使用,前者是比较麻烦的。

编译导出函数到.so

现在的haskell项目比较流行用stack来管理了,这是一个很大的进步,但是它现在好像并不直接支持和FFI相关的编译(至少文档上找不到可用的信息),因此这部份会做一些看起来很多余的工作。最靠谱的可参考文档来自Python Wiki上的一篇文章

第一步:编写代码

建议把要导出的函数放在单独的文件里,如ExampleFFI.hs,而导出直接相关的代码放到另一个文件里,如ExampleFFIC.hs。这样做的主要是因为stack ghci载入包括FFI函数的模块会编译出错。而分开在两个模块的话,则仍然可以在stack ghci里正常载入Example.hs,可随手计算或进行调试等。

-- ExampleFFI.hs:

module ExampleFFI
  (
    -- * 下面是要导出的函数
    outputAsNames,
  ) where

-- 类型是String到String,只是个示例,干不了什么事
outputAsNames :: String -> String
outputAsNames input_ = "Aha: " ++ input_
{-# LANGUAGE ForeignFunctionInterface #-}
-- ExampleFFIC.hs: 上面一行是必须的,这是语言扩展,告诉编译器要用`FFI` [1]
module ExampleFFIC
  (
    -- | 这是要导出到`Python`的函数名.
    outputAsNamesC
  ) where

import ExampleFFI -- 从`ExampleFFI.hs`导入
import Foreign.C.String -- 提供C数据类型 `CString`
-- 和在`String`和`CString`间转换的函数,如 `withCString` [2]

-- | 导出 `outputAsNames` 为C函数
-- `foreign export ccall`这一行指定了要导出的函数和类型
-- 注意到参数和返回全部需要是C中的类型
foreign export ccall outputAsNamesC :: CString -> IO CString [3]
-- 实现这个函数,用了一个warp函数*装饰*了一下要导出的函数
outputAsNamesC :: CString -> IO CString
outputAsNamesC x_c = warpAsCString outputAsNames x_c

-- *装饰函数*的功能只是将输入和输出的类型转换为相关的C类型
warpAsCString :: ( String -> String) -> (CString -> IO CString)
warpAsCString f x_c = do -- [4] 因为涉及在C里操作内存,所以要用Monad语法了
  x <- peekCString x_c -- peekCString :: CString -> String
  newCString (f x) -- newCString :: CString -> IO CString

以上需要解释的是:

  1. 使用FFI要打开语言选项
  2. 导出的函数需要转换为C的参数和返回类型
  3. 要使用foreign export ccall导出转换好的函数
  4. 原来的函数是String -> String,转换的函数返回应是IO CString,相应要用到Monad语法

我们还需要一个文件,将上面的代码连成一个C库。文档中提供了通用性的C文件:

/* module_init.c : 这个文件直接来自参考文档,没怎么研究 */
#define CAT(a,b) XCAT(a,b)
#define XCAT(a,b) a ## b
#define STR(a) XSTR(a)
#define XSTR(a) #a

#include <HsFFI.h>

extern void CAT (__stginit_, MODULE) (void);

static void library_init (void) __attribute__ ((constructor));
static void
library_init (void)
{
  /* This seems to be a no-op, but it makes the GHCRTS envvar work. */
  static char *argv[] = { STR (MODULE) ".so", 0 }, **argv_ = argv;
  static int argc = 1;

  hs_init (&argc, &argv_);
  hs_add_root (CAT (__stginit_, MODULE));
}

/* hs_exit会有一点问题:如果导出的函数init的时候出错了,会多余性的做这个动作,
报warning */
static void library_exit (void) __attribute__ ((destructor));
static void
library_exit (void)
{
  hs_exit ();
}

第二步:编译

下一步就是编译以上三个文件。

比较可怜的是,stack不仅没法帮助FFI编译,还可能会造成一些阻碍:

  • stack ghc不能直接接受ghc的支持的FFI相关flags
  • snapshots包数据库的形式是不被ghc接受的单一文件,也是不能直接用的

只好手动完成这件事。

# 从`stack path`的输出找出编译器路径,只是能保证用的是同一个版本
GHC=`stack path | grep "compiler-exe" | cut -d':' -f2 | sed -e 's/ //'`

.PHONY: default
default: sharedlib cleantemp

# 要注意到这样是无法使用snapshots包数据库的,还没有找到文档解决,
# 现在是直接把依赖的源码放到src目录下 [1]
.PHONY: sharedlib
sharedlib:
	GHC -O2 --make -no-hs-main -optl '-shared' \
	-optc '-DMODULE=ExampleFFIC' -o ExampleFFI.so \
	ExampleFFI.hs ExampleFFIC.hs module_init.c


.PHONY: cleantemp
cleantemp:
	$(RM) *.hi *.h *.o

.PHONY: cleanall
cleanall: cleantemp
	$(RM) *.so
  1. TODO:看看有没有什么好办法优雅的解决依赖编译的问题

在Python中调用导出的函数

不出意外的话,在上个部分应该可以得到一个C库ExampleFFI.so,使用ctypes容易调用。

 #! -*- encoding:utf-8 -*-
from ctypes import * # provides cdll, c_char_p, etc..
from functools import partial

# 可以复用的函数,参数是函数签名和库路径,返回函数名做为键、函数为值的字典
def import_functions(function_signatures, lib_path):
    '''
    Setup functions from a shared lib.

    **Beware: this function doesn't handel any exceptions.**

    Args:
        functions_signatures (list) : as [(function_name, return_type, [argtype]), ...]
        lib_path (str) : where the lib is

    Returns:
        (dict) : functions ready to use, with their names as keys
    '''
    # load shared lib
    lib = cdll.LoadLibrary(lib_path) # 载入函数,这就行了
    import_functions = dict()
    for function_name, return_type, argtypes in function_signatures:
        # 函数是lib的一个属性,属性名是函数标识名 [1]
        func_ = getattr(lib, function_name)
        func_.restype = return_type # 返回类型
        func_.argtypes = argtypes # 参数类型列表
        import_functions[function_name] = func_
    return import_functions

def setup_functions(lib_path):
    '''
    Setup functions imported
    '''
    function_signatures = [ ('outputAsNamesC', c_char_p, [c_char_p]),
		# 如果有其他导出函数,可以继续增加在这里
		]
    functions = import_functions(function_signatures, lib_path)
    return functions

# 转化一下输入输出为str
warp_btypes_string = lambda f, x : f(x.encode("utf-8")).decode("utf-8")

if __name__ == "__main__":
	lib_path = "path/to/ExampleFFI.so"
	lib = setup_functions(lib_path)
	output_as_names = partial(warp_btypes_string, # [2]
		lib['outputAsNamesC'])
	print(output_as_names("小明"))

这里要解释的主要有两点:

  1. 使用cdll.LoadLibrary载入库之后,函数已经可以通过lib对象属性访问了,但是现在还要再设定参数和返回类型才能用
  2. haskell中的CString到这里是c_char_p,对应Python3bytes,用str的话要转换

分发

如果在linux下编译共享库,又不想要求调用系统安装haskell,怎么进行分发呢?

这里的解决方法是将共享库连接的libHs<xxx>一同分发,然后在调用系统更新链接。(具体会用到lddldconfig两个命令)

需要注意的是共享库在linux下同时还会连接一些内核库或libc的库,这些不要一起分发到调用系统。主要是因为更新链接时可能会和调用系统冲突,造成segmentation fault。而如果使用CI工具的时候,内核错误往往不能产生正常的日志,很可能要浪费你的时间。

结语

以上就是导出Haskell函数到python的过程。

如果只是用haskell来实现核心模型的话,建议把输入和输出简单化为基本的类型,这样就没有必要折腾复杂类型的转化。


以下是吐槽:

  • haskell社区的开发工具和文档体验还是比较糟糕的,使得这个很不错的语言蒙尘了
  • split这样的广泛应用都需要找第三方包,没有一个比较结实的核心标准库还是不够趁手

本站管理员 huandzh | 2018-2021 © Huan Di 未经授权请勿转载

京公网安备 11011502003050号 | 京ICP备18009918号

Hosted by Coding Pages