内容简介:使用 Go 已经一年,深深沉浸在其简洁的设计中,就像其官网描述的:Rob Pike 在Go 的设计目标是取代 C/C++,所以 Go 里面的 struct 和 C 的类似,与 int/float 一样属于
使用 Go 已经一年,深深沉浸在其简洁的设计中,就像其官网描述的:
Go is expressive, concise, clean, and efficient. It’s a fast, statically typed, compiled language that feels like a dynamically typed, interpreted language.
Rob Pike 在 Simplicity is Complicated 中也提到 Go 的简洁是其流行的重要原因。简洁并不意味着简单,Go 有着诸多设计确保了把复杂性隐藏在背后。本文就结合笔者自身经验,来讨论 Go 中 struct/interface 的设计理念与最佳实践,帮助读者写出健壮、高效的 Go 程序。
值类型的 struct
Go 的设计目标是取代 C/C++,所以 Go 里面的 struct 和 C 的类似,与 int/float 一样属于 值类型 ,值类型最重要的特点是在进行赋值时,新变量会得到一份拷贝后的值,这和 Java 中以引用赋值的 Object 有着本质区别。
这意味着,如果要改变 struct 的内部状态,需要将其定义为指针类型 *struct 。
type student struct {
name string
}
foo := student{name: "foo"}
bar := foo
bar.name = "bar"
fmt.Println(foo.name) // 输出 foo
bar2 := &foo
bar2.name = "bar"
fmt.Println(foo.name) // 输出 bar
与之类似的,使用 for range 遍历 []struct map[xx]struct 时,得到的也是一份拷贝。
m := map[int]student{
1: {name: "1"},
}
m[1].name = "2" // 编译错误: cannot assign to struct field m[1].name in map
可以看到,无法直接对 map 中的 struct 进行赋值,这是由于这里的赋值操作是个 read-modify-write 操作,无法保证原子性,所以目前版本的 Go 直接不支持这种操作,可参考 #3117 。
笔者多次遇到这个“坑”,那是不是说把所有的 struct 都定义为指针就好了呢?这里需要了解下 Go 的逃逸分析才能回答这个问题。
逃逸分析
逃逸分析的主要作用是决定对象分配在内存中的位置,Go 会尽量分配在 stack 上,这样的好处显而易见:回收简单,减轻 GC 压力。可以通过 go build -gcflags -m xx.go 查看
func returnByValue(name string) student {
return student{name}
}
func returnByPointer(name string) *student {
return &student{name}
}
./snippet.go:6:18: &student literal escapes to heap
可以看到, returnByPointer 方法的返回值会逃逸,最终分配在 heap 上,关于变量分配在 stack / heap 上的性能差距,可参考: github gist 、 gitee
// snippet.go
package main
import (
"fmt"
)
type student struct {
name string
}
//go:noinline
func (s student) getNameByValue() string {
return s.name
}
//go:noinline
func (s *student) getNameByPointer() string {
return s.name
}
const randStr = "a very long string,a very long string,a very long string,a very long string"
//go:noinline
func returnByValue() student {
return student{randStr}
}
//go:noinline
func returnByPointer() *student {
return &student{randStr}
}
// bench_test.go
package main
import "testing"
var blackholeStr = ""
var blackholeValue student
var blackholePointer *student
func BenchmarkPointerVSStruct(b *testing.B) {
b.Run("return pointer", func(b *testing.B) {
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
blackholePointer = returnByPointer()
}
})
b.Run("return value", func(b *testing.B) {
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
blackholeValue = returnByValue()
}
})
b.Run("value receiver", func(b *testing.B) {
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
r := student{
name: randStr,
}
blackholeStr = r.getNameByValue()
}
})
b.Run("pointer receiver", func(b *testing.B) {
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
r := &student{
name: randStr,
}
blackholeStr = r.getNameByPointer()
}
})
}
测试结果:
go test -run ^NOTHING -bench Struct bench_test.go snippet.go goos: darwin goarch: amd64 BenchmarkPointerVSStruct/return_pointer-8 34476903 32.4 ns/op 16 B/op 1 allocs/op BenchmarkPointerVSStruct/return__value-8 530538498 2.27 ns/op 0 B/op 0 allocs/op BenchmarkPointerVSStruct/value_receiver-8 415309486 2.86 ns/op 0 B/op 0 allocs/op BenchmarkPointerVSStruct/pointer_receiver-8 348904872 3.23 ns/op 0 B/op 0 allocs/op PASS ok command-line-arguments 5.699s
可以看到,方法返回 pointer 时,会有一次 heap 分配,占 16 个字节,这正好是 name 字段(string 类型)的大小,8 个字节表示指向数据的指针,8 个字节表示长度(笔者为 64 位系统),类似下面的结构
type StringHeader struct {
Data uintptr
Len int
}
方法返回 value 时,则没有 heap 分配,说明所有变量都分配在 stack 上。
对于 receiver 为 pointer 或 value 性能则无差别,这是因为 s 在两种情况下均无逃逸,所以都分配在了 stack 上,这也说明变量分配在那里与是否为指针无关。
value vs pointer
结合上面的实验,可以按照下述流程确定选用 value/pointer:
unsafe.Sizeof(struct)
为了确定出 2 中的阈值,可以在 struct 中添加一数组元素,之后再来跑上述测试即可,在笔者机器中,这个阈值大概为 72K,很少有 struct 会达到这个量级,这是由于 Go 中常用的 slice/map/string 均为复合类型(可认为由 header+data 两部分组成),在 struct 的结构中,只保存 header 部分,所以大小是固定的,而 array 有的地方也不是很多,所以读者可认为只要 struct 状态不需要改变,value 则是最佳选择。
type student struct {
name string
dummy [9000]int64 // 添加一数组元素
}
BenchmarkPointerVSStruct/return_pointer-8 150147 8147 ns/op 73728 B/op 1 allocs/op
BenchmarkPointerVSStruct/return__value-8 138591 8146 ns/op 0 B/op 0 allocs/op
| 简单类型 | 复合类型 |
|---|---|
| bool | slice |
| numeric | map |
| (unsafe)pointer | channel |
| struct | function |
| array | interface |
| string |
map[string]uint64{
"ptr": uint64(unsafe.Sizeof(&struct{}{})),
"map": uint64(unsafe.Sizeof(map[bool]bool{})),
"slice": uint64(unsafe.Sizeof([]struct{}{})),
"chan": uint64(unsafe.Sizeof(make(chan struct{}))),
"func": uint64(unsafe.Sizeof(func() {})),
"interface": uint64(unsafe.Sizeof(interface{}(0))),
}
// 输出
map[chan:8 func:8 interface:16 map:8 ptr:8 slice:24]
可以看到,
- chan/func/map/ptr 均为 8 个字节,即一个指向具体数据的指针
- interface 为 16,两个指针,一个指向具体类型,一个指向具体数据。细节可参考 Russ Cox 的 Go Data Structures: Interfaces
- slice 为 24,包括一个指向底层 array 的指针,两个整型,分布表示 cap、len
内存对齐
struct 中的字段会按照机器字长进行对齐,所以在性能要求比较高的地方,可以尽量把相同类型的字段放一起。
fmt.Println(
unsafe.Sizeof(struct {
a bool
b string
c bool
}{}),
unsafe.Sizeof(struct {
a bool
c bool
b string
}{}),
)
上述代码会依次输出 32 24 ,下面的图示清晰的展示了两个顺序的 struct 在内存中的布局:( 图片来源 )
基于组合的 interface
如果说 struct 是对状态的封装,那么 interface 就是对行为的封装,是 Go 中构造抽象的基础。由于 Go 中没有 oop 的概念,主要是通过组合,而非继承来实现不同组件的整合,比如 io 包下的 Reader/Writer。
但就组合来说,并没有什么优势,Java 中也可以实现,但 Go 中的隐式“继承” 让组合变得十分灵活。
Embedded struct
下面通过一示例进行说明:
type RecordWriter struct {
code int
http.ResponseWriter
}
func (rw *RecordWriter) WriteHeader(statusCode int) {
rw.code = statusCode
rw.ResponseWriter.WriteHeader(statusCode)
}
func URLStat(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
// if w.WriteHeader isn't called inside handlerFunc, 200 is the default code.
rw := &RecordWriter{ResponseWriter: w, code: 200}
next(rw, r)
metrics.HTTPReqs.WithLabelValues(r.URL.Path, r.Method, strconv.FormatInt(int64(rw.code), 10)).Inc()
}
上述代码片段为 negroni 中的一个 middleware,用来记录 http code。自定义 Writer 通过嵌入 ResponseWriter,实现了 ResponseWriter 接口,然后通过重写 WriteHeader 的方式来实现业务需求,由于需要改变状态,所以采用指针类型 *RecordWriter 来作为 receiver,整个实现非常简洁扼要。
New func type
第二个示例是关于如何通过自定义 type,来达到简化 err 处理的目的。在 net/http 中,handlerFunc 没有返回值,这就导致在每个异常处理的后面加上一个空的 return 来中止逻辑处理,这样不仅繁琐,还容易遗漏,
func viewRecord(w http.ResponseWriter, r *http.Request) {
c := appengine.NewContext(r)
key := datastore.NewKey(c, "Record", r.FormValue("id"), 0, nil)
record := new(Record)
if err := datastore.Get(c, key, record); err != nil {
http.Error(w, err.Error(), 500)
return
}
if err := viewTemplate.Execute(w, record); err != nil {
http.Error(w, err.Error(), 500)
}
}
这时便可通过自定义新类型来解决这个问题:
type appError struct {
Error error
Message string
Code int
}
type appHandler func(http.ResponseWriter, *http.Request) appError
func (fn appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if e := fn(w, r); e != nil { // e is *appError, not os.Error.
c := appengine.NewContext(r)
c.Errorf("%v", e.Error)
http.Error(w, e.Message, e.Code)
}
}
func viewRecord(w http.ResponseWriter, r *http.Request) appError {
c := appengine.NewContext(r)
key := datastore.NewKey(c, "Record", r.FormValue("id"), 0, nil)
record := new(Record)
if err := datastore.Get(c, key, record); err != nil {
return appError{err, "Record not found", 404}
}
if err := viewTemplate.Execute(w, record); err != nil {
return appError{err, "Can't display record", 500}
}
return appError{}
}
mux.HandleFunc("/view", appHandler(viewRecord))
可以看到,上述示例通过定义 appHandler 新函数类型,并隐式“继承” http.Handler 接口来达到了统一集中处理 err 的需求。
该实现漂亮的地方为函数增加新类型,且函数签名与 ServeHTTP 一致,这样就可以直接复用参数。对于初学者来说,可能没想到也可以给 func 类型来定义方法,但是在 Go 中,可以给任何类型增加方法。
之前在网上看到一些框架,采用 panic 的方式来简化 err 处理,感觉这属于对 panic 的滥用,先不说对性能是否有损耗,更主要的是破坏了 if err != nil 的处理方式。希望读者在后续处理繁琐的逻辑时,多去考虑如何抽象新类型来解决。
总结
Go 的精妙设计保证了其简洁的特性,而且这些特性可能和传统的 oop 不同,这对于从这些语言转过来的读者来说会采用旧思维去思考问题,这无可厚非,但作为优秀的 Go 程序员,更多的需要从 Go 自身特点来考虑问题,这样就不至于产生“为什么 XX 特性在 Go 中没有”的疑惑,要知道 Go 的作者可是 Rob Pike, Ken Thompson :-)
如果读者阅读/实现过基于 interface 的精巧设计,欢迎留言分享。
参考
以上所述就是小编给大家介绍的《Go struct/interface 最佳实践》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!
猜你喜欢:- vue项目实践004~~~一篮子的实践技巧
- HBase实践 | 阿里云HBase数据安全实践
- Spark 实践:物化视图在 SparkSQL 中的实践
- Spark实践|物化视图在 SparkSQL 中的实践
- HBase实践 | 数据人看Feed流-架构实践
- Kafka从上手到实践-实践真知:搭建Zookeeper集群
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
有限与无限的游戏
[美]詹姆斯·卡斯 / 马小悟、余倩 / 电子工业出版社 / 2013-10 / 35.00元
在这本书中,詹姆斯·卡斯向我们展示了世界上两种类型的「游戏」:「有限的游戏」和「无限的游戏」。 有限的游戏,其目的在于赢得胜利;无限的游戏,却旨在让游戏永远进行下去。有限的游戏在边界内玩,无限的游戏玩的就是边界。有限的游戏具有一个确定的开始和结束,拥有特定的赢家,规则的存在就是为了保证游戏会结束。无限的游戏既没有确定的开始和结束,也没有赢家,它的目的在于将更多的人带入到游戏本身中来,从而延续......一起来看看 《有限与无限的游戏》 这本书的介绍吧!