左耳听风-Go语言编程模式:Go Generation

前言

Go 语言的代码生成主要还是用来解决编程泛型的问题。泛型编程主要是解决这样一个问题:因为静态类型语言有类型,所以,相关的算法或是对数据处理的程序会因为类型不同而需要复制一份,这样会导致数据类型和算法功能耦合。

我之所以说泛型编程可以解决这样的问题,就是说,在写代码的时候,不用关心处理数据的类型,只需要关心相关的处理逻辑。

泛型编程是静态语言中非常非常重要的特征,如果没有泛型,我们就很难做到多态,也很难完成抽象,这就会导致我们的代码冗余量很大。

现实中的类比

我们用螺丝刀来做打比方,螺丝刀本来只有一个拧螺丝的作用,但是因为螺丝的类型太多,有平口的,有十字口的,有六角的……螺丝还有不同的尺寸,这就导致我们的螺丝刀为了要适配各种千奇百怪的螺丝类型(样式和尺寸),也是各种样式的。

而真正的抽象是,螺丝刀不应该关心螺丝的类型,它只要关注自己的功能是不是完备,并且让自己可以适配不同类型的螺丝就行了,这就是所谓的泛型编程要解决的实际问题。

Go 语方的类型检查

因为 Go 语言目前并不支持真正的泛型,所以,只能用 interface{} 这样的类似于 void* 的过度泛型来玩,这就导致我们要在实际过程中进行类型检查。

Go 语言的类型检查有两种技术,一种是 Type Assert,一种是 Reflection。

Type Assert

这种技术,一般是对某个变量进行 .(type)的转型操作,它会返回两个值,分别是 variable 和 error。 variable 是被转换好的类型,error 表示如果不能转换类型,则会报错。

在下面的示例中,我们有一个通用类型的容器,可以进行 Put(val)和 Get(),注意,这里使用了 interface{}做泛型。

1
2
3
4
5
6
7
8
9
10
11
12
13
//Container is a generic container, accepting anything.
type Container []interface{}

//Put adds an element to the container.
func (c *Container) Put(elem interface{}) {
*c = append(*c, elem)
}
//Get gets an element from the container.
func (c *Container) Get() interface{} {
elem := (*c)[0]
*c = (*c)[1:]
return elem
}

我们可以这样使用:

1
2
3
intContainer := &Container{}
intContainer.Put(7)
intContainer.Put(42)

但是,在把数据取出来时,因为类型是 interface{} ,所以,你还要做一个转型,只有转型成功,才能进行后续操作(因为 interface{}太泛了,泛到什么类型都可以放)。

下面是一个 Type Assert 的示例

1
2
3
4
5
6
7
// assert that the actual type is int
elem, ok := intContainer.Get().(int)
if !ok {
fmt.Println("Unable to read an int from intContainer")
}

fmt.Printf("assertExample: %d (%T)\n", elem, elem)

Reflection

对于 Reflection,我们需要把上面的代码修改如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
type Container struct {
s reflect.Value
}
func NewContainer(t reflect.Type, size int) *Container {
if size <=0 { size=64 }
return &Container{
s: reflect.MakeSlice(reflect.SliceOf(t), 0, size),
}
}
func (c *Container) Put(val interface{}) error {
if reflect.ValueOf(val).Type() != c.s.Type().Elem() {
return fmt.Errorf("Put: cannot put a %T into a slice of %s",
val, c.s.Type().Elem()))
}
c.s = reflect.Append(c.s, reflect.ValueOf(val))
return nil
}
func (c *Container) Get(refval interface{}) error {
if reflect.ValueOf(refval).Kind() != reflect.Ptr ||
reflect.ValueOf(refval).Elem().Type() != c.s.Type().Elem() {
return fmt.Errorf("Get: needs *%s but got %T", c.s.Type().Elem(), refval)
}
reflect.ValueOf(refval).Elem().Set( c.s.Index(0) )
c.s = c.s.Slice(1, c.s.Len())
return nil
}

这里的代码并不难懂,这是完全使用 Reflection 的玩法,我简单解释下。

  • 在 NewContainer()时,会根据参数的类型初始化一个 Slice。
  • 在 Put()时,会检查 val 是否和 Slice 的类型一致。
  • 在 Get()时,我们需要用一个入参的方式,因为我们没有办法返回 reflect.Value 或 interface{},不然还要做 Type Assert。
  • 不过有类型检查,所以,必然会有检查不对的时候,因此,需要返回 error。

于是,在使用这段代码的时候,会是下面这个样子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
f1 := 3.1415926
f2 := 1.41421356237

c := NewMyContainer(reflect.TypeOf(f1), 16)

if err := c.Put(f1); err != nil {
panic(err)
}
if err := c.Put(f2); err != nil {
panic(err)
}

g := 0.0

if err := c.Get(&g); err != nil {
panic(err)
}
fmt.Printf("%v (%T)\n", g, g) //3.1415926 (float64)
fmt.Println(c.s.Index(0)) //1.4142135623

他山之石

对于泛型编程最牛的语言 C++ 来说,这类问题都是使用 Template 解决的。

1
2
3
4
5
6
7
//用<class T>来描述泛型
template <class T>
T GetMax (T a, T b) {
T result;
result = (a>b)? a : b;
return (result);
}
1
2
3
4
5
6
7
int i=5, j=6, k; 
//生成int类型的函数
k=GetMax<int>(i,j);

long l=10, m=5, n;
//生成long类型的函数
n=GetMax<long>(l,m);

C++ 的编译器会在编译时分析代码,根据不同的变量类型来自动化生成相关类型的函数或类,在 C++ 里,叫模板的具体化。

Go Generator

要玩 Go 的代码生成,你需要三个东西:

  1. 一个函数模板,在里面设置好相应的占位符;
  2. 一个脚本,用于按规则来替换文本并生成新的代码;
  3. 一行注释代码。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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 语言接口的编程模式。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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()

结构体嵌入法优化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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()

使用接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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 的接口,我们用这个接口把“业务类型” Country 和 City 和“控制逻辑” Print() 给解耦了。于是,只要实现了Stringable 接口,都可以传给 PrintStr() 来使用。

这就是面向对象编程方法的黄金法则——“Program to an interface not an implementation”。

接口完整性检查

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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())
}

quare 并没有实现 Shape 接口的所有方法,程序可以跑通.

强验证的方法

1
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.Time 和 time.Duration 这两个类型。

  • 在命令行上,flag 通过 time.ParseDuration 支持了 time.Duration。JSON 中的 encoding/
  • json 中也可以把time.Time 编码成 RFC 3339 的格式。
  • 数据库使用的 database/sql 也支持把 DATATIME 或 TIMESTAMP 类型转成 time.Time。
  • YAML 也可以使用 gopkg.in/yaml.v2 支持 time.Time 、time.Duration 和 RFC 3339 格式。

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

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

性能提示

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

  • 如果需要把数字转换成字符串,使用 strconv.Itoa() 比 fmt.Sprintf() 要快一倍左右。
  • 尽可能避免把String转成[]Byte ,这个转换会导致性能下降。
  • 如果在 for-loop 里对某个 Slice 使用 append(),请先把 Slice 的容量扩充到位,这样可以避免内存重新分配以及系统自动按 2 的 N 次方幂进行扩展但又用不到的情况,从而避免浪费内存。
  • 使用StringBuffer 或是StringBuild 来拼接字符串,性能会比使用 + 或 +=高三到四个数量级。
  • 尽可能使用并发的 goroutine,然后使用 sync.WaitGroup 来同步分片操作。
  • 避免在热代码中进行内存分配,这样会导致 gc 很忙。尽可能使用 sync.Pool 来重用对象。
  • 使用 lock-free 的操作,避免使用 mutex,尽可能使用 sync/Atomic包(关于无锁编程的相关话题,可参看《无锁队列实现》或《无锁 Hashmap 实现》)。
  • 使用 I/O 缓冲,I/O 是个非常非常慢的操作,使用 bufio.NewWrite() 和 bufio.NewReader() 可以带来更高的性能。
  • 对于在 for-loop 里的固定的正则表达式,一定要使用 regexp.Compile() 编译正则表达式。性能会提升两个数量级。
  • 如果你需要更高性能的协议,就要考虑使用 protobuf 或 msgp 而不是 JSON,因为 JSON 的序列化和反序列化里使用了反射。
  • 你在使用 Map 的时候,使用整型的 key 会比字符串的要快,因为整型比较比字符串比较要快。

左耳听风-Go语言编程模式:Go Generation
http://blog.chcaty.cn/2021/10/18/zuo-er-ting-feng-go-yu-yan-bian-cheng-mo-shi-go-generation/
作者
caty
发布于
2021年10月18日
许可协议