标签 编译器 下的文章

为什么这个T类型实例无法调用*T类型的方法

本文永久链接 – https://tonybai.com/2022/02/27/go-addressable

近期在“Go语言第一课”专栏后台看到一位学员的一则留言,如下图:

由于有课程上下文,所以我这里将问题的上下文重新描述一下。

专栏的第25讲,我们学习了Go语言提供的一个“语法糖”,比如下面这个例子:

type T struct {
    a int
}

func (t T) M1() {
    t.a = 10
}

func (t *T) M2() {
    t.a = 11
}

func main() {
    var t1 T
    t1.M1()
    t1.M2()

    var t2 = &T{}
    t2.M1()
    t2.M2()
}

Go语言的类型有方法集合(method set)的概念,以上面例子来说,类型T的方法集合为{M1},而类型*T的方法集合为{M1, M2}。不过方法集合仅用于判断某类型是否实现某接口类型。当我们通过类型实例来调用方法时,Go会提供“语法糖”。上面这个例子先声明了类型T的变量t1,我们看到它不仅可以调用其方法集合中receiver参数类型为T的方法M1,它还可以直接调用不属于其方法集合的、receiver参数类型为*T的方法M2。T类型的实例t1之所以可以调用receiver参数类型为*T的方法M2都是Go编译器在背后自动进行转换的结果,即t1.M2()这种用法是Go提供的“语法糖”:Go判断t1的类型为T,与方法M2的receiver参数类型*T不一致后,会自动将t1.M2()转换为(&t1).M2()。

同理,类型为*T的实例t2,它不仅可以调用receiver参数类型为*T的方法M2,还可以调用receiver参数类型为T的方法M1,这同样是因为Go编译器在背后做了转换:Go判断t2的类型为*T,与方法M1的receiver参数类型T不一致后,会自动将t2.M1()转换为(*t2).M1()。

好了,问题来了!我们参考本文开头处那位学员的留言给出另外一个例子:

func main() {
    T{}.M2() // 编译器错误:cannot call pointer method M2 on T
    (&T{}).M1()  // OK
    (&T{}).M2()  // OK
}

在这个例子中,我们通过T{}对T进行实例化后并调用receiver参数类型为*T的M2方法,但编译器报了错误:cannot call pointer method M2 on T

前后两个例子,同样是基于T类型实例,一个可以使用“语法糖”调用M2方法,一个则不行。why?

其实答案就在于:上面的“语法糖”使用有一个前提,那就是T类型的实例需要是可被取地址的,即Go语言规范中的addressable

什么是addressable呢?Go语言规范中的原话是这样的:

“For an operand x of type T, the address operation &x generates a pointer of type *T to x. The operand must be addressable, that is, either a variable, pointer indirection, or slice indexing operation; or a field selector of an addressable struct operand; or an array indexing operation of an addressable array. As an exception to the addressability requirement, x may also be a (possibly parenthesized) composite literal. ”

翻译过来,大致是说:下面情况中的&x操作后面的操作数x是可被取地址的:

  • 一个变量。比如:&x
  • 指针解引用(pointer indirection)。比如:&*x
  • 切片下标操作。比如:&sl[2]
  • 可被取地址的结构体(struct)的字段。比如:&Person.Name
  • 可被取地址的数组的下标操作。比如:&arr[1]
  • 如果T是一个复合类型,那么&T{}是一个例外,是合法的。

不过,Go语言规范中并没有明确说明哪些情况的操作数或值是不可被取地址的。Go 101作者老貘在其“非官方Go FAQ”中,对不可被取地址的情况做了梳理,这里我们也借鉴一下:

  • 字符串中的字节元素
s := "hello"
println(&s[1]) // invalid operation: cannot take address of s[1] (value of type byte)
  • map键值对中的值元素
m := make(map[string]int)
m["hello"] = 5
println(&m["hello"]) // invalid operation: cannot take address of m["hello"] (map index expression of type int)

for k, v := range m {
    println(&k) // ok, 键元素是可以取地址的
    _ = v
}
  • 接口值的动态值(类型断言的结果)
var a int = 5
var i interface{} = a
println(&(i.(int))) // invalid operation: cannot take address of i.(int) (comma, ok expression of type int)
  • 常量(包括具名常量和字面量)
const s = "hello" // 具名常量

println(&s) // invalid operation: cannot take address of s (untyped string constant "hello")
println(&("golang")) // invalid operation: cannot take address of "golang" (untyped string constant)
  • 包级函数
func Foo() {}
func foo() {}

func main() {
    f := func() {} 

    println(&f) //ok, 局部匿名函数可取地址
    println(&Foo) // invalid operation: cannot take address of Foo (value of type func())
    println(&foo) // invalid operation: cannot take address of foo (value of type func())
}
  • 方法(用做函数值)
type T struct {
    a int
}

func (T) M1() {}

func main() {
    var t T
    println(&(t.M1)) // invalid operation: cannot take address of t.M1 (value of type func())
    println(&(T.M1)) // invalid operation: cannot take address of T.M1 (value of type func(T))
}
  • 中间结果值
    • 函数调用
    • 显式值转换
    • channel接收操作
    • 子字符串操作
    • 子切片操作
    • 加减乘除法操作
// 函数调用
func add(a, b int) int {
    return a + b
}

println(&(add(5, 6)))  // invalid operation: cannot take address of add(5, 6) (value of type int)

// 显示值转换

var b byte = 12
println(&int(b)) // invalid operation: cannot take address of int(b) (value of type int)

// channel接收操作

var c = make(chan int)
println(&(<-c)) // invalid operation: cannot take address of <-c (comma, ok expression of type int)

// 子字符串操作

var s = "hello"
println(&(s[1:3])) // invalid operation: cannot take address of s[1:3] (value of type string)

// 子切片操作

var sl = []int{1, 2, 3, 4, 5}
println(&(sl[1:3])) // invalid operation: cannot take address of sl[1:3] (value of type []int)

// 加减乘除操作

var a, b int = 10, 20
println(&(a + b)) // invalid operation: cannot take address of a + b (value of type int)
println(&(a - b)) // invalid operation: cannot take address of a - b (value of type int)
println(&(a * b)) // invalid operation: cannot take address of a * b (value of type int)
println(&(a / b)) // invalid operation: cannot take address of a / b (value of type int)

最后貘兄在非官方Go FAQ中也提到了&T{}是一个例外(貘兄认为是一个语法糖,&T{}被编译器替换为tmp := T{}; (&tmp)),但不代表T{}是可被取地址的。事实告诉我们:T{}不可被取地址。这也是文章开头处那个留言中问题的答案。


“Gopher部落”知识星球正式转正(从试运营星球变成了正式星球)!“gopher部落”旨在打造一个精品Go学习和进阶社群!高品质首发Go技术文章,“三天”首发阅读权,每年两期Go语言发展现状分析,每天提前1小时阅读到新鲜的Gopher日报,网课、技术专栏、图书内容前瞻,六小时内必答保证等满足你关于Go语言生态的所有需求!部落目前虽小,但持续力很强,欢迎大家加入!

img{512x368}

img{512x368}
img{512x368}
img{512x368}

我爱发短信:企业级短信平台定制开发专家 https://tonybai.com/。smspush : 可部署在企业内部的定制化短信平台,三网覆盖,不惧大并发接入,可定制扩展; 短信内容你来定,不再受约束, 接口丰富,支持长短信,签名可选。2020年4月8日,中国三大电信运营商联合发布《5G消息白皮书》,51短信平台也会全新升级到“51商用消息平台”,全面支持5G RCS消息。

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻)归档仓库 – https://github.com/bigwhite/gopherdaily

我的联系方式:

  • 微博:https://weibo.com/bigwhite20xx
  • 微信公众号:iamtonybai
  • 博客:tonybai.com
  • github: https://github.com/bigwhite
  • “Gopher部落”知识星球:https://public.zsxq.com/groups/51284458844544

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

Go究竟是否为空切片分配了底层数组

本文永久链接 – https://tonybai.com/2022/02/15/whether-go-allocate-underlying-array-for-empty-slice

这周在“Go语言第一课”的留言区看到一位同学的这样一个问题:

切片是Go语言中的一个重要的语法元素,也是日常Go开发中使用最为频繁的语法元素。有过Go语言开发经验的童鞋估计大多都知道空切片(empty slice)与nil切片(nil slice)比较的梗,这也是Go面试中的一道高频题。

var sl1 = []int{} // sl1是空切片
var sl2 []int     // sl2是nil切片

要真正理解切片,离不开运行时的切片表示。在我的专栏《Go语言精进之路》一书中都有对切片在运行时表示的细致讲解。

切片在运行时由三个字段构成,reflect包中有切片在类型系统中表示的对应的定义:

// $GOROOT/src/reflect/value.go
type SliceHeader struct {
    Data uintptr
    Len  int
    Cap  int
}

基于这个定义我们来理解空切片和nil切片就容易多了。我们用一段代码来看看这两种切片的差别:

// dumpslice.go
package main

import (
    "fmt"
    "reflect"
    "unsafe"
)

func main() {
    var sl1 = []int{}
    ph1 := (*reflect.SliceHeader)(unsafe.Pointer(&sl1))
    fmt.Printf("empty slice's header is %#v\n", *ph1)
    var sl2 []int
    ph2 := (*reflect.SliceHeader)(unsafe.Pointer(&sl2))
    fmt.Printf("nil slice's header is %#v\n", *ph2)
}

在这段代码中,我们通过unsafe包以及reflect.SliceHeader输出了空切片与nil切片在内存中的表示,即SliceHeader各个字段的值。我们在Go 1.18beta2下运行一下上述代码(使用-gcflags ‘-l -N’可关闭Go编译器的优化):

$go run -gcflags '-l -N' dumpslice.go
empty slice's header is reflect.SliceHeader{Data:0xc000092eb0, Len:0, Cap:0}
nil slice's header is reflect.SliceHeader{Data:0x0, Len:0, Cap:0}

通过输出结果,我们看到nil切片在运行时表示的三个字段值都是0;而空切片的len、cap值为0,但data值不为零

好了,此时我们再回到本文开始处那个童鞋提出的那个问题:空切片到底分没分配底层数组

答案是肯定的:没有分配!那么上述代码中空切片在运行时表示中第一个字段data的值0xc000092eb0从何而来,难道不是底层数组的地址么?

要想回答这个问题,我们需要下沉到汇编层面去看。

Go使用plan9的汇编语法,目前市面上关于这种汇编的资料比较少,比较权威是Go官方的asm资料和Rob Pike编写的A Manual for the Plan 9 assembler。此外IBM工程师的 Dropping down Go functions in assembly language这份资料也十分不错。国内《Go语言高级编程》一书以及曹春辉的plan9 assembly 完全解析讲解的十分全面,值得大家参考。

我们以下面这段最简单的有关空切片的代码为例:

// layout6.go

1 package main
2
3 func main() {
4     var sl = []int{}
5     _ = sl
6 }

生成go源码对应汇编代码的主要方法有:go tool compile -S xxx.go和针对编译后的二进制文件使用go tool objdump -S exe_file。

我们看看这段代码对应的汇编代码,我们使用下面命令将上述go源码转换为汇编代码(Go 1.18beta2 on darwin amd64):

$go tool compile -S -N -l layout6.go > layout6.s // -N -l两个命令行选项用于关闭Go编译器的优化,优化后的代码会掩盖实现细节

(在MacOS上)生成的layout6.s汇编代码如下(汇编代码中的FUNCDATA和PCDATA是Go编译器插入的、给GC使用的指示符,这里将其滤掉了):

"".main STEXT nosplit size=48 args=0x0 locals=0x30 funcid=0x0 align=0x0
    0x0000 00000 (layout6.go:3) TEXT    "".main(SB), NOSPLIT|ABIInternal, $48-0 // 48是main函数的栈帧大小,0表示参数大小
    0x0000 00000 (layout6.go:3) SUBQ    $48, SP
    0x0004 00004 (layout6.go:3) MOVQ    BP, 40(SP)
    0x0009 00009 (layout6.go:3) LEAQ    40(SP), BP
    0x000e 00014 (layout6.go:4) LEAQ    ""..autotmp_2(SP), AX
    0x0012 00018 (layout6.go:4) MOVQ    AX, ""..autotmp_1+8(SP)
    0x0017 00023 (layout6.go:4) TESTB   AL, (AX)
    0x0019 00025 (layout6.go:4) JMP 27
    0x001b 00027 (layout6.go:4) MOVQ    AX, "".sl+16(SP)
    0x0020 00032 (layout6.go:4) MOVUPS  X15, "".sl+24(SP)
    0x0026 00038 (layout6.go:6) MOVQ    40(SP), BP
    0x002b 00043 (layout6.go:6) ADDQ    $48, SP
    0x002f 00047 (layout6.go:6) RET
    0x0000 48 83 ec 30 48 89 6c 24 28 48 8d 6c 24 28 48 8d  H..0H.l$(H.l$(H.
    0x0010 04 24 48 89 44 24 08 84 00 eb 00 48 89 44 24 10  .$H.D$.....H.D$.
    0x0020 44 0f 11 7c 24 18 48 8b 6c 24 28 48 83 c4 30 c3  D..|$.H.l$(H..0.
go.cuinfo.packagename. SDWARFCUINFO dupok size=0
    0x0000 6d 61 69 6e                                      main
""..inittask SNOPTRDATA size=24
    0x0000 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
    0x0010 00 00 00 00 00 00 00 00                          ........
gclocals·33cdeccccebe80329f1fdbee7f5874cb SRODATA dupok size=8
    0x0000 01 00 00 00 00 00 00 00                          ........
gclocals·ff19ed39bdde8a01a800918ac3ef0ec7 SRODATA dupok size=9
    0x0000 01 00 00 00 04 00 00 00 00                       .........

关于汇编语法的问题,大家可以参考前面提供的参考资料,这里不赘述。我们这里最关注的是对应Go源码第4行Go代码的汇编源码,这里我把这段汇编源码单独提出来放在下面:

    0x000e 00014 (layout6.go:4) LEAQ    ""..autotmp_2(SP), AX
    0x0012 00018 (layout6.go:4) MOVQ    AX, ""..autotmp_1+8(SP)
    0x0017 00023 (layout6.go:4) TESTB   AL, (AX)
    0x0019 00025 (layout6.go:4) JMP 27
    0x001b 00027 (layout6.go:4) MOVQ    AX, "".sl+16(SP)
    0x0020 00032 (layout6.go:4) MOVUPS  X15, "".sl+24(SP)

我们逐行看一下:

  • 00014行:将SP寄存器指向的内存单元(该内存单元被命名为autotmp_2)的地址存入AX寄存器中;
  • 00019行:将AX寄存器中存储的值写入地址为SP+8的内存单元中,这个内存单元被命名为autotmp_1;
  • 00023行:将AL寄存器中的值与AX寄存器指向的内存单元的值做逻辑与操作,设置相关标志位;
  • 00025行:无条件跳转至00027行执行;
  • 00027行:将AX寄存器中存储的值写入sl切片变量运行时表示的第一个字段data中,该字段的地址为SP+16;
  • 00032行:使用intel平台上的SIMD指令集SSE的MOVUPS指令通过X15代表的固定的零寄存器对起始地址为SP+24的连续128bit(16个字节)进行清零。即sl切片变量运行时的len和cap字段被清零。

关于X15寄存器的含义,在Go internal ABI specification中有说明。

我这里用一幅图展示一下上面操作后的main函数栈情况:

我们看到切片sl的指向底层数组的指针data的值实际上是一个栈上的内存单元的地址,Go编译器并没有在堆上额外分配新的内存空间作为切片sl的底层数组。只是上面汇编代码的第00019行、00023行的操作让人很迷,不知道这两部指令操作的意图为何。

我们再来看一个例子,以进一步证实我们上面的结论。这个例子的源码如下:

// layout7.go
1 package main
2
3 func main() {
4     var sl = []int{}
5     sl = append(sl, 1)
6 }

在这个例子中,我们先是声明了一个空切片sl,之后又通过append为sl追加了一个元素。append时,由于sl为空切片,Go势必会为sl新分配底层存储数组,我们通过对比一下第4行和第5行两个操作的异同来确认“空切片并未分配底层数组”的结论。我们同样通过go tool compile -S命令得到该源码对应的汇编代码:

$go tool compile -S -N -l layout7.go > layout7.s

layout7.s中main函数的汇编代码如下(过滤掉了PCDATA和FUNCDATA指示符行):

"".main STEXT size=114 args=0x0 locals=0x70 funcid=0x0 align=0x0
    0x0000 00000 (layout7.go:3) TEXT    "".main(SB), ABIInternal, $112-0
    0x0000 00000 (layout7.go:3) CMPQ    SP, 16(R14)
    0x0004 00004 (layout7.go:3) JLS 107
    0x0006 00006 (layout7.go:3) SUBQ    $112, SP
    0x000a 00010 (layout7.go:3) MOVQ    BP, 104(SP)
    0x000f 00015 (layout7.go:3) LEAQ    104(SP), BP
    0x0014 00020 (layout7.go:4) LEAQ    ""..autotmp_2+64(SP), BX
    0x0019 00025 (layout7.go:4) MOVQ    BX, ""..autotmp_1+72(SP)
    0x001e 00030 (layout7.go:4) TESTB   AL, (BX)
    0x0020 00032 (layout7.go:4) JMP 34
    0x0022 00034 (layout7.go:4) MOVQ    BX, "".sl+80(SP)
    0x0027 00039 (layout7.go:4) MOVUPS  X15, "".sl+88(SP)
    0x002d 00045 (layout7.go:5) JMP 47
    0x002f 00047 (layout7.go:5) LEAQ    type.int(SB), AX
    0x0036 00054 (layout7.go:5) XORL    CX, CX
    0x0038 00056 (layout7.go:5) MOVQ    CX, DI
    0x003b 00059 (layout7.go:5) MOVL    $1, SI
    0x0040 00064 (layout7.go:5) CALL    runtime.growslice(SB)
    0x0045 00069 (layout7.go:5) LEAQ    1(BX), DX
    0x0049 00073 (layout7.go:5) JMP 75
    0x004b 00075 (layout7.go:5) MOVQ    $1, (AX)
    0x0052 00082 (layout7.go:5) MOVQ    AX, "".sl+80(SP)
    0x0057 00087 (layout7.go:5) MOVQ    DX, "".sl+88(SP)
    0x005c 00092 (layout7.go:5) MOVQ    CX, "".sl+96(SP)
    0x0061 00097 (layout7.go:6) MOVQ    104(SP), BP
    0x0066 00102 (layout7.go:6) ADDQ    $112, SP
    0x006a 00106 (layout7.go:6) RET
    0x006b 00107 (layout7.go:6) NOP
    0x006b 00107 (layout7.go:3) CALL    runtime.morestack_noctxt(SB)
    0x0070 00112 (layout7.go:3) JMP 0
    ... ...

有了对layout6.s的汇编的分析的基础,再来看这段汇编似乎就好很多了。首先layout7.s中对应var sl = []int{}代码的第00020到00039的原理与layout6.s一致。sl的data字段被赋值为一个栈上内存单元(SP+64)的地址。

从第00047到00073实际上是为调用runtime.growslice函数做准备以及调用runtime.growslice函数。runtime.growslice函数负责在堆上分配新的底层数组用于存储切片sl的元素。runtime.growslice返回后,我们看到,第00075行,Go将一个立即数1写入AX寄存器指向的内存单元,即growslice新分配的底层数组的第一个元素的内存单元。

之后,sl的三个字段被重新做了赋值:

    0x0052 00082 (layout7.go:5) MOVQ    AX, "".sl+80(SP)
    0x0057 00087 (layout7.go:5) MOVQ    DX, "".sl+88(SP)
    0x005c 00092 (layout7.go:5) MOVQ    CX, "".sl+96(SP)

我们看到:00082行,sl的data字段(SP+80)被赋值为AX寄存器中的值,即堆上分配新的底层数组的地址。而后的len和cap字段也分配用DX和CX寄存器的值做了赋值,这两个寄存器分配存储了切片的len和cap。

我这里同样用一幅示意图展示append后main函数栈的情况:

通过这个例子,我们可以看到,如果Go在堆上为切片分配底层数组,我们会在汇编代码中看到growslice或newobject这样的调用。

如果一个非空切片没有逃逸到堆上,那么Go也可能在栈上为该切片分配底层数组空间,比如下面这段代码:

// layout10.go
package main

func main() {
    var sl = []int{11, 12, 13}
    _ = sl
}

它对应的汇编如下:

"".main STEXT nosplit size=103 args=0x0 locals=0x40 funcid=0x0 align=0x0
    0x0000 00000 (layout10.go:3)    TEXT    "".main(SB), NOSPLIT|ABIInternal, $64-0
    0x0000 00000 (layout10.go:3)    SUBQ    $64, SP
    0x0004 00004 (layout10.go:3)    MOVQ    BP, 56(SP)
    0x0009 00009 (layout10.go:3)    LEAQ    56(SP), BP
    0x000e 00014 (layout10.go:4)    MOVUPS  X15, ""..autotmp_2(SP)
    0x0013 00019 (layout10.go:4)    MOVUPS  X15, ""..autotmp_2+8(SP)
    0x0019 00025 (layout10.go:4)    LEAQ    ""..autotmp_2(SP), AX
    0x001d 00029 (layout10.go:4)    MOVQ    AX, ""..autotmp_1+24(SP)
    0x0022 00034 (layout10.go:4)    TESTB   AL, (AX)
    0x0024 00036 (layout10.go:4)    MOVQ    $11, ""..autotmp_2(SP)
    0x002c 00044 (layout10.go:4)    TESTB   AL, (AX)
    0x002e 00046 (layout10.go:4)    MOVQ    $12, ""..autotmp_2+8(SP)
    0x0037 00055 (layout10.go:4)    TESTB   AL, (AX)
    0x0039 00057 (layout10.go:4)    MOVQ    $13, ""..autotmp_2+16(SP)
    0x0042 00066 (layout10.go:4)    TESTB   AL, (AX)
    0x0044 00068 (layout10.go:4)    JMP 70
    0x0046 00070 (layout10.go:4)    MOVQ    AX, "".sl+32(SP)
    0x004b 00075 (layout10.go:4)    MOVQ    $3, "".sl+40(SP)
    0x0054 00084 (layout10.go:4)    MOVQ    $3, "".sl+48(SP)
    0x005d 00093 (layout10.go:6)    MOVQ    56(SP), BP
    0x0062 00098 (layout10.go:6)    ADDQ    $64, SP
    0x0066 00102 (layout10.go:6)    RET

这段汇编代码就留给大家自己阅读分析吧。


“Gopher部落”知识星球正式转正(从试运营星球变成了正式星球)!“gopher部落”旨在打造一个精品Go学习和进阶社群!高品质首发Go技术文章,“三天”首发阅读权,每年两期Go语言发展现状分析,每天提前1小时阅读到新鲜的Gopher日报,网课、技术专栏、图书内容前瞻,六小时内必答保证等满足你关于Go语言生态的所有需求!部落目前虽小,但持续力很强,欢迎大家加入!

img{512x368}

img{512x368}
img{512x368}
img{512x368}

我爱发短信:企业级短信平台定制开发专家 https://tonybai.com/。smspush : 可部署在企业内部的定制化短信平台,三网覆盖,不惧大并发接入,可定制扩展; 短信内容你来定,不再受约束, 接口丰富,支持长短信,签名可选。2020年4月8日,中国三大电信运营商联合发布《5G消息白皮书》,51短信平台也会全新升级到“51商用消息平台”,全面支持5G RCS消息。

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻)归档仓库 – https://github.com/bigwhite/gopherdaily

我的联系方式:

  • 微博:https://weibo.com/bigwhite20xx
  • 微信公众号:iamtonybai
  • 博客:tonybai.com
  • github: https://github.com/bigwhite
  • “Gopher部落”知识星球:https://public.zsxq.com/groups/51284458844544

微信赞赏:
img{512x368}

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats