理解go语言编程-进阶话题

Go语言的反射实现了反射的大部分功能,但没有像Java语言那样内置类型工厂,故而无法做到像Java那样通过类型字符串创建对象实例。反射是把双刃剑,功能强大但代码可读性并不理想。若非必要,我们并不推荐使用反射。

Type为io.Reader,Value为MyReader{“a.txt”}。顾名思义,Type主要表达的是被反射的这个变量本身的类型信息,而Value则为该变量实例本身的信息。

1
2
3
4
5
6
7
8
type MyReader struct {
Name string
}
func (r MyReader)Read(p []byte) (n int, err error) {
// 实现自己的Read方法
}
var reader io.Reader
reader = &MyReader{"a.txt"}

直接传递一个float到函数时,函数不能对外部的这个float变量有任何影响,要想有影响的话,可以传入该float变量的指针.

1
2
3
4
5
6
7
8
9
var x float64 = 3.4
p := reflect.ValueOf(&x) // 注意:得到X的地址
fmt.Println("type of p:", p.Type())
fmt.Println("settability of p:" , p.CanSet())
v := p.Elem()
fmt.Println("settability of v:" , v.CanSet())
v.SetFloat(7.1)
fmt.Println(v.Interface())
fmt.Println(x)

对结构体的反射,只是用了Field()方法来按索引获取 对应的成员。

1
2
3
4
5
6
7
8
t := T{203, "mh203"}
s := reflect.ValueOf(&t).Elem()
typeOfT := s.Type()
for i := 0; i < s.NumField(); i++ {
f := s.Field(i)
fmt.Printf("%d: %s %s = %v\n", i,
typeOfT.Field(i).Name, f.Type(), f.Interface())
}

cgo使用,根本就不存在一个名为C的包。这个import语句其实就是一个信号,告诉Cgo它应该开始工作了。就是对应这条import语句之前的块注释中的C源代码自动生成包装性质的Go代码。
函数调用从汇编的角度看,就是一个将参数按顺序压栈(push),然后进行函数调用(call)的过程。Cgo生成的代码只不过是帮你封装了这个压栈和调用的过程,从外面看起来就是一个普通的Go函数调用。
这个例子里用的是一个块注释,实际上用行注释也是没问题的,只要是紧贴在import语句之前即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/*
#include <stdlib.h> */
import "C"

func Random() int {
return int(C.random())
}
func Seed(i int) {
C.srandom(C.uint(i))
}
func main() {
Seed(100)
fmt.Println("Random:", Random())
}

对于C语言的原生类型,Cgo都会将其映射为Go语言中的类型:C.char和C.schar(对应于C语言中的signed char)、C.uchar(对应于C语言中的unsigned char)、C.short和C.ushort(对应于unsigned short)、C.int和C.uint(对应于unsigned int)、C.long和C.ulong(对应于unsigned long)、C.longlong(对应于C语言中的long long)、C.ulonglong(对应于C语言中的unsigned long long类型)以及C.float和C.double。C语言中的void*指针类型在Go语言中则用特殊的unsafe.Pointer类型来对应。
C语言中的struct、union和enum类型,对应到Go语言中都会变成带这样前缀的类型名称:struct_、union_和enum_。比如一个在C语言中叫做person的struct会被Cgo翻译为C.struct_person。
如果C语言中的类型名称或变量名称与Go语言的关键字相同,Cgo会自动给这些名字加上下划线前缀

Cgo提供了一系列函数来提供支持:C.CString、C.GoString和C.GoStringN。需要注意的是,每次转换都将导致一次内存复制,因此字符串内容其实是不可修改的(实际上,Go语言的string也不允许对其中的内容进行修改)。
由于C.CString的内存管理方式与Go语言自身的内存管理方式不兼容,我们设法期待Go语言可以帮我们做垃圾收集,因此在使用完后必须显式释放调用C.CString所生成的内存块,否则将导致严重的内存泄露。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/*
#include <stdlib.h>
#include <stdio.h>

void mysprintf(char *str, int data) {
sprintf(str, "content is %d", data);
}

void myprintf(char *str) {
printf("%s", str);
}
*/
import "C"

var gostr string
// ... 初始化gostr
cstr := C.CString(gostr)
defer C.free(unsafe.Pointer(cstr)) // 接下来大胆地使用cstr吧,因为保证可以被释放掉了
C.mysprintf(cstr, 123)
C.myprintf(cstr)

Cgo提供了#cgo这样的伪C文法,让开发者有机会指 定依赖的第三方库和编译选项。

1
2
3
4
5
6
7
8
9
10
// #cgo CFLAGS: -DPNG_DEBUG=1
// #cgo linux CFLAGS: -DLINUX=1
// #cgo LDFLAGS: -lpng
// #include <png.h>
import "C"

// or
// #cgo pkg-config: png cairo
// #include <png.h>
import "C"

函数调用:开发者只要在运行cgo指令后查看一下生成的Go代码,就可以知道如何写对应的调用代码。

1
n, err := C.f(&array[0]) // 需要显示指定第一个元素的地址

在用gdb调试的时候,要设置断点:b <函数名>,这里的<函数名>是指“链接符号”,而非我们平常看到的语言文法层面使用的符号。
链接名:

1
2
3
4
5
6
7
func New(cfg Config) *MockFS
func (fs *MockFS) Mkdir(dir string) (code int, err error)
func (fs MockFS) Foo(bar Bar)
它们的链接符号分别为:
qbox.us/mockfs.New
qbox.us/mockfs.*MockFS·Mkdir
qbox.us/mockfs.MockFS·Foo

协程,也有人称之为轻量级线程,具备以下几个特点。

  • 能够在单一的系统线程中模拟多个任务的并发执行。
  • 在一个特定的时间,只有一个任务在运行,即并非真正地并行。
  • 被动的任务调度方式,即任务没有主动抢占时间片的说法。当一个任务正在执行时,外部没有办法中止它。要进行任务切换,只能通过由该任务自身调用yield()来主动出让CPU使用权。
  • 每个协程都有自己的堆栈和局部变量。

c语言版本协程:https://swtch.com/libtask/,下载下来,make && make install,gcc tcpproxy.c -ltask -o tcpproxy

在实现了一个任务函数后,真要让这个函数加入到调度队列中,我们还需要显式调用taskcreate()函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct Task {
char name[256];
char state[256];
Task *next;
Task *prev;
Task *allnext;
Task *allprev;
Context context;
uvlong alarmtime;
uint id;
uchar *stk;
uint stksize;
int exiting;
int alltaskslot;
int system;
int ready;
void (*startfn)(void*);
void *startarg;
void *udata;
};

可以看到,每一个任务需要保存以下这几个关键数据:

  • 任务上下文,用于在切换任务时保持当前任务的运行环境
  • 状态
  • 该任务所对应的业务函数(primetask()函数)
  • 任务的调用参数
  • 之前和之后的任务

任务的创建过程,因为在taskalloc()中的最后一行,我们可以看到每一个任务的上下文被设置为taskstart()函数相关,所以一旦调用swapcontext()切换到任务所记录的上下文,则将会导致taskstart()函数被调用,从而在taskstart()函数中进一步调用真正的业务函数,比如上例中的primetask()函数就是这么被调用到的(被设置为任务的startfn成员)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
static int taskidgen;

static Task*
taskalloc(void (*fn)(void*), void *arg, uint stack)
{
Task *t;
sigset_t zero;
uint x, y;
ulong z;

/* allocate the task and stack together */
t = malloc(sizeof *t+stack);
if(t == nil){
fprint(2, "taskalloc malloc: %r\n");
abort();
}
memset(t, 0, sizeof *t);
t->stk = (uchar*)(t+1);
t->stksize = stack;
t->id = ++taskidgen;
t->startfn = fn;
t->startarg = arg;

/* do a reasonable initialization */
memset(&t->context.uc, 0, sizeof t->context.uc);
sigemptyset(&zero);
sigprocmask(SIG_BLOCK, &zero, &t->context.uc.uc_sigmask);

/* must initialize with current context */
if(getcontext(&t->context.uc) < 0){
fprint(2, "getcontext: %r\n");
abort();
}

/* call makecontext to do the real work. */
/* leave a few words open on both ends */
t->context.uc.uc_stack.ss_sp = t->stk+8;
t->context.uc.uc_stack.ss_size = t->stksize-64;
#if defined(__sun__) && !defined(__MAKECONTEXT_V2_SOURCE) /* sigh */
#warning "doing sun thing"
/* can avoid this with __MAKECONTEXT_V2_SOURCE but only on SunOS 5.9 */
t->context.uc.uc_stack.ss_sp =
(char*)t->context.uc.uc_stack.ss_sp
+t->context.uc.uc_stack.ss_size;
#endif
/*
* All this magic is because you have to pass makecontext a
* function that takes some number of word-sized variables,
* and on 64-bit machines pointers are bigger than words.
*/
//print("make %p\n", t);
z = (ulong)t;
y = z;
z >>= 16; /* hide undefined 32-bit shift from 32-bit compilers */
x = z>>16;
makecontext(&t->context.uc, (void(*)())taskstart, 2, y, x);

return t;
}

static void
taskstart(uint y, uint x)
{
Task *t;
ulong z;

z = x<<16; /* hide undefined 32-bit shift from 32-bit compilers */
z <<= 16;
z |= y;
t = (Task*)z;

//print("taskstart %p\n", t);
t->startfn(t->startarg);
//print("taskexits %p\n", t);
taskexit(0);
//print("not reacehd\n");
}

int
taskcreate(void (*fn)(void*), void *arg, uint stack)
{
int id;
Task *t;

t = taskalloc(fn, arg, stack);
taskcount++;
id = t->id;
if(nalltask%64 == 0){
alltask = realloc(alltask, (nalltask+64)*sizeof(alltask[0]));
if(alltask == nil){
fprint(2, "out of memory\n");
abort();
}
}
t->alltaskslot = nalltask;
alltask[nalltask++] = t;
taskready(t);
return id;
}

这个过程其实就是创建并设置了一个Task对象,然后将这个对象添加到alltask列表中,接着将该Task对象的状态设置为就绪,表示该任务可以接受调度器的调度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// main
taskcreate(taskmainstart, nil, mainstacksize);
taskscheduler();

static void
taskscheduler(void)
{
int i;
Task *t;

taskdebug("scheduler enter");
for(;;){
if(taskcount == 0)
exit(taskexitval);
t = taskrunqueue.head;
if(t == nil){
fprint(2, "no runnable tasks! %d tasks stalled\n", taskcount);
exit(1);
}
deltask(&taskrunqueue, t);
t->ready = 0;
taskrunning = t;
tasknswitch++;
taskdebug("run %d (%s)", t->id, t->name);
contextswitch(&taskschedcontext, &t->context);
//print("back in scheduler\n");
taskrunning = nil;
if(t->exiting){
if(!t->system)
taskcount--;
i = t->alltaskslot;
alltask[i] = alltask[--nalltask];
alltask[i]->alltaskslot = i;
free(t);
}
}
}

就是循环执行正在等待中的任务,直到执行完所有的任务后退出.

ucontext上下文切换,gcc context.c -o context -D_XOPEN_SOURCE
主函数里的swapcontext()调用将导致f2()函数被调用,因为ctx[2]中填充的内容为f2()函数的执行信息。而在执行f2()的过程中又遇到一次swapcontext()调用,这次切换到了f1()函数。这也是先打印两个start信息而没有任何一个函数先结束的原因。
我们现在还在f1()函数中,继续执行,结果又遇到了一个swapcontext(),由于第二个参数为ctx[2],因此再次切换回到了f2()。由于之前f2()函数在执行swapcontext()时将那个时刻的上下文全部记录到了ctx2中,因此从f1()再次切换回来后,f2()的执行将从之前的那一行代码继续执行,在本例中即执行打印“finish f2”信息。这也是f2()先于f1()结束的原因。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <stdio.h>
#include <ucontext.h>

static ucontext_t ctx[3];
static void f1 (void)
{
puts("start f1");
swapcontext(&ctx[1], &ctx[2]);
puts("finish f1");
}
static void f2 (void)
{
puts("start f2");
swapcontext(&ctx[2], &ctx[1]);
puts("finish f2");
}

int main (void) {
char st1[8192];
char st2[8192];
getcontext(&ctx[1]);
ctx[1].uc_stack.ss_sp = st1;
ctx[1].uc_stack.ss_size = sizeof st1;
ctx[1].uc_link = &ctx[0];
makecontext(&ctx[1], f1, 0);
getcontext(&ctx[2]);
ctx[2].uc_stack.ss_sp = st2;
ctx[2].uc_stack.ss_size = sizeof st2;
ctx[2].uc_link = &ctx[1];
makecontext(&ctx[2], f2, 0);
swapcontext(&ctx[0], &ctx[2]);
return 0;
}

// start f2
// start f1
// finish f2
// finish f1

在任务的执行过程中发生任务切换只会因为以下原因之一:

  • 该任务的业务代码主动要求切换,即主动让出执行权;主动调用taskyield()来完成
  • 发生了IO,导致执行阻塞。

taskswitch()切换上下文以具体做到任务切换,taskready()函数将一个具体的任务设置为等待执行状态,tasksyield()则借助其他的函数完成执行权出让

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
void
taskswitch(void)
{
needstack(0);
contextswitch(&taskrunning->context, &taskschedcontext); // 切换到调度的上下文
}

void
taskready(Task *t)
{
t->ready = 1;
addtask(&taskrunqueue, t);
}

int
taskyield(void)
{
int n;

n = tasknswitch;
taskready(taskrunning);
taskstate("yield");
taskswitch();
return tasknswitch - n - 1;
}

那么到底切换到哪里去了呢?我们只要查看一下调用contextswitch()时传入的第二个参数taskschedcontext具体对应的代码位置就可以。非常容易地查到切换的目的地,这就是调度器在将执行上下文切换到具体一个任务之前所记录的taskscheduler()函数自身的执行上下文。因此,taskyield()将导致调度器函数taskscheduler()函数重新被激活,并从contextswitch()的下一行继续执行。

一个任务遭遇到阻塞的IO动作时自动让出执行权:当发生IO事件时,程序会先让其他处于yield状态的任务先执行,待清理掉这些可以执行的任务后,开始调用poll来监听所有处于IO阻塞状态的pollfd,一旦有某些pollfd成功读写,则将对应的任务切换为可调度状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// 网络io等阻塞fd才会调用
void
fdtask(void *v)
{
int i, ms;
Task *t;
uvlong now;

tasksystem();
taskname("fdtask");
for(;;){
/* let everyone else run */
while(taskyield() > 0)
;
/* we're the only one runnable - poll for i/o */
errno = 0;
taskstate("poll");
if((t=sleeping.head) == nil)
ms = -1;
else{
/* sleep at most 5s */
now = nsec();
if(now >= t->alarmtime)
ms = 0;
else if(now+5*1000*1000*1000LL >= t->alarmtime)
ms = (t->alarmtime - now)/1000000;
else
ms = 5000;
}
if(poll(pollfd, npollfd, ms) < 0){
if(errno == EINTR)
continue;
fprint(2, "poll: %s\n", strerror(errno));
taskexitall(0);
}

/* wake up the guys who deserve it */
for(i=0; i<npollfd; i++){
while(i < npollfd && pollfd[i].revents){
taskready(polltask[i]);
--npollfd;
pollfd[i] = pollfd[npollfd];
polltask[i] = polltask[npollfd];
}
}

now = nsec();
while((t=sleeping.head) && now >= t->alarmtime){
deltask(&sleeping, t);
if(!t->system && --sleepingcounted == 0)
taskcount--;
taskready(t);
}
}
}

从根本上来说,channel只是一个数据结构,可以被写入数据,也可以被读取数据。所谓的发送数据到channel,或者从channel读取数据,说白了就是对一个数据结构的操作,仅此而已。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
struct Alt
{
Channel *c;
void *v;
unsigned int op;
Task *task;
Alt *xalt;
};

struct Altarray
{
Alt **a;
unsigned int n;
unsigned int m;
};

struct Channel
{
unsigned int bufsize;
unsigned int elemsize;
unsigned char *buf;
unsigned int nbuf;
unsigned int off;
Altarray asend;
Altarray arecv;
char *name;
};

Channel*
chancreate(int elemsize, int bufsize)
{
Channel *c;

c = malloc(sizeof *c+bufsize*elemsize); // 分配的内存缓存就紧跟在这个channel结构之后
if(c == nil){
fprint(2, "chancreate malloc: %r");
exit(1);
}
memset(c, 0, sizeof *c);
c->elemsize = elemsize;
c->bufsize = bufsize;
c->nbuf = 0;
c->buf = (uchar*)(c+1);
return c;
}

以看到channel的基本组成如下:

  • 内存缓存,用于存放元素;
  • 发送队列;
  • 接受队列。

因为协程原则上不会出现多线程编程中经常遇到的资源竞争问题,所以这个channel的数据结构甚至在访问的时候都不用加锁(因为Go语言支持多CPU核心并发执行多个goroutine,会造成资源竞争,所以在必要的位置还是需要加锁的)

一个简单的逻辑就是需要获取这个类型的所有方法集合(集合A),并获取该接口包含的所有方法集合(集合B),然后判断列表B是否为列表A的子集,是则意味着SimpleSpeaker类型实现了ISpeaker接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type ISpeaker interface {
Speak()
}
type SimpleSpeaker struct {
Message string
}

func (speaker *SimpleSpeaker) Speak() {
fmt.Println("I am speaking? ", speaker.Message)
}
func main() {
var speaker ISpeaker
speaker = &SimpleSpeaker{"Hell"}
speaker.Speak()
}

每个接口的数据结构都包含两个基本的信息:本接口的接口方法表(InterfaceInfo)以及所指向的具体实现类型的类型信息(TypeInfo)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// ISpeaker接口的底层表现
typedef struct _ISpeakerTbl {
InterfaceInfo* inter;
TypeInfo* type;
int (*Speak)(void* this);
} ISpeakerTbl;
typedef struct _ISpeaker {
ISpeakerTbl* tab;
void* data;
} ISpeaker;
const char* g_Tags_ISpeaker[] = {
"Speak()",
NULL
};
InterfaceInfo g_InterfaceInfo_ISpeaker = {
g_Tags_ISpeaker
};

// SimpleSpeaker类型的底层表达方法
typedef struct _SimpleSpeaker {
char Message[256];
} A;
void SimpleSpeaker_Speak(A* this) {
printf("I am speaking... %s\n", this->Message);
}
MemberInfo g_Members_SimpleSpeaker[] = {
{ "Speak()", SimpleSpeaker_Speak },
{ NULL, NULL }
};
TypeInfo g_TypeInfo_SimpleSpeaker = {
g_Members_SimpleSpeaker
};

我们可以很容易判断SimpleSpeaker是否实现了ISpeaker接口:只需要将g_Mem-bers_SimpeSpeaker数组和g_Tags_ISpeaker数组的内容进行字符串比对即可。因为两者都包含了完整名称为Speak()的方法,因此SimpleSpeaker实现了ISpeaker。

编译器可以先通过以上的逻辑判断是否该类型和该接口之间可以赋值,之后专门为SimpleSpeaker类型生成一个全局的ISpeaker接口表

1
2
3
4
5
ISpeakerTbl g_Itbl_ISpeaker_SimpleSpeaker = {
&g_InterfaceInfo_ISpeaker,
&g_TypeInfo_SimpleSpeaker,
(int (*)(void* this))SimpleSpeaker_Speak
}

1
2
speaker = &SimpleSpeaker{"Hell"}
speaker.Speak()

上面类型到接口的赋值和调用语句,对应的底层实现会接近如下的写法(类型赋值给接口时可以做的编译期优化)

1
2
3
4
5
6
7
// 这时候的SimpleSpeaker只是一个纯数据接口
SimpleSpeaker* unnamed = NewSimpleSpeaker("Hello");
ISpeaker p = {
&g_Itbl_ISpeaker_SimpleSpeaker,
unnamed
};
p.tbl->Speak(p.data)

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
type IReadWriter interface {
Read(buf *byte, cb int) int
Write(buf *byte, cb int) int
}
type A struct {
a int
}

func NewA(params int) *A {
fmt.Println("NewA:", params)
return &A{params}
}
func (this *A) Read(buf *byte, cb int) int {
fmt.Println("A_Read:", this.a)
return cb
}
func (this *A) Write(buf *byte, cb int) int {
fmt.Println("A_Write:", this.a)
return cb
}

type B struct {
A
}

func NewB(params int) *B {
fmt.Println("NewB:", params)
return &B{A{params}}
}

func (this *B) Write(buf *byte, cb int) int {
fmt.Println("B_Write:", this.a)
return cb
}
func (this *B) Foo() {
fmt.Println("B_Foo:", this.a)
}
func main() {
var p IReadWriter = NewB(8)
p.Read(nil, 10)
p.Write(nil, 10)
}

// NewB: 8
// A_Read: 8
// B_Write: 8

IReader所对应的类型是否也实现了IReadWriter接口,这样它可以切 换到IReadWriter接口,然后调用该接口的Write()方法写入数据

1
2
3
4
var reader IReader = NewReader()
if writer, ok := reader.(IReadWriter); ok {
writer.Write()
}

相比类型赋值给接口时可以做的编译期优化,运行期接口查询就只能老老实实地做一次接口匹配了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
typedef struct _ITbl {
InterfaceInfo* inter;
TypeInfo* type;
//...
} ITbl;

ITbl* MakeItbl(InterfaceInfo* intf, TypeInfo* ti) {
size_t i, n = MemberCount(intf);
ITbl* dest = (ITbl*)malloc(n * sizeof(void*) + sizeof(ITbl));
void** addrs = (void**)(dest + 1);
for (i = 0; i < n; i++) {
addrs[i] = MemberFind(ti, intf->tags[i]);
if (addrs[i] == NULL) {
free(dest);
return NULL;
}
}
dest->inter = intf;
dest->type = ti;
return dest;
}

这是一个动态的接口匹配过程。这个流程就是按接口信息表中包含的方法名逐一查询匹配,如果发现传入的类型信息ti的方法列表是intf的方法列表的超集(即intf方法列表中的所有方法都存在于ti方法列表中),则表示接口查询成功。

在编译期,编译器就能判断是否可进行接口转换。如果可转换,编译器将为所有用到的接口赋值,生成各自的赋值函数

1
2
var rw IReadWriter = ...
var r IReader = rw

1
2
3
4
5
6
7
IWriterTbl* Itbl_IWriter_From_IReadWriter(IReadWriterTbl* src) {
IWriterTbl* dest = (IWriterTbl*)malloc(sizeof(IWriterTbl));
dest->inter = &g_InterfaceInfo_IWriter,
dest->type = src->type;
dest->Write = src->Write;
return dest;
}
nephen wechat
欢迎您扫一扫上面的微信公众号,订阅我的博客!
坚持原创技术分享,您的支持将鼓励我继续创作!