Go 語言 類型轉(zhuǎn)換

2023-03-22 14:59 更新

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


2.3 類型轉(zhuǎn)換

最初 CGO 是為了達到方便從 Go 語言函數(shù)調(diào)用 C 語言函數(shù)(用 C 語言實現(xiàn) Go 語言聲明的函數(shù))以復(fù)用 C 語言資源這一目的而出現(xiàn)的(因為 C 語言還會涉及回調(diào)函數(shù),自然也會涉及到從 C 語言函數(shù)調(diào)用 Go 語言函數(shù)(用 Go 語言實現(xiàn) C 語言聲明的函數(shù)))?,F(xiàn)在,它已經(jīng)演變?yōu)?C 語言和 Go 語言雙向通訊的橋梁。要想利用好 CGO 特性,自然需要了解此二語言類型之間的轉(zhuǎn)換規(guī)則,這是本節(jié)要討論的問題。

2.3.1 數(shù)值類型

在 Go 語言中訪問 C 語言的符號時,一般是通過虛擬的 “C” 包訪問,比如 C.int 對應(yīng) C 語言的 int 類型。有些 C 語言的類型是由多個關(guān)鍵字組成,但通過虛擬的 “C” 包訪問 C 語言類型時名稱部分不能有空格字符,比如 unsigned int 不能直接通過 C.unsigned int 訪問。因此 CGO 為 C 語言的基礎(chǔ)數(shù)值類型都提供了相應(yīng)轉(zhuǎn)換規(guī)則,比如 C.uint 對應(yīng) C 語言的 unsigned int。

Go 語言中數(shù)值類型和 C 語言數(shù)據(jù)類型基本上是相似的,以下是它們的對應(yīng)關(guān)系表 2-1 所示。

C 語言類型 CGO 類型 Go 語言類型
char C.char byte
singed char C.schar int8
unsigned char C.uchar uint8
short C.short int16
unsigned short C.ushort uint16
int C.int int32
unsigned int C.uint uint32
long C.long int32
unsigned long C.ulong uint32
long long int C.longlong int64
unsigned long long int C.ulonglong uint64
float C.float float32
double C.double float64
size_t C.size_t uint

表 2-1 Go 語言和 C 語言類型對比

需要注意的是,雖然在 C 語言中 intshort 等類型沒有明確定義內(nèi)存大小,但是在 CGO 中它們的內(nèi)存大小是確定的。在 CGO 中,C 語言的 int 和 long 類型都是對應(yīng) 4 個字節(jié)的內(nèi)存大小,size_t 類型可以當(dāng)作 Go 語言 uint 無符號整數(shù)類型對待。

CGO 中,雖然 C 語言的 int 固定為 4 字節(jié)的大小,但是 Go 語言自己的 int 和 uint 卻在 32 位和 64 位系統(tǒng)下分別對應(yīng) 4 個字節(jié)和 8 個字節(jié)大小。如果需要在 C 語言中訪問 Go 語言的 int 類型,可以通過 GoInt 類型訪問,GoInt 類型在 CGO 工具生成的 _cgo_export.h 頭文件中定義。其實在 _cgo_export.h 頭文件中,每個基本的 Go 數(shù)值類型都定義了對應(yīng)的 C 語言類型,它們一般都是以單詞 Go 為前綴。下面是 64 位環(huán)境下,_cgo_export.h 頭文件生成的 Go 數(shù)值類型的定義,其中 GoInt 和 GoUint 類型分別對應(yīng) GoInt64 和 GoUint64

typedef signed char GoInt8;
typedef unsigned char GoUint8;
typedef short GoInt16;
typedef unsigned short GoUint16;
typedef int GoInt32;
typedef unsigned int GoUint32;
typedef long long GoInt64;
typedef unsigned long long GoUint64;
typedef GoInt64 GoInt;
typedef GoUint64 GoUint;
typedef float GoFloat32;
typedef double GoFloat64;

除了 GoInt 和 GoUint 之外,我們并不推薦直接訪問 GoInt32、GoInt64 等類型。更好的做法是通過 C 語言的 C99 標(biāo)準(zhǔn)引入的 <stdint.h> 頭文件。為了提高 C 語言的可移植性,在 <stdint.h> 文件中,不但每個數(shù)值類型都提供了明確內(nèi)存大小,而且和 Go 語言的類型命名更加一致。Go 語言類型 <stdint.h> 頭文件類型對比如表 2-2 所示。

C 語言類型 CGO 類型 Go 語言類型
int8_t C.int8_t int8
uint8_t C.uint8_t uint8
int16_t C.int16_t int16
uint16_t C.uint16_t uint16
int32_t C.int32_t int32
uint32_t C.uint32_t uint32
int64_t C.int64_t int64
uint64_t C.uint64_t uint64

表 2-2 <stdint.h> 類型對比

前文說過,如果 C 語言的類型是由多個關(guān)鍵字組成,則無法通過虛擬的 “C” 包直接訪問(比如 C 語言的 unsigned short 不能直接通過 C.unsigned short 訪問)。但是,在 <stdint.h> 中通過使用 C 語言的 typedef 關(guān)鍵字將 unsigned short 重新定義為 uint16_t 這樣一個單詞的類型后,我們就可以通過 C.uint16_t 訪問原來的 unsigned short 類型了。對于比較復(fù)雜的 C 語言類型,推薦使用 typedef 關(guān)鍵字提供一個規(guī)則的類型命名,這樣更利于在 CGO 中訪問。

2.3.2 Go 字符串和切片

在 CGO 生成的 _cgo_export.h 頭文件中還會為 Go 語言的字符串、切片、字典、接口和管道等特有的數(shù)據(jù)類型生成對應(yīng)的 C 語言類型:

typedef struct {const char *p; GoInt n;} GoString;
typedef void *GoMap;
typedef void *GoChan;
typedef struct {void *t; void *v;} GoInterface;
typedef struct {void *data; GoInt len; GoInt cap;} GoSlice;

不過需要注意的是,其中只有字符串和切片在 CGO 中有一定的使用價值,因為 CGO 為他們的某些 GO 語言版本的操作函數(shù)生成了 C 語言版本,因此二者可以在 Go 調(diào)用 C 語言函數(shù)時馬上使用; 而 CGO 并未針對其他的類型提供相關(guān)的輔助函數(shù),且 Go 語言特有的內(nèi)存模型導(dǎo)致我們無法保持這些由 Go 語言管理的內(nèi)存指針,所以它們 C 語言環(huán)境并無使用的價值。

在導(dǎo)出的 C 語言函數(shù)中我們可以直接使用 Go 字符串和切片。假設(shè)有以下兩個導(dǎo)出函數(shù):

//export helloString
func helloString(s string) {}

//export helloSlice
func helloSlice(s []byte) {}

CGO 生成的 _cgo_export.h 頭文件會包含以下的函數(shù)聲明:

extern void helloString(GoString p0);
extern void helloSlice(GoSlice p0);

不過需要注意的是,如果使用了 GoString 類型則會對 _cgo_export.h 頭文件產(chǎn)生依賴,而這個頭文件是動態(tài)輸出的。

Go1.10 針對 Go 字符串增加了一個 _GoString_ 預(yù)定義類型,可以降低在 cgo 代碼中可能對 _cgo_export.h 頭文件產(chǎn)生的循環(huán)依賴的風(fēng)險。我們可以調(diào)整 helloString 函數(shù)的 C 語言聲明為:

extern void helloString(_GoString_ p0);

因為 _GoString_ 是預(yù)定義類型,我們無法通過此類型直接訪問字符串的長度和指針等信息。Go1.10 同時也增加了以下兩個函數(shù)用于獲取字符串結(jié)構(gòu)中的長度和指針信息:

size_t _GoStringLen(_GoString_ s);
const char *_GoStringPtr(_GoString_ s);

更嚴(yán)謹(jǐn)?shù)淖龇ㄊ菫?C 語言函數(shù)接口定義嚴(yán)格的頭文件,然后基于穩(wěn)定的頭文件實現(xiàn)代碼。

2.3.3 結(jié)構(gòu)體、聯(lián)合、枚舉類型

C 語言的結(jié)構(gòu)體、聯(lián)合、枚舉類型不能作為匿名成員被嵌入到 Go 語言的結(jié)構(gòu)體中。在 Go 語言中,我們可以通過 C.struct_xxx 來訪問 C 語言中定義的 struct xxx 結(jié)構(gòu)體類型。結(jié)構(gòu)體的內(nèi)存布局按照 C 語言的通用對齊規(guī)則,在 32 位 Go 語言環(huán)境 C 語言結(jié)構(gòu)體也按照 32 位對齊規(guī)則,在 64 位 Go 語言環(huán)境按照 64 位的對齊規(guī)則。對于指定了特殊對齊規(guī)則的結(jié)構(gòu)體,無法在 CGO 中訪問。

結(jié)構(gòu)體的簡單用法如下:

/*
struct A {
    int i;
    float f;
};
*/
import "C"
import "fmt"

func main() {
    var a C.struct_A
    fmt.Println(a.i)
    fmt.Println(a.f)
}

如果結(jié)構(gòu)體的成員名字中碰巧是 Go 語言的關(guān)鍵字,可以通過在成員名開頭添加下劃線來訪問:

/*
struct A {
    int type; // type 是 Go 語言的關(guān)鍵字
};
*/
import "C"
import "fmt"

func main() {
    var a C.struct_A
    fmt.Println(a._type) // _type 對應(yīng) type
}

但是如果有 2 個成員:一個是以 Go 語言關(guān)鍵字命名,另一個剛好是以下劃線和 Go 語言關(guān)鍵字命名,那么以 Go 語言關(guān)鍵字命名的成員將無法訪問(被屏蔽):

/*
struct A {
    int   type;  // type 是 Go 語言的關(guān)鍵字
    float _type; // 將屏蔽 CGO 對 type 成員的訪問
};
*/
import "C"
import "fmt"

func main() {
    var a C.struct_A
    fmt.Println(a._type) // _type 對應(yīng) _type
}

C 語言結(jié)構(gòu)體中位字段對應(yīng)的成員無法在 Go 語言中訪問,如果需要操作位字段成員,需要通過在 C 語言中定義輔助函數(shù)來完成。對應(yīng)零長數(shù)組的成員,無法在 Go 語言中直接訪問數(shù)組的元素,但其中零長的數(shù)組成員所在位置的偏移量依然可以通過 unsafe.Offsetof(a.arr) 來訪問。

/*
struct A {
    int   size: 10; // 位字段無法訪問
    float arr[];    // 零長的數(shù)組也無法訪問
};
*/
import "C"
import "fmt"

func main() {
    var a C.struct_A
    fmt.Println(a.size) // 錯誤: 位字段無法訪問
    fmt.Println(a.arr)  // 錯誤: 零長的數(shù)組也無法訪問
}

在 C 語言中,我們無法直接訪問 Go 語言定義的結(jié)構(gòu)體類型。

對于聯(lián)合類型,我們可以通過 C.union_xxx 來訪問 C 語言中定義的 union xxx 類型。但是 Go 語言中并不支持 C 語言聯(lián)合類型,它們會被轉(zhuǎn)為對應(yīng)大小的字節(jié)數(shù)組。

/*
#include <stdint.h>

union B1 {
    int i;
    float f;
};

union B2 {
    int8_t i8;
    int64_t i64;
};
*/
import "C"
import "fmt"

func main() {
    var b1 C.union_B1;
    fmt.Printf("%T\n", b1) // [4]uint8

    var b2 C.union_B2;
    fmt.Printf("%T\n", b2) // [8]uint8
}

如果需要操作 C 語言的聯(lián)合類型變量,一般有三種方法:第一種是在 C 語言中定義輔助函數(shù);第二種是通過 Go 語言的 "encoding/binary" 手工解碼成員 (需要注意大端小端問題);第三種是使用 unsafe 包強制轉(zhuǎn)型為對應(yīng)類型 (這是性能最好的方式)。下面展示通過 unsafe 包訪問聯(lián)合類型成員的方式:

/*
#include <stdint.h>

union B {
    int i;
    float f;
};
*/
import "C"
import "fmt"

func main() {
    var b C.union_B;
    fmt.Println("b.i:", *(*C.int)(unsafe.Pointer(&b)))
    fmt.Println("b.f:", *(*C.float)(unsafe.Pointer(&b)))
}

雖然 unsafe 包訪問最簡單、性能也最好,但是對于有嵌套聯(lián)合類型的情況處理會導(dǎo)致問題復(fù)雜化。對于復(fù)雜的聯(lián)合類型,推薦通過在 C 語言中定義輔助函數(shù)的方式處理。

對于枚舉類型,我們可以通過 C.enum_xxx 來訪問 C 語言中定義的 enum xxx 結(jié)構(gòu)體類型。

/*
enum C {
    ONE,
    TWO,
};
*/
import "C"
import "fmt"

func main() {
    var c C.enum_C = C.TWO
    fmt.Println(c)
    fmt.Println(C.ONE)
    fmt.Println(C.TWO)
}

在 C 語言中,枚舉類型底層對應(yīng) int 類型,支持負(fù)數(shù)類型的值。我們可以通過 C.ONE、C.TWO 等直接訪問定義的枚舉值。

2.3.4 數(shù)組、字符串和切片

在 C 語言中,數(shù)組名其實對應(yīng)于一個指針,指向特定類型特定長度的一段內(nèi)存,但是這個指針不能被修改;當(dāng)把數(shù)組名傳遞給一個函數(shù)時,實際上傳遞的是數(shù)組第一個元素的地址。為了討論方便,我們將一段特定長度的內(nèi)存統(tǒng)稱為數(shù)組。C 語言的字符串是一個 char 類型的數(shù)組,字符串的長度需要根據(jù)表示結(jié)尾的 NULL 字符的位置確定。C 語言中沒有切片類型。

在 Go 語言中,數(shù)組是一種值類型,而且數(shù)組的長度是數(shù)組類型的一個部分。Go 語言字符串對應(yīng)一段長度確定的只讀 byte 類型的內(nèi)存。Go 語言的切片則是一個簡化版的動態(tài)數(shù)組。

Go 語言和 C 語言的數(shù)組、字符串和切片之間的相互轉(zhuǎn)換可以簡化為 Go 語言的切片和 C 語言中指向一定長度內(nèi)存的指針之間的轉(zhuǎn)換。

CGO 的 C 虛擬包提供了以下一組函數(shù),用于 Go 語言和 C 語言之間數(shù)組和字符串的雙向轉(zhuǎn)換:

// Go string to C string
// The C string is allocated in the C heap using malloc.
// It is the caller's responsibility to arrange for it to be
// freed, such as by calling C.free (be sure to include stdlib.h
// if C.free is needed).
func C.CString(string) *C.char

// Go []byte slice to C array
// The C array is allocated in the C heap using malloc.
// It is the caller's responsibility to arrange for it to be
// freed, such as by calling C.free (be sure to include stdlib.h
// if C.free is needed).
func C.CBytes([]byte) unsafe.Pointer

// C string to Go string
func C.GoString(*C.char) string

// C data with explicit length to Go string
func C.GoStringN(*C.char, C.int) string

// C data with explicit length to Go []byte
func C.GoBytes(unsafe.Pointer, C.int) []byte

其中 C.CString 針對輸入的 Go 字符串,克隆一個 C 語言格式的字符串;返回的字符串由 C 語言的 malloc 函數(shù)分配,不使用時需要通過 C 語言的 free 函數(shù)釋放。C.CBytes 函數(shù)的功能和 C.CString 類似,用于從輸入的 Go 語言字節(jié)切片克隆一個 C 語言版本的字節(jié)數(shù)組,同樣返回的數(shù)組需要在合適的時候釋放。C.GoString 用于將從 NULL 結(jié)尾的 C 語言字符串克隆一個 Go 語言字符串。C.GoStringN 是另一個字符數(shù)組克隆函數(shù)。C.GoBytes 用于從 C 語言數(shù)組,克隆一個 Go 語言字節(jié)切片。

該組輔助函數(shù)都是以克隆的方式運行。當(dāng) Go 語言字符串和切片向 C 語言轉(zhuǎn)換時,克隆的內(nèi)存由 C 語言的 malloc 函數(shù)分配,最終可以通過 free 函數(shù)釋放。當(dāng) C 語言字符串或數(shù)組向 Go 語言轉(zhuǎn)換時,克隆的內(nèi)存由 Go 語言分配管理。通過該組轉(zhuǎn)換函數(shù),轉(zhuǎn)換前和轉(zhuǎn)換后的內(nèi)存依然在各自的語言環(huán)境中,它們并沒有跨越 Go 語言和 C 語言??寺》绞綄崿F(xiàn)轉(zhuǎn)換的優(yōu)點是接口和內(nèi)存管理都很簡單,缺點是克隆需要分配新的內(nèi)存和復(fù)制操作都會導(dǎo)致額外的開銷。

在 reflect 包中有字符串和切片的定義:

type StringHeader struct {
    Data uintptr
    Len  int
}

type SliceHeader struct {
    Data uintptr
    Len  int
    Cap  int
}

如果不希望單獨分配內(nèi)存,可以在 Go 語言中直接訪問 C 語言的內(nèi)存空間:

/*
#include <string.h>
char arr[10];
char *s = "Hello";
*/
import "C"
import (
    "reflect"
    "unsafe"
)
func main() {
    // 通過 reflect.SliceHeader 轉(zhuǎn)換
    var arr0 []byte
    var arr0Hdr = (*reflect.SliceHeader)(unsafe.Pointer(&arr0))
    arr0Hdr.Data = uintptr(unsafe.Pointer(&C.arr[0]))
    arr0Hdr.Len = 10
    arr0Hdr.Cap = 10

    // 通過切片語法轉(zhuǎn)換
    arr1 := (*[31]byte)(unsafe.Pointer(&C.arr[0]))[:10:10]

    var s0 string
    var s0Hdr = (*reflect.StringHeader)(unsafe.Pointer(&s0))
    s0Hdr.Data = uintptr(unsafe.Pointer(C.s))
    s0Hdr.Len = int(C.strlen(C.s))

    sLen := int(C.strlen(C.s))
    	s1 := string((*[31]byte)(unsafe.Pointer(C.s))[:sLen:sLen])
}

因為 Go 語言的字符串是只讀的,用戶需要自己保證 Go 字符串在使用期間,底層對應(yīng)的 C 字符串內(nèi)容不會發(fā)生變化、內(nèi)存不會被提前釋放掉。

在 CGO 中,會為字符串和切片生成和上面結(jié)構(gòu)對應(yīng)的 C 語言版本的結(jié)構(gòu)體:

typedef struct {const char *p; GoInt n;} GoString;
typedef struct {void *data; GoInt len; GoInt cap;} GoSlice;

在 C 語言中可以通過 GoString 和 GoSlice 來訪問 Go 語言的字符串和切片。如果是 Go 語言中數(shù)組類型,可以將數(shù)組轉(zhuǎn)為切片后再行轉(zhuǎn)換。如果字符串或切片對應(yīng)的底層內(nèi)存空間由 Go 語言的運行時管理,那么在 C 語言中不能長時間保存 Go 內(nèi)存對象。

關(guān)于 CGO 內(nèi)存模型的細(xì)節(jié)在稍后章節(jié)中會詳細(xì)討論。

2.3.5 指針間的轉(zhuǎn)換

在 C 語言中,不同類型的指針是可以顯式或隱式轉(zhuǎn)換的,如果是隱式只是會在編譯時給出一些警告信息。但是 Go 語言對于不同類型的轉(zhuǎn)換非常嚴(yán)格,任何 C 語言中可能出現(xiàn)的警告信息在 Go 語言中都可能是錯誤!指針是 C 語言的靈魂,指針間的自由轉(zhuǎn)換也是 cgo 代碼中經(jīng)常要解決的第一個重要的問題。

在 Go 語言中兩個指針的類型完全一致則不需要轉(zhuǎn)換可以直接通用。如果一個指針類型是用 type 命令在另一個指針類型基礎(chǔ)之上構(gòu)建的,換言之兩個指針底層是相同完全結(jié)構(gòu)的指針,那么我我們可以通過直接強制轉(zhuǎn)換語法進行指針間的轉(zhuǎn)換。但是 cgo 經(jīng)常要面對的是 2 個完全不同類型的指針間的轉(zhuǎn)換,原則上這種操作在純 Go 語言代碼是嚴(yán)格禁止的。

cgo 存在的一個目的就是打破 Go 語言的禁止,恢復(fù) C 語言應(yīng)有的指針的自由轉(zhuǎn)換和指針運算。以下代碼演示了如何將 X 類型的指針轉(zhuǎn)化為 Y 類型的指針:

var p *X
var q *Y

q = (*Y)(unsafe.Pointer(p)) // *X => *Y
p = (*X)(unsafe.Pointer(q)) // *Y => *X

為了實現(xiàn) X 類型指針到 Y 類型指針的轉(zhuǎn)換,我們需要借助 unsafe.Pointer 作為中間橋接類型實現(xiàn)不同類型指針之間的轉(zhuǎn)換。unsafe.Pointer 指針類型類似 C 語言中的 void* 類型的指針。

下面是指針間的轉(zhuǎn)換流程的示意圖:


圖 2-1 X 類型指針轉(zhuǎn) Y 類型指針

任何類型的指針都可以通過強制轉(zhuǎn)換為 unsafe.Pointer 指針類型去掉原有的類型信息,然后再重新賦予新的指針類型而達到指針間的轉(zhuǎn)換的目的。

2.3.6 數(shù)值和指針的轉(zhuǎn)換

不同類型指針間的轉(zhuǎn)換看似復(fù)雜,但是在 cgo 中已經(jīng)算是比較簡單的了。在 C 語言中經(jīng)常遇到用普通數(shù)值表示指針的場景,也就是說如何實現(xiàn)數(shù)值和指針的轉(zhuǎn)換也是 cgo 需要面對的一個問題。

為了嚴(yán)格控制指針的使用,Go 語言禁止將數(shù)值類型直接轉(zhuǎn)為指針類型!不過,Go 語言針對 unsafe.Pointr 指針類型特別定義了一個 uintptr 類型。我們可以 uintptr 為中介,實現(xiàn)數(shù)值類型到 unsafe.Pointr 指針類型到轉(zhuǎn)換。再結(jié)合前面提到的方法,就可以實現(xiàn)數(shù)值和指針的轉(zhuǎn)換了。

下面流程圖演示了如何實現(xiàn) int32 類型到 C 語言的 char* 字符串指針類型的相互轉(zhuǎn)換:


圖 2-2 int32 和 char* 指針轉(zhuǎn)換

轉(zhuǎn)換分為幾個階段,在每個階段實現(xiàn)一個小目標(biāo):首先是 int32 到 uintptr 類型,然后是 uintptr 到 unsafe.Pointr 指針類型,最后是 unsafe.Pointr 指針類型到 *C.char 類型。

2.3.7 切片間的轉(zhuǎn)換

在 C 語言中數(shù)組也一種指針,因此兩個不同類型數(shù)組之間的轉(zhuǎn)換和指針間轉(zhuǎn)換基本類似。但是在 Go 語言中,數(shù)組或數(shù)組對應(yīng)的切片都不再是指針類型,因此我們也就無法直接實現(xiàn)不同類型的切片之間的轉(zhuǎn)換。

不過 Go 語言的 reflect 包提供了切片類型的底層結(jié)構(gòu),再結(jié)合前面討論到不同類型之間的指針轉(zhuǎn)換技術(shù)就可以實現(xiàn) []X 和 []Y 類型的切片轉(zhuǎn)換:

var p []X
var q []Y

pHdr := (*reflect.SliceHeader)(unsafe.Pointer(&p))
qHdr := (*reflect.SliceHeader)(unsafe.Pointer(&q))

pHdr.Data = qHdr.Data
pHdr.Len = qHdr.Len * unsafe.Sizeof(q[0]) / unsafe.Sizeof(p[0])
pHdr.Cap = qHdr.Cap * unsafe.Sizeof(q[0]) / unsafe.Sizeof(p[0])

不同切片類型之間轉(zhuǎn)換的思路是先構(gòu)造一個空的目標(biāo)切片,然后用原有的切片底層數(shù)據(jù)填充目標(biāo)切片。如果 X 和 Y 類型的大小不同,需要重新設(shè)置 Len 和 Cap 屬性。需要注意的是,如果 X 或 Y 是空類型,上述代碼中可能導(dǎo)致除 0 錯誤,實際代碼需要根據(jù)情況酌情處理。

下面演示了切片間的轉(zhuǎn)換的具體流程:


圖 2-3 X 類型切片轉(zhuǎn) Y 類型切片

針對 CGO 中常用的功能,作者封裝了 "github.com/chai2010/cgo" 包,提供基本的轉(zhuǎn)換功能,具體的細(xì)節(jié)可以參考實現(xiàn)代碼。



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

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號