如何在haskell中封装对象构造函数和析构函数

How do I encapsulate object constructors and destructors in haskell

本文关键字:对象 构造函数 析构函数 封装 haskell      更新时间:2023-10-16

我有Haskell代码,它需要与C库接口,有点像这样:

// MyObject.h
typedef struct MyObject *MyObject;
MyObject newMyObject(void);
void myObjectDoStuff(MyObject myObject);
//...
void freeMyObject(MyObject myObject);

最初的FFI代码使用unsafePerformIO将所有这些函数包装为纯函数。这导致了错误和不一致,因为操作的顺序是未定义的。

我正在寻找一种在Haskell中处理对象的通用方法,而不需要在IO中做任何事情。最好是我可以做一些事情,比如:

myPureFunction :: String -> Int
-- create object, call methods, call destructor, return results

有什么好方法可以实现这一点吗?

我们的想法是不断从每个组件传递接力棒,迫使每个组件按顺序进行评估。这基本上就是monad的状态(IO实际上是一个奇怪的状态monad.Kinda)。

{-# LANGUAGE GeneralizedNewtypeDeriving #-}
import Control.Monad.State
data Baton = Baton -- Hide the constructor!
newtype CLib a = CLib {runCLib :: State Baton a} deriving Monad

然后你把操作串在一起。将它们注入CLib monad将意味着它们被测序。从本质上讲,你是在伪造自己的IO,因为你可以逃跑,所以这是一种更不安全的方式。

然后必须确保将constructdestruct添加到所有CLib链的末尾。这很容易通过导出这样的函数来完成

clib :: CLib a -> a
clib m = runCLib $ construct >> m >> destruct

最后一个需要跳过的大环是确保当你unsafePerformIO时,无论construct中有什么,它都会得到评估。


坦率地说,这一切都是毫无意义的,因为它已经存在,战斗证明了IO。与其整个复杂的过程,不如只使用

construct :: IO Object
destruct  :: IO ()
runClib :: (Object -> IO a) -> a
runClib = unsafePerformIO $ construct >>= m >> destruct

如果您不想使用名称IO:

newtype CLib a = {runCLib :: IO a} deriving (Functor, Applicative, Monad)

我的最终解决方案。它可能有一些我没有考虑过的细微错误,但它是迄今为止唯一符合所有原始标准的解决方案:

  • 严格-所有操作顺序正确
  • 摘要-库被导出为一个有状态的monad,而不是一组泄漏的IO操作
  • 安全-用户可以在不使用unsafePerformIO的情况下将此代码嵌入纯代码中,并且他们可以期望结果是纯的

不幸的是,实现有点复杂。

例如

// Stack.h
typedef struct Stack *Stack;
Stack newStack(void);
void pushStack(Stack, int);
int popStack(Stack);
void freeStack(Stack);

c2hs文件:

{-# LANGUAGE ForeignFunctionInterface, GeneralizedNewtypeDeriving #-}
module CStack(StackEnv(), runStack, pushStack, popStack) where
import Foreign.C.Types
import Foreign.Ptr
import Foreign.ForeignPtr
import qualified Foreign.Marshal.Unsafe
import qualified Control.Monad.Reader
#include "Stack.h"
{#pointer Stack foreign newtype#}
newtype StackEnv a = StackEnv
 (Control.Monad.Reader.ReaderT (Ptr Stack) IO a)
 deriving (Functor, Monad)
runStack :: StackEnv a -> a
runStack (StackEnv (Control.Monad.Reader.ReaderT m))
 = Foreign.Marshal.Unsafe.unsafeLocalState $ do
  s <- {#call unsafe newStack#}
  result <- m s
  {#call unsafe freeStack#} s
  return result
pushStack :: Int -> StackEnv ()
pushStack x = StackEnv . Control.Monad.Reader.ReaderT $
 flip {#call unsafe pushStack as _pushStack#} (fromIntegral x)
popStack :: StackEnv Int
popStack = StackEnv . Control.Monad.Reader.ReaderT $
 fmap fromIntegral . {#call unsafe popStack as _popStack#}

测试程序:

-- Main.hs
module Main where
import qualified CStack
main :: IO ()
main = print $ CStack.runStack x where
 x :: CStack.StackEnv Int
 x = pushStack 42 >> popStack

构建:

$ gcc -Wall -Werror -c Stack.c
$ c2hs CStack.chs
$ ghc --make -Wall -Werror Main.hs Stack.o
$ ./Main
42

免责声明:我从未真正使用过Haskell的C语言,所以我在这里不是根据经验说话。

但我脑海中浮现的是写这样的东西:

withMyObject :: NFData r => My -> Object -> Constructor -> Params -> (MyObject -> r) -> r

您将C++构造函数/析构函数包装为IO操作。withMyObject使用IO对构造函数进行排序,调用用户指定的函数,调用析构函数,并返回结果。然后它可以unsafePerformIO整个do块(与其中的单个操作相反,您已经烹饪过的操作不起作用)。您也需要使用deepSeq(这就是NFData约束存在的原因),否则懒惰可能会将MyObject的使用推迟到它被破坏之后。

这样做的优点是:

  1. 您可以使用任何您喜欢的普通代码编写纯MyObject -> r函数,不需要monad
  2. withMyObject的帮助下,您可以决定构造一个MyObject,以便在其他普通纯代码的中间调用这些函数
  3. 使用withMyObject时,不能忘记调用析构函数
  4. 在调用MyObject上的析构函数后不能使用它1
  5. 在你的系统中,只有一个(小)地方可以使用unsafePerformIO,因此,这是你唯一需要小心担心的地方,你是否有正确的测序来证明它是安全的。你只需要担心一个地方,那就是确保正确使用析构函数

它基本上是"构造、使用、销毁"模式,其中"使用"步骤的细节被抽象为一个参数,这样每次需要使用该模式时都可以有一个单独的实现覆盖。

主要的缺点是构造一个MyObject然后将其传递给几个不相关的函数有点尴尬。您必须将它们捆绑到一个函数中,该函数返回每个原始结果的元组,然后在其中使用withMyObject。或者,如果您还分别公开构造函数和析构函数的IO版本,则如果IO比使包装函数传递给withMyObject不那么尴尬,则用户可以选择使用这些版本(但用户可能会在释放MyObject后意外使用它,或者忘记释放它)。


1除非您做了一些愚蠢的事情,比如使用id作为MyObject -> r函数。假设没有NFData MyObject实例。此外,这种错误往往来自故意的滥用,而不是意外的误解。