前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >实效go编程--2

实效go编程--2

作者头像
李海彬
发布2018-03-26 12:48:36
8520
发布2018-03-26 12:48:36
举报
文章被收录于专栏:Golang语言社区Golang语言社区

Go函数的返回值或结果“形参”可被命名,并作为常规变量使用,就像传入的形参一样。 命名后,一旦该函数开始执行,它们就会被初始化为与其类型相应的零值; 若该函数执行了一条不带实参的 return 语句,则结果形参的当前值将被返回。

此名称不是强制性的,但它们能使代码更加简短清晰:它们就是文档。若我们命名了 nextInt 的结果,那么它返回的 int 就值如其意了。

func nextInt(b []byte, pos int) (value, nextPos int) { 由于被命名的结果已经初始化,且已经关联至无参数的返回,它们就能让代码简单而清晰。 下面的 io.ReadFull 就是个很好的例子:

func ReadFull(r Reader, buf []byte) (n int, err error) { for len(buf) > 0 && err == nil { var nr int nr, err = r.Read(buf) n += nr buf = buf[nr:] } return } Defer

Go的 defer 语句用于预设一个函数调用(即推迟执行函数), 该函数会在执行 defer 的函数返回之前立即执行。它显得非比寻常, 但却是处理一些事情的有效方式,例如无论以何种路径返回,都必须释放资源的函数。 典型的例子就是解锁互斥和关闭文件。

// Contents 将文件的内容作为字符串返回。 func Contents(filename string) (string, error) { f, err := os.Open(filename) if err != nil { return "", err } defer f.Close() // f.Close 会在我们结束后运行。

var result []byte buf := make([]byte, 100) for { n, err := f.Read(buf[0:]) result = append(result, buf[0:n]...) // append 将在后面讨论。 if err != nil { if err == io.EOF { break } return "", err // 我们在这里返回后,f 就会被关闭。 } } return string(result), nil // 我们在这里返回后,f 就会被关闭。 } 推迟诸如 Close 之类的函数调用有两点好处:第一, 它能确保你不会忘记关闭文件。如果你以后又为该函数添加了新的返回路径时, 这种情况往往就会发生。第二,它意味着“关闭”离“打开”很近, 这总比将它放在函数结尾处要清晰明了。

被推迟函数的实参(如果该函数为方法则还包括接收者)在推迟执行时就会求值, 而不是在调用执行时才求值。这样不仅无需担心变量值在函数执行时被改变, 同时还意味着单个已推迟的调用可推迟多个函数的执行。下面是个简单的例子。

for i := 0; i < 5; i++ { defer fmt.Printf("%d ", i) } 被推迟的函数按照后进先出(LIFO)的顺序执行,因此以上代码在函数返回时会打印 4 3 2 1 0。一个更具实际意义的例子是通过一种简单的方法, 用程序来跟踪函数的执行。我们可以编写一对简单的跟踪例程:

func trace(s string) { fmt.Println("entering:", s) } func untrace(s string) { fmt.Println("leaving:", s) }

// 像这样使用它们: func a() { trace("a") defer untrace("a") // 做一些事情.... } 我们可以充分利用这个特点,即被推迟函数的实参在 defer 执行时才会被求值。 跟踪例程可针对反跟踪例程设置实参。以下例子:

func trace(s string) string { fmt.Println("entering:", s) return s }

func un(s string) { fmt.Println("leaving:", s) }

func a() { defer un(trace("a")) fmt.Println("in a") }

func b() { defer un(trace("b")) fmt.Println("in b") a() }

func main() { b() } 会打印

entering: b in b entering: a in a leaving: a leaving: b 对于习惯其它语言中块级资源管理的程序员,defer 似乎有点怪异, 但它最有趣而强大的应用恰恰来自于其基于函数而非块的特点。在 panic 和 recover 这两节中,我们将看到关于它可能性的其它例子。

数据

new 分配

Go提供了两种分配原语,即内建函数 new 和 make。 它们所做的事情不同,所应用的类型也不同。它们可能会引起混淆,但规则却很简单。 让我们先来看看 new。这是个用来分配内存的内建函数, 但与其它语言中的同名函数不同,它不会初始化内存,只会将内存置零。 也就是说,new(T) 会为类型为 T 的新项分配已置零的内存空间, 并返回它的地址,也就是一个类型为 *T 的值。用Go的术语来说,它返回一个指针, 该指针指向新分配的,类型为 T 的零值。

既然 new 返回的内存已置零,那么当你设计数据结构时, 每种类型的零值就不必进一步初始化了,这意味着该数据结构的使用者只需用 new 创建一个新的对象就能正常工作。例如,bytes.Buffer 的文档中提到“零值的 Buffer 就是已准备就绪的缓冲区。" 同样,sync.Mutex 并没有显式的构造函数或 Init 方法, 而是零值的 sync.Mutex 就已经被定义为已解锁的互斥锁了。

“零值属性”可以带来各种好处。考虑以下类型声明。

type SyncedBuffer struct { lock sync.Mutex buffer bytes.Buffer } SyncedBuffer 类型的值也是在声明时就分配好内存就绪了。后续代码中, p 和 v 无需进一步处理即可正确工作。

p := new(SyncedBuffer) // type *SyncedBuffer var v SyncedBuffer // type SyncedBuffer 构造函数与复合字面

有时零值还不够好,这时就需要一个初始化构造函数,如来自 os 包中的这段代码所示。

func NewFile(fd int, name string) *File { if fd < 0 { return nil } f := new(File) f.fd = fd f.name = name f.dirinfo = nil f.nepipe = 0 return f } 这里显得代码过于冗长。我们可通过复合字面来简化它, 该表达式在每次求值时都会创建新的实例。

func NewFile(fd int, name string) *File { if fd < 0 { return nil } f := File{fd, name, nil, 0} return &f } 请注意,返回一个局部变量的地址完全没有问题,这点与C不同。该局部变量对应的数据 在函数返回后依然有效。实际上,每当获取一个复合字面的地址时,都将为一个新的实例分配内存, 因此我们可以将上面的最后两行代码合并:

return &File{fd, name, nil, 0} 复合字面的字段必须按顺序全部列出。但如果以 字段:值 对的形式明确地标出元素,初始化字段时就可以按任何顺序出现,未给出的字段值将赋予零值。 因此,我们可以用如下形式:

return &File{fd: fd, name: name} 少数情况下,若复合字面不包括任何字段,它将创建该类型的零值。表达式 new(File) 和 &File{} 是等价的。

复合字面同样可用于创建数组、切片以及映射,字段标签是索引还是映射键则视情况而定。 在下例初始化过程中,无论 Enone、Eio 和 Einval 的值是什么,只要它们的标签不同就行。

a := [...]string {Enone: "no error", Eio: "Eio", Einval: "invalid argument"} s := []string {Enone: "no error", Eio: "Eio", Einval: "invalid argument"} m := map[int]string{Enone: "no error", Eio: "Eio", Einval: "invalid argument"} make 分配

再回到内存分配上来。内建函数 make(T, args) 的目的不同于 new(T)。它只用于创建切片、映射和信道,并返回类型为 T(而非 *T)的一个已初始化 (而非置零)的值。 出现这种用差异的原因在于,这三种类型本质上为引用数据类型,它们在使用前必须初始化。 例如,切片是一个具有三项内容的描述符,包含一个指向(数组内部)数据的指针、长度以及容量, 在这三项被初始化之前,该切片为 nil。对于切片、映射和信道,make 用于初始化其内部的数据结构并准备好将要使用的值。例如,

make([]int, 10, 100) 会分配一个具有100个 int 的数组空间,接着创建一个长度为10, 容量为100并指向该数组中前10个元素的切片结构。(生成切片时,其容量可以省略,更多信息见切片一节。) 与此相反,new([]int) 会返回一个指向新分配的,已置零的切片结构, 即一个指向 nil 切片值的指针。

下面的例子阐明了 new 和 make 之间的区别:

var p *[]int = new([]int) // 分配切片结构;*p == nil;基本没用 var v []int = make([]int, 100) // 切片 v 现在引用了一个具有 100 个 int 元素的新数组

// 没必要的复杂: var p *[]int = new([]int) *p = make([]int, 100, 100)

// 习惯用法: v := make([]int, 100) 请记住,make 只适用于映射、切片和信道且不返回指针。若要获得明确的指针, 请使用 new 分配内存。

数组

在详细规划内存布局时,数组是非常有用的,有时还能避免过多的内存分配, 但它们主要用作切片的构件。这是下一节的主题了,不过要先说上几句来为它做铺垫。

以下为数组在Go和C中的主要区别。在Go中,

数组是值。将一个数组赋予另一个数组会复制其所有元素。 特别地,若将某个数组传入某个函数,它将接收到该数组的一份副本而非指针。 数组的大小是其类型的一部分。类型 [10]int 和 [20]int 是不同的。 数组为值的属性很有用,但代价高昂;若你想要C那样的行为和效率,你可以传递一个指向该数组的指针。

func Sum(a *[3]float64) (sum float64) { for _, v := range *a { sum += v } return }

array := [...]float64{7.0, 8.5, 9.1} x := Sum(&array) // 注意显式的取址操作 但这并不是Go的习惯用法,切片才是。

切片

切片通过对数组进行封装,为数据序列提供了更通用、强大而方便的接口。 除了矩阵变换这类需要明确维度的情况外,Go中的大部分数组编程都是通过切片来完成的。

切片保存了对底层数组的引用,若你将某个切片赋予另一个切片,它们会引用同一个数组。 若某个函数将一个切片作为参数传入,则它对该切片元素的修改对调用者而言同样可见, 这可以理解为传递了底层数组的指针。因此,Read 函数可接受一个切片实参 而非一个指针和一个计数;切片的长度决定了可读取数据的上限。以下为 os 包中 File 类型的 Read 方法签名:

func (file *File) Read(buf []byte) (n int, err error) 该方法返回读取的字节数和一个错误值(若有的话)。若要从更大的缓冲区 b 中读取前32个字节,只需对其进行切片即可。

n, err := f.Read(buf[0:32]) 这种切片的方法常用且高效。若不谈效率,以下片段同样能读取该缓冲区的前32个字节。

var n int var err error for i := 0; i < 32; i++ { nbytes, e := f.Read(buf[i:i+1]) // 读取一个字节 if nbytes == 0 || e != nil { err = e break } n += nbytes } 只要切片不超出底层数组的限制,它的长度就是可变的,只需将它赋予其自身的切片即可。 切片的容量可通过内建函数 cap 获得,它将给出该切片可取得的最大长度。 以下是将数据追加到切片的函数。若数据超出其容量,则会重新分配该切片。返回值即为所得的切片。 该函数中所使用的 len 和 cap 在应用于 nil 切片时是合法的,它会返回0.

func Append(slice, data[]byte) []byte { l := len(slice) if l + len(data) > cap(slice) { // 重新分配 // 为了后面的增长,需分配两份。 newSlice := make([]byte, (l+len(data))*2) // copy 函数是预声明的,且可用于任何切片类型。 copy(newSlice, slice) slice = newSlice } slice = slice[0:l+len(data)] for i, c := range data { slice[l+i] = c } return slice } 最终我们必须返回切片,因为尽管 Append 可修改 slice 的元素,但切片自身(其运行时数据结构包含指针、长度和容量)是通过值传递的。

向切片追加东西的想法非常有用,因此有专门的内建函数 append。 要理解该函数的设计,我们还需要一些额外的信息,我们将稍后再介绍它。

二维切片

Go的数组和切片都是一维的。要创建等价的二维数组或切片,就必须定义一个数组的数组, 或切片的切片,就像这样:

type Transform [3][3]float64 // 一个 3x3 的数组,其实是包含多个数组的一个数组。 type LinesOfText [][]byte // 包含多个字节切片的一个切片。 由于切片长度是可变的,因此其内部可能拥有多个不同长度的切片。在我们的 LinesOfText 例子中,这是种常见的情况:每行都有其自己的长度。

text := LinesOfText{ []byte("Now is the time"), []byte("for all good gophers"), []byte("to bring some fun to the party."), } 有时必须分配一个二维数组,例如在处理像素的扫描行时,这种情况就会发生。 我们有两种方式来达到这个目的。一种就是独立地分配每一个切片;而另一种就是只分配一个数组, 将各个切片都指向它。采用哪种方式取决于你的应用。若切片会增长或收缩, 就应该通过独立分配来避免覆盖下一行;若不会,用单次分配来构造对象会更加高效。 以下是这两种方法的大概代码,仅供参考。首先是一次一行的:

// 分配顶层切片。 picture := make([][]uint8, YSize) // 每 y 个单元一行。 // 遍历行,为每一行都分配切片 for i := range picture { picture[i] = make([]uint8, XSize) } 现在是一次分配,对行进行切片:

// 分配顶层切片,和前面一样。 picture := make([][]uint8, YSize) // 每 y 个单元一行。 // 分配一个大的切片来保存所有像素 pixels := make([]uint8, XSize*YSize) // 拥有类型 []uint8,尽管图片是 [][]uint8. // 遍历行,从剩余像素切片的前面切出每行来。 for i := range picture { picture[i], pixels = pixels[:XSize], pixels[XSize:] } 映射

映射是方便而强大的内建数据结构,它可以关联不同类型的值。其键可以是任何相等性操作符支持的类型, 如整数、浮点数、复数、字符串、指针、接口(只要其动态类型支持相等性判断)、结构以及数组。 切片不能用作映射键,因为它们的相等性还未定义。与切片一样,映射也是引用类型。 若将映射传入函数中,并更改了该映射的内容,则此修改对调用者同样可见。

映射可使用一般的复合字面语法进行构建,其键-值对使用逗号分隔,因此可在初始化时很容易地构建它们。

var timeZone = map[string]int{ "UTC": 0*60*60, "EST": -5*60*60, "CST": -6*60*60, "MST": -7*60*60, "PST": -8*60*60, } 赋值和获取映射值的语法类似于数组,不同的是映射的索引不必为整数。

offset := timeZone["EST"] 若试图通过映射中不存在的键来取值,就会返回与该映射中项的类型对应的零值。 例如,若某个映射包含整数,当查找一个不存在的键时会返回 0。 集合可实现成一个值类型为 bool 的映射。将该映射中的项置为 true 可将该值放入集合中,此后通过简单的索引操作即可判断是否存在。

attended := map[string]bool{ "Ann": true, "Joe": true, ... }

if attended[person] { // 若某人不在此映射中,则为 false fmt.Println(person, "正在开会") } 有时你需要区分某项是不存在还是其值为零值。如对于一个值本应为零的 "UTC" 条目,也可能是由于不存在该项而得到零值。你可以使用多重赋值的形式来分辨这种情况。

var seconds int var ok bool seconds, ok = timeZone[tz] 显然,我们可称之为“逗号 ok”惯用法。在下面的例子中,若 tz 存在, seconds 就会被赋予适当的值,且 ok 会被置为 true; 若不存在,seconds 则会被置为零,而 ok 会被置为 false。

func offset(tz string) int { if seconds, ok := timeZone[tz]; ok { return seconds } log.Println("unknown time zone:", tz) return 0 } 若仅需判断映射中是否存在某项而不关心实际的值,可使用空白标识符 (_)来代替该值的一般变量。

_, present := timeZone[tz] 要删除映射中的某项,可使用内建函数 delete,它以映射及要被删除的键为实参。 即便对应的键不在该映射中,此操作也是安全的。

delete(timeZone, "PDT") // 现在用标准时间 打印

Go采用的格式化打印风格和C的 printf 族类似,但却更加丰富而通用。 这些函数位于 fmt 包中,且函数名首字母均为大写:如 fmt.Printf、fmt.Fprintf,fmt.Sprintf 等。 字符串函数(Sprintf 等)会返回一个字符串,而非填充给定的缓冲区。

你无需提供一个格式字符串。每个 Printf、Fprintf 和 Sprintf 都分别对应另外的函数,如 Print 与 Println。 这些函数并不接受格式字符串,而是为每个实参生成一种默认格式。Println 系列的函数还会在实参中插入空格,并在输出时追加一个换行符,而 Print 版本仅在操作数两侧都没有字符串时才添加空白。以下示例中各行产生的输出都是一样的。

fmt.Printf("Hello %d\n", 23) fmt.Fprint(os.Stdout, "Hello ", 23, "\n") fmt.Println("Hello", 23) fmt.Println(fmt.Sprint("Hello ", 23)) fmt.Fprint 一类的格式化打印函数可接受任何实现了 io.Writer 接口的对象作为第一个实参;变量os.Stdout 与 os.Stderr 都是人们熟知的例子。

从这里开始,就与C有些不同了。首先,像 %d 这样的数值格式并不接受表示符号或大小的标记, 打印例程会根据实参的类型来决定这些属性。

var x uint64 = 1<<64 - 1 fmt.Printf("%d %x; %d %x\n", x, x, int64(x), int64(x)) 将打印

18446744073709551615 ffffffffffffffff; -1 -1 若你只想要默认的转换,如使用十进制的整数,你可以使用通用的格式 %v(对应“值”);其结果与 Print 和 Println 的输出完全相同。此外,这种格式还能打印任意值,甚至包括数组、结构体和映射。 以下是打印上一节中定义的时区映射的语句。

fmt.Printf("%v\n", timeZone) // 或只用 fmt.Println(timeZone) 这会输出

map[CST:-21600 PST:-28800 EST:-18000 UTC:0 MST:-25200] 当然,映射中的键可能按任意顺序输出。当打印结构体时,改进的格式 %+v 会为结构体的每个字段添上字段名,而另一种格式 %#v 将完全按照Go的语法打印值。

type T struct { a int b float64 c string } t := &T{ 7, -2.35, "abc\tdef" } fmt.Printf("%v\n", t) fmt.Printf("%+v\n", t) fmt.Printf("%#v\n", t) fmt.Printf("%#v\n", timeZone) 将打印

&{7 -2.35 abc def} &{a:7 b:-2.35 c:abc def} &main.T{a:7, b:-2.35, c:"abc\tdef"} map[string] int{"CST":-21600, "PST":-28800, "EST":-18000, "UTC":0, "MST":-25200} (请注意其中的&符号)当遇到 string 或 []byte 值时, 可使用 %q 产生带引号的字符串;而格式 %#q 会尽可能使用反引号。 (%q 格式也可用于整数和符文,它会产生一个带单引号的符文常量。) 此外,%x 还可用于字符串、字节数组以及整数,并生成一个很长的十六进制字符串, 而带空格的格式(% x)还会在字节之间插入空格。

另一种实用的格式是 %T,它会打印某个值的类型.

fmt.Printf("%T\n", timeZone) 会打印

map[string] int 若你想控制自定义类型的默认格式,只需为该类型定义一个具有 String() string 签名的方法。对于我们简单的类型 T,可进行如下操作。

func (t *T) String() string { return fmt.Sprintf("%d/%g/%q", t.a, t.b, t.c) } fmt.Printf("%v\n", t) 会打印出如下格式:

7/-2.35/"abc\tdef" (如果你需要像指向 T 的指针那样打印类型 T 的值, String 的接收者就必须是值类型的;上面的例子中接收者是一个指针, 因为这对结构来说更高效而通用。更多详情见指针vs.值接收者一节.)

我们的 String 方法也可调用 Sprintf, 因为打印例程可以完全重入并按这种方式封装。不过要理解这种方式,还有一个重要的细节: 请勿通过调用 Sprintf 来构造 String 方法,因为它会无限递归你的的 String 方法。

type MyString string

func (m MyString) String() string { return fmt.Sprintf("MyString=%s", m) // 错误:会无限递归 } 要解决这个问题也很简单:将该实参转换为基本的字符串类型,它没有这个方法。

type MyString string func (m MyString) String() string { return fmt.Sprintf("MyString=%s", string(m)) // 可以:注意转换 } 在初始化一节中,我们将看到避免这种递归的另一种技术。

另一种打印技术就是将打印例程的实参直接传入另一个这样的例程。Printf 的签名为其最后的实参使用了 ...interface{} 类型,这样格式的后面就能出现任意数量,任意类型的形参了。

func Printf(format string, v ...interface{}) (n int, err error) { 在 Printf 函数中,v 看起来更像是 []interface{} 类型的变量,但如果将它传递到另一个变参函数中,它就像是常规实参列表了。 以下是我们之前用过的 log.Println 的实现。它直接将其实参传递给 fmt.Sprintln 进行实际的格式化。

// Println 通过 fmt.Println 的方式将日志打印到标准记录器。 func Println(v ...interface{}) { std.Output(2, fmt.Sprintln(v...)) // Output 接受形参 (int, string) } 在该 Sprintln 嵌套调用中,我们将 ... 写在 v 之后来告诉编译器将 v 视作一个实参列表,否则它会将 v 当做单一的切片实参来传递。

还有很多关于打印知识点没有提及。详情请参阅 godoc 对 fmt 包的说明文档。

顺便一提,... 形参可指定具体的类型,例如从整数列表中选出最小值的函数 min,其形参可为 ...int 类型。

func Min(a ...int) int { min := int(^uint(0) >> 1) // 最大的 int for _, i := range a { if i < min { min = i } } return min } 追加

现在我们要对内建函数 append 的设计进行补充说明。append 函数的签名不同于前面我们自定义的 Append 函数。大致来说,它就像这样:

func append(slice []T, 元素 ...T) []T 其中的 T 为任意给定类型的占位符。实际上,你无法在Go中编写一个类型 T 由调用者决定的函数。这也就是为何 append 为内建函数的原因:它需要编译器的支持。 append 会在切片末尾追加元素并返回结果。我们必须返回结果, 原因与我们手写的 Append 一样,即底层数组可能会被改变。以下简单的例子

x := []int{1,2,3} x = append(x, 4, 5, 6) fmt.Println(x) 将打印 [1 2 3 4 5 6]。因此 append 有点像 Printf 那样,可接受任意数量的实参。

但如果我们要像 Append 那样将一个切片追加到另一个切片中呢? 很简单:在调用的地方使用 ...,就像我们在上面调用 Output 那样。以下代码片段的输出与上一个相同。

x := []int{1,2,3} y := []int{4,5,6} x = append(x, y...) fmt.Println(x) 如果没有 ...,它就会由于类型错误而无法编译,因为 y 不是 int 类型的。

初始化

尽管从表面上看,Go的初始化过程与C或C++并不算太大,但它确实更为强大。 在初始化过程中,不仅可以构建复杂的结构,还能正确处理不同包对象间的初始化顺序。

常量

Go中的常量就是不变量。它们在编译时创建,即便它们可能是函数中定义的局部变量。 常量只能是数字、字符(符文)、字符串或布尔值。由于编译时的限制, 定义它们的表达式必须也是可被编译器求值的常量表达式。例如 1<<3 就是一个常量表达式,而 math.Sin(math.Pi/4) 则不是,因为对 math.Sin 的函数调用在运行时才会发生。

在Go中,枚举常量使用枚举器 iota 创建。由于 iota 可为表达式的一部分,而表达式可以被隐式地重复,这样也就更容易构建复杂的值的集合了。

type ByteSize float64

const ( // 通过赋予空白标识符来忽略第一个值 _ = iota // ignore first value by assigning to blank identifier KB ByteSize = 1 << (10 * iota) MB GB TB PB EB ZB YB ) 由于可将 String 之类的方法附加在用户定义的类型上, 因此它就为打印时自动格式化任意值提供了可能性,即便是作为一个通用类型的一部分。 尽管你常常会看到这种技术应用于结构体,但它对于像 ByteSize 之类的浮点数标量等类型也是有用的。

func (b ByteSize) String() string { switch { case b >= YB: return fmt.Sprintf("%.2fYB", b/YB) case b >= ZB: return fmt.Sprintf("%.2fZB", b/ZB) case b >= EB: return fmt.Sprintf("%.2fEB", b/EB) case b >= PB: return fmt.Sprintf("%.2fPB", b/PB) case b >= TB: return fmt.Sprintf("%.2fTB", b/TB) case b >= GB: return fmt.Sprintf("%.2fGB", b/GB) case b >= MB: return fmt.Sprintf("%.2fMB", b/MB) case b >= KB: return fmt.Sprintf("%.2fKB", b/KB) } return fmt.Sprintf("%.2fB", b) } 表达式 YB 会打印出 1.00YB,而 ByteSize(1e13) 则会打印出 9.09。

在这里用 Sprintf 实现 ByteSize 的 String 方法很安全(不会无限递归),这倒不是因为类型转换,而是它以 %f 调用了 Sprintf,它并不是一种字符串格式:Sprintf 只会在它需要字符串时才调用 String 方法,而 %f 需要一个浮点数值。

变量

变量的初始化与常量类似,但其初始值也可以是在运行时才被计算的一般表达式。

var ( home = os.Getenv("HOME") user = os.Getenv("USER") gopath = os.Getenv("GOPATH") ) init 函数

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2017-01-18,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 Golang语言社区 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档