希望在python
写的web服务器中调用haskell
函数,需要用到FFI
。具体来说实际上是要将haskell
函数编译为共享库,再用python
的ctypes
包来调用。
接触到haskell
这门传说中的论文语言是大概6、7年前的事情了,当时写了些toy程序,但是只是觉得好玩,并没有找到能用到哪里。这两天才终于有机会用它做了一个模型。
用法早已经忘得差不多,难免掉坑。其中一个就是FFI
导出haskell
函数到其他语言(Python
)的问题。
之所以要用这个功能,是出于两个原因:
- 模型有保密性质需要能够编译分发,要有较好的性能,对内需要有较好的可读性
- 会做成简单的web api,不会花过多精力
前者适合优雅的haskell
,后者适合敏捷的python
。
导出Haskell函数可用的方法
这个问题在stackoverflow上有讨论,基本上有四种方法:
FFI
:导出到C
,再用ctypes
或者swig
导入到Python
,直接调用haskell
写一个服务: 比如说用http进行通讯,通过网络IO传递函数的参数和结果vanilla
+shell
:就是和shell进行交互,通过命令行传递函数的参数和结果haskell
到python
的包,回答中提到过几个,但没有太成熟的
前三种都能用,其中第三种最对口味。但是如果真的想追求性能的话,可能也要直接用FFI
导入的函数才更快。因此这里选用了第一种。
使用FFI导出haskell函数到Python
这个工作主要有两个部分:把导出函数编译为shared lib .so
和用ctypes
在Python
里使用,前者是比较麻烦的。
编译导出函数到.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
以上需要解释的是:
- 使用
FFI
要打开语言选项 - 导出的函数需要转换为C的参数和返回类型
- 要使用
foreign export ccall
导出转换好的函数 - 原来的函数是
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
相关flagssnapshots
包数据库的形式是不被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
- 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("小明"))
这里要解释的主要有两点:
- 使用
cdll.LoadLibrary
载入库之后,函数已经可以通过lib
对象属性访问了,但是现在还要再设定参数和返回类型才能用 haskell
中的CString
到这里是c_char_p
,对应Python3
的bytes
,用str的话要转换
分发
如果在linux下编译共享库,又不想要求调用系统安装haskell
,怎么进行分发呢?
这里的解决方法是将共享库连接的libHs<xxx>
一同分发,然后在调用系统更新链接。(具体会用到ldd
和ldconfig
两个命令)
需要注意的是共享库在linux下同时还会连接一些内核库或libc的库,这些不要一起分发到调用系统。主要是因为更新链接时可能会和调用系统冲突,造成segmentation fault
。而如果使用CI工具的时候,内核错误往往不能产生正常的日志,很可能要浪费你的时间。
结语
以上就是导出Haskell
函数到python
的过程。
如果只是用haskell
来实现核心模型的话,建议把输入和输出简单化为基本的类型,这样就没有必要折腾复杂类型的转化。
以下是吐槽:
haskell
社区的开发工具和文档体验还是比较糟糕的,使得这个很不错的语言蒙尘了- 连
split
这样的广泛应用都需要找第三方包,没有一个比较结实的核心标准库还是不够趁手