你好,我是陈皓,网名左耳朵耗子。

今天是我们的第一节课,我先带你学习下Go语言编程模式的一些基本技术和要点。了解了这些内容,你就可以更轻松地掌握Go语言编程了,其中主要包括数组切片的一些小坑、接口编程,以及时间和程序运行性能相关的内容。

话不多说,我们直接开始。

Slice

首先,我来介绍下Slice,中文翻译叫“切片”,这个东西在Go语言中不是数组,而是一个结构体,其定义如下:

type slice struct {
    array unsafe.Pointer //指向存放数据的数组指针
    len   int            //长度有多大
    cap   int            //容量有多大
}

一个空的Slice的表现如下图所示:

熟悉C/C++的同学一定会知道在结构体里用数组指针的问题——数据会发生共享!下面我们来看看Slice的一些操作:

foo = make([]int, 5)
foo[3] = 42
foo[4] = 100

bar  := foo[1:4]
bar[1] = 99

我来解释下这段代码:

为了方便你理解,我画了一张图:


从这张图片中,我们可以看到,因为foo和bar的内存是共享的,所以,foo和bar对数组内容的修改都会影响到对方。

接下来,我们再来看一个数据操作 append() 的示例:

a := make([]int, 32)
b := a[1:16]
a = append(a, 1)
a[2] = 42

在这段代码中,把 a[1:16] 的切片赋给 b ,此时,ab 的内存空间是共享的,然后,对 a 做了一个 append()的操作,这个操作会让 a 重新分配内存,这就会导致 ab 不再共享,如下图所示:


从图中,我们可以看到,append()操作让 a 的容量变成了64,而长度是33。这里你需要重点注意一下,append()这个函数在 cap 不够用的时候,就会重新分配内存以扩大容量,如果够用,就不会重新分配内存了

我们再来看一个例子:

func main() {
    path := []byte("AAAA/BBBBBBBBB")
    sepIndex := bytes.IndexByte(path,'/')

    dir1 := path[:sepIndex]
    dir2 := path[sepIndex+1:]

    fmt.Println("dir1 =>",string(dir1)) //prints: dir1 => AAAA
    fmt.Println("dir2 =>",string(dir2)) //prints: dir2 => BBBBBBBBB

    dir1 = append(dir1,"suffix"...)

    fmt.Println("dir1 =>",string(dir1)) //prints: dir1 => AAAAsuffix
    fmt.Println("dir2 =>",string(dir2)) //prints: dir2 => uffixBBBB
}

在这个例子中,dir1dir2 共享内存,虽然 dir1 有一个 append() 操作,但是因为 cap 足够,于是数据扩展到了dir2 的空间。下面是相关的图示(注意上图中 dir1dir2 结构体中的 caplen 的变化):


如果要解决这个问题,我们只需要修改一行代码。我们要把代码

dir1 := path[:sepIndex]

修改为:

dir1 := path[:sepIndex:sepIndex]

新的代码使用了 Full Slice Expression,最后一个参数叫“Limited Capacity”,于是,后续的 append() 操作会导致重新分配内存。

深度比较

当我们复制一个对象时,这个对象可以是内建数据类型、数组、结构体、Map……在复制结构体的时候,如果我们需要比较两个结构体中的数据是否相同,就要使用深度比较,而不只是简单地做浅度比较。这里需要使用到反射 reflect.DeepEqual() ,下面是几个示例:

import (
    "fmt"
    "reflect"
)

func main() {

    v1 := data{}
    v2 := data{}
    fmt.Println("v1 == v2:",reflect.DeepEqual(v1,v2))
    //prints: v1 == v2: true

    m1 := map[string]string{"one": "a","two": "b"}
    m2 := map[string]string{"two": "b", "one": "a"}
    fmt.Println("m1 == m2:",reflect.DeepEqual(m1, m2))
    //prints: m1 == m2: true

    s1 := []int{1, 2, 3}
    s2 := []int{1, 2, 3}
    fmt.Println("s1 == s2:",reflect.DeepEqual(s1, s2))
    //prints: s1 == s2: true
}

接口编程

下面,我们来看段代码,其中是两个方法,它们都是要输出一个结构体,其中一个使用一个函数,另一个使用一个“成员函数”。

func PrintPerson(p *Person) {
    fmt.Printf("Name=%s, Sexual=%s, Age=%d\n",
  p.Name, p.Sexual, p.Age)
}

func (p *Person) Print() {
    fmt.Printf("Name=%s, Sexual=%s, Age=%d\n",
  p.Name, p.Sexual, p.Age)
}

func main() {
    var p = Person{
        Name: "Hao Chen",
        Sexual: "Male",
        Age: 44,
    }

    PrintPerson(&p)
    p.Print()
}

你更喜欢哪种方式呢?在 Go 语言中,使用“成员函数”的方式叫“Receiver”,这种方式是一种封装,因为 PrintPerson()本来就是和 Person强耦合的,所以理应放在一起。更重要的是,这种方式可以进行接口编程,对于接口编程来说,也就是一种抽象,主要是用在“多态”,这个技术,我在《Go语言简介(上):接口与多态》中讲过,你可以点击链接查看。

在这里,我想讲另一个Go语言接口的编程模式。

首先,我们来看一段代码:

type Country struct {
    Name string
}

type City struct {
    Name string
}

type Printable interface {
    PrintStr()
}
func (c Country) PrintStr() {
    fmt.Println(c.Name)
}
func (c City) PrintStr() {
    fmt.Println(c.Name)
}

c1 := Country {"China"}
c2 := City {"Beijing"}
c1.PrintStr()
c2.PrintStr()

可以看到,这段代码中使用了一个 Printable 的接口,而 CountryCity 都实现了接口方法 PrintStr() 把自己输出。然而,这些代码都是一样的,能不能省掉呢?

其实,我们可以使用“结构体嵌入”的方式来完成这个事,如下所示:

type WithName struct {
    Name string
}

type Country struct {
    WithName
}

type City struct {
    WithName
}

type Printable interface {
    PrintStr()
}

func (w WithName) PrintStr() {
    fmt.Println(w.Name)
}

c1 := Country {WithName{ "China"}}
c2 := City { WithName{"Beijing"}}
c1.PrintStr()
c2.PrintStr()

引入一个叫 WithName的结构体,但是这会带来一个问题:在初始化的时候变得有点乱。那么,有没有更好的方法呢?再来看另外一个解。

type Country struct {
    Name string
}

type City struct {
    Name string
}

type Stringable interface {
    ToString() string
}
func (c Country) ToString() string {
    return "Country = " + c.Name
}
func (c City) ToString() string{
    return "City = " + c.Name
}

func PrintStr(p Stringable) {
    fmt.Println(p.ToString())
}

d1 := Country {"USA"}
d2 := City{"Los Angeles"}
PrintStr(d1)
PrintStr(d2)

在这段代码中,我们可以看到,我们使用了一个叫Stringable 的接口,我们用这个接口把“业务类型” CountryCity 和“控制逻辑” Print() 给解耦了。于是,只要实现了Stringable 接口,都可以传给 PrintStr() 来使用。

这种编程模式在Go 的标准库有很多的示例,最著名的就是 io.Readioutil.ReadAll 的玩法,其中 io.Read 是一个接口,你需要实现它的一个 Read(p []byte) (n int, err error) 接口方法,只要满足这个规则,就可以被 ioutil.ReadAll这个方法所使用。这就是面向对象编程方法的黄金法则——“Program to an interface not an implementation”。

接口完整性检查

另外,我们可以看到,Go语言的编译器并没有严格检查一个对象是否实现了某接口所有的接口方法,如下面这个示例:

type Shape interface {
    Sides() int
    Area() int
}
type Square struct {
    len int
}
func (s* Square) Sides() int {
    return 4
}
func main() {
    s := Square{len: 5}
    fmt.Printf("%d\n",s.Sides())
}

可以看到,Square 并没有实现 Shape 接口的所有方法,程序虽然可以跑通,但是这样的编程方式并不严谨,如果我们需要强制实现接口的所有方法,那该怎么办呢?

在Go语言编程圈里,有一个比较标准的做法:

var _ Shape = (*Square)(nil)

声明一个 _ 变量(没人用)会把一个 nil 的空指针从 Square 转成 Shape,这样,如果没有实现完相关的接口方法,编译器就会报错:

cannot use (*Square)(nil) (type *Square) as type Shape in assignment: *Square does not implement Shape (missing Area method)

这样就做到了强验证的方法。

时间

对于时间来说,这应该是编程中比较复杂的问题了,相信我,时间是一种非常复杂的事(比如《你确信你了解时间吗?》《关于闰秒》等文章)。而且,时间有时区、格式、精度等问题,其复杂度不是一般人能处理的。所以,一定要重用已有的时间处理,而不是自己干。

在 Go 语言中,你一定要使用 time.Timetime.Duration 这两个类型。

如果你要和第三方交互,实在没有办法,也请使用 RFC 3339 的格式。

最后,如果你要做全球化跨时区的应用,一定要把所有服务器和时间全部使用UTC时间。

性能提示

Go 语言是一个高性能的语言,但并不是说这样我们就不用关心性能了,我们还是需要关心的。下面我给你提供一份在编程方面和性能相关的提示。

参考文档

其实,还有很多不错的技巧,我给你推荐一些参考文档,它们可以帮助你写出更好的Go的代码,必读!

好了,这节课就到这里。如果你觉得今天的内容对你有所帮助,欢迎你帮我分享给更多人。

评论