在C#中使用高阶Haskell类型

27

如何在C#(DLLImport)中使用和调用具有高阶类型签名的Haskell函数,例如...

double :: (Int -> Int) -> Int -> Int -- higher order function

typeClassFunc :: ... -> Maybe Int    -- type classes

data MyData = Foo | Bar              -- user data type
dataFunc :: ... -> MyData

在C#中,相应的类型签名是什么?
[DllImport ("libHSDLLTest")]
private static extern ??? foo( ??? );

此外(因为这可能更容易):我如何在C#中使用“未知”Haskell类型,以便至少可以将它们传递,而不需要C#知道任何特定类型?我现在最需要的重要功能是传递类型类(如Monad或Arrow)。
我已经知道如何将Haskell库编译为DLL并在C#中使用,但仅适用于一阶函数。我也知道Stackoverflow - 在.NET中调用Haskell函数为什么GHC不可用于.NEThs-dotnet,在那里我没有找到任何文档和示例(针对C#到Haskell方向)。
3个回答

18
我将在FUZxxl的帖子中详细阐述我的评论。
您发布的示例都可以使用FFI实现。一旦使用FFI导出函数,您就可以像已经想到的那样将程序编译为DLL。
.NET的设计初衷是能够轻松地与C、C++、COM等进行接口交互。这意味着一旦您能够将函数编译为DLL,您就可以(相对)容易地从.NET调用它。正如我之前在您链接的另一篇文章中提到的那样,请记住在导出函数时指定的调用约定。.NET的标准是stdcall,而Haskell FFI的大多数示例使用ccall
到目前为止,我发现FFI导出的唯一限制是多态类型或未完全应用的类型。例如,除了kind *以外的任何内容(您无法导出Maybe,但您可以导出Maybe Int)。
我编写了一个Hs2lib工具,可以自动覆盖和导出您示例中的任何函数。它还具有生成unsafe C#代码的选项,这使得它几乎可以“即插即用”。我选择使用不安全的代码是因为它更容易处理指针,从而使数据结构的编组更容易。
为了完整起见,我将详细介绍该工具如何处理您的示例以及我计划如何处理多态类型。
  • 高阶函数

在导出高阶函数时,函数需要略作更改。高阶参数需要成为 FunPtr 的元素。基本上它们被视为显式函数指针(或 c# 中的委托),这是典型的命令式语言中处理高阶函数的方式。
假设我们将 Int 转换为 CInt,则 double 类型会发生变化。

(Int -> Int) -> Int -> Int

转换为

FunPtr (CInt -> CInt) -> CInt -> IO CInt

这些类型是为一个包装函数(在本例中是doubleA)生成的,该函数被导出而不是double本身。包装函数将导出值和原始函数的预期输入值之间进行映射。需要IO是因为构造FunPtr不是纯操作。
需要记住的一件事是,构造或取消引用FunPtr的唯一方法是通过静态创建导入来指示GHC为此创建存根。
foreign import stdcall "wrapper" mkFunPtr  :: (Cint -> CInt) -> IO (FunPtr (CInt -> CInt))
foreign import stdcall "dynamic" dynFunPtr :: FunPtr (CInt -> CInt) -> CInt -> CInt

"wrapper" 函数允许我们创建一个 FunPtr,而 "dynamic" FunPtr 则允许我们推迟其引用。

在 C# 中,我们将输入声明为 IntPtr,然后使用 Marshaller 帮助函数 Marshal.GetDelegateForFunctionPointer 创建一个我们可以调用的函数指针,或者使用反向函数从函数指针创建 IntPtr

同时,请记住传递给 FunPtr 的参数函数的调用约定必须与接收参数的函数的调用约定相匹配。换句话说,将 &foo 传递给 bar 需要 foobar 具有相同的调用约定。

  • 用户数据类型
导出用户自定义类型其实相当简单。需要为每个需要导出的数据类型创建一个Storable实例。这些实例指定了GHC需要的编组信息,以便能够导出/导入该类型。除其他事项外,您还需要定义类型的sizealignment,以及如何读取/写入指针的类型值。我部分使用Hsc2hs来完成此任务(因此文件中有C宏)。
只有一个构造函数的newtypesdatatypes很容易处理。这些变成了平面结构,因为在构造/解构这些类型时只有一种可能的选择。具有多个构造函数的类型变成了联合(在C#中设置了Layout属性为Explicit的结构)。但是我们还需要包含一个枚举来标识正在使用哪个构造函数。
总的来说,定义为Single的数据类型如下:
data Single = Single  { sint   ::  Int
                      , schar  ::  Char
                      }

创建以下Storable实例。
instance Storable Single where
    sizeOf    _ = 8
    alignment _ = #alignment Single_t

    poke ptr (Single a1 a2) = do
        a1x <- toNative a1 :: IO CInt
        (#poke Single_t, sint) ptr a1x
        a2x <- toNative a2 :: IO CWchar
        (#poke Single_t, schar) ptr a2x

    peek ptr = do 
        a1' <- (#peek Single_t, sint) ptr :: IO CInt
        a2' <- (#peek Single_t, schar) ptr :: IO CWchar
        x1 <- fromNative a1' :: IO Int
        x2 <- fromNative a2' :: IO Char
        return $ Single x1 x2

和C结构体

typedef struct Single Single_t;

struct Single {
     int sint;
     wchar_t schar;
} ;

函数foo :: Int -> Single将被导出为foo :: CInt -> Ptr Single 而一个具有多个构造函数的数据类型

data Multi  = Demi  {  mints    ::  [Int]
                    ,  mstring  ::  String
                    }
            | Semi  {  semi :: [Single]
                    }

生成以下 C 代码:

enum ListMulti {cMultiDemi, cMultiSemi};

typedef struct Multi Multi_t;
typedef struct Demi Demi_t;
typedef struct Semi Semi_t;

struct Multi {
    enum ListMulti tag;
    union MultiUnion* elt;
} ;

struct Demi {
     int* mints;
     int mints_Size;
     wchar_t* mstring;
} ;

struct Semi {
     Single_t** semi;
     int semi_Size;
} ;

union MultiUnion {
    struct Demi var_Demi;
    struct Semi var_Semi;
} ;

Storable实例相对简单,应该很容易从C结构定义中理解。

  • 应用类型

我的依赖跟踪器会为类型Maybe Int发出对类型IntMaybe的依赖关系。这意味着,在生成Maybe IntStorable实例时,头部看起来像

instance Storable Int => Storable (Maybe Int) where

那就意味着只要应用程序参数有可存储实例,类型本身也可以导出。由于 Maybe a 被定义为具有多态参数的 Just a,因此在创建结构体时会丢失某些类型信息。结构体将包含一个 void* 参数,您必须手动将其转换为正确的类型。在我看来,另一种方法太过繁琐,这是创建专门的结构体,例如 struct MaybeInt。但是,从普通模块生成的专用结构的数量可能会迅速增加(稍后可能会将其作为标志添加)。为了简化这种信息损失,我的工具将导出在函数中找到的任何 Haddock 文档作为生成的包含文件中的注释。它还将在注释中放置原始的 Haskell 类型签名。然后 IDE 将其作为其智能感知的一部分呈现(代码完成)。与所有这些示例一样,我忽略了 .NET 方面的代码,如果您对此感兴趣,可以查看 Hs2lib 的输出。还有一些其他需要特殊处理的类型,特别是列表和元组。
  1. 由于我们要与不受管理的语言进行接口交互,而数组的大小在这些语言中并不是隐含已知的,因此列表需要传递从中编组的数组的大小。同样地,当我们返回列表时,还需要返回列表的大小。
  2. 元组是特殊的内置类型,为了导出它们,我们必须先将它们映射到“普通”数据类型,并导出这些类型。在该工具中,这一点一直持续到8元组。

    • 多态类型

多态类型的问题在于,例如map :: (a -> b) -> [a] -> [b],无法确定a和b的大小。也就是说,由于我们不知道它们是什么,因此无法为参数和返回值预留空间。我计划通过允许您为a和b指定可能的值并为这些类型创建专门的包装函数来支持此功能。 另一方面,在命令式语言中,我将使用重载来向用户呈现您选择的类型。

关于类,Haskell的开放世界假设通常是一个问题(例如,可以随时添加实例)。但是,在编译时只有一个静态已知的实例列表可用。我打算提供一个选项,该选项将使用这些列表自动导出尽可能多的专门实例。例如,导出(+)在编译时为所有已知的Num实例导出一个专门的函数(例如IntDouble等)。
该工具也相当信任。由于我无法真正检查代码的纯度,因此我始终相信程序员是诚实的。例如,您不会将具有副作用的函数传递给期望纯函数的函数。请诚实并将高阶参数标记为不纯以避免问题。
希望这可以帮助您,并希望这不会太长。
更新:最近我发现了一个相当大的陷阱。我们必须记住,.NET中的String类型是不可变的。因此,当 marshaller 将其发送到 Haskell 代码时,我们在那里得到的 CWString 是原始字符串的副本。我们必须释放它。当 C# 中执行 GC 时,它不会影响 CWString,这是一个副本。
然而,问题在于当我们在 Haskell 代码中释放它时,我们不能使用 freeCWString。该指针未使用 C(msvcrt.dll)的 alloc 进行分配。有三种解决方法(我知道的)。
  • 在调用Haskell函数时,请在C#代码中使用char*,而不是String。这样,当您调用返回时,您就有了要释放的指针,或者使用fixed初始化函数。
  • 在Haskell中导入CoTaskMemFree并释放指针。
  • 请使用StringBuilder而不是String。我不太确定这一点,但想法是由于StringBuilder被实现为本地指针,Marshaller只需将此指针传递给您的Haskell代码(该代码也可以更新它)。当调用返回后执行GC时,StringBuilder应该被释放。

我认为这几乎涵盖了所有的内容。我喜欢你们! :) - Gerold Meisinger
非常欢迎 :) 如果您有更多问题,请随时提问 :) - Phyx

4
您尝试过通过FFI导出函数吗?这将允许您创建更C式的接口以供函数使用。我怀疑直接从C#调用Haskell函数是不可能的。请参阅文档以获取更多信息(上面的链接)。
经过一些测试后,我认为通常情况下无法通过FFI导出高阶函数和具有类型参数的函数。[需要引用]

@lambdor,我误解了你的问题。你提供的博客文章已经使用了foreign export结构。如果你看一下我提供的链接,你会发现GHC生成了一个存根.c文件,其中包含这些导出函数的原型。你可以尝试一些高阶类型的实验。 - fuz
@lambdor 经过一些测试,似乎 FFI 仅允许“简单”类型。也就是说:没有类型变量,没有高阶函数,没有 ADT。 - fuz
@lambdor 但是看起来,如果您手动限制类型,实际上可以导出函数。 - fuz
你的意思是限制使用 (a -> a) 而不是 (Int -> Int) 吗?在 C# 中对应的类型是什么?有一个 Func<T> 泛型,但我不知道它是否与 Haskell 函数兼容(http://mikehadlow.blogspot.com/2010/03/combining-higher-order-functions-in-c.html)。 - Gerold Meisinger
我来晚了,但是你在例子中提到的所有情况都是可以实现的。事实上,我的工具(Hs2lib)已经处理了它们。就我所知,FFI 的唯一限制是导出的类型必须是单态化和完全应用的。例如,Maybe Int 可以被导出,但 Maybe 或 Maybe a 不能。 - Phyx
显示剩余2条评论

3

好的,感谢FUZxxl提出了用于“未知类型”的解决方案。在IO上下文中使用Haskell MVar存储数据,并使用一阶函数从C#与Haskell通信,这可能至少是简单情况下的解决方案。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接