使用C接口:FFI

编程语言并不完全孤立。他们居住在一个由工具和库组成的生态系统中,该生态系统已经建立了数十年,并且通常以多种编程语言编写。良好的工程实践建议我们重复使用该工作。Haskell外部功能接口(“ FFI”)是Haskell代码可以使用并由其他语言编写的代码使用的方式。在本章中,我们将研究FFI的工作原理,以及如何生成与C库的Haskell绑定,包括如何使用FFI预处理器来自动化大部分工作。挑战:采用PCRE,这是与Perl兼容的标准正则表达式库,并使其可以在Haskell中以有效且实用的方式使用。在整个过程中,我们将寻求抽象出C实现所需的手动工作,将这项工作委托给Haskell,以使界面更加健壮,从而产生干净的高级绑定。我们仅假定对正则表达式有一些基本的了解。

将一种语言与另一种语言绑定是一项艰巨的任务。绑定语言需要了解目标语言的调用约定,类型系统,数据结构,内存分配机制和链接策略,才能使工作正常进行。任务是仔细调整两种语言的语义,以便两种语言都能理解在两种语言之间传递的数据。

对于Haskell,此技术堆栈由Haskell报告中的“外部功能接口”附录指定。FFI报告介绍了如何将Haskell和C正确绑定在一起,以及如何将绑定扩展到其他语言。该标准设计为可移植的,因此FFI绑定将在Haskell实现,操作系统和C编译器之间可靠地工作。 1条评论

Haskell的所有实现都支持FFI,这是在新领域中使用Haskell的一项关键技术。无需在域中重新实现标准库,我们只需绑定到用Haskell以外的语言编写的现有库即可。

FFI为语言增加了灵活性的新维度:如果由于某种原因(例如,我们正在编程新硬件或实现操作系统),我们需要访问原始硬件,则FFI使我们能够访问该硬件。它还给我们带来了性能逃逸的阴影:如果我们不能足够快地获得代码热点,则总是可以选择在C中重试。因此,让我们看一下FFI对于编写代码的实际含义

外语绑定:基础知识

毫无疑问,我们最想做的操作是从Haskell调用C函数。因此,通过绑定到标准C数学库中的某些函数来完成此操作。我们将绑定放入源文件中,然后将其编译为使用C代码的Haskell二进制文件。 没意见

首先,我们需要启用外部功能接口扩展,因为默认情况下未启用FFI附录支持。我们一如既往地通过LANGUAGE源文件顶部的编译指示来执行此操作:

  1. -- file: ch17/SimpleFFI.hs
  2. {-# LANGUAGE ForeignFunctionInterface #-}

LANGUAGE编译指示指示哪个扩展的Haskell 98的模块的用途。这次我们只提供FFI扩展程序。跟踪所需语言的扩展很重要。较少的扩展通常意味着更可移植,更健壮的代码。的确,尽管语言的语法,类型系统和核心库发生了变化,但由于标准化,对于十多年前编写的Haskell程序来说,如今能够完美地编译是很常见的。 1条评论

下一步是导入Foreign模块,这些模块提供有用的类型(例如指针,数字类型,数组)和实用程序函数(例如malloc和alloca),用于编写与其他语言的绑定:

  1. -- file: ch17/SimpleFFI.hs
  2. import Foreign
  3. import Foreign.C.Types

为了与国外图书馆进行广泛的合作,对Foreign模块的全面了解 至关重要。其他有用的模块包括 Foreign.C.String,Foreign.Ptr和 Foreign.Marshal.Array。

现在我们可以开始调用C函数了。为此,我们需要了解三件事:C函数的名称,其类型及其关联的头文件。此外,对于标准C库未提供的代码,出于链接目的,我们需要知道C库的名称。实际的绑定工作是通过foreign import 声明完成的,例如:

  1. -- file: ch17/SimpleFFI.hs
  2. foreign import ccall "math.h sin"
  3. c_sin :: CDouble -> CDouble

这定义了一个新的Haskell函数,c_sin该函数的具体实现在C中sin。当 c_sin被调用时,将对实际对象sin进行调用(使用由ccall关键字指示的标准C调用约定 )。Haskell运行时将控制权传递给C,C将其结果返回给Haskell。然后将结果包装为type的Haskell值CDouble。 1条评论

编写FFI绑定时,一个常见的习惯用法是使用前缀“ c_”公开C函数,以使其与更用户友好的高级函数区分开。原始C函数由math.h标头指定,在标头中声明其类型为:

  1. double sin(double x);

在编写绑定时,程序员必须将这样的C类型签名转换为Haskell FFI等效项,以确保数据表示形式匹配。例如,double在C对应CDouble在Haskell。我们在这里需要小心,因为如果出错,Haskell编译器将很高兴生成不正确的代码来调用C!可怜的Haskell编译器对C函数实际需要什么类型一无所知,因此,如果得到指示,它将使用错误的参数调用C函数。充其量这将导致C编译器警告,并且更有可能以运行时崩溃为结尾。在最坏的情况下,错误将被忽略,直到发生一些严重的故障为止。因此,请确保使用正确的FFI类型,并且不要担心使用QuickCheck通过绑定来测试C代码。 [ 36 ]

最重要的原始的C类型来表示在Haskell与直观性名称(符号和无符号类型)CChar,CUChar,CInt, CUInt,CLong,CULong, CSize,CFloat,CDouble。在FFI标准中定义了更多内容,可以在Haskell基础库中的下找到Foreign.C.Types。也可以为C定义自己的Haskell端表示类型,我们将在后面看到。

注意副作用

需要注意的一点是,我们sin在Haskell中绑定为纯函数,没有副作用。在这种情况下就可以了,因为sin C中的函数是参照透明的。通过将纯C函数绑定到纯Haskell函数,可以向Haskell编译器学习有关C代码的知识,即它没有副作用,可以使优化更容易。对于Haskell程序员而言,纯代码也是更灵活的代码,因为它会产生自然持久的数据结构和线程安全功能。但是,尽管纯Haskell代码始终是线程安全的,但很难保证使用C。即使文档表明该函数可能不会暴露副作用,也没有什么可以确保它也是线程安全的,除非明确记录为“可重入”。纯线程安全的C代码虽然很少见,但却是有价值的商品。这是Haskell使用的最简单的C语言。

当然,在命令式语言中,带有副作用的代码更为常见,其中明确的语句顺序鼓励使用效果。在C语言中,由于全局或局部状态的变化或具有其他副作用,在给定相同参数的情况下,函数返回不同的值的情况更为常见。通常,这是通过C以信号形式发送的,该函数仅返回状态值或某些void类型,而不返回有用的结果值。这表明该功能的真正作用在于其副作用。对于此类功能,我们需要捕获IO monad中的那些副作用(通过将return类型更改为IO CDouble, 例如)。与C相比,在Haskell代码中极其常见的是多个线程,因此我们也需要非常小心,不要同时使用可重入的纯C函数。我们可能需要通过调节对C的访问,使不可重入的代码安全使用。 FFI与事务性锁绑定,或复制基础C状态。

高级封装

随着国外进口的方式进行,下一步就是要转变我们通过和外语呼叫到原始Haskell类型收到C型,包裹结合,因此显示为普通的Haskell功能:

  1. -- file: ch17/SimpleFFI.hs
  2. fastsin :: Double -> Double
  3. fastsin x = realToFrac (c_sin (realToFrac x))

在像这样的绑定上编写方便的封装器时,要记住的主要事情是正确地将输入和输出转换回普通的Haskell类型。要在浮点值之间进行转换,我们可以使用 realToFrac,它使我们能够相互转换不同的浮点值(并且这些转换,例如fromCDouble到 Double,通常是自由的,因为基础表示形式不变)。对于整数值fromIntegral可用。对于其他常见的C数据类型,例如数组,我们可能需要将数据解压缩为更适用的Haskell类型(例如列表),或者可能使C数据不透明,并仅对其进行间接操作(也许通过 ByteString) 。选择取决于转换的成本,以及源和目标类型上可用的功能。

现在,我们可以继续在程序中使用绑定函数。例如,我们可以将Csin函数应用于十分之一的Haskell列表:

  1. -- file: ch17/SimpleFFI.hs
  2. main = mapM_ (print . fastsin) [0/10, 1/10 .. 10/10]

这个简单的程序在计算结果时将其打印出来。将完整的绑定放入文件中SimpleFFI.hs,我们可以用GHCi运行它:

  1. $ ghci SimpleFFI.hs
  2. *Main> main
  3. 0.0
  4. 9.983341664682815e-2
  5. 0.19866933079506122
  6. 0.2955202066613396
  7. 0.3894183423086505
  8. 0.479425538604203
  9. 0.5646424733950354
  10. 0.644217687237691
  11. 0.7173560908995227
  12. 0.7833269096274833
  13. 0.8414709848078964

或者,我们可以将代码编译为可执行文件,并与相应的C库进行动态链接:

  1. $ ghc -O --make SimpleFFI.hs
  2. [1 of 1] Compiling Main ( SimpleFFI.hs, SimpleFFI.o )
  3. Linking SimpleFFI ...

然后运行:

  1. $ ./SimpleFFI
  2. 0.0
  3. 9.983341664682815e-2
  4. 0.19866933079506122
  5. 0.2955202066613396
  6. 0.3894183423086505
  7. 0.479425538604203
  8. 0.5646424733950354
  9. 0.644217687237691
  10. 0.7173560908995227
  11. 0.7833269096274833
  12. 0.8414709848078964

现在,我们已经有了一个完整的程序,它与C静态链接,而且程序完整,该程序将C和Haskell代码交织在一起,并跨语言边界传递数据。像上面这样的简单绑定几乎是微不足道的,因为标准 Foreign库为诸如的常见类型提供了方便的别名CDouble。在下一节中,我们将研究一个更大的工程任务:绑定到PCRE库,这会带来内存管理和类型安全性的问题。

Haskell的正则表达式:PCRE的绑定

正如我们在前几节中所见,Haskell程序偏向于将列表作为基础数据结构。列表函数是基础库的核心部分,该语言还附带了用于构造和分解列表结构的便捷语法。当然,字符串只是字符列表(而不是例如字符的平面数组)。这种灵活性很好,但是它导致标准库倾向于使用多态列表操作,而以字符串特定的操作为代价。

确实,许多常见任务可以通过基于正则表达式的字符串处理来解决,但是对正则表达式的支持并不是Haskell的一部分Prelude。因此,让我们看一下如何使用现成的正则表达式库PCRE,并为其提供自然,方便的Haskell绑定,从而为Haskell提供有用的正则表达式。 没意见

PCRE本身是实现Perl样式正则表达式的无处不在的C库。它广泛可用,并已预先安装在许多系统上。如果没有,请访问http://www.pcre.org/ 。在以下各节中,我们假定PCRE库和标头在计算机上可用。

简单的任务:使用C预处理器

开始将新的FFI绑定从Haskell写入C时,最简单的任务是将C标头中定义的常量绑定到等效的Haskell值。例如,PCRE提供了一组标志,用于修改核心模式匹配系统的工作方式(例如忽略大小写或允许在换行符上进行匹配)。这些标志在PCRE头文件中显示为数字常量:

  1. /* Options */
  2. #define PCRE_CASELESS 0x00000001
  3. #define PCRE_MULTILINE 0x00000002
  4. #define PCRE_DOTALL 0x00000004
  5. #define PCRE_EXTENDED 0x00000008

要将这些值导出到Haskell,我们需要以某种方式将它们插入Haskell源文件中。一种明显的方法是使用C预处理程序将C中的定义替换为Haskell源代码,然后将其编译为普通的Haskell源文件。使用预处理器,我们甚至可以通过在Haskell源文件上进行文本替换来声明简单的常量:

  1. -- file: ch17/Enum1.hs
  2. {-# LANGUAGE CPP #-}
  3. #define N 16
  4. main = print [ 1 .. N ]

用预处理器以类似于C源的方式处理文件(当发现LANGUAGE杂点时,由Haskell编译器为我们运行CPP ),导致程序输出:

  1. $ runhaskell Enum.hs
  2. [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16]

但是,依靠CPP是一种相当脆弱的方法。C预处理器不知道它正在处理Haskell源文件,并乐于以使我们的Haskell代码无效的方式包含文本或转换源。我们需要注意不要混淆CPP。如果要包含C标头,则有可能替换掉不需要的符号,或将C类型信息和原型插入Haskell源中,否则会造成混乱。

为了解决这些问题,绑定预处理器hsc2hs与GHC一起分发。它为在Haskell中包括C绑定信息提供了一种方便的语法,还使我们可以安全地使用标头。它是大多数Haskell FFI绑定的首选工具。

使用hsc2hs将Haskell绑定到C

要将hsc2hs用作Haskell的智能绑定工具,我们需要创建一个.hsc文件,该文件Regex.hsc将保存用于绑定的Haskell源以及hsc2hs处理规则,C标头和C类型信息。首先,我们需要一些注释和导入:

  1. -- file: ch17/Regex-hsc.hs
  2. {-# LANGUAGE CPP, ForeignFunctionInterface #-}
  3. module Regex where
  4. import Foreign
  5. import Foreign.C.Types
  6. #include <pcre.h>

该模块从FFI绑定的典型前言开始:启用CPP,启用外部函数接口语法,声明模块名称,然后从基库中导入一些内容。不寻常的项目是最后一行,其中包括PCRE的C标头。这在.hs源文件中无效 ,但是在.hsc代码中很好。

为PCRE增加类型安全性

接下来,我们需要一个类型来表示PCRE编译时标志。在C语言中,这些是compile函数的整数标志,因此我们只能使用 CInt它们来表示它们。我们对标志的了解仅是它们是C数字常量,因此CInt适当的表示形式也是如此。

但是,作为Haskell图书馆作家,这有点草率。可以用作正则表达式标志的值的类型包含的值少于CInt 允许的值。没有什么可以阻止最终用户将非法整数值作为参数传递,或者将仅在正则表达式编译时传递的标志与运行时标志混合在一起。也可以对标志进行任意数学运算,或者在整数和标志混淆时犯其他错误。实际上,我们确实需要更精确地指定标志的类型与其运行时表示形式不同(以数值表示)。如果能够做到这一点,我们就可以静态地防止一类与标志滥用有关的错误。

添加这样的类型安全层相对容易,并且是newtype类型引入声明的一个很好的用例 。是什么newtype让我们做的是创建一个相同的运行时表示到另一种类型的类型,但被视为在编译时一个独立的类型。我们可以将标志表示为CInt 值,但是在编译时,将为类型检查器对它们进行明显的标记。这使使用无效标志值(因为我们仅指定那些有效标志,并防止访问数据构造函数)或将标志传递给期望整数的函数而导致类型错误。我们开始使用Haskell类型系统为C PCRE API引入类型安全层。 没意见

要做到这一点,我们定义newtype为PCRE编译时间选项,其表示实际上是一个中CInt值,就像这样:

  1. -- file: ch17/Regex-hsc.hs
  2. -- | A type for PCRE compile-time options. These are newtyped CInts,
  3. -- which can be bitwise-or'd together, using '(Data.Bits..|.)'
  4. --
  5. newtype PCREOption = PCREOption { unPCREOption :: CInt }
  6. deriving (Eq,Show)

类型名称为PCREOption,它具有一个也称为的构造函数,该构造函数通过将值包装在构造函数中将PCREOption其提升CInt为新的类型。我们还可以unPCREOption使用Haskell记录语法为底层的 高兴地定义一个访问器CInt。一行中有很多便利。在这里时,我们还可以派生一些有用的标志类型类操作(相等性和打印性)。我们还需要记住从源模块抽象地导出数据构造函数,以确保用户无法构造自己的 PCREOption值。

绑定常量

现在,我们提取了所需的模块,打开了所需的语言功能,并定义了表示PCRE选项的类型,我们实际上需要定义一些与那些PCRE常量相对应的Haskell值。

我们可以使用hsc2hs以两种方式执行此操作。第一种方法是使用#consthsc2hs提供的 关键字。这使我们可以命名由C预处理器提供的常量。我们可以手动绑定到常量,通过上市为他们的CPP符号#const关键字:

  1. -filech17 / Regex-hsc-const.hs caseless
  2. :: PCREOption caseless
  3. = PCREOption #const PCRE_CASELESS
  4. dollar_endonly :: PCREOption
  5. dollar_endonly = PCREOption #const PCRE_DOLLAR_ENDONLY
  6. dotall :: PCREOption
  7. dotall = PCREOption #const PCRE_DOTALL

将常量包装在newtype构造函数中,因此它们PCREOption仅作为抽象类型提供给程序员。

这是创建.hsc文件的第一步。现在,我们实际上需要创建一个Haskell源文件,并完成C预处理。时间运行hsc2hs在.hsc文件:

  1. $ hsc2hs Regex.hsc

这将创建一个新的输出文件,Regex.hs在其中扩展了CPP变量,并产生有效的Haskell代码:

  1. -filech17 / Regex-hsc-const-
  2. generation.hs caseless :: PCREOption caseless
  3. = PCREOption 1
  4. {-#LINE 21 Regex.hsc”#-}
  5. dollar_endonly :: PCREOption
  6. dollar_endonly = PCREOption 32
  7. {-#LINE 24 Regex.hsc“#-}
  8. dotall :: PCREOption
  9. dotall = PCREOption 4
  10. {-#LINE 27 Regex.hsc“#-}

还要注意如何.hsc通过LINE编译指示在每个扩展定义旁边列出中的原始行。编译器使用此信息在原始文件中而不是在生成的错误中报告错误的来源。我们可以将生成的.hs文件加载 到解释器中,并处理结果:

  1. $ ghci Regex.hs
  2. *Regex> caseless
  3. PCREOption {unPCREOption = 1}
  4. *Regex> unPCREOption caseless
  5. 1
  6. *Regex> unPCREOption caseless + unPCREOption caseless
  7. 2
  8. *Regex> caseless + caseless
  9. interactive>:1:0:
  10. No instance for (Num PCREOption)

所以事情按预期进行。值是不透明的,如果尝试破坏抽象,则会遇到类型错误,并且可以解开它们并在需要时对其进行操作。该unPCREOption访问是用来解开了盒子。这是一个好的开始,但是让我们看看如何进一步简化此任务。

自动化绑定

显然,手动列出所有C定义并包装它们很繁琐且容易出错。将所有文字包装在 newtype构造函数中的工作也很烦人。这种绑定是一种常见的任务,它hsc2hs提供了方便的语法来自动化它:#enum构造。 5条评论

我们可以用等效项替换顶级绑定列表:

  1. -- file: ch17/Regex-hsc.hs
  2. -- PCRE compile options
  3. #{enum PCREOption, PCREOption
  4. , caseless = PCRE_CASELESS
  5. , dollar_endonly = PCRE_DOLLAR_ENDONLY
  6. , dotall = PCRE_DOTALL
  7. }

这更加简洁!该#enum构造为我们提供了三个领域。第一个是我们希望C定义为的类型的名称。这使我们不仅 CInt可以选择绑定内容。我们选择PCREOption构造。

第二个字段是一个可选的构造函数,位于符号前面。这是专门针对我们要构造newtype 值的情况,并且节省了大量精力。#enum语法的最后一部分 是不言自明的:它仅定义了Haskell名称,用于通过CPP填写的常量。

与以前一样,通过hsc2hs运行此代码会生成一个Haskell文件,并生成以下绑定代码(LINE为简洁起见,删除了编译指示):

  1. -filech17 / Regex.hs caseless
  2. :: PCREOption caseless
  3. = PCREOption 1
  4. dollar_endonly :: PCREOption
  5. dollar_endonly = PCREOption 32
  6. dotall :: PCREOption
  7. dotall = PCREOption 4

完善。现在,我们可以使用这些值在Haskell中进行操作。我们的目标是将标志视为抽象类型,而不是C中的整数位字段。在C中传递多个标志将通过按位或多个标志在一起来完成。但是对于抽象类型,这将暴露太多信息。保留抽象并赋予其Haskell风格,我们希望用户在库本身合并的列表中传递标志。这是一个折叠实现简单:

  1. -- file: ch17/Regex.hs
  2. -- | Combine a list of options into a single option, using bitwise (.|.)
  3. combineOptions :: [PCREOption] -> PCREOption
  4. combineOptions = PCREOption . foldr ((.|.) . unPCREOption) 0

这个简单的循环从初始值0开始,解压缩每个标志,然后(.|.)在底层上按位或CInt将每个值与循环累加器组合在一起。然后将最终的累积状态包装在 PCREOption构造函数中。

现在让我们转向实际编译一些正则表达式。

在Haskell和C之间传递字符串数据

下一个任务是编写与PCRE正则表达式compile函数的绑定 。让我们直接从头pcre.h文件看一下它的类型 :

  1. pcre *pcre_compile(const char *pattern,
  2. int options,
  3. const char **errptr,
  4. int *erroffset,
  5. const unsigned char *tableptr);

该函数将正则表达式模式编译为某种内部格式,以该模式为参数,并带有一些标志和一些用于返回状态信息的变量。

我们需要找出Haskell的类型来表示每个参数。这些类型中的大多数都由FFI标准为我们定义的等效项涵盖,并且可以在中找到Foreign.C.Types。第一个参数,即正则表达式本身,作为以null结束的char指针传递给C,等效于HaskellCString类型。我们已经选择将PCRE编译时间选项表示为抽象新类型 PCREOption,其运行时表示形式为 CInt。由于可以保证表示形式完全相同,因此我们可以newtype安全通过。其他论点稍微复杂一些,需要一些工作来构造和分解。

第三个参数是指向C字符串的指针,将用作对在编译表达式时生成的任何错误消息的引用。指针的值将由C函数修改为指向自定义错误字符串。我们可以用一个Ptr CString类型来表示。Haskell中的指针是为原始地址分配堆的容器,可以使用FFI库中的许多分配原语创建和操作指针。例如,我们可以将C的指针表示int为Ptr CInt,将无符号char的指针表示为Ptr Word8。

关于指针的说明

有了HaskellPtr值后,就可以使用它进行各种类似于指针的操作。我们可以将其与以特殊nullPtr常量表示的空指针进行相等性比较。我们可以将一种类型的指针转​​换为另一种类型的指针,也可以使用来将指针前移一个字节偏移量plusPtr。我们甚至可以使用修改指向的值poke,当然也可以使用取消引用产生它指向的指针的指针peek。在大多数情况下,Haskell程序员无需直接对指针进行操作,但是在需要它们时,这些工具将派上用场。

然后的问题是,pcre当我们编译正则表达式时,如何表示返回的抽象指针。我们需要找到一个与C类型一样抽象的Haskell类型。由于对C类型进行了抽象处理,因此我们可以为数据分配任何已分配堆的Haskell类型,只要对它进行的操作很少或没有操作即可。对于任意类型的外部数据,这是一个常见的技巧。用于表示未知外来数据的惯用简单类型是指向该()类型的指针。我们可以使用类型同义词来记住绑定:

  1. -- file: ch17/PCRE-compile.hs
  2. type PCRE = ()

也就是说,外部数据是一些未知的,不透明的对象,我们将其视为的指针(),完全了解我们永远不会真正取消引用该指针。这为我们提供了的以下外部导入绑定pcre_compile,必须在中IO,因为返回的指针在每次调用时都会有所不同,即使返回的对象在功能上是等效的:

  1. -- file: ch17/PCRE-compile.hs
  2. foreign import ccall unsafe "pcre.h pcre_compile"
  3. c_pcre_compile :: CString
  4. -> PCREOption
  5. -> Ptr CString
  6. -> Ptr CInt
  7. -> Ptr Word8
  8. -> IO (Ptr PCRE)

键入的指针

关于安全的注意事项

进行外部导入声明时,我们可以选择使用safeorunsafe关键字指定在进行调用时要使用的“安全”级别 。安全调用的效率较低,但是可以保证可以从C安全调用Haskell系统。“不安全”调用的开销要少得多,但是被调用的C代码一定不能回调到Haskell。默认情况下,国外进口是“安全的”,但是实际上很少有C代码回调到Haskell,因此为了提高效率,我们大多使用“不安全”的调用。

通过使用“类型化”指针而不是类型,我们可以进一步提高绑定的安全性()。也就是说,与单元类型不同的唯一类型没有有意义的运行时表示形式。无法为其构造数据的类型,因此将其取消引用是一种类型错误。构造这种不可检查的数据类型的一种好方法是使用null数据类型:

  1. -- file: ch17/PCRE-nullary.hs
  2. data PCRE

这需要EmptyDataDecls语言扩展。该类型显然不包含任何值!我们只能构造指向此类值的指针,因为没有任何具体的值(底部)除外。

我们也可以使用递归来实现相同的目的,而无需语言扩展newtype:

  1. -- file: ch17/PCRE-recursive.hs
  2. newtype PCRE = PCRE (Ptr PCRE)

再说一次,我们真的不能对这种类型的值做任何事情,因为它没有运行时表示形式。以这种方式使用类型化指针只是在C语言所提供的Haskell层上增加安全性的另一种方式。可以在Haskell绑定的类型系统中静态地强制执行C程序员需要进行纪律的工作(记住永远不要取消引用PCRE指针)。如果该代码可以编译,则类型检查器将为我们提供证明,证明在Haskell端永远不会取消引用C返回的PCRE对象。

现在,我们已经整理了外部导入声明,下一步是将数据整理为正确的格式,以便最终可以调用C代码。

内存管理:让垃圾收集器完成工作

一个尚未解决的问题是如何管理与PCREC库返回的抽象结构关联的内存。调用者不必分配它:库通过在C端分配内存来解决这一问题。在某些时候,尽管我们需要取消分配它。同样,这是通过将Haskell绑定内的复杂性隐藏起来来抽象化使用C库的乏味机会。

一旦不再使用C结构,我们将使用Haskell垃圾回收器自动取消分配C结构。为此,我们将利用Haskell垃圾收集器的终结器和ForeignPtr类型。

我们不希望用户必须手动释放Ptr PCRE 外部调用返回的值。PCRE库特别声明结构在C侧使用分配malloc,并且在不再使用时需要释放结构,否则我们就有泄漏内存的风险。Haskell垃圾收集器已经竭尽全力来自动化为Haskell值管理内存的任务。巧妙地,我们还可以将辛勤工作的垃圾收集器分配给照顾C的内存的任务。诀窍是将一段Haskell数据与外部分配器数据相关联,并为Haskell垃圾回收器提供一个任意函数,该函数可以在注意到Haskell数据完成后立即释放C资源。

这里有两个可用的工具,不透明的ForeignPtr 数据类型和newForeignPtr具有类型的函数:

  1. -- file: ch17/ForeignPtr.hs
  2. newForeignPtr :: FinalizerPtr a -> Ptr a -> IO (ForeignPtr a)

该函数有两个参数,一个终结器,当数据超出范围时运行,以及指向关联的C数据的指针。它返回一个新的托管指针,一旦垃圾收集器确定不再使用该数据,它将运行其终结器。多么可爱的抽象!

每当C库要求用户在不再使用资源时显式取消分配或清理资源时,这些可终结指针都是适用的。这是一种简单的设备,可以使C库在味道上更自然,更实用。

因此,考虑到这一点,我们可以将手动管理的Ptr PCRE 类型隐藏在自动管理的数据结构中,从而产生用于表示用户将看到的正则表达式的数据类型:

  1. -- file: ch17/PCRE-compile.hs
  2. data Regex = Regex !(ForeignPtr PCRE)
  3. !ByteString
  4. deriving (Eq, Ord, Show)

这个新的Regex数据类型由两部分组成。第一个是abstract ForeignPtr,我们将使用它来管理PCREC中分配的基础 数据。第二个组件是strict ByteString,这是我们编译的正则表达式的字符串表示形式。通过在Regex类型内部方便地使用正则表达式的用户级表示形式,可以更轻松地打印友好的错误消息,并Regex 以有意义的方式显示自身。

这个新的Regex数据类型由两部分组成。第一个是abstract ForeignPtr,我们将使用它来管理PCREC中分配的基础 数据。第二个组件是strict ByteString,这是我们编译的正则表达式的字符串表示形式。通过在Regex类型内部方便地使用正则表达式的用户级表示形式,可以更轻松地打印友好的错误消息,并Regex 以有意义的方式显示自身。

高级界面:封送数据

一旦确定了Haskell类型,编写FFI绑定时的挑战就是将Haskell程序员熟悉的常规数据类型转换为指向数组和其他C类型的低级指针。正则表达式编译的理想Haskell接口应该是什么样的?我们有一些设计直觉可以指导我们。

对于初学者来说,编译的操作应该是参照透明的操作:尽管C库将为我们提供指向功能相同的表达式的明显不同的指针,但每次传递相同的regex字符串在功能上都将产生相同的编译模式。如果我们可以隐藏这些内存管理细节,则应该能够将绑定表示为纯函数。在Haskell中将C函数表示为纯操作的能力是实现灵活性的关键一步,并且该接口的指示器将易于使用(因为使用该接口不需要初始化复杂的状态)。

尽管是纯函数,但该函数仍可能失败。如果用户提供的正则表达式输入格式错误,则返回错误字符串。代表具有错误值的可选故障的良好数据类型是 Either。也就是说,要么返回有效的编译正则表达式,要么返回错误字符串。将C函数的结果编码为熟悉的基本Haskell类型,这是使绑定更加惯用的另一有用步骤。

对于用户提供的参数,我们已经决定将编译标志作为列表传递。我们可以选择以有效形式ByteString或正则形式传递输入正则表达式String。因此,对于具有值的参照透明编译成功或具有错误字符串的失败,适当的类型签名应为:

  1. -- file: ch17/PCRE-compile.hs
  2. compile :: ByteString -> [PCREOption] -> Either String Regex

输入是ByteString可从Data.ByteString.Char8模块中获取的, (我们将其导入 qualified以避免名称冲突),包含正则表达式和标志列表(如果没有要传递的标志,则为空列表)。结果是错误字符串或新的编译正则表达式。

编组字节字符串

给定这种类型,我们可以勾勒出compile函数:原始C绑定的高级接口。它的核心将调用 c_pcre_compile。在此之前,它必须将输入编组ByteString为CString。这是通过ByteString库的useAsCString函数完成的,该函数将输入复制ByteString到以空值终止的C数组中(还有一个不安全的零复制变量,假定该变量 ByteString已经为空值终止):

  1. -- file: ch17/ForeignPtr.hs
  2. useAsCString :: ByteString -> (CString -> IO a) -> IO a

此功能以aByteString作为输入。第二个参数是用户定义的函数,将与结果一起运行 CString。我们在这里看到了另一个有用的习惯用法:通过闭包自然限定范围的数据编组功能。我们的 useAsCString函数会将输入数据转换为C字符串,然后我们可以将其作为指针传递给C。然后,我们的负担就是为其提供代码块以调用C。

这种样式的代码通常以悬挂的“ do-block”符号编写。下面的伪代码说明了此结构:

  1. -- file: ch17/DoBlock.hs
  2. useAsCString str $ \cstr -> do
  3. ... operate on the C string
  4. ... return a result

这里的第二个参数是一个匿名函数,即lambda,带有一个用于主体的单子“ do”块。($) 在定义代码块参数时,通常使用简单的应用程序运算符来避免使用括号。这是处理此类代码块参数时要记住的有用用法。

分配本地C数据:Storable类

我们可以很高兴地将ByteString数据封送为C兼容类型,但是该pcre_compile函数还需要一些指针和数组,以在其中放置其他返回值。这些应该只是短暂存在,所以我们不需要复杂的分配策略。可以使用以下alloca功能创建此类短时C数据:

  1. -- file: ch17/ForeignPtr.hs
  2. alloca :: Storable a => (Ptr a -> IO b) -> IO b

该函数将接受指向某种C类型的指针的代码块作为参数,并安排使用正确分配的正确形状的统一数据调用该函数。分配机制以其他语言镜像本地堆栈变量。一旦参数函数退出,释放的已分配内存。通过这种方式,我们得到了低级数据类型的词法范围分配,这些范围保证在范围退出时将被释放。我们可以使用它来分配具有Storable类型类实例的任何数据类型。这样重载分配运算符的含义是,可以根据使用情况从类型信息中推断出分配的数据类型!Haskell将根据我们在该数据上使用的功能知道要分配什么。

CString例如, 要分配指向的指针(CString通过调用函数将其更新为指向某个特定对象的指针),我们将alloca使用伪代码调用,其方式为:

  1. -- file: ch17/DoBlock.hs
  2. alloca $ \stringptr -> do
  3. ... call some Ptr CString function
  4. peek stringptr

这将在本地分配aPtr CString并将代码块应用于该指针,然后该代码块调用C函数以修改指针内容。最后,我们使用Storable类peek函数取消引用指针 ,从而产生一个 CString。

现在,我们可以将所有内容放在一起,以完成高级PCRE编译包装程序。

全部放在一起

我们已经决定了用什么Haskell类型来表示C函数,将用什么结果数据表示以及如何管理其内存。我们为pcre_compile函数的标志选择了一种表示形式,并研究了如何从检查代码的代码中获取C字符串。因此,让我们从Haskell编写用于编译PCRE正则表达式的完整函数:

  1. -- file: ch17/PCRE-compile.hs
  2. compile :: ByteString -> [PCREOption] -> Either String Regex
  3. compile str flags = unsafePerformIO $
  4. useAsCString str $ \pattern -> do
  5. alloca $ \errptr -> do
  6. alloca $ \erroffset -> do
  7. pcre_ptr <- c_pcre_compile pattern (combineOptions flags) errptr erroffset nullPtr
  8. if pcre_ptr == nullPtr
  9. then do
  10. err <- peekCString =<< peek errptr
  11. return (Left err)
  12. else do
  13. reg <- newForeignPtr finalizerFree pcre_ptr -- release with free()
  14. return (Right (Regex reg str))

而已!因为它很密集,所以让我们仔细地浏览细节。突出的第一件事是使用 unsafePerformIO,这是一个臭名昭著的函数,具有非常不寻常的类型,是从不祥之物中引入的System.IO.Unsafe:

  1. -- file: ch17/ForeignPtr.hs
  2. unsafePerformIO :: IO a -> a

此函数的作用很奇怪:它需要一个IO值并将其转换为纯值!在警告了影响危险很长时间之后,这里我们将危险影响的促成因素放在一行。如果不明智地使用此功能,我们将避开Haskell类型系统提供的所有安全保证,将任意副作用插入Haskell程序中的任何位置。这样做的危险是巨大的:我们可以破坏优化,修改内存中的任意位置,删除用户计算机上的文件或从斐波那契序列中发射核导弹。那么为什么这个功能根本存在呢?

它的存在恰恰是为了使Haskell能够绑定到我们知道是参照透明的C代码,但无法向Haskell类型系统证明这种情况。它让我们对编译器说:“我知道我在做什么-此代码确实是纯净的”。对于正则表达式编译,我们知道是这样:给定相同的模式,我们应该每次都获得相同的正则表达式匹配器。但是,要证明对编译器的意义超出了Haskell类型系统,因此我们不得不断言该代码是纯代码。使用unsafePerformIO允许我们做到这一点。

但是,如果我们知道C代码是纯代码,为什么不通过在import声明中给它一个纯类型来仅仅声明它呢?由于我们必须为C函数分配本地内存,因此必须在IO monad中完成,因为这是本地副作用。但是,这些效果无法逃脱外来调用周围的代码,因此在包装时,我们 unsafePerformIO用来重新引入纯度。

参数tounsafePerformIO是我们编译函数的实际主体,它由四个部分组成:将Haskell数据编组为C形式;调用C库;检查返回值;最后,根据结果构造一个Haskell值。

我们用useAsCStringand编组alloca,设置需要传递给C的数据,并使用combineOptions先前开发的将标志列表折叠为一个 CInt。一切就绪后,我们终于可以调用 c_pcre_compile模式,标志和结果指针。我们使用nullPtr字符编码表,这种情况下该表未使用。

从C调用返回的结果是指向抽象PCRE结构的指针 。然后,我们针对进行测试 nullPtr。如果正则表达式有问题,我们必须取消引用错误指针,产生一个CString。然后,我们使用库函数将其解压缩为普通的Haskell列表 peekCString。错误路径的最终结果是的值 Left err,指示调用者失败。

但是,如果调用成功,我们将使用C函数分配一个新的存储管理指针ForeignPtr。特殊值 finalizerFree绑定为该数据的终结器,该终结器使用标准Cfree取消分配数据。然后将其包装为不透明 Regex值。成功的结果将以标记 Right,并返回给用户。现在我们完成了!

我们需要使用hsc2hs处理源文件,然后将函数加载到GHCi中。但是,这样做会导致首次尝试时出错:

  1. $ hsc2hs Regex.hsc
  2. $ ghci Regex.hs
  3. During interactive linking, GHCi couldn't find the following symbol:
  4. pcre_compile
  5. This may be due to you not asking GHCi to load extra object files,
  6. archives or DLLs needed by your current session. Restart GHCi, specifying
  7. the missing library using the -L/path/to/object/dir and -lmissinglibname
  8. flags, or simply by naming the relevant files on the GHCi command line.

有点吓人。但是,这仅仅是因为我们没有将要调用的C库链接到Haskell代码。假设PCRE库已安装在系统上的默认库位置中,则可以通过添加-lpcre到GHCi命令行来让GHCi知道它。现在我们可以在一些正则表达式上试用代码,查看成功和错误的情况:

  1. $ ghci Regex.hs -lpcre
  2. *Regex> :m + Data.ByteString.Char8
  3. *Regex Data.ByteString.Char8> compile (pack "a.*b") []
  4. Right (Regex 0x00000000028882a0 "a.*b")
  5. *Regex Data.ByteString.Char8> compile (pack "a.*b[xy]+(foo?)") []
  6. Right (Regex 0x0000000002888860 "a.*b[xy]+(foo?)")
  7. *Regex Data.ByteString.Char8> compile (pack "*") []
  8. Left "nothing to repeat"

正则表达式打包成字节字符串并编组到C,然后由PCRE库对其进行编译。然后将结果返回给Haskell,在其中我们使用默认Show实例显示结构 。我们的下一步是使用这些已编译的正则表达式对某些字符串进行模式匹配。

匹配字符串

好的正则表达式库的第二部分是匹配函数。给定一个已编译的正则表达式,此函数将对某些输入进行已编译的正则表达式的匹配,指示其是否匹配,如果匹配,则说明字符串的哪些部分匹配。在PCRE中,此函数为pcre_exec,其类型为:

  1. int pcre_exec(const pcre *code,
  2. const pcre_extra *extra,
  3. const char *subject,
  4. int length,
  5. int startoffset,
  6. int options,
  7. int *ovector,
  8. int ovecsize);

最重要的参数是pcre我们从中获得的输入指针结构pcre_compile和主题字符串。其他标志允许我们提供簿记结构和返回值的空间。我们可以将这种类型直接转换为Haskell导入声明:

  1. -- file: ch17/RegexExec.hs
  2. foreign import ccall "pcre.h pcre_exec"
  3. c_pcre_exec :: Ptr PCRE
  4. -> Ptr PCREExtra
  5. -> Ptr Word8
  6. -> CInt
  7. -> CInt
  8. -> PCREExecOption
  9. -> Ptr CInt
  10. -> CInt
  11. -> IO CInt

我们使用与以前相同的方法为该PCREExtra结构创建类型化的指针,并使用anewtype表示在正则表达式执行时传递的标志。这使我们确保用户在正则表达式运行时不会错误地传递编译时标志。

提取有关模式的信息

调用pcre_exec所涉及的主要复杂因素是int指针数组,该指针数组用于保存模式匹配器找到的匹配子字符串的偏移量。这些偏移量保存在偏移量矢量中,该偏移量所需的大小是通过分析输入的正则表达式确定所包含的捕获模式的数量来确定的。PCRE提供了函数,pcre_fullinfo用于确定有关正则表达式的很多信息,包括模式数量。我们需要调用它,现在,我们可以直接记下用于绑定的Haskell类型pcre_fullinfo:

  1. -- file: ch17/RegexExec.hs
  2. foreign import ccall "pcre.h pcre_fullinfo"
  3. c_pcre_fullinfo :: Ptr PCRE
  4. -> Ptr PCREExtra
  5. -> PCREInfo
  6. -> Ptr a
  7. -> IO CInt

此函数最重要的参数是已编译的正则表达式和PCREInfo标志,用于指示我们感兴趣的信息。在这种情况下,我们关心捕获的模式计数。标志以数字常量编码,我们需要专门使用该PCRE_INFO_CAPTURECOUNT值。还有一系列其他常数可以确定函数的结果类型,我们可以#enum像以前一样使用构造将其绑定。最后一个参数是指向存储有关模式信息的位置的指针(其大小取决于传入的flag参数!)。

调用pcre_fullinfo以确定捕获的模式计数非常简单:

  1. -- file: ch17/RegexExec.hs
  2. capturedCount :: Ptr PCRE -> IO Int
  3. capturedCount regex_ptr =
  4. alloca $ \n_ptr -> do
  5. c_pcre_fullinfo regex_ptr nullPtr info_capturecount n_ptr
  6. return . fromIntegral =<< peek (n_ptr :: Ptr CInt)

这将使用原始的PCRE指针,并为CInt 匹配的模式的计数分配空间。然后,我们调用信息函数并查看结果结构,找到一个CInt。最后,我们将其转换为普通的Haskell Int,并将其传递回用户。

模式与子串匹配

现在让我们编写正则表达式匹配函数。用于匹配的Haskell类型类似于用于编译正则表达式的类型:

  1. -- file: ch17/RegexExec.hs
  2. match :: Regex -> ByteString -> [PCREExecOption] -> Maybe [ByteString]

此功能是用户将字符串与已编译正则表达式进行匹配的方式。同样,主要设计要点是它是一个纯函数。匹配是一个纯函数:给定相同的输入正则表达式和主题字符串,它将始终返回相同的匹配子字符串。我们通过类型签名将该信息传达给用户,表明在调用此函数时不会发生副作用。

这些参数是包含输入数据的已编译Regex,strict ByteString,以及在运行时修改正则表达式引擎行为的标志列表。结果要么根本不匹配(由Nothing值指示),要么只是匹配的子字符串列表。我们使用该Maybe类型在类型中明确指出匹配可能失败。通过ByteString对输入数据使用strict s,我们可以在不复制的情况下恒定时间提取匹配的子字符串,从而使接口相当有效。如果输入中的子字符串匹配,则偏移量矢量将在主题字符串中填充成对的整数偏移量。我们需要遍历该结果向量,读取偏移量并构建ByteString切片。

比赛包装器的实现可以分为三个部分。在顶层,我们的函数分解编译后的Regex 结构,产生基础PCRE指针:

  1. -- file: ch17/RegexExec.hs
  2. match :: Regex -> ByteString -> [PCREExecOption] -> Maybe [ByteString]
  3. match (Regex pcre_fp _) subject os = unsafePerformIO $ do
  4. withForeignPtr pcre_fp $ \pcre_ptr -> do
  5. n_capt <- capturedCount pcre_ptr
  6. let ovec_size = (n_capt + 1) * 3
  7. ovec_bytes = ovec_size * sizeOf (undefined :: CInt)

纯粹来说,我们可以使用它unsafePerformIO在内部隐藏任何分配效果。在对PCRE类型进行模式匹配之后,我们需要对ForeignPtr隐藏了C分配的原始PCRE数据的进行分解。我们可以使用withForeignPtr。这会在进行呼叫时保留与PCRE值关联的Haskell数据,从而至少在此呼叫使用它的时间内阻止收集该数据。然后,我们调用信息函数,并使用该值来计算偏移矢量的大小(该公式在PCRE文档中给出)。我们需要的字节数是元素数乘以a的大小CInt。为了可移植地计算C类型的大小,Storable该类提供了一个sizeOf 函数,该函数采用所需类型的任意值(我们可以使用undefined值进行我们的类型分配)。

下一步是分配我们计算出的大小的偏移向量,以将输入ByteString转换为指向Cchar数组的指针 。最后,我们pcre_exec用所有必需的参数调用:

  1. -- file: ch17/RegexExec.hs
  2. allocaBytes ovec_bytes $ \ovec -> do
  3. let (str_fp, off, len) = toForeignPtr subject
  4. withForeignPtr str_fp $ \cstr -> do
  5. r <- c_pcre_exec
  6. pcre_ptr
  7. nullPtr
  8. (cstr `plusPtr` off)
  9. (fromIntegral len)
  10. 0
  11. (combineExecOptions os)
  12. ovec
  13. (fromIntegral ovec_size)

对于偏移量向量,我们用于allocaBytes精确控制分配数组的大小。就像一样alloca,但是它没有使用Storable类来确定所需的大小,而是需要以字节为单位的显式大小来进行分配。将分开 ByteString,产生指向它们所包含的内存的基础指针toForeignPtr,这是通过完成的,它将好的 ByteString类型转换为托管指针。withForeignPtr在结果上使用 会给我们raw Ptr CChar,这正是将输入字符串传递给C所需要的。在Haskell中进行编程通常只是解决类型难题!

然后,我们仅c_pcre_exec使用原始PCRE指针,正确偏移量的输入字符串指针,其长度和结果矢量指针进行调用。返回状态码,最后,我们分析结果:

  1. -- file: ch17/RegexExec.hs
  2. if r < 0
  3. then return Nothing
  4. else let loop n o acc =
  5. if n == r
  6. then return (Just (reverse acc))
  7. else do
  8. i <- peekElemOff ovec o
  9. j <- peekElemOff ovec (o+1)
  10. let s = substring i j subject
  11. loop (n+1) (o+2) (s : acc)
  12. in loop 0 0 []
  13. where
  14. substring :: CInt -> CInt -> ByteString -> ByteString
  15. substring x y _ | x == y = empty
  16. substring a b s = end
  17. where
  18. start = unsafeDrop (fromIntegral a) s
  19. end = unsafeTake (fromIntegral (b-a)) start

如果结果值小于零,则说明存在错误或不匹配,因此我们返回Nothing给用户。否则,我们需要一个循环,从偏移量向量(via peekElemOff)窥视偏移量对。这些偏移量用于查找匹配的子字符串。要构建子字符串,我们使用一个辅助函数,该函数在给定了开始和结束偏移的情况下会丢弃主题字符串的周围部分,仅产生匹配的部分。循环一直运行,直到提取出匹配器告知我们的子字符串数。

子字符串在尾部递归循环中累积,建立每个字符串的反向列表。在返回用户的子字符串之前,我们需要翻转该列表并将其包装在成功的 Just标签中。让我们尝试一下!

真正的好处:编译和匹配正则表达式

如果我们把这个功能,它的周围hsc2hs定义和数据包装,并与hsc2hs过程中,我们可以加载GHCI所产生的Haskell文件,并尝试了我们的代码(我们需要进口 Data.ByteString.Char8,所以我们可以建立ByteString从字符串常量S):

  1. $ hsc2hs Regex.hsc
  2. $ ghci Regex.hs -lpcre
  3. *Regex> :t compile
  4. compile :: ByteString -> [PCREOption] -> Either String Regex
  5. *Regex> :t match
  6. match :: Regex -> ByteString -> Maybe [ByteString]

事情似乎井然有序。现在让我们尝试一些编译和匹配。首先,简单点:

  1. *Regex> :m + Data.ByteString.Char8
  2. *Regex Data.ByteString.Char8> let Right r = compile (pack "the quick brown fox") []
  3. *Regex Data.ByteString.Char8> match r (pack "the quick brown fox") []
  4. Just ["the quick brown fox"]
  5. *Regex Data.ByteString.Char8> match r (pack "The Quick Brown Fox") []
  6. Nothing
  7. *Regex Data.ByteString.Char8> match r (pack "What
  8. do you know about the quick brown fox?") []
  9. Just ["the quick brown fox"]

(我们也可以pack通过使用OverloadedStrings扩展名来避免通话 )。否则我们可以冒险一些:

  1. *Regex Data.ByteString.Char8> let Right r = compile (pack "a*abc?xyz+pqr{3}ab{2,}xy{4,5}pq{0,6}AB{0,}zz") []
  2. *Regex Data.ByteString.Char8> match r (pack "abxyzpqrrrabbxyyyypqAzz") []
  3. Just ["abxyzpqrrrabbxyyyypqAzz"]
  4. *Regex Data.ByteString.Char8> let Right r = compile (pack "^([^!]+)!(.+)=apquxz\\.ixr\\.zzz\\.ac\\.uk$") []
  5. *Regex Data.ByteString.Char8> match r (pack "abc!pqr=apquxz.ixr.zzz.ac.uk") []
  6. Just ["abc!pqr=apquxz.ixr.zzz.ac.uk","abc","pqr"]

太棒了 在Haskell中,Perl正则表达式的全部功能唾手可得。

在本章中,我们研究了如何声明允许Haskell代码调用C函数的绑定,如何在两种语言之间封送不同的数据类型,如何在较低级别分配内存(通过本地分配或通过C的内存管理)。 ,以及如何通过利用Haskell类型系统和垃圾收集器来自动化处理C的大部分艰苦工作。最后,我们研究了FFI预处理程序如何减轻构建新绑定的大部分工作。其结果是一个自然的Haskell API,这实际上是在C.主要实现

FFI的大多数任务属于上述类别。我们无法涵盖的其他高级技术包括:将Haskell链接到C程序,将一种语言注册为另一种语言的回调以及 c2hs预处理工具。可以在网上找到有关这些主题的更多信息。

[ 36 ]一些更高级的绑定工具可提供更高级别的类型检查。例如,c2hs能够解析C头并为您生成绑定定义,并且特别适用于指定了完整API的大型项目。