• 2.8 C++ 类包装
    • 2.8.1 C++ 类到 Go 语言对象
      • 2.8.1.1 准备一个 C++ 类
      • 2.8.1.2 用纯C函数接口封装 C++ 类
      • 2.8.1.3 将纯C接口函数转为Go函数
      • 2.8.1.4 包装为Go对象
    • 2.8.2 Go 语言对象到 C++ 类
      • 2.8.2.1 构造一个Go对象
      • 2.8.2.2 导出C接口
      • 2.8.2.3 封装C++对象
      • 2.8.2.4 封装C++对象改进
    • 2.8.3 彻底解放C++的this指针

    2.8 C++ 类包装

    CGO是C语言和Go语言之间的桥梁,原则上无法直接支持C++的类。CGO不支持C++语法的根本原因是C++至今为止还没有一个二进制接口规范(ABI)。一个C++类的构造函数在编译为目标文件时如何生成链接符号名称、方法在不同平台甚至是C++的不同版本之间都是不一样的。但是C++是兼容C语言,所以我们可以通过增加一组C语言函数接口作为C++类和CGO之间的桥梁,这样就可以间接地实现C++和Go之间的互联。当然,因为CGO只支持C语言中值类型的数据类型,所以我们是无法直接使用C++的引用参数等特性的。

    2.8.1 C++ 类到 Go 语言对象

    实现C++类到Go语言对象的包装需要经过以下几个步骤:首先是用纯C函数接口包装该C++类;其次是通过CGO将纯C函数接口映射到Go函数;最后是做一个Go包装对象,将C++类到方法用Go对象的方法实现。

    2.8.1.1 准备一个 C++ 类

    为了演示简单,我们基于std::string做一个最简单的缓存类MyBuffer。除了构造函数和析构函数之外,只有两个成员函数分别是返回底层的数据指针和缓存的大小。因为是二进制缓存,所以我们可以在里面中放置任意数据。

    1. // my_buffer.h
    2. #include <string>
    3. struct MyBuffer {
    4. std::string* s_;
    5. MyBuffer(int size) {
    6. this->s_ = new std::string(size, char('\0'));
    7. }
    8. ~MyBuffer() {
    9. delete this->s_;
    10. }
    11. int Size() const {
    12. return this->s_->size();
    13. }
    14. char* Data() {
    15. return (char*)this->s_->data();
    16. }
    17. };

    我们在构造函数中指定缓存的大小并分配空间,在使用完之后通过析构函数释放内部分配的内存空间。下面是简单的使用方式:

    1. int main() {
    2. auto pBuf = new MyBuffer(1024);
    3. auto data = pBuf->Data();
    4. auto size = pBuf->Size();
    5. delete pBuf;
    6. }

    为了方便向C语言接口过渡,在此处我们故意没有定义C++的拷贝构造函数。我们必须以new和delete来分配和释放缓存对象,而不能以值风格的方式来使用。

    2.8.1.2 用纯C函数接口封装 C++ 类

    如果要将上面的C++类用C语言函数接口封装,我们可以从使用方式入手。我们可以将new和delete映射为C语言函数,将对象的方法也映射为C语言函数。

    在C语言中我们期望MyBuffer类可以这样使用:

    1. int main() {
    2. MyBuffer* pBuf = NewMyBuffer(1024);
    3. char* data = MyBuffer_Data(pBuf);
    4. auto size = MyBuffer_Size(pBuf);
    5. DeleteMyBuffer(pBuf);
    6. }

    先从C语言接口用户的角度思考需要什么样的接口,然后创建 my_buffer_capi.h 头文件接口规范:

    1. // my_buffer_capi.h
    2. typedef struct MyBuffer_T MyBuffer_T;
    3. MyBuffer_T* NewMyBuffer(int size);
    4. void DeleteMyBuffer(MyBuffer_T* p);
    5. char* MyBuffer_Data(MyBuffer_T* p);
    6. int MyBuffer_Size(MyBuffer_T* p);

    然后就可以基于C++的MyBuffer类定义这些C语言包装函数。我们创建对应的my_buffer_capi.cc文件如下:

    1. // my_buffer_capi.cc
    2. #include "./my_buffer.h"
    3. extern "C" {
    4. #include "./my_buffer_capi.h"
    5. }
    6. struct MyBuffer_T: MyBuffer {
    7. MyBuffer_T(int size): MyBuffer(size) {}
    8. ~MyBuffer_T() {}
    9. };
    10. MyBuffer_T* NewMyBuffer(int size) {
    11. auto p = new MyBuffer_T(size);
    12. return p;
    13. }
    14. void DeleteMyBuffer(MyBuffer_T* p) {
    15. delete p;
    16. }
    17. char* MyBuffer_Data(MyBuffer_T* p) {
    18. return p->Data();
    19. }
    20. int MyBuffer_Size(MyBuffer_T* p) {
    21. return p->Size();
    22. }

    因为头文件my_buffer_capi.h是用于CGO,必须是采用C语言规范的名字修饰规则。在C++源文件包含时需要用extern "C"语句说明。另外MyBuffer_T的实现只是从MyBuffer继承的类,这样可以简化包装代码的实现。同时和CGO通信时必须通过MyBuffer_T指针,我们无法将具体的实现暴露给CGO,因为实现中包含了C++特有的语法,CGO无法识别C++特性。

    将C++类包装为纯C接口之后,下一步的工作就是将C函数转为Go函数。

    2.8.1.3 将纯C接口函数转为Go函数

    将纯C函数包装为对应的Go函数的过程比较简单。需要注意的是,因为我们的包中包含C++11的语法,因此需要通过#cgo CXXFLAGS: -std=c++11打开C++11的选项。

    1. // my_buffer_capi.go
    2. package main
    3. /*
    4. #cgo CXXFLAGS: -std=c++11
    5. #include "my_buffer_capi.h"
    6. */
    7. import "C"
    8. type cgo_MyBuffer_T C.MyBuffer_T
    9. func cgo_NewMyBuffer(size int) *cgo_MyBuffer_T {
    10. p := C.NewMyBuffer(C.int(size))
    11. return (*cgo_MyBuffer_T)(p)
    12. }
    13. func cgo_DeleteMyBuffer(p *cgo_MyBuffer_T) {
    14. C.DeleteMyBuffer((*C.MyBuffer_T)(p))
    15. }
    16. func cgo_MyBuffer_Data(p *cgo_MyBuffer_T) *C.char {
    17. return C.MyBuffer_Data((*C.MyBuffer_T)(p))
    18. }
    19. func cgo_MyBuffer_Size(p *cgo_MyBuffer_T) C.int {
    20. return C.MyBuffer_Size((*C.MyBuffer_T)(p))
    21. }

    为了区分,我们在Go中的每个类型和函数名称前面增加了cgo_前缀,比如cgo_MyBuffer_T是对应C中的MyBuffer_T类型。

    为了处理简单,在包装纯C函数到Go函数时,除了cgo_MyBuffer_T类型外,对输入参数和返回值的基础类型,我们依然是用的C语言的类型。

    2.8.1.4 包装为Go对象

    在将纯C接口包装为Go函数之后,我们就可以很容易地基于包装的Go函数构造出Go对象来。因为cgo_MyBuffer_T是从C语言空间导入的类型,它无法定义自己的方法,因此我们构造了一个新的MyBuffer类型,里面的成员持有cgo_MyBuffer_T指向的C语言缓存对象。

    1. // my_buffer.go
    2. package main
    3. import "unsafe"
    4. type MyBuffer struct {
    5. cptr *cgo_MyBuffer_T
    6. }
    7. func NewMyBuffer(size int) *MyBuffer {
    8. return &MyBuffer{
    9. cptr: cgo_NewMyBuffer(size),
    10. }
    11. }
    12. func (p *MyBuffer) Delete() {
    13. cgo_DeleteMyBuffer(p.cptr)
    14. }
    15. func (p *MyBuffer) Data() []byte {
    16. data := cgo_MyBuffer_Data(p.cptr)
    17. size := cgo_MyBuffer_Size(p.cptr)
    18. return ((*[1 << 31]byte)(unsafe.Pointer(data)))[0:int(size):int(size)]
    19. }

    同时,因为Go语言的切片本身含有长度信息,我们将cgo_MyBuffer_Data和cgo_MyBuffer_Size两个函数合并为MyBuffer.Data方法,它返回一个对应底层C语言缓存空间的切片。

    现在我们就可以很容易在Go语言中使用包装后的缓存对象了(底层是基于C++的std::string实现):

    1. package main
    2. //#include <stdio.h>
    3. import "C"
    4. import "unsafe"
    5. func main() {
    6. buf := NewMyBuffer(1024)
    7. defer buf.Delete()
    8. copy(buf.Data(), []byte("hello\x00"))
    9. C.puts((*C.char)(unsafe.Pointer(&(buf.Data()[0]))))
    10. }

    例子中,我们创建了一个1024字节大小的缓存,然后通过copy函数向缓存填充了一个字符串。为了方便C语言字符串函数处理,我们在填充字符串的默认用’\0’表示字符串结束。最后我们直接获取缓存的底层数据指针,用C语言的puts函数打印缓存的内容。

    2.8.2 Go 语言对象到 C++ 类

    要实现Go语言对象到C++类的包装需要经过以下几个步骤:首先是将Go对象映射为一个id;然后基于id导出对应的C接口函数;最后是基于C接口函数包装为C++对象。

    2.8.2.1 构造一个Go对象

    为了便于演示,我们用Go语言构建了一个Person对象,每个Person可以有名字和年龄信息:

    1. package main
    2. type Person struct {
    3. name string
    4. age int
    5. }
    6. func NewPerson(name string, age int) *Person {
    7. return &Person{
    8. name: name,
    9. age: age,
    10. }
    11. }
    12. func (p *Person) Set(name string, age int) {
    13. p.name = name
    14. p.age = age
    15. }
    16. func (p *Person) Get() (name string, age int) {
    17. return p.name, p.age
    18. }

    Person对象如果想要在C/C++中访问,需要通过cgo导出C接口来访问。

    2.8.2.2 导出C接口

    我们前面仿照C++对象到C接口的过程,也抽象一组C接口描述Person对象。创建一个person_capi.h文件,对应C接口规范文件:

    1. // person_capi.h
    2. #include <stdint.h>
    3. typedef uintptr_t person_handle_t;
    4. person_handle_t person_new(char* name, int age);
    5. void person_delete(person_handle_t p);
    6. void person_set(person_handle_t p, char* name, int age);
    7. char* person_get_name(person_handle_t p, char* buf, int size);
    8. int person_get_age(person_handle_t p);

    然后是在Go语言中实现这一组C函数。

    需要注意的是,通过CGO导出C函数时,输入参数和返回值类型都不支持const修饰,同时也不支持可变参数的函数类型。同时如内存模式一节所述,我们无法在C/C++中直接长期访问Go内存对象。因此我们使用前一节所讲述的技术将Go对象映射为一个整数id。

    下面是person_capi.go文件,对应C接口函数的实现:

    1. // person_capi.go
    2. package main
    3. //#include "./person_capi.h"
    4. import "C"
    5. import "unsafe"
    6. //export person_new
    7. func person_new(name *C.char, age C.int) C.person_handle_t {
    8. id := NewObjectId(NewPerson(C.GoString(name), int(age)))
    9. return C.person_handle_t(id)
    10. }
    11. //export person_delete
    12. func person_delete(h C.person_handle_t) {
    13. ObjectId(h).Free()
    14. }
    15. //export person_set
    16. func person_set(h C.person_handle_t, name *C.char, age C.int) {
    17. p := ObjectId(h).Get().(*Person)
    18. p.Set(C.GoString(name), int(age))
    19. }
    20. //export person_get_name
    21. func person_get_name(h C.person_handle_t, buf *C.char, size C.int) *C.char {
    22. p := ObjectId(h).Get().(*Person)
    23. name, _ := p.Get()
    24. n := int(size) - 1
    25. bufSlice := ((*[1 << 31]byte)(unsafe.Pointer(buf)))[0:n:n]
    26. n = copy(bufSlice, []byte(name))
    27. bufSlice[n] = 0
    28. return buf
    29. }
    30. //export person_get_age
    31. func person_get_age(h C.person_handle_t) C.int {
    32. p := ObjectId(h).Get().(*Person)
    33. _, age := p.Get()
    34. return C.int(age)
    35. }

    在创建Go对象后,我们通过NewObjectId将Go对应映射为id。然后将id强制转义为person_handle_t类型返回。其它的接口函数则是根据person_handle_t所表示的id,让根据id解析出对应的Go对象。

    2.8.2.3 封装C++对象

    有了C接口之后封装C++对象就比较简单了。常见的做法是新建一个Person类,里面包含一个person_handle_t类型的成员对应真实的Go对象,然后在Person类的构造函数中通过C接口创建Go对象,在析构函数中通过C接口释放Go对象。下面是采用这种技术的实现:

    1. extern "C" {
    2. #include "./person_capi.h"
    3. }
    4. struct Person {
    5. person_handle_t goobj_;
    6. Person(const char* name, int age) {
    7. this->goobj_ = person_new((char*)name, age);
    8. }
    9. ~Person() {
    10. person_delete(this->goobj_);
    11. }
    12. void Set(char* name, int age) {
    13. person_set(this->goobj_, name, age);
    14. }
    15. char* GetName(char* buf, int size) {
    16. return person_get_name(this->goobj_ buf, size);
    17. }
    18. int GetAge() {
    19. return person_get_age(this->goobj_);
    20. }
    21. }

    包装后我们就可以像普通C++类那样使用了:

    1. #include "person.h"
    2. #include <stdio.h>
    3. int main() {
    4. auto p = new Person("gopher", 10);
    5. char buf[64];
    6. char* name = p->GetName(buf, sizeof(buf)-1);
    7. int age = p->GetAge();
    8. printf("%s, %d years old.\n", name, age);
    9. delete p;
    10. return 0;
    11. }

    2.8.2.4 封装C++对象改进

    在前面的封装C++对象的实现中,每次通过new创建一个Person实例需要进行两次内存分配:一次是针对C++版本的Person,再一次是针对Go语言版本的Person。其实C++版本的Person内部只有一个person_handle_t类型的id,用于映射Go对象。我们完全可以将person_handle_t直接当中C++对象来使用。

    下面时改进后的包装方式:

    1. extern "C" {
    2. #include "./person_capi.h"
    3. }
    4. struct Person {
    5. static Person* New(const char* name, int age) {
    6. return (Person*)person_new((char*)name, age);
    7. }
    8. void Delete() {
    9. person_delete(person_handle_t(this));
    10. }
    11. void Set(char* name, int age) {
    12. person_set(person_handle_t(this), name, age);
    13. }
    14. char* GetName(char* buf, int size) {
    15. return person_get_name(person_handle_t(this), buf, size);
    16. }
    17. int GetAge() {
    18. return person_get_age(person_handle_t(this));
    19. }
    20. };

    我们在Person类中增加了一个叫New静态成员函数,用于创建新的Person实例。在New函数中通过调用person_new来创建Person实例,返回的是person_handle_t类型的id,我们将其强制转型作为Person*类型指针返回。在其它的成员函数中,我们通过将this指针再反向转型为person_handle_t类型,然后通过C接口调用对应的函数。

    到此,我们就达到了将Go对象导出为C接口,然后基于C接口再包装为C++对象以便于使用的目的。

    2.8.3 彻底解放C++的this指针

    熟悉Go语言的用法会发现Go语言中方法是绑定到类型的。比如我们基于int定义一个新的Int类型,就可以有自己的方法:

    1. type Int int
    2. func (p Int) Twice() int {
    3. return int(p)*2
    4. }
    5. func main() {
    6. var x = Int(42)
    7. fmt.Println(int(x))
    8. fmt.Println(x.Twice())
    9. }

    这样就可以在不改变原有数据底层内存结构的前提下,自由切换int和Int类型来使用变量。

    而在C++中要实现类似的特性,一般会采用以下实现:

    1. class Int {
    2. int v_;
    3. Int(v int) { this.v_ = v; }
    4. int Twice() const{ return this.v_*2; }
    5. };
    6. int main() {
    7. Int v(42);
    8. printf("%d\n", v); // error
    9. printf("%d\n", v.Twice());
    10. }

    新包装后的Int类虽然增加了Twice方法,但是失去了自由转回int类型的权利。这时候不仅连printf都无法输出Int本身的值,而且也失去了int类型运算的所有特性。这就是C++构造函数的邪恶之处:以失去原有的一切特性的代价换取class的施舍。

    造成这个问题的根源是C++中this被固定为class的指针类型了。我们重新回顾下this在Go语言中的本质:

    1. func (this Int) Twice() int
    2. func Int_Twice(this Int) int

    在Go语言中,和this有着相似功能的类型接收者参数其实只是一个普通的函数参数,我们可以自由选择值或指针类型。

    如果以C语言的角度来思考,this也只是一个普通的void*类型的指针,我们可以随意自由地将this转换为其它类型。

    1. struct Int {
    2. int Twice() {
    3. const int* p = (int*)(this);
    4. return (*p) * 2;
    5. }
    6. };
    7. int main() {
    8. int x = 42;
    9. printf("%d\n", x);
    10. printf("%d\n", ((Int*)(&x))->Twice());
    11. return 0;
    12. }

    这样我们就可以通过将int类型指针强制转为Int类型指针,代替通过默认的构造函数后new来构造Int对象。
    在Twice函数的内部,以相反的操作将this指针转回int类型的指针,就可以解析出原有的int类型的值了。
    这时候Int类型只是编译时的一个壳子,并不会在运行时占用额外的空间。

    因此C++的方法其实也可以用于普通非 class 类型,C++到普通成员函数其实也是可以绑定到类型的。
    只有纯虚方法是绑定到对象,那就是接口。