../
像Rust一样使用Haskell
=====================

2025-05-31

## 引子

在系统编程领域,内存安全一直是C/C++程序员的梦魇。一个简单的`malloc`或`free`,就
可能埋下导致程序崩溃、数据损坏甚至安全漏洞的隐患。今天,我们将探索如何用Haskell
来驯服这些内存猛兽,甚至达到类似于Rust的内存安全保证。

首先让我们从一些不怎么安全的C代码开始。下面是三个函数,分别是创建矩阵、向矩阵中
填充随机浮点数,以及矩阵乘法运算。因为矩阵是用malloc函数创建的,所以如果要销毁
矩阵,直接用free即可。


    #include <stdlib.h>
    #include <time.h>
    #include <openblas/cblas.h>
    
    #define N 1000
    
    double* new_matrix() {
        double* mat = (double*)malloc(N * N * sizeof(double));
        return mat;
    }
    
    void fill_matrix(double* mat) {
        for (int i = 0; i < N; i++) {
            for (int j = 0; j < N; j++) {
                mat[i*N + j] = (double)rand() / RAND_MAX;
            }
        }
    }
    
    // 计算 c = a * b
    void mat_mul(double* c, double* a, double* b) {
        cblas_dgemm(
            CblasRowMajor,
            CblasNoTrans,
            CblasNoTrans,
            N, N, N, 1.0,
            a, N, b, N, 1.0, c, N);
    }

然后我们写一个main函数:

    int main() {
        double *a, *b, *c;

        srand(time(NULL));
    
        a = new_matrix();
        b = new_matrix();
        c = new_matrix();
    
        fill_matrix(a);
        fill_matrix(b);
    
    
        mat_mul(c, a, b);
    
        free(a);
        free(b);
        free(c);
    
        return 0;
    }

虽然这是一个很简单的例子,但是因为这里用的是不安全的C语言,所以我们有无数种方法
可以让程序崩溃。

比如说,**双重free**:

    free(a);
    free(a);

或者,在**free后使用**:

    free(a);
    fill_matrix(a);

又或者因为忘记free导致**内存泄露**,等等。

## Haskell FFI

通过Haskell的FFI功能,我们可以在Haskell中调用这些函数。

首先打开FFI扩展:

    {-# LANGUAGE ForeignFunctionInterface #-}

导入一些必要的功能模块:

    import Foreign.Ptr (Ptr)
    import Foreign.C.Types (CDouble)
    import Foreign.Marshal.Alloc (free)

定义FFI函数:

    foreign import ccall "new_matrix"
      cNewMatrix :: IO (Ptr CDouble)
    
    foreign import ccall "fill_matrix"
      cFillMatrix :: Ptr CDouble -> IO ()
    
    foreign import ccall "mat_mul"
      cMatMul :: Ptr CDouble -> Ptr CDouble -> Ptr CDouble -> IO ()

然后就可以调用这些函数了:

    main = do
      a <- cNewMatrix
      b <- cNewMatrix
      c <- cNewMatrix
    
      cFillMatrix a
      cFillMatrix b
    
      cMatMul c a b
    
      free a
      free b
      free c
    
      return ()

这里的main函数几乎是和C语言中的版本逐行对应的,但是,这样一来,C语言中的同样的
安全问题也会如影随形,上面提到的几个C语言相关的安全问题这里都会出现。

不过,和C语言不同,这次,我们可以解决这个问题。

## 安全指针

Haskell的`Foreign.ForeignPtr`可以提供类似于RAII的机制,规避绝大多数内存安全问题。

具体到上面的例子,首先,我们导入一些库函数:

    import Foreign.ForeignPtr

然后把指针的定义从裸指针改为安全的ForeignPtr:

    a <- cNewMatrix >>= newForeignPtr free
    b <- cNewMatrix >>= newForeignPtr free
    c <- cNewMatrix >>= newForeignPtr free

然后使用的时候,用withForeignPtr创建一个作用域,这样就可以保证在作用域范围内安
全的使用这些指针。完整代码如下:

    main = do
      a <- cNewMatrix >>= newForeignPtr finalizerFree
      b <- cNewMatrix >>= newForeignPtr finalizerFree
      c <- cNewMatrix >>= newForeignPtr finalizerFree
      withForeignPtr a $ \a ->
        withForeignPtr b $ \b ->
          withForeignPtr c $ \c -> do
            cFillMatrix a
            cFillMatrix b
            cMatMul c a b
  
这样我们就不用担心内存安全的问题了。

## 线性类型

但是上面的方法仍然有局限性,所能实现的生命周期和所有权管理非常粗糙。

例如,我们无法实现类似这样的只有一部分重叠的两个对象的生命周期:

    a <- cNewMatrix
    -- ..
    b <- cNewMatrix
    free a
    -- ...
    free b


也无法实现类似这样的操作:

    a <- cNewMatrix
    if foo then
        free a 
    else
        sendToAnotherThread a

如果想要精细的生命周期控制,就需要最新最酷炫的线性类型扩展:

    {-# LANGUAGE LinearTypes #-}
    {-# LANGUAGE QualifiedDo #-}

这里还启用了QualifiedDo扩展,这是为了启用Linear.do,使用线性类型的IO Monad,后面会提到。

在线性类型中,我们可以定义一个这样的函数:

    func :: a %1 -> b

这个函数签名的意思是,在func中,如果函数的返回值b被使用了一次,那么函数的参数a
必须使用且只能只用一次,否则编译器会报错。

这样,上面的几个安全问题都会在编译器就收到报错。

1. 双重free:对象使用了两次,不符合规则;
2. free后使用:同理,对象使用了两次,不符合规则;
3. 内存泄露:忘记free,对象没有被使用过,不符合规则。

而如果要对对象进行读写操作,只需要在操作完成之后把这个对象原路返回,这样就可以
“假装”这个对象从未被使用过。很类似Rust中的借用规则。

例如,上面的填充矩阵,就可以写成

    a <- fillMatrix a

而矩阵乘法可以写成这样:

    (c, a, b) <- matMul c a b

基于这样的思路,我们可以对我们FFI中的矩阵操作进行封装。这里要用到linear-base这
个库,这是目前Haskell社区实际上的线性类型标准库。

对于IO操作,我们要用专门的Linear IO Monad。Linear IO Monad可以和原先的标准Monad
互相互相转换。

    import Prelude (IO, (>>), (>>=), fmap, return)
    import Prelude.Linear
    import qualified System.IO.Linear as Linear
    import qualified Control.Functor.Linear as Linear

    data Mat where Mat :: (Ptr CDouble) -> Mat

    -- 消耗完资源又返回,实际上并没有消耗
    fillMat :: Mat %1-> Linear.IO Mat
    fillMat (Mat ptr) = Linear.fromSystemIO $
      cFillMatrix ptr >> 
      return (Mat ptr)
    
    -- 同上,看起来消耗了资源,实际上这些资源又返回了
    matMul :: Mat %1-> Mat %1-> Mat %1-> Linear.IO (Mat, Mat, Mat)
    matMul (Mat a) (Mat b) (Mat c) = Linear.fromSystemIO $
        cMatMul a b c >>
        return (Mat a, Mat b, Mat c)
    
    deleteMat :: Mat %1 -> Linear.IO ()
    deleteMat (Mat ptr) = Linear.fromSystemIO $ free ptr

然后改写main函数,这里我们使用了`Linear.do`,可以对Linear.IO进行monad操作:

    main = Linear.withLinearIO $ Linear.do
      a <- newMatrix
      b <- newMatrix
      c <- newMatrix
    
      a <- fillMat a
      b <- fillMat b
    
      (c,a,b) <- matMul c a b
    
      deleteMat a
      deleteMat b
      deleteMat c
      Linear.return $ Ur ()

我们可以试试故意在main函数里面写一些内存不安全的代码。我们会发现无论怎么尝试,编译器都会报错。

这里我们用到了`Ur`这个东西,它表示 () 值是“无限制的”(不被线性消耗),这个解释
起来有点复杂,如果感兴趣可以去翻看linear-base的文档和教程。

## 借用

不过,用线性类型像这样把资源传来传去,每次调用函数都要把参数写两遍,输入一遍、
输出一遍,会看起来很笨拙。Rust对此的解决方法是借用一个reference,对这个
reference进行各种操作,在此期间原来的变量会不可用,等reference离开作用域后原来
的变量又会回来,也就是“借用”机制。例如:

在Haskell中,我们可以通过一个类似于`withForeignPtr`的函数,实现类似于“借用”的效
果。我们把这个函数起名为`borrow`。

为了区分资源的**所有权**和**使用权**,我们引入一个新的类型 `MatRef`。`Mat` 类型
表示我们拥有对矩阵内存的完全所有权,而 `MatRef` 则表示我们只是临时“借用”了矩阵,
可以对其进行读写操作,但不能释放它或转移其所有权。

    data Mat where Mat :: (Ptr CDouble) -> Mat
    
    -- MatRef 允许我们对矩阵进行操作,但不会“消耗”矩阵资源
    data MatRef where MatRef :: (Ptr CDouble) -> MatRef
    
    newMatrix :: Linear.IO Mat
   newMatrix = Linear.fromSystemIO $ fmap Mat cNewMatrix
    
    -- 释放矩阵资源,因此它消耗一个 Mat 类型的值
    deleteMat :: Mat %1 -> Linear.IO ()
    deleteMat (Mat ptr) = Linear.fromSystemIO $ free ptr
    
    -- 填充矩阵的操作,不消耗资源,因此接受 MatRef
    fillMat :: MatRef -> IO ()
    fillMat (MatRef ptr) = cFillMatrix ptr
    
    -- 矩阵乘法操作,不消耗资源,因此接受 MatRef
    matMul :: MatRef -> MatRef -> MatRef -> IO ()
    matMul (MatRef a) (MatRef b) (MatRef c) = cMatMul a b c

然后我们实现borrow函数,这里用到了一点多态小科技:

    class Borrow io b where
        borrow :: Mat %1 -> (MatRef -> io b) %1-> Linear.IO (Mat, b)
    
    instance Borrow Linear.IO a where
        borrow :: Mat %1 -> (MatRef -> Linear.IO b) %1-> Linear.IO (Mat, b)
        borrow (Mat ptr) body =
          body (MatRef ptr) Linear.>>= \x->
          Linear.return (Mat ptr, x)
    
    instance (a ~ ()) => Borrow IO a where
        borrow :: Mat %1 -> (MatRef -> IO b) %1-> Linear.IO (Mat, b)
        borrow (Mat ptr) body =
          Linear.fromSystemIO (body (MatRef ptr)) Linear.>>= \x->
          Linear.return (Mat ptr, x)

通过增加一个类型约束,我们可以将限制borrow内部的IO操作只能返回unit,确保没有引
用值“逃逸”到borrow的外面,导致不安全的内存操作。

最后效果如下:

    main = Linear.withLinearIO $ Linear.do
      a <- newMatrix
      b <- newMatrix
      c <- newMatrix

      (a, (b, (c, ()))) <-
        borrow a $ \a ->
        borrow b $ \b ->
        borrow c $ \c -> do
          fillMat a
          fillMat b
          matMul c a b

      deleteMat a
      deleteMat b
      deleteMat c
      Linear.return (Ur ())

在borrow函数内部,我们可以对MatRef进行任意次数的操作,不用担心资源问题,代码也
很简洁直观。而在borrow函数的外面,我们可以用线性类型对资源的生命周期进行精细而
安全的管理,实现安全的零成本抽象。

## 总结

通过线性类型,Haskell即使在与不安全的C代码交互时,也可以实现Rust相媲美的内存安
全性。

直到这里,我们全部用的是命令式的编程方式,Haskell还有无比强大的函数式编程功能供
我们选用,还有比C++模板元编程更好用更强大的类型级别编程。总之,Haskell不愧为一
门优秀的命令式编程语言。



--------------------------------------------------------------------
Email: i (at) mistivia (dot) com