楔子
Python 可以和 C 无缝结合,通过 C 来为 Python 编写扩展可以极大地提升 Python 的效率,但是使用 C 来编程显然不是很方便,于是本人想到了 Go。对比 C 和 Go 会发现两者非常相似,没错,Go 语言具有强烈的 C 语言背景,其设计者以及语言的设计目标都和 C 有着千丝万缕的联系。因为 Go 语言的诞生就是因为 Google 中的一些开发者觉得 C++ 太复杂了,所以才决定开发一门简单易用的语言,而 Google 的工程师大部分都有 C 的背景,因此在设计 Go 语言的时候保持了 C 语言的风格。
而在 Go 和 C 的交互方面,Go 语言也是提供了非常大的支持(CGO),可以直接通过注释的方式将 C 源代码嵌入在 Go 文件中,这是其它语言所无法比拟的。最初 CGO 是为了能复用 C 资源这一目的而出现的,而现在它已经变成 Go 和 C 之间进行双向通讯的桥梁,也就是 Go 不仅能调用 C 的函数,还能将自己的函数导出给 C 调用。也正因为如此,Python 和 Go 之间才有了交互的可能。因为 Python 和 Go 本身其实是无法交互的,但是它们都可以和 C 勾搭上,所以需要通过 C 充当媒介,来为 Python 和 Go 牵线搭桥。
我们知道 Python 和 C 之间是双向的,也就是可以互相调用,而 Go 和 C 之间也是双向的,那么 Python 和 Go 之间自然仍是双向的。我们可以在 Python 为主导的项目中引入 Go,也可以在 Go 为主导的项目中引入 Python,而对于我本人来说,Python 是我的主语言、或者说老本行,因此这里我只介绍如何在 Python 为主导的项目中引入 Go。
而在 Python 为主导的项目中引入 Go 有以下几种方式:
将 Go 源文件编译成动态库,然后直接通过 Python 的 ctypes 模块调用
将 Go 源文件编译成动态库或者静态库,再结合 Cython 生成对应的 Python 扩展模块,然后直接 import 即可
将 Go 源文件直接编译成 Python 扩展模块,当然这要求在使用 CGO 的时候需要遵循 Python 提供的 C API
对于第一种方式,使用哪种操作系统无关紧要,操作都是一样的。但是对于第二种和第三种,我只在 Linux 上成功过,当然 Windows 肯定也是可以的,只不过操作方式会复杂一些(个人不是很熟悉)。因此这里我统一使用 Linux 进行演示,下面介绍一下我的相关环境:
Python 版本:3.6.8,系统自带的 Python,当然 3.7、3.8、3.9 同样是没有问题的(个人最喜欢 3.8)
Go 版本:1.16.4,一个比较新的版本了,至于其它版本也同样可以
gcc 版本:4.8.5,系统自带(Windows 系统的话,需要去下载 MingGW)
下面我们来介绍一下上面这几种方式。
Go 源文件编译成动态库
首先如果 Go 想要编译成动态库给 Python 调用,那么必须启用 CGO 特性,并将想要被 Python 调用的函数导出。而启用 CGO 则需要保证环境变量 CGO_ENABLE 的值设置为 1,在本地构建的时候默认是开启的,但是交叉编译(比如在 Windows 上编译 Linux 动态库)的时候,则是禁止的。
下面来看看一个最简单的 CGO 程序是什么样子的。
// 文件名:file.go
package main
import "C"
import "fmt"
func main() {
fmt.Println("你好,古明地觉,我的公主大人")
}
相较于普通的 Go 只是多了一句 import "C",除此之外没有任何和 CGO 相关的代码,也没有调用 CGO 的相关函数。但是由于这个 import,会使得 go build 命令在编译和链接阶段启动 gcc 编译器,所以这已经是一个完整的 CGO 程序了。
[root@satori go_py]# go run file.go
你好,古明地觉,我的公主大人
直接运行,打印输出。当然我们也可以基于 C 标准库函数来输出字符串:
// 文件名:file.go
package main
//#include <stdio.h>
import "C"
func main() {
// C.CString 表示将 Go 的字符串转成 C 的字符串
C.puts(C.CString("觉大人,你能猜到此刻我在想什么吗"))
}
可能有人好奇 import "C" 上面那段代码是做什么的,答案是导入 C 中的标准库。我们说 Go 里面是可以直接编写 C 代码的,而 C 代码要通过注释的形式写在 import "C" 这行语句上方(中间不可以有空格,这是规定)。而一旦导入,就可以通过 C 这个名字空间进行调用,比如这里的 C.puts、C.CString 等等。
[root@satori go_py]# go run file.go
觉大人,你能猜到此刻我在想什么吗
至于这里的 import "C",它不是导入一个名为 C 的包,我们可以将其理解为一个名字空间,C 语言的所有类型、函数等等都可以通过这个名字空间去调用。
最后注意里面的 C.CString,我们说这是将 Go 的字符串转成 C 的字符串,但是当我们不用了的时候它依旧会停留在内存里,所以我们要将其释放掉,具体做法后面会说。但是对于当前这个小程序来说,这样是没有问题的,因为程序退出后操作系统会回收所有的资源。
我们也可以自己定义一个函数:
// 文件名:file.go
package main
/*
#include <stdio.h>
void SayWhat(const char *s) {
puts(s);
}
*/
import "C"
// 上面也可以写多行注释
func main() {
// 即便是我们自己定义的函数也是需要通过 C 来调用, 不然的话 go 编译器怎么知道这个函数是 C 的函数还是 go 的函数呢
C.SayWhat(C.CString("少女觉"))
}
同样是可以执行成功的。
[root@satori go_py]# go run file.go
少女觉
除此之外我们还可以将 C 的代码放到单独的文件中,比如:
// 文件名:1.c
#include <stdio.h>
void SayWhat(const char* s) {
puts(s);
}
然后 Go 源文件如下:
// 文件名:file.go
package main
/*
#include "1.c"
*/
import "C"
func main() {
C.SayWhat(C.CString("古明地恋")) // 古明地恋
}
直接执行即可打印出结果,当然我们会更愿意把 C 函数的声明写在头文件当中,具体实现写在C源文件中。
// 1.h
void SayWhat(const char* s);
// 1.c
#include <stdio.h>
void SayWhat(const char* s) {
puts(s);
}
然后在 Go 只需要导入头文件即可使用,比如:
// 文件名:file.go
package main
/*
#include "1.h"
*/
import "C"
func main() {
C.SayWhat(C.CString("恋,对不起,我爱的是你姐姐"))
}
然后重点来了,这个时候如果执行 go run file.go 是会报错的:
[root@satori go_py]# go run file.go
# command-line-arguments
/tmp/go-build24597302/b001/_x002.o:在函数‘_cgo_f2c21e79afe5_Cfunc_SayWhat’中:
/tmp/go-build/cgo-gcc-prolog:49:对‘SayWhat’未定义的引用
collect2: 错误:ld 返回 1
虽然文件中出现了 #include "1.h",但是和 1.h 相关的源文件 1.c 则没有任何体现,除非你在go的注释里面再加上 #include "1.c",但这样头文件就没有意义了。因此在编译的时候,我们不能对这个具体的 file.go 源文件进行编译;也就是说不要执行 go build file.go,而是要在这个 Go 文件所在的目录直接执行 go build,会对整个包进行编译,此时就可以找到当前目录中对应的 C 源文件了。
[root@satori go_py]# go build -o a.out
[root@satori go_py]# ./a.out
恋,对不起,我爱的是你姐姐
但是需要注意的是:我当前目录为 /root/go_py,里面的 Go 文件只有一个 file.go,但如果内部有多个 Go文件的话,那么对整个包进行编译的时候,要确保只能有一个文件有 main 函数。
另外对于 go1.16 而言,需要先通过 go mod init 来初始化项目,否则编译包的时候会失败。
Go 导出函数给 Python 调用
上面算是简单介绍了一下 CGO 以及 Go 如何调用 C 函数,但是 Go 调用 C 函数并不是我们的重点,我们的重点是 Go 导出函数给 Python 使用。
// 文件名:file.go
package main
import "C"
import "fmt"
//export SayWhat
func SayWhat(s *C.char) {
// C.GoString 是将 C 的字符串转成 Go 的字符串
fmt.Println(C.GoString(s))
}
func main() {
//这个main函数我们不用, 但是必须要写
}
我们看到函数上面有一行注释://export SayWhat,这一行注释必须要有,即 //export 函数名。并且该注释要和函数紧挨着,之间不能有空行,而它的作用就是将 SayWhat 函数导出,然后 Python 才可以调用,如果不导出的话,Python 会调用不到的。而且导出的时候是 C 函数的形式导出的,因为 Python 和 Go 交互需要 C 作为媒介,因此导出函数的参数和返回值都必须是 C 的类型。
导出函数的名称不要求首字母大写,小写的话依旧可以导出。
最后是 main 函数,这个 main 函数也是必须要有的,尽管里面可以什么都不写,但是必须要有,否则编译不通过。然后我们来将这个文件编译成动态库:
go build -buildmode=c-shared -o 动态库 [go源文件 go源文件 go源文件 ...]
以当前的 file.go 为例:gcc build -buildmode=c-shared -o libgo.so file.go,如果是对整个包编译,那么不指定 go源文件即可。
[root@satori go_py]# go build -buildmode=c-shared -o libgo.so file.go
这里我们将 file.go 编译成动态库 libgo.so,然后 Python 来调用一下试试。
在 Linux 上,动态库的后缀名为 .so;在 Windows 上,动态库的后缀名为 .dll。而 Python 的扩展模块在 Linux 上的后缀名也为 .so,在 Windows 上的的后缀名则是 .pyd(pyd 也可以看做是 dll)。因此我们发现所谓 Python 扩展模块实际上就是对应系统上的一个动态库,如果是遵循标准 Python/C API 的 C 源文件生成的动态库,Python 解释器是可以直接识别的,我们可以通过 import 导入;但如果不是,比如我们上面刚生成的 libgo.so,或者 Linux 自带的大量动态库,那么我们就需要通过 ctypes 的方式加载了。
from ctypes import *
libgo = CDLL("./libgo.so")
libgo.SayWhat(c_char_p("古明地觉".encode("utf-8")))
libgo.SayWhat(c_char_p("芙兰朵露".encode("utf-8")))
libgo.SayWhat(c_char_p("雾雨魔理沙".encode("utf-8")))
"""
古明地觉
芙兰朵露
雾雨魔理沙
"""
我们看到成功打印了,那么打印是哪里来的呢?显然是 Go 里面的 fmt.Println。
以上就实现了 Go 导出 Python 函数给 Python 调用,但是很明显这还不够,我们还需要能够传递参数、以及获取返回值。而想要实现这一点,我们必须要了解一下不同语言之间类型的对应关系。
数值类型
在 Go 语言中访问 C 语言的符号时,一般是通过虚拟的 "C" 包访问,比如 C.int 对应 C 语言的 int 类型。但有些 C 语言的类型是由多个关键字组成,而通过虚拟的 "C" 包访问 C 语言类型时名称部分不能有空格字符,比如 unsigned int 不能直接通过 C.unsigned int 访问,这是不合法的。因此 CGO 为 C 语言的基础数值类型都提供了相应转换规则,比如 C.uint 对应 C 语言的 unsigned int。
Go 语言中数值类型和 C 语言数据类型基本上是相似的,以下是它们的对应关系表。
数值类型虽然有很多,但是整型我们直接使用 long、浮点型使用 double 即可,另外我们在 Go 中定义的函数名不可以和 C 中的关键字冲突。
下面我们举个栗子演示一下:
// 文件名:file.go
package main
import "C"
//export Int
func Int(val C.long) C.long {
// C 的整型可以直接和 Go 的整型相加
// 但前提是个常量,如果是变量,那么需要使用 C.long 转化一下
var a = 1
// Go 对类型的要求很严格,这里需要转化,但如果是 val + 1 是可以的,因为 1 是个常量
return val + C.long(a)
// 这里函数不能起名为 int,因为 int 是 C 中的关键字
}
//export Double
func Double(val C.double) C.double {
// 对于浮点型也是需要转化,但如果是常量,也可以直接相加
return val + 2.2
}
//export boolean
func boolean(val C._Bool) C._Bool {
// 接收一个 bool 类型,true 返回 false,false 返回 true
var flag = bool(val)
return C._Bool(!flag)
}
//export Char
func Char(val C.char) C.char {
// 接收一个字符,进行大小写转化
return val ^ 0x20
}
// main 函数必须要有
func main() {}
然后重新编译生成动态库,交给 Python 调用。
from ctypes import *
libgo = CDLL("./libgo.so")
"""
注意: Python 在获取返回值的时候,默认都是按照整型解析的,如果 Go 的导出函数返回的不是整型,那么再按照整型解析的话必然会出问题
因此我们需要在调用函数之前指定返回值的类型,我们这里调用类 CDLL 返回的就是动态库, 假设里面有一个 xxx 函数, 返回了一个 cgo 中的 C.double
那么我们就需要在调用 xxx 函数之前, 通过 go_ext.xxx.restype = c_double 提前指定返回值的类型, 这样才能获取正常的结果
"""
# 因为默认是按照整型解析的,所以对于返回整型的函数我们无需指定返回值类型,当然指定的话也是好的
print(libgo.Int(c_long(123))) # 124
# Float 函数,接收一个浮点数,然后加上 2.2 返回
libgo.Double.restype = c_double
print(libgo.Double(c_double(2.5))) # 4.7
# boolean: 接收一个布尔值, 返回相反的布尔值
libgo.boolean.restype = c_bool
print(libgo.boolean(c_bool(True))) # False
print(libgo.boolean(c_bool(False))) # True
# Char: 接收一个字符,然后进行大小写转换
libgo.Char.restype = c_char
print(libgo.Char(c_char(97))) # b\'A\'
print(libgo.Char(c_char(b\'v\'))) # b\'V\'
怎么样,是不是很简单呢?
我们在生成 libgo.so 的同时,还会自动帮我们生成一个 libgo.h,在里面会为 Go 语言的字符串、切片、字典、接口和管道等特有的数据类型生成对应的 C 语言类型:
不过需要注意的是,其中只有字符串和切片在 CGO 中有一定的使用价值,因为二者可以直接被 C 和 Python 调用。但是 CGO 并未针对其它的类型提供相关的辅助函数,且 Go 语言特有的内存模型导致我们无法保持这些由 Go 语言管理的内存指针,所以它们在编写动态库给 Python 调用这一场景中并无使用价值,比如 channel,这东西在 Python 里面根本没法用,还有 Map 也是同样道理。
字符串
字符串可以说是用的最为频繁了,而且使用字符串还需要考虑内存泄漏的问题,至于为什么会有内存泄漏以及如何解决它后面会说,目前先来看看如何操作字符串。
// 文件名:file.go
package main
import "C"
//export unicode
func unicode(val *C.char) *C.char {
// 将 C 的字符串转成 Go 的字符串, 可以使用 C.GoString
var s = C.GoString(val)
s += "古明地觉"
//然后转成 C 的字符串返回, 字符串无论是从 Go 转 C, 还是 C 转 Go, 都是拷贝一份
return C.CString(s)
}
func main() {}
还是调用 go build -buildmode=c-shared -o libgo.so file.go 将其编译成动态库,然后 Python 进行调用。
from ctypes import *
go_ext = CDLL(r"./libgo.so")
# unicode: 接收一个 c_char_p,返回一个 c_char_p,注意 c_char_p 里面的字符串要转成字节
go_ext.unicode.restype = c_char_p
# 调用函数返回的也是一个字节,我们需要再使用 utf-8 转回来
print(go_ext.unicode(c_char_p("我永远喜欢
请发表评论