云原生系列Go语言篇-恶龙三剑客:反射、unsafe 和 cgo

Coding Alan 1年前 (2022-11-12) 1425次浏览 0个评论 扫描二维码

本文来自正在规划的Go语言&云原生自我提升系列,欢迎关注后续文章。

未知世界总是让人心生畏惧。古老的地图上对于未到达过的区域总会使用恶龙和狮子进行标记。在前面的文章中,我们强调了Go是一门安全的编程语言,含有类型的变量让我们清楚地知道使用的是哪类数据,还有垃圾回收管理着内存。哪怕是指针也没有C和C++所具备的槽点。

以上这些都没错,对于我们所写的大部分Go代码,Go的运行时都会守护我们。但总有例外。有时Go程序需要探索未知之地。本文中,我们会学习如何处理普通Go代码无法解决的问题。例如,在编译期无法决定数据的类型时,我们可以使用reflect包所提供的反射支持来与数据交互甚至是构造数据。在需要在Go中使用数据类型的内存布局时,可以使用unsafe包。而如果某些功能的库仅由C语言编写时,我们可以使用cgo来调用C代码。

读者可能会想新手为什么要知道这些高级概念呢?有两大原因,一是开发者在搜索解决方案时,有时对CV大法背后的代码并不能完全理解。最好在把它们加到代码库中之前能知道一些可能引起问题的高级技术。二是这些工具也很有意思。因为它们可以实现Go中常规无法实现的功能,把玩这些工具会很刺激。

反射使我们可以在运行时操作类型

人们喜欢使用Go的一个原因是它是静态语言。通常在Go中声明变量、类型和函数都不复杂。在需要使用类型、变量或函数时,直接定义就好了:

我们使用类型来表示编程时需要用到的数据结构。因为类型是Go的核心部分,编译器使用它们来保障代码的正确性。但有时仅依赖编译时的信息会产生限制。我们可能需要在运行时使用编写程序时尚不存在的信息操作变量。可能是尝试将文件或网络请求的数据映射到变量上,或是构建一个可处理多种类型的函数。在这些场景中,我们需要用到反射。反射让我们可以在运行时查看数据类型。还可以通过它在运行时检查、修改及创建变量、函数和结构体。

那么在什么时候用到这一功能呢?Go的标准库里有你要的答案。其用法主要有以下几类:

  • 读取、写入数据库。database/sql包使用反射向数据库发送记录及回读数据。
  • Go的内置模板库text/templatehtml/template使用反射来处理传递给模板的值。
  • fmt包重度使用了反射,因为fmt.Println及相关函数调用时依赖反射获取参数的类型。
  • errors包使用反射实现errors.Iserrors.As
  • sort包使用反射实现各种类型切片排序的函数:sort.Slicesort.SliceStablesort.SliceIsSorted
  • 最后Go标准库对反射的主要用法还有数据转JSON和XML格式及其它encoding包中定义的数据格式的序列化和反序列化。结构体标签(很快会讲到)通过反射进行访问,结构体中的字段也是通过反射进行读取和写入。

这些例子基本都有一个共同点:需要访问和格式化导入或导出Go程序的数据。通常看到使用反射的地方都是在程序与外部世界进行互通时。

在学习可通过反射实现的技术时,记住能力的背后是代价。相同的操作有反射要比没反射慢一些。在仅在值得之处使用反射一节中会讨论到。更重要的是,使用反射的代码既易出错又冗长。reflect包中的很多函数和方法在传递错误类型数据时会panic。请在代码中添加注释说明用反射做什么,这样未来审查代码的人(包括你自己)会更清楚。

Go标准库中对reflect包还有一个用法:测试。在讲切片时,我们提到reflect中的一个函数DeepEqual。它位于reflect包中的原因是需要用到反射。reflect.DeepEqual函数查看两值是否“底层相等”。这比使用==比较要更为彻底,在标准库中使用它来校验测试结果。也可使用它来比较无法使用==比较的数据,如切片和字典。

大部分时候都用不到DeepEqual。Go 1.21发布后,合适slices.Equalmaps.Equal来比较切片和字典会更快速。

Type、Kind和Value

我们已经知道反射是什么以及何时使用,下面就来学习其原理。标准库中的reflect包是Go语言中实现反射的类型和函数的地方。反射的构建有三大核心概念:Type、Kind和Value。

Type和Kind

type就是字面意思。它定义了变量的属性、存储的内容以及如何使用。借助反射,我们可以使用代码查询类型并了解这些属性。

我们可使用reflect包中的TypeOf函数获取到变量的类型:

 

 

 

 

reflect.TypeOf函数返回reflect.Type类型值,表示传入TypeOf函数中变量的类型。reflect.Type类型定义了有关变量类型的信息。我们无法穷举所有方法,下面仅列举部分。

Name方法不出所料返回的是该类型的名称。下面是一个快速示例:

上例中先定义了一个类型为int的变量x。我们将其传递给了reflect.TypeOf获取一个reflect.Type实例。对于像int这样的基础类型,Name()返回类型的名称,此处即字符串int。对于结构体则返回结构体的名称。切片或指针这种类型是没有名称的,此时Name返回空字符串。

reflect.TypeKind方法返回类型为reflect.Kind的值,这是一个有关类型组成的常量:切片、map、指针、结构体、接口、字符串、数组、函数、整型或其它基础类型。kind和type之间的区别不太容易理解。记住一个规则:如果定义了结构体Foo,kind为reflect.Struct,而type为Foo。

kind非常重要。在使用反射时需要注意的是reflect包的所有方法都假定使用者知道自己在干什么。reflect.Typereflect包中定义的一些方法仅适用于特定的kind。例如在reflect.Type中有一个方法NumIn。如果reflect.Type实例表示一个函数,它返回的是函数的入参数量。而在reflect.Type实例不是函数时,NumIn会让整个程序panic。

注意虽然以上代码的返回值不同,但在控制台打印时你看到的仍是ptr或int。这是由于打印时调用了func (k Kind) String() string方法。

以上为一个名称为空的reflect.Type实例,kind为reflect.Ptr或者指针。在reflect.Type表示指针时,Elem返回该指针所指向类型的reflect.Type。本例中Name返回“int”,Kind返回reflect.IntElem方法还可用于切片、map、通道和数组。

reflect.Type上有针对结构体的反射方法。使用NumField方法可获取结构体中字段的数量,使用Field方法可通过索引获取结构体中的字段。它返回reflect.StructField,中所描述的每个字段的结构,有字段的名称、排序、类型和结构体标签。可在Go Playground中快速运行如下示例:

这里创建了一个类型Foo的实例,并使用reflect.TypeOf获取freflect.Type。接着我们使用NumField构建了一个for循环获取f中每个字段的索引。然后使用Field方法获取表示字段的reflect.StructField结构体,然后我们可以使用reflect.StructField中的字段获取有关字段的更多信息。输出结果为:

reflect.Type中有很多方法,基本都是同样的形式,可用于访问描述变量类型的一些信息。可以阅读标准库中reflect.Type文档了解更多的信息。

Value

除了查看变量的类型,还可以使用反射来读取变量值、设置变量值或从头新建值。

我们使用reflect.ValueOf函数来创建代表变量值的reflect.Value实例:

Go语言中的所有变量都拥有类型,所以reflect.Value有一个Type方法返回reflect.Valuereflect.Type。同时reflect.Type上还有一个Kind方法。

就像reflect.Type有查看变量类型信息的方法一样,reflect.Value具备查看变量值信息的方法。这里不会一一列举,但我们一起来看看如何使用reflect.Value获取变量的值。

首先看如何从reflect.Value中读取值。Interface方法以空接口的形式返回变量值。但丢失了类型信息,在将Interface返回的值放入变量时,可以使用类型断言获取到正确的类型:

虽然包含任意类型的reflect.Value实例均可调用Interface,还有一些针对内置基础类型的特殊方法:Bool、Complex、Int、Uint、Float和String。另外在变量是一个字节切片时还可以使用Bytes方法。如果使用的方法与reflect.Value的类型不相符的话,代码会panic。

也可以使用反射来设置变量值,但要经过三步。

首先,将变量的指针传给reflect.ValueOf。这会返回一个表示该指针的reflect.Value

接下来我们需要获取待设置的实际值。可以对reflect.Value使用Elem方法获取传递给reflect.ValueOf的指针所指向的值。如同reflect.Type中的Elem返回的是所包含类型所指向的类型一样,reflect.Value中的Elem返回的是指针所指向的值或是存储在接口中的值:

最后用到的是实现设置值的方法。在读取基础类型时有各自的方法,同样设置基础类型也有各自的方法:SetBool、SetInt、SetFloat、SetString和SetUint。本例中调用ivv.SetInt(20)会修改i的值。此时打印i得到的值是20:

对于所有其它类型,需要使用Set方法,它接收一个类型为reflect.Value的变量。所设置的值无需是指针,因为只要读取值,而并不涉及修改。正如我们使用Interface()读取基础类型一样,我们可以使用Set写入基础类型。

需要传指针给reflect.ValueOf来修改入参的值的原因和Go语言中其它函数是一样的。在指针表示可谈参数一节中,我们使用了指针类型的参数来表示希望修改参数的值。修改值时,对指针解引用,然后设置值。如下两个函数的处理一致:

小贴士:如果不向reflect.ValueOf传递变量的指针,仍可使用反射读取变量值。但如若尝试使用修改变量值的方法,方法调用会理所当然地panic。

生成新值

在学习使用反射的最佳实践之前,还有一个内容要讲解:如何创建值。reflect.New是反射中与new函数对应的函数。它接收reflect.Type然后返回reflect.Value,后者是指定类型的reflect.Value的指针。因其是指针,可以使用Interface方法为变量赋修改后的值。

reflect.New创建的是标量类型指针,我们还可以使用反射完成和make函数同样的操作,使用如下的函数:

这些函数接收一个表示复合类型(而非其所包含类型)的reflect.Type

构造reflect.Type时总是需要从一个值开始。但有一个小技巧可以在没有值时创建表示reflect.Type的变量:

变量stringType包含一个表示字符串的reflect.Type,变量stringSliceType包含一个表示[]string的reflect.Type。第一行需要花点精力进行解码。这里所做的是将nil转换成字符串指针,使用reflect.TypeOf生成该指针类型的reflect.Type,然后对指针的reflect.Type调用Elem获取底层的类型。我们在括号里放*string的原因是Go语言中运算的顺序,不加括号的话,编译器会认为我们将nil转换为字符串,这是非法操作。

对于stringSliceType则更为简单,因为nil是一个有效切片值。我们只需要将nil的类型转换为[]string,将其传递给reflect.Type

有了这些类型,我们就可以学习如何使用reflect.Newreflect.MakeSlice

可以在Go Playground中自行测试这段代码。

使用反射检查接口值是否为nil

我们在接口和nil一节中讨论过,如果一个具体类型的nil变量赋值给接口类型的变量,接口类型的变量就不是nil。这是因为该接口变量有一个关联类型。如果希望检查接口所关联的值是否为nil,需要通过反射使用两个方法:IsValidIsNil

如果reflect.Value存放的不为nil接口IsValid返回true。我们要先进行这一检测,因为在IsValid为false时对reflect.Value调用其它方法会panic。如果reflect.Value的值是nil的话IsNil方法返回true,但仅在reflect.Kind可为nil时才能进行调用。如果对零值不为nil的类型调用该方法,也会panic。

虽然可以监测到接口是否为nil,还是应该在编写代码时保证与nil接口关联的值也可以正确执行。把这段留在别无选择时使用。

使用反射编写数据序列化工具

前面已讲过,标准库用反射实现序列化和反序列化。我们来学习如何自己构建一个数据序列化工具。Go语言提供了csv.NewReadercsv.NewWriter方法用于从将CSV文件读取到字符切片的切片中以及将字符切片的切片写至CSV文件中,但无法将该数据映射到结构体的字段。我们就能完成这个功能。

注:本例为了便于讲解,简化为所支持的类型。完成代表请见Go Playground

我们会开始定义自己的API。和其它序列化工具一样,我们会定义结构体标签来指定结构体中的字段与数据中字段之间的映射:

对外的API有两个函数:

我们先编写Marshal函数本身,然后来看其使用的两个帮助函数:

因为我们要函数化任意类型的结构体,所以参数的类型需要使用interface{}。这里用的不是结构体切片的指针,因为我们只要从切片中进行读取,无需修改。

我们CSV的第一行是含列名的头,因此可以通过结构体中字段标签获取到这些列名。我们使用Type方法来从reflect.Value中获取切片的reflect.Type,然后调用Elem方法来获取切片元素的reflect.Type。之后将其传递给marshalHeader并追加到输出的响应中。

接着,我们使用反射遍历结构体中的每个元素,将每个元素的reflect.Value传递给marshalOne,追加到输出结果中。完成遍历后,返回字符串切片的切片。

我们来看第一个帮助函数marshalHeader的实现:

这个函数遍历reflect.Type的字段,读取每个字段的csv标签追加至字符串切片并返回该切片。

第二个帮助函数是marshalOne

它接收reflect.Value,返回一个string切片。我们创建了字符串切片,然后对结构体中的每个字段,判断reflect.Kind来决定如何将其转换为字符串,并追加到输出中。

至此我们就完成了一个简单的序列化工具。下面来看如何进行反序列化:

因为我们要将数据拷贝到各类结构体,需要使用interface{}类型的参数。此外,因为要修改参数中存储的值,需要传递结构体切片的指针。Unmarshal函数将结构体指针切片转化为reflect.Value,然后获取底层的切片,之后得到底层切片中结构体的类型。

前面已经说过,我们会假定数据的第一行是列头字段名。我们使用这一信息来构造map,因此将csv结构体标签值关联到正确的数据元素。

然后我们遍历剩下的所有string切片,使用结构体的reflect.Type新建reflect.Value,调用unmarshalOne来将当前string切片中的数据复制到结构体中,然后将结构体添加到切片中。遍历完所有数据行后返回。

剩下的就是unmarshalOne的实现:

这个函数遍历新建的reflect.Value中的每个字段,使用当前字段的csv结构体标签查找其名称,使用namePosmap查找数据切片中的元素,将值由字符串转化为相应的类型,并为当前字段设置值。完成所有字段后函数返回。

我们已经编写好序列化和反序列化工具,可以与Go标准库已有的CSV进行集成:

使用反射构建函数自动完成重复任务

通过Go的反射还可以实现函数的创建。我们可以使用这一技术对已有的函数封装通用功能来避免编写重复的代码。下例为所传递函数添加一个计时的工厂函数:

该函数可接收任意函数,因此参数类型为interface{}。然后将表示函数的reflect.Type传递给reflect.MakeFunc,同时传递的还有的闭包,它捕获开始时间、使用反射调用原始函数、捕获结束时间、打印时间差并返回原函数所运算的值。reflect.MakeFunc所返回的值为一个reflect.Value,我们调用Interface方法获取返回值。使用方式如下:

可通过Go Playground查看程序的完整版本。

虽然生成函数很精巧,但请谨慎使用这一功能。确保在使用生成的函数时添加的功能足够清晰。否则程序的数据流会很难理解。此外我们在仅在值得之处使用反射一节会讨论到,反射会拉慢程序,所以使用反射生成或调用函数会影响到性能,除非代码所生成的函数原本就很慢,像网络调用。记住反射最佳用途是程序内外的数据映射。

遵循这一生成函数规则的项目有SQL映射库Proteus。它通过SQL查询和函数字段或变量生成函数创建一个类型安全的数据库API。可以在GopherCon 2017的演讲中更多地了解Proteus,演讲主题为Runtime Generated, Typesafe, and Declarative: Pick Any Three,源代码位于GitHub

可使用反射构建结构体,但别这么干

反射还能实现一个奇怪的功能。reflect.StructOf函数接收reflect.StructField切片返回表示新结构体类型的reflect.Type。这些结构体仅能使用interface{}类型的变量赋值,其字段也仅能使用反射读取和写入。

大多数情况这种特性仅具有学术意义。如果希望看下如何使用reflect.StructOf,可以在Go Playground查看memoizer函数。它使用动态生成的结构体作为缓存函数输出的map的键。

反射无法生成方法

我们了解了反射可实现的功能,但有一件事它做不了。虽然我们可以使用反射新建函数和结构体类型,但无法使用反射为一个类型添加方法。也就是说无法使用反射新建一个实现了接口的类型。

仅在利大于弊时使用反射

虽然Go中反射是和外界互换数据非常重要,但用于其它场景时要很谨慎。反射是开销的。为进行演示,我们使用反射实现一个Filter。这是很多编程语言中的常见函数,接收一个列表,检测列表中的每一项,然后仅返回那些通过检测的子项。Go不允许我们编写一个针对所有类型切片的类型安全函数,但可以使用反射来编写Filter

用法如下:

打印出的结果为:

这个使用了反射的过滤器函数并不难理解,但一定比自定义函数要么。我们来看i7-8700内存32GB的机器使用Go 1.14过滤1000个元素的字符和数字切片的执行性能,并对比自定义函数:

示例代码请见GitHub,读者可自行测试。

使用反射比字符串自定义过滤函数慢大约30倍,而对于整数则慢了近70倍。它使用大量的内存并执行了几千次内存分配,为垃圾回收增加了大量工作。根据不同需求,你也许能接受这种折衷做法,但一定要深思熟虑。

另一个严重的弊端是编译器无法防止你向slicefilter参数传递错误的类型。读者可能不介意几千纳秒的CPU时间,但如果有人向Filter传递了错误类型的函数或切片,会导致程序在生产环境崩溃。这种维护成本可能会过高。为不同类型重复编写相同的函数虽然很重复,但节省几行代码在大部分时候都显得不值得。

unsafe是不安全的

reflect包允许我们操作的是类型和值,unsafe包则允许我们操作内存。unsafe包很小也很奇怪。它定义了三个函数和一个类型,和其它包中的类型和函数行为都不相似。

三个函数分别为Sizeof(接收任意类型的变量返回其使用的字节数)、Offsetof(接收结构体字段返回结构体起始至字段起始之间的字节数)和Alignof(接收字段或变量返回所需要的字节对齐系数)。与其它Go中的非内置函数不同,对这些函数可以传递任意值,返回值为常量,因此可用于常量表达式。

unsafe.Pointer类型是一种特殊类型,存在的目的只有一个:任意类型的指针可与unsafe.Pointer互转。除指针外,unsafe.Pointer还可与特殊的整型互转,称为uintptr。与其它整数类型一样,可对其做数学运算。这样我们可以进入一种类型的实例,提取单独的字节。我们也可以像C和C++中的指针那样执行指数运算。对字节的操作会修改变量的值。

unsafe代码中有两种常见的模式。第一种是实现两种通常不可互转的变量类型之间的互转。第二种是通过将变量转化为unsafe.Pointer、将unsafe.Pointer转化为uintptr然后再复制或操作底层字节来读取或修改变量中的字节。我们来看什么时候应该使用它以及使用时候不该使用。

使用unsafe 转换外部二进制数据

既然Go的核心是内存安全,你可能会纳闷为什么会存在unsafe。就像使用reflect来进行外部和Go之间的文本数据互通,我们使用unsafe来转换二进制数据。使用unsafe有两个主要原因。Costa、Mujahid、Abdalkareem和Shihab在2020年发表了篇名为Breaking Type-Safety in Go: An Empirical Study on the Usage of the unsafe Package的论文,调查了2438个流行的开源项目,发现:

  • 所研究项目中的24%至少在代码中使用了一次unsafe
  • unsafe的使用大部分是为了集成操作系统和C代码(45.7%)。
  • 开发者还经常使用unsafe来编写效率更高的Go代码(23.6%)。

大部分对unsafe的使用都是为了操作系统。Go标准库使用unsafe来向操作系统读取或写入数据。可以在标准库的syscall包或更高阶的sys包中看到示例。我们可以在Matt Layher的著名博客文章中学习到更多有关如何使用unsafe来与操作系统进行交互。

人们使用unsafe的第二大原因是为了性能,尤其是从网络读取数据的场景。如果希望与Go的数据结构进行映射,unsafe.Pointer提供了一种非常快速的方式。我们来使用一个假想的示例进行探索。假设有一个结构如下的线路协议:

  • Value:4字节,表示无符号大端字节序32位整数
  • Label:10字节,值的ASCII名称
  • Active:1字节,表示字段是否为活跃的布尔标记
  • Padding:1字节,因为我们希望内容刚好16字节

注:通过网络传输的数据通过都是采用大端序格式(大序字节排前面),通过称之为网络字节序。而当前大部分CPU都是小端序(或是以小端模式运行的双端序),在对网络读取或写入数据时要格外小心。

可以这样定义该数据结构:

假设我们从网络读取如下字节:

我们将这些字节计入一个长16的数组,然后将该数组转化为前述的结构体。

注:为什么使用的是数组而非切片呢?记住数组和结构体一样是值类弄:字节直接分配。在下一节中我们会学习如何对切片使用unsafe

通过安全的Go代码,可以这样进行映射:

或者可以使用unsafe.Pointer

第一行代码看上去有些迷,我们可以拆开来进行理解。首先,我们接收一个字节数组的指针,将其转换为unsafe.Pointer。然后中将unsafe.Pointer转换为(*Data)(需要将(*Data)放到括号里,这是因为Go的运算顺序)。我们希望返回一个结构体,而不是其指针,因此要解引用该指针。接下来我们检查标记看是否为小端序平台。若是,则反转Value字段中的字节。最后返回该值。

我们是怎么知道平台是否为小端序的呢?所使用的代码如下:

我们在init函数:非必要勿使用一节中讨论过,应当避免使用init函数,除非是初始化包级的不可变值。因为处理器的大小端在程序运行时不会发生改变,这正是可用的场景。

在小端序平台上,表示x的字节存储为[00 FF]。而在大端序平台,x在内存中存储为[FF 00]。我们使用unsafe.Pointer将数字转换为一个字节数组,然后查看第一个字节来决定isLE的值。

同样,如果希望将数据回写至网络,我们可以使用安全的Go代码:

或者使用unsafe:

是否值得这么做呢?在i7-8700的电脑上(小端序),使用unsafe.Pointer的速度大约为2倍:

注:本节中的所有代码请见GitHub

如果程序中有大量这类转换,则值得使用更低级的技术。但对于大部分程序,请使用安全代码。

unsafe字符串和切片

我们也可以使用unsafe与切片和字符串进行交互。在字符串、符文和字节一节中,我们曾提到Go中的字符串使用一组字节的指针和长度进行表示。reflect包中有一个reflect.StringHeader类型具有这种结构,我们使用它来访问、修改底层的字节。

我们可以使用指针运算读取字符串中的字节,用的是sHdr中的Data字段,其类型为uintptr

reflect.StringHeader中的Data字段类型为uintptr,正如我们所讨论的,uintptr指向有效内存的可靠性可能仅一行。如何防止垃圾回收让指针失效呢?我们通过在函数末尾添加一个runtime.KeepAlive(s)调用来进行实现。这告诉Go运行时在调用KeepAlive之前不要回收s

如果想测试这段代码,请访问Go Playground

就像可以用unsafe从字符串获取reflect.StringHeader,也可从切片获取reflect.SliceHeader。它有三个字段:LenCapData,分别表示切片的长度、容量和数据指针:

我对字符串的操作一样,我们将int切片转换为了unsafe.Pointer。然后将unsafe.Pointer转换为reflect.SliceHeader指针。接着可以通过LenCap字段访问切片的长度和容量。然后遍历切片:

因为int的大小可能是32位也可能是64位,我们必须使用unsafe.Sizeof来确定Data字段所指向的内存块占多少字节。然后将i转化为uintptr,乘上int的大小,添加至Data字段,由uintptr转化为unsafe.Pointer,然后将指针转化为int,最后对int进行解引用获取其值。

可以在Go Playground中运行这段代码。

unsafe工具

Go是一种值传递的语言,有一个编译器标记用于发现uintptrunsafe.Pointer的误用。使用-gcflags=-d=checkptr标记运行代码来添加额外的运行时检测。和数据争用检测一样,并不能保证找到所有的unsafe问题并且会拖慢程序。但在测试代码是一种良好实践。

如果希望学习更多有关unsafe的知识,请阅读该包的官方文档

警告⚠️unsafe很强大也很底层。除非知道在干什么并且需要其所提供的性能提升否则请不要使用它。

cgo的作用是集成,而非性能

和反射和unsafe一样,cgo也多用于处理Go程序与外部世界的跨界问题。反射有助于集成外部文本数据,unsafe用于操作系统和网络数据,而cgo用于集成C语言库。

虽然已经50多岁了,C仍是编程语言的通用语言(lingua franca)。所有主流操作系统都是由C或C++编写的,也就是说打包了C编写的很多库。同时也表明几乎所有操作语言都提供了与C库集成的方式。Go的FFI(语言交互接口)调用C cgo

我们已经多次看到Go是一种偏好显示声明的语言。Go开发者有时不屑于其它语言称为“魔法”的自动化行为。但是,使用cgo就像是来到了霍格沃兹。我们来看看这种魔法胶水代码。无法在Go Playground中运行cgo代码,示例代码请见GitHub。我们先使用一个简单的程序调用C代码执行数学运算:

mylib.h头文件以及mylib.c位于main.go的同目录下:

假设电脑中安装了C编译器,那么只需要使用go build编译该程序:

背后发生了什么呢?标准库中并没有名为C的包。C是一个自动生成的包,其标识符多来自注释中嵌入的C代码。本例中,我们声明了C函数addcgo让其在Go程序中可以C.add进行访问。我们还可以调用注释代码块中通过头文件导入的库中的函数或全局变量,可以看到调用了main(通过math.h导入)中的C.sqrt或是C.multiply(通过mylib.h导入)。

除了在注释块中出现(或导入的)的标识名,伪包C还定义了类型C.intC.char用于表示内置的C语言类型和函数,如C.CString将Go字符串转化为C字符串。

我们可以用更多魔法来在C函数中调用Go函数。可通过在函数前添加//export注释来将Go函数暴露给C代码:

如果这么做,就不能在import "C"语句前的注释中直接声明C代码了。只能声明函数,而无法定义函数:

然后可以将C代码放到Go代码同目录的.c文件中并包含魔法头文件"_cgo_export.h"

至此,一切都看起来很简单,但使用cgo还有一个绊脚石:Go是一种带垃圾回收的语言,而C却不是。这样大型Go代码与C集成会很困难。虽然可以向C代码传递指针。这很局限性,因为字符串、切片和函数通过指针实现,因此包含在传递给C函数的结构体中。不止如此:C函数无法存储函数返回后仍存在的Go指针的副本。如果违反规则,程序会编译并运行,但在指针指向的内存被回收时运行时可能会崩溃或出错。

还有其它的限制。例如,无法使用cgo调用C的宏函数(如printf)。C的共同体类型会转换成字节数组。并且无法调用C函数指针(但如果将其赋值给Go变量再传回C函数则没有问题)。

这些规则使得cgo的使用颇费周折。如果读者有Python或Ruby背景的话,可能觉得出于性能原因cgo是有价值的。这些开发者用C编写重性能的部分。NumPy的快速就是因为Python代码所封装的C库。

大部分情况下,Go代码比Python或Ruby快数倍,因此使用更低级代码重写算法的需要大幅减少。你可能觉得将cgo保留到那些需要有性能收益的地方,但不幸的是,很难让使用cgo的代码更快速。因为处理和内存模型的不一致,通过Go调用C函数要比C自身调用慢大约29倍。在CapitalGo 2018上,Filippo Valsorda做了一个演讲,名为Why cgo is slow。可惜该演讲没有录制下来,但可查看其幻灯片。其中解释了为什么cgo很慢并且为什么未来不会明显地提速。

因为cgo并不快,并且在大型程序中也不易使用,使用cgo的唯一原因是有一个C必须要用,而Go语言又没有合适的替代程序。除了自己编写cgo代码,可以看看有没有第三方模块已经提供了这种封装。例如,如果希望在Go应用中嵌入SQLite,请见GitHub。而对于ImageMagick,查看这个代码仓库

如果发现需要使用内部的C库或是找不到封装的第三方库,可以参见Go官方文档查看编写集成代码的更多详情。对于有关使用cgo时会遇到的性能问题以及设计权衡,可以阅读Tobias Griege博客中的文章The Cost and Complexity of Cgo

小结

本文中,我们讲解了反射、unsafecgo。这些特性可能是Go中最令人激动的部分,因为它们允许打破无聊Go作为类型安全、内存安全语言所设定的条条框框。更重要的是,我们学习到了为什么要打破这些规则以及为何在大部分时间避免这么做。

喜欢 (1)
[]
分享 (0)
发表我的评论
取消评论

表情 贴图 加粗 删除线 居中 斜体 签到

Hi,您需要填写昵称和邮箱!

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址