写点什么

翻译: Effective Go (5)

用户头像
申屠鹏会
关注
发布于: 2020 年 08 月 14 日

数据

new 分配内存

Go 有两个分配新内存的语法,内置函数 new 和 make。它们执行不同的操作并应用于不同的类型,这虽然有点困惑,但是规则很简单。我们先来讨论 new。这是一个内置的函数,可以申请内存空间,但与其他一些语言中的同名函数不同的是,它不对内存进行初始化,只是将内存给予零值。也就是说,new(T)为类型 T 分配零值内存,并返回它的地址,即*T 类型的值。用 Go 的术语来说,它返回一个指针,指向新分配 T 类型的零值。

由于 new 返回的内存是零值化的,所以在设计数据结构时,每个类型的零值可以不需要初始化函数就可以使用。这意味着数据结构的用户可以用 new 创建一个数据结构,然后直接开始工作。例如,bytes.Buffer 的文档中指出:Buffer 的零值是一个准备使用的空缓冲区同样,syn.Mutex 也没有一个显示的构造函数或 init 方法,想法,syn.Mutex 的零值被定义为一个未锁定的锁。

零值可以被用来中间过度,考虑下面的类型声明:

type SyncedBuffer struct {    lock    sync.Mutex    buffer  bytes.Buffer}
复制代码

SyncedBuffer 类型的值可以通过分配或声明进行使用。下面,p 和 v 不需要额外的处理就会同时正确的工作:

p := new(SyncedBuffer)  // type *SyncedBuffervar 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 语言不同的是,Go 返回局部变量的地址是完全可以的。在函数返回后,该变量关联的内存会继续存在。事实上,每次获取一个复合字面量的地址时,都会分配一个新的实例, 因此我们可以将上面的最后两行代码进行合并。

return &File{fd, name, nil, 0}
复制代码

复合字面量的字段是按顺序排列的,并且必须全部列出。但是,通过字段:值进行明确标注的时候,初始化可以以任意顺序出现,缺失的字段作为零值。因此,我们可以这么写。

return &File{fd: fd, name: name}
复制代码

在少数情况下,如果一个复合字面量不包含任何字段,那么它会创建该类型的零值。new(File)和 &File{}是等价的。

复合字面量也可以创建数组,切片(slices),映射(map),字段自动变为索引或者键(key)。在这些例子中,无论 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 个元素的切片结构。(make 切片时,可以省略容量,更多信息可以看切片这一节)。与 make 相反,new([]int)返回一个指向新分配的,零值的切片指针,即指向 nil 的指针。

var p *[]int = new([]int)       // allocates slice structure; *p == nil; rarely usefulvar v  []int = make([]int, 100) // the slice v now refers to a new array of 100 ints
// Unnecessarily complex:var p *[]int = new([]int)*p = make([]int, 100, 100)
// Idiomatic: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) // Note the explicit address-of operator
复制代码

但这不是 Go 的风格,通常会使用切片(slices)

切片(slices)

切片封装了数组,为序列化数据提供了一个更通用,强大和方便的接口。除了具有显式唯独的项目,如矩阵变换等,Go 中的大多数数组编程都是用切片而不是简单的数组来完成的。

切片保存了对底层数组的引用,如果你把一个切片赋给另一个切片,那么两个切片都会指向同一个数组。如果一个函数接受一个切片,那么它对切片中元素的修改对调用方也是可见的,这就类似传递一个指向底层数组的指针。因此,Read 函数可以接受一个切片参数,而不是一个指针和一个计数 count。切片中的长度设置了一个读取数据的上限,下面是 os 包中的 File 类型 Read 方法的签名。

func (f *File) Read(buf []byte) (n int, err error)
复制代码

该方法返回读取的字节数和一个错误值(如果有的话)。要从一个较大的缓冲区 buf 读取前 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])  // Read one byte.        n += nbytes        if nbytes == 0 || e != nil {            err = e            break        }    }
复制代码

切片的长度可以改变,只要它仍然适合底层的数组,只需将它分配给一个自身即可。切片的容量可以通过内置函数 cap 访问,它表示了切片的最大长度。又一个函数可以将数据追加到切片中,如果数据超过了容量,切片会被重新分配。所得到的切片会被返回。该函数利用 len 和 cap 在应用到 nil 切片是合法的事实,并返回 0.

func Append(slice, data []byte) []byte {    l := len(slice)    if l + len(data) > cap(slice) {  // reallocate        // Allocate double what's needed, for future growth.        newSlice := make([]byte, (l+len(data))*2)        // The copy function is predeclared and works for any slice type.        copy(newSlice, slice)        slice = newSlice    }    slice = slice[0:l+len(data)]    copy(slice[l:], data)    return slice}
复制代码

最终我们必须返回 slice,因为虽然 append 可以修改 slice 的元素,但 slice 本身(保存指针,长度和容量的运行时数据结构)是通过值传递的。

向切片追加数据的想法非常有用,所以有了内置的 append 函数。不过要理解这个函数的设计,需要更多的信息,所以稍后再进行讨论。

二维切片

Go 的数组和切片是一维的,要创建一个相当于二维数组或二维切片的数组或切片,需要定义一个数组的数组或切片的切片,就像这样:

type Transform [3][3]float64  // A 3x3 array, really an array of arrays.type LinesOfText [][]byte     // A slice of byte slices.
复制代码

由于切片的长度是可变的,所以可以让每个内部的切片有不同的长度。这可能是一种常见的情况,就像我们的 LinesOfText 例子:每一行都有一个独立的长度。

text := LinesOfText{	[]byte("Now is the time"),	[]byte("for all good gophers"),	[]byte("to bring some fun to the party."),}
复制代码

有时需要分配一个二维的切片,比如在处理按行扫描像素时。有两种方式可以实现。一种是独立分配每个切片;另一种是分配一个单独的数组,并将单个切片指向其中。使用哪种方法取决于你的应用。如果切片可能会增长或缩小,就应该独立分配,以避免覆盖下一行;如果不是,用单个分配来构造会更有效率。下面是两种方式的大概代码,仅供参考。首先,一次一行:

// Allocate the top-level slice.picture := make([][]uint8, YSize) // One row per unit of y.// Loop over the rows, allocating the slice for each row.for i := range picture {	picture[i] = make([]uint8, XSize)}
复制代码

然后是一次分配,按行切片

// Allocate the top-level slice, the same as before.picture := make([][]uint8, YSize) // One row per unit of y.// Allocate one large slice to hold all the pixels.pixels := make([]uint8, XSize*YSize) // Has type []uint8 even though picture is [][]uint8.// Loop over the rows, slicing each row from the front of the remaining pixels slice.for i := range picture {	picture[i], pixels = pixels[:XSize], pixels[XSize:]}
复制代码

映射

映射是一种方便且强大的内建数据结构,可以将一种类型的值(key)与另一种类型的值(value)关联起来。键(key)可以是定义了相等运算符的任意类型,例如整数,浮点数和复数,字符串,指针,接口(只要动态类型支持相等),结构和数组。切片不能用作键,因为它没有定义相等性。和切片一样,映射保存对底层数据结构的引用,如果你把一个映射传递给一个函数并修改其内容,那么此修改对调用方也是可见的。

映射可以用通常的复合字面量语法进行构建,其 key-value 键值对使用冒号分隔,因此,在初始化过程中很容易创建映射。

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.可以用值为布尔类型的映射实现集合。将映射值设为 true,将键设为要放入的值,然后通过简单的索引进行测试。

attended := map[string]bool{    "Ann": true,    "Joe": true,    ...}
if attended[person] { // will be false if person is not in the map fmt.Println(person, "was at the meeting")}
复制代码

有时需要区分缺失的值和零值。是有一个“UTC”的 value 为 0,还是它根本不在映射中才返回 0?这时可以用一种多重赋值的方式来区分。

var seconds intvar ok boolseconds, ok = timeZone[tz]
复制代码

出于明显的原因,这被称为“逗号 ok”用法,在这个例子中,如果 tz 存在,秒数会被设置为 true,如果不存在秒数会被设置为 0,同时 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")  // Now on Standard Time
复制代码

打印输出(printing)

Go 的格式化打印使用了类似 C 语言 printf 系列风格,但是更丰富和通用。这些函数在 fmt 包中,并首字母大写:fmt.Printf, fmt.Fprintf, fmt.Sprintf 等。字符串函数(Stringf 等)返回一个字符串,而不是填入提供的缓冲区。

你不需要提供一个格式化的字符,对于每个 Printf, Fprintf 和 Springf 都有另外一对函数,例如 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 - 1fmt.Printf("%d %x; %d %x\n", x, x, int64(x), int64(x))
复制代码

会打印

18446744073709551615 ffffffffffffffff; -1 -1
复制代码

如果你只是想要默认的转换,例如整数的十进制转换,可以使用通用格式 %v(表示 value 的意思);结果和 Print 和 PrintLn 所产生的结果完全一样。此外,该格式还可以打印任何值,甚至是数组,切片,结构和映射。下面是上一节定义的时区映射的打印语句。

fmt.Printf("%v\n", timeZone)  // or just fmt.Println(timeZone)
复制代码

会输出

map[CST:-21600 EST:-18000 MST:-25200 PST:-28800 UTC:0]
复制代码

对于映射来说,Printf 类的函数会按照键进行词法排序。当打印一个结构时,修改后的格式 % +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, "EST":-18000, "MST":-25200, "PST":-28800, "UTC":0}
复制代码

(注意 &号)当遇到字符串类型或[]byte 类型的值时,可以用 %q 获取带引号的字符串。%#q 会尽可能使用反引号。(%q 适用于整数和 rune,产生单引号的字符常量)此外,%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。如果 Sprintf 调用试图将接收方直接打印为字符串,则可能会发生这种情况,,而 String 又会在此调用该方法。如本例所示,是一个常见且容易犯的错误。

type MyString string
func (m MyString) String() string { return fmt.Sprintf("MyString=%s", m) // Error: will recur forever.}
复制代码

不过这很容易修复:将参数变为基本的字符串类型,它就没有 String 方法了。

type MyString stringfunc (m MyString) String() string {    return fmt.Sprintf("MyString=%s", string(m)) // OK: note conversion.}
复制代码

在初始化部分,我们可以看到另一种避免无限递归的方式。

另一种打印技巧是传递一个打印函数给另一个打印函数,Printf 使用 interface{}作为最后一个参数,以指定可以在格式之后显示任意数量的参数(任意类型)。

func Printf(format string, v ...interface{}) (n int, err error) {

在函数 Printf 中,v 的作用类似于[]interface{}类型的变量,但如果将其传递给另一个可变参数的函数,则其作用类似于常规的参数列表。这是我们上面使用的功能 log.Println 的实现。它将其参数直接传递给 fmt.Sprintln 进行实际的格式化。

// Println prints to the standard logger in the manner of fmt.Println.func Println(v ...interface{}) {    std.Output(2, fmt.Sprintln(v...))  // Output takes parameters (int, string)}
复制代码

v 后面的...以告诉编译器将 v 视为参数列表,否则它将 v 作为单个 slice 参数进行传递。除了这里介绍的内容外,还有更多有关 print 的内容,有关详细信息可以参见 fmt 包的 godoc 文档。

顺便提一下,一个...参数可以是具体的类型,例如...int 可以用于选择最小整数列表的 mini 函数。

func Min(a ...int) int {    min := int(^uint(0) >> 1)  // largest int    for _, i := range a {        if i < min {            min = i        }    }    return min}
复制代码

附加(append)

现在我们还没解释内建函数 append 的设计。append 的签名与上面自定义的 Append 函数不同,大致来说,它是这样的:

func append(slice []T, elements ...T) []T

其中 T 是给任何给定类型的占位符,实际上你无法在 Go 中编写一个由调用者确定类型 T 的函数。这就是内置 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 另一个切片该怎么办?很简单:用...进行传参。下面的代码会和上面的输出一样:

x := []int{1,2,3}y := []int{4,5,6}x = append(x, y...)fmt.Println(x)
复制代码

如果没有...,将会因为类型错误而无法变异,因为 y 不是 int 类型


发布于: 2020 年 08 月 14 日阅读数: 53
用户头像

申屠鹏会

关注

enjoy~ 2018.11.08 加入

https://xabc.site

评论

发布
暂无评论
翻译: Effective Go (5)