Go 語言 C++ 類包裝

2023-03-22 15:00 更新

原文鏈接:https://chai2010.cn/advanced-go-programming-book/ch2-cgo/ch2-08-class.html


2.8 C++ 類包裝

CGO 是 C 語言和 Go 語言之間的橋梁,原則上無法直接支持 C++ 的類。CGO 不支持 C++ 語法的根本原因是 C++ 至今為止還沒有一個二進(jìn)制接口規(guī)范 (ABI)。一個 C++ 類的構(gòu)造函數(shù)在編譯為目標(biāo)文件時如何生成鏈接符號名稱、方法在不同平臺甚至是 C++ 的不同版本之間都是不一樣的。但是 C++ 是兼容 C 語言,所以我們可以通過增加一組 C 語言函數(shù)接口作為 C++ 類和 CGO 之間的橋梁,這樣就可以間接地實現(xiàn) C++ 和 Go 之間的互聯(lián)。當(dāng)然,因為 CGO 只支持 C 語言中值類型的數(shù)據(jù)類型,所以我們是無法直接使用 C++ 的引用參數(shù)等特性的。

2.8.1 C++ 類到 Go 語言對象

實現(xiàn) C++ 類到 Go 語言對象的包裝需要經(jīng)過以下幾個步驟:首先是用純 C 函數(shù)接口包裝該 C++ 類;其次是通過 CGO 將純 C 函數(shù)接口映射到 Go 函數(shù);最后是做一個 Go 包裝對象,將 C++ 類到方法用 Go 對象的方法實現(xiàn)。

2.8.1.1 準(zhǔn)備一個 C++ 類

為了演示簡單,我們基于 std::string 做一個最簡單的緩存類 MyBuffer。除了構(gòu)造函數(shù)和析構(gòu)函數(shù)之外,只有兩個成員函數(shù)分別是返回底層的數(shù)據(jù)指針和緩存的大小。因為是二進(jìn)制緩存,所以我們可以在里面中放置任意數(shù)據(jù)。

// my_buffer.h
#include <string>

struct MyBuffer {
    std::string* s_;

    MyBuffer(int size) {
        this->s_ = new std::string(size, char('\0'));
    }
    ~MyBuffer() {
        delete this->s_;
    }

    int Size() const {
        return this->s_->size();
    }
    char* Data() {
        return (char*)this->s_->data();
    }
};

我們在構(gòu)造函數(shù)中指定緩存的大小并分配空間,在使用完之后通過析構(gòu)函數(shù)釋放內(nèi)部分配的內(nèi)存空間。下面是簡單的使用方式:

int main() {
    auto pBuf = new MyBuffer(1024);

    auto data = pBuf->Data();
    auto size = pBuf->Size();

    delete pBuf;
}

為了方便向 C 語言接口過渡,在此處我們故意沒有定義 C++ 的拷貝構(gòu)造函數(shù)。我們必須以 new 和 delete 來分配和釋放緩存對象,而不能以值風(fēng)格的方式來使用。

2.8.1.2 用純 C 函數(shù)接口封裝 C++ 類

如果要將上面的 C++ 類用 C 語言函數(shù)接口封裝,我們可以從使用方式入手。我們可以將 new 和 delete 映射為 C 語言函數(shù),將對象的方法也映射為 C 語言函數(shù)。

在 C 語言中我們期望 MyBuffer 類可以這樣使用:

int main() {
    MyBuffer* pBuf = NewMyBuffer(1024);

    char* data = MyBuffer_Data(pBuf);
    auto size = MyBuffer_Size(pBuf);

    DeleteMyBuffer(pBuf);
}

先從 C 語言接口用戶的角度思考需要什么樣的接口,然后創(chuàng)建 my_buffer_capi.h 頭文件接口規(guī)范:

// my_buffer_capi.h
typedef struct MyBuffer_T MyBuffer_T;

MyBuffer_T* NewMyBuffer(int size);
void DeleteMyBuffer(MyBuffer_T* p);

char* MyBuffer_Data(MyBuffer_T* p);
int MyBuffer_Size(MyBuffer_T* p);

然后就可以基于 C++ 的 MyBuffer 類定義這些 C 語言包裝函數(shù)。我們創(chuàng)建對應(yīng)的 my_buffer_capi.cc 文件如下:

// my_buffer_capi.cc

#include "./my_buffer.h"

extern "C" {
    #include "./my_buffer_capi.h"
}

struct MyBuffer_T: MyBuffer {
    MyBuffer_T(int size): MyBuffer(size) {}
    ~MyBuffer_T() {}
};

MyBuffer_T* NewMyBuffer(int size) {
    auto p = new MyBuffer_T(size);
    return p;
}
void DeleteMyBuffer(MyBuffer_T* p) {
    delete p;
}

char* MyBuffer_Data(MyBuffer_T* p) {
    return p->Data();
}
int MyBuffer_Size(MyBuffer_T* p) {
    return p->Size();
}

因為頭文件 my_buffer_capi.h 是用于 CGO,必須是采用 C 語言規(guī)范的名字修飾規(guī)則。在 C++ 源文件包含時需要用 extern "C" 語句說明。另外 MyBuffer_T 的實現(xiàn)只是從 MyBuffer 繼承的類,這樣可以簡化包裝代碼的實現(xiàn)。同時和 CGO 通信時必須通過 MyBuffer_T 指針,我們無法將具體的實現(xiàn)暴露給 CGO,因為實現(xiàn)中包含了 C++ 特有的語法,CGO 無法識別 C++ 特性。

將 C++ 類包裝為純 C 接口之后,下一步的工作就是將 C 函數(shù)轉(zhuǎn)為 Go 函數(shù)。

2.8.1.3 將純 C 接口函數(shù)轉(zhuǎn)為 Go 函數(shù)

將純 C 函數(shù)包裝為對應(yīng)的 Go 函數(shù)的過程比較簡單。需要注意的是,因為我們的包中包含 C++11 的語法,因此需要通過 #cgo CXXFLAGS: -std=c++11 打開 C++11 的選項。

// my_buffer_capi.go

package main

/*
#cgo CXXFLAGS: -std=c++11

#include "my_buffer_capi.h"
*/
import "C"

type cgo_MyBuffer_T C.MyBuffer_T

func cgo_NewMyBuffer(size int) *cgo_MyBuffer_T {
    p := C.NewMyBuffer(C.int(size))
    return (*cgo_MyBuffer_T)(p)
}

func cgo_DeleteMyBuffer(p *cgo_MyBuffer_T) {
    C.DeleteMyBuffer((*C.MyBuffer_T)(p))
}

func cgo_MyBuffer_Data(p *cgo_MyBuffer_T) *C.char {
    return C.MyBuffer_Data((*C.MyBuffer_T)(p))
}

func cgo_MyBuffer_Size(p *cgo_MyBuffer_T) C.int {
    return C.MyBuffer_Size((*C.MyBuffer_T)(p))
}

為了區(qū)分,我們在 Go 中的每個類型和函數(shù)名稱前面增加了 cgo_ 前綴,比如 cgo_MyBuffer_T 是對應(yīng) C 中的 MyBuffer_T 類型。

為了處理簡單,在包裝純 C 函數(shù)到 Go 函數(shù)時,除了 cgo_MyBuffer_T 類型外,對輸入?yún)?shù)和返回值的基礎(chǔ)類型,我們依然是用的 C 語言的類型。

2.8.1.4 包裝為 Go 對象

在將純 C 接口包裝為 Go 函數(shù)之后,我們就可以很容易地基于包裝的 Go 函數(shù)構(gòu)造出 Go 對象來。因為 cgo_MyBuffer_T 是從 C 語言空間導(dǎo)入的類型,它無法定義自己的方法,因此我們構(gòu)造了一個新的 MyBuffer 類型,里面的成員持有 cgo_MyBuffer_T 指向的 C 語言緩存對象。

// my_buffer.go

package main

import "unsafe"

type MyBuffer struct {
    cptr *cgo_MyBuffer_T
}

func NewMyBuffer(size int) *MyBuffer {
    return &MyBuffer{
        cptr: cgo_NewMyBuffer(size),
    }
}

func (p *MyBuffer) Delete() {
    cgo_DeleteMyBuffer(p.cptr)
}

func (p *MyBuffer) Data() []byte {
    data := cgo_MyBuffer_Data(p.cptr)
    size := cgo_MyBuffer_Size(p.cptr)
    return ((*[1 << 31]byte)(unsafe.Pointer(data)))[0:int(size):int(size)]
}

同時,因為 Go 語言的切片本身含有長度信息,我們將 cgo_MyBuffer_Data 和 cgo_MyBuffer_Size 兩個函數(shù)合并為 MyBuffer.Data 方法,它返回一個對應(yīng)底層 C 語言緩存空間的切片。

現(xiàn)在我們就可以很容易在 Go 語言中使用包裝后的緩存對象了(底層是基于 C++ 的 std::string 實現(xiàn)):

package main

//#include <stdio.h>
import "C"
import "unsafe"

func main() {
    buf := NewMyBuffer(1024)
    defer buf.Delete()

    copy(buf.Data(), []byte("hello\x00"))
    C.puts((*C.char)(unsafe.Pointer(&(buf.Data()[0]))))
}

例子中,我們創(chuàng)建了一個 1024 字節(jié)大小的緩存,然后通過 copy 函數(shù)向緩存填充了一個字符串。為了方便 C 語言字符串函數(shù)處理,我們在填充字符串的默認(rèn)用'\0'表示字符串結(jié)束。最后我們直接獲取緩存的底層數(shù)據(jù)指針,用 C 語言的 puts 函數(shù)打印緩存的內(nèi)容。

2.8.2 Go 語言對象到 C++ 類

要實現(xiàn) Go 語言對象到 C++ 類的包裝需要經(jīng)過以下幾個步驟:首先是將 Go 對象映射為一個 id;然后基于 id 導(dǎo)出對應(yīng)的 C 接口函數(shù);最后是基于 C 接口函數(shù)包裝為 C++ 對象。

2.8.2.1 構(gòu)造一個 Go 對象

為了便于演示,我們用 Go 語言構(gòu)建了一個 Person 對象,每個 Person 可以有名字和年齡信息:

package main

type Person struct {
    name string
    age  int
}

func NewPerson(name string, age int) *Person {
    return &Person{
        name: name,
        age:  age,
    }
}

func (p *Person) Set(name string, age int) {
    p.name = name
    p.age = age
}

func (p *Person) Get() (name string, age int) {
    return p.name, p.age
}

Person 對象如果想要在 C/C++ 中訪問,需要通過 cgo 導(dǎo)出 C 接口來訪問。

2.8.2.2 導(dǎo)出 C 接口

我們前面仿照 C++ 對象到 C 接口的過程,也抽象一組 C 接口描述 Person 對象。創(chuàng)建一個 person_capi.h 文件,對應(yīng) C 接口規(guī)范文件:

// person_capi.h
#include <stdint.h>

typedef uintptr_t person_handle_t;

person_handle_t person_new(char* name, int age);
void person_delete(person_handle_t p);

void person_set(person_handle_t p, char* name, int age);
char* person_get_name(person_handle_t p, char* buf, int size);
int person_get_age(person_handle_t p);

然后是在 Go 語言中實現(xiàn)這一組 C 函數(shù)。

需要注意的是,通過 CGO 導(dǎo)出 C 函數(shù)時,輸入?yún)?shù)和返回值類型都不支持 const 修飾,同時也不支持可變參數(shù)的函數(shù)類型。同時如內(nèi)存模式一節(jié)所述,我們無法在 C/C++ 中直接長期訪問 Go 內(nèi)存對象。因此我們使用前一節(jié)所講述的技術(shù)將 Go 對象映射為一個整數(shù) id。

下面是 person_capi.go 文件,對應(yīng) C 接口函數(shù)的實現(xiàn):

// person_capi.go
package main

//#include "./person_capi.h"
import "C"
import "unsafe"

//export person_new
func person_new(name *C.char, age C.int) C.person_handle_t {
    id := NewObjectId(NewPerson(C.GoString(name), int(age)))
    return C.person_handle_t(id)
}

//export person_delete
func person_delete(h C.person_handle_t) {
    ObjectId(h).Free()
}

//export person_set
func person_set(h C.person_handle_t, name *C.char, age C.int) {
    p := ObjectId(h).Get().(*Person)
    p.Set(C.GoString(name), int(age))
}

//export person_get_name
func person_get_name(h C.person_handle_t, buf *C.char, size C.int) *C.char {
    p := ObjectId(h).Get().(*Person)
    name, _ := p.Get()

    n := int(size) - 1
    bufSlice := ((*[1 << 31]byte)(unsafe.Pointer(buf)))[0:n:n]
    n = copy(bufSlice, []byte(name))
    bufSlice[n] = 0

    return buf
}

//export person_get_age
func person_get_age(h C.person_handle_t) C.int {
    p := ObjectId(h).Get().(*Person)
    _, age := p.Get()
    return C.int(age)
}

在創(chuàng)建 Go 對象后,我們通過 NewObjectId 將 Go 對應(yīng)映射為 id。然后將 id 強制轉(zhuǎn)義為 person_handle_t 類型返回。其它的接口函數(shù)則是根據(jù) person_handle_t 所表示的 id,讓根據(jù) id 解析出對應(yīng)的 Go 對象。

2.8.2.3 封裝 C++ 對象

有了 C 接口之后封裝 C++ 對象就比較簡單了。常見的做法是新建一個 Person 類,里面包含一個 person_handle_t 類型的成員對應(yīng)真實的 Go 對象,然后在 Person 類的構(gòu)造函數(shù)中通過 C 接口創(chuàng)建 Go 對象,在析構(gòu)函數(shù)中通過 C 接口釋放 Go 對象。下面是采用這種技術(shù)的實現(xiàn):

extern "C" {
    #include "./person_capi.h"
}

struct Person {
    person_handle_t goobj_;

    Person(const char* name, int age) {
        this->goobj_ = person_new((char*)name, age);
    }
    ~Person() {
        person_delete(this->goobj_);
    }

    void Set(char* name, int age) {
        person_set(this->goobj_, name, age);
    }
    char* GetName(char* buf, int size) {
        return person_get_name(this->goobj_ buf, size);
    }
    int GetAge() {
        return person_get_age(this->goobj_);
    }
}

包裝后我們就可以像普通 C++ 類那樣使用了:

#include "person.h"

#include <stdio.h>

int main() {
    auto p = new Person("gopher", 10);

    char buf[64];
    char* name = p->GetName(buf, sizeof(buf)-1);
    int age = p->GetAge();

    printf("%s, %d years old.\n", name, age);
    delete p;

    return 0;
}

2.8.2.4 封裝 C++ 對象改進(jìn)

在前面的封裝 C++ 對象的實現(xiàn)中,每次通過 new 創(chuàng)建一個 Person 實例需要進(jìn)行兩次內(nèi)存分配:一次是針對 C++ 版本的 Person,再一次是針對 Go 語言版本的 Person。其實 C++ 版本的 Person 內(nèi)部只有一個 person_handle_t 類型的 id,用于映射 Go 對象。我們完全可以將 person_handle_t 直接當(dāng)中 C++ 對象來使用。

下面時改進(jìn)后的包裝方式:

extern "C" {
    #include "./person_capi.h"
}

struct Person {
    static Person* New(const char* name, int age) {
        return (Person*)person_new((char*)name, age);
    }
    void Delete() {
        person_delete(person_handle_t(this));
    }

    void Set(char* name, int age) {
        person_set(person_handle_t(this), name, age);
    }
    char* GetName(char* buf, int size) {
        return person_get_name(person_handle_t(this), buf, size);
    }
    int GetAge() {
        return person_get_age(person_handle_t(this));
    }
};

我們在 Person 類中增加了一個叫 New 靜態(tài)成員函數(shù),用于創(chuàng)建新的 Person 實例。在 New 函數(shù)中通過調(diào)用 person_new 來創(chuàng)建 Person 實例,返回的是 person_handle_t 類型的 id,我們將其強制轉(zhuǎn)型作為 Person* 類型指針返回。在其它的成員函數(shù)中,我們通過將 this 指針再反向轉(zhuǎn)型為 person_handle_t 類型,然后通過 C 接口調(diào)用對應(yīng)的函數(shù)。

到此,我們就達(dá)到了將 Go 對象導(dǎo)出為 C 接口,然后基于 C 接口再包裝為 C++ 對象以便于使用的目的。

2.8.3 徹底解放 C++ 的 this 指針

熟悉 Go 語言的用法會發(fā)現(xiàn) Go 語言中方法是綁定到類型的。比如我們基于 int 定義一個新的 Int 類型,就可以有自己的方法:

type Int int

func (p Int) Twice() int {
    return int(p)*2
}

func main() {
    var x = Int(42)
    fmt.Println(int(x))
    fmt.Println(x.Twice())
}

這樣就可以在不改變原有數(shù)據(jù)底層內(nèi)存結(jié)構(gòu)的前提下,自由切換 int 和 Int 類型來使用變量。

而在 C++ 中要實現(xiàn)類似的特性,一般會采用以下實現(xiàn):

class Int {
    int v_;

    Int(v int) { this.v_ = v; }
    int Twice() const{ return this.v_*2;}
};

int main() {
    Int v(42);

    printf("%d\n", v); // error
    printf("%d\n", v.Twice());
}

新包裝后的 Int 類雖然增加了 Twice 方法,但是失去了自由轉(zhuǎn)回 int 類型的權(quán)利。這時候不僅連 printf 都無法輸出 Int 本身的值,而且也失去了 int 類型運算的所有特性。這就是 C++ 構(gòu)造函數(shù)的邪惡之處:以失去原有的一切特性的代價換取 class 的施舍。

造成這個問題的根源是 C++ 中 this 被固定為 class 的指針類型了。我們重新回顧下 this 在 Go 語言中的本質(zhì):

func (this Int) Twice() int
func Int_Twice(this Int) int

在 Go 語言中,和 this 有著相似功能的類型接收者參數(shù)其實只是一個普通的函數(shù)參數(shù),我們可以自由選擇值或指針類型。

如果以 C 語言的角度來思考,this 也只是一個普通的 void* 類型的指針,我們可以隨意自由地將 this 轉(zhuǎn)換為其它類型。

struct Int {
    int Twice() {
        const int* p = (int*)(this);
        return (*p) * 2;
    }
};
int main() {
    int x = 42;
    printf("%d\n", x);
    printf("%d\n", ((Int*)(&x))->Twice());
    return 0;
}

這樣我們就可以通過將 int 類型指針強制轉(zhuǎn)為 Int 類型指針,代替通過默認(rèn)的構(gòu)造函數(shù)后 new 來構(gòu)造 Int 對象。 在 Twice 函數(shù)的內(nèi)部,以相反的操作將 this 指針轉(zhuǎn)回 int 類型的指針,就可以解析出原有的 int 類型的值了。 這時候 Int 類型只是編譯時的一個殼子,并不會在運行時占用額外的空間。

因此 C++ 的方法其實也可以用于普通非 class 類型,C++ 到普通成員函數(shù)其實也是可以綁定到類型的。 只有純虛方法是綁定到對象,那就是接口。



以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號