为什么不建议在go项目中使用init()

2022年6月23日 428点热度 0人点赞 0条评论

最近在学习Golang,所以将学习过程记为笔记,以后翻阅的时候也方便,顺便也给大家做一点分享,希望能坚持下去。


关注本公众号,即可领取视频教程

2022年GO语言全套精讲系列-入门到精通96集


学习与交流:Go语言技术微信群

商务合作加微信:LetsFeng


课本,文档学习Go语言,个人强烈推荐这本书


现在就开始你的Go语言学习之旅吧!人生苦短,let’s Go.



图片

图片

前言

go的 init函数给人的感觉怪怪的,我想不明白聪明的 google团队为何要设计出这么一个“鸡肋“的机制。实际编码中,我主张尽量不要使用init函数。

首先来看看 init函数的作用吧。

init() 介绍

init()与包的初始化顺序息息相关,所以先介绍一个go中包的初始化顺序吧。(下面的内容部分摘自《The go programinng language》)

大体而言,顺序如下:

  • 首先初始化包内声明的变量

  • 之后调用 init 函数

  • 最后调用 main 函数

变量的初始化顺序

变量的初始化顺序由他们的依赖关系决定

应该任何强类型语言都是这样子吧。

例如:

var a = b + c;
var b = f();    // 需要调用 f() 函数
var c = 1
func f() int{return c + 1;}

a 依赖 b 和 cb 依赖 f()f() 依赖 c。因此,他们的初始化顺序理所当然是 c -> b -> a

graph TB; b-->a c-->a f-->b c-->b

Ps:其实在这里可能引申出一个没用的小技巧。当你有一个函数需要在包被初始化的过程中被调用时,你可以把这个函数赋值给一个包级变量。

这样,当包被初始化时就会自动调用这个函数了,这个函数甚至能够在 init() 之前被调用!不过话说回来,它既然比 init() 更早被调用,那它才是真正的 init() 才对;此外你也可以在 init() 中调用该函数,这样才更合理一些。

// 笨版
// 函数必须得有一个返回值才行
var _ = func() interface{} {
    fmt.Println("hello")
    return nil
}()

func init() {
    fmt.Println("world")
}

func main() {

}
// Output:
// hello
// world

// 更合理的版本
func init() {
    fmt.Println("hello")
    fmt.Println("world")
}

func main() {

}
// Output:
// hello
// world

包内变量的初始化顺序

一个包内往往有多个 go文件,这么go文件的初始化顺序由它们被提交给编译器的顺序决定,顺序和这些文件的名字有关。

init()

主角出场了。先来看看它的设计动机吧:

Each variable declared at package level starts life with the value of its initializer expression, if any, but for some variables, like tables of data,an initializer expression may not be the simplest way to set its initial value.In that case,the init function mechanism may be simpler. 《The go pragramming language P44》

这句话的意思是有的包级变量没办法用一条简单的表达式来初始化,这个 时候,init机制就派上用场了。

init() 不能被调用,也不能被 reference,它们会在程序启动时自动执行。

同一个 go 文件中 init 函数的调用顺序

一个包内,甚至 go 文件内可以包含多个 init(),同一个 go 文件中的 init() 调用顺序由他们的声明顺序决定 。

func init() {
    fmt.Print("a")
}
func init() {
    fmt.Print("b")
}
func init() {
    fmt.Print("c")
}
// Output
// abc

同一个包下面不同 go 文件中 init() 的调用顺序

依旧是由它们的声明顺序决定,同一个包下面的所有go 文件在编译时会被编译器合并成一个“大的go文件“(并不是真正合并,仅仅是效果类似而已)。合并的顺序由编译器决定。

不要把程序是否能够正常工作寄托在init()能够按照你期待的顺序被调用上。

不过话说回来,正经人谁在一个包里写很多 init() 呀,而且还把这些 init() 放在不同文件里,更可恶的是每个文件里还有多个 init()。要是看到这样的代码,我立马:@#$%^&*...balabala...

一个包里最多写一个init()(我甚至觉得最好连一个 init() 都不要有)

不同包内 init 函数的调用顺序

唯独这个顺序,我们程序员是绝对可控的。它们的调用顺序由包之间的依赖关系决定。假设 a包需要 import b包,b包需要import c包,那么很显然他们的调用顺序是,c包的init()最先被调用,其次是b包,最后是a包。

graph LRc-->bb-->a

一个包的init函数最多会被调用一次

道理类似于一个变量最多会被初始化一次。

有的同学会问,一个变量明明可以多次赋值呀,可第二次对这个变量赋值那还能够叫初始化么?

例如有如下的包结构,B包和C包都分别import A包,D包需要import B包和C包。

graph TD; A-->B A-->C B-->D C-->D

在 A包中有 init()

func init() {
    fmt.Println("hello world")
}

D包是 main 包,最终程序只输出了一句 hello world

我不喜欢 init 函数的原因

我不喜欢 init 函数的一个重要原因是,它会隐藏掉程序的一些细节,它会在没有经过你同意的情况下,偷偷干一些事情。go 的函数王国里,所有的函数都需要程序员显示的调用(Call)才会被执行,只有它——init(),是个例如,你明明没 Call 它,它却偷偷执行了。

有的同学会说,c++ 里类的构造函数也是在对象被创建时就会默默执行呀。确实是这样,但在 c++ 里,当你点进这个类的定义时,你就能立马看到它的构造函数和析构函数。

在 go 里,当你点进某个包时,你能立马看到包内的init()么?这个包有没有init()以及有几个init()完全是个未知数,你需要在包内的所有文件中搜索 init() 这个关键字才能摸清包的 init()情况,而大多数人包括我懒得费这个功夫。

c++中创建对象时,程序员能够很清楚的意识到这个操作会触发这个类的构造函数,这个构造函数的内容也能很快找到;但在 go 中,import 包时,一切却没那么清晰了。

希望将来 goland 或者 vscode 能够分析包内的 init() 情况,这样我对 init() 的恶意会减半。

init() 给项目维护带来的困难

当你看到这样的 import 代码时

import(
    _ "pkg"
)

你立马能够知道,这个 import 的目的就是调用 pkg 包的 int()

当看到

import(
    "pkg"
)

你却很难知道,pkg 包里藏着一个 init(),它被偷偷调用了。

但这还好,你起码知道如果 pkg 包有 init() 的话,它会在此处被调用。

但当pkg 包,被多个包 import 时,pkg 包内的 init() 何时被调用的,就是一个谜了。你得搞清楚这些包之间的 import 先后顺序关系,这是一场噩梦。

使用 init()的时机

先说一下我的结论:我认为 init()应该仅被用来初始化包内变量。

《The go programming language》提供了一个使用 init函数的例子。

// pc[i] 是 i 中 bit = 1 的数量
var pc [256]byte

func init() {
    for i := range pc {
        pc[i] = pc[i/2] + byte(i&1)
    }
}

// 返回 x 中等于 1 的 bit 的数量
func PopCount(x uint64) int {
    return int(pc[byte(x>>(0*8))] +
        pc[byte(x>>(1*8))] +
        pc[byte(x>>(2*8))] +
        pc[byte(x>>(3*8))] +
        pc[byte(x>>(4*8))] +
        pc[byte(x>>(5*8))] +
        pc[byte(x>>(6*8))] +
        pc[byte(x>>(7*8))])
}
PopCount 函数的作用数计算数字中等于 1 的 bit 的数量。例如 :
var i uint64 = 2

变量 i 的二进制表示形式为

0000000000000000000000000000000000000000000000000000000000000010

把它传入 PopCount 最终得到的结果将为 1,因为它只有一个 bit 的值为 1

pc 是一个表,它的 index 为 x,其中 0 <= x <= 255,value 为 x 中等于 1 的 bit 的数量。

它的初始化思想是:

  • 如果一个数x最后的 bit 为 1,那么这个数值为 1 的bit数 = x/2 的值为1的bit数 + 1;

  • 如果一个数x最后的 bit 为 0,那么这个数值为 1 的bit数 = x/2 的值为1的bit数;

在 PopCount 中把一个 8byte 数拆成了 8 个单 byte 数,分别计算这8个单 byte 数中 bit 为 1 的数量,最后累加即可。

这里 pc 的初始化确实比较复杂,无法直接用

var pc = []byte{011,...}

这种形式给出。

一个可以替代 init()的方法是:

var pc = generatePc()

func generatePc() [256]byte {
    var localPc [256]byte
    for i := range localPc {
        localPc[i] = localPc[i/2] + byte(i&1)
    }
    return localPc
}

我觉得这样子初始化比利用 init() 初始化要更好,因为你可以立马知道 pc 是怎样得来的,而利用 init() 时,你需要利用 ide 来查找 pc 的 write reference,之后才能知道,哦,原来它(pc)来这里(init()) 被初始化了呀。

当包内有多个变量的初始化流程比较复杂时,可能会写出如下代码。

var pc = generatePc()
var pc2 = generatePc2()
var pc3 = generatePc3()
// ...

有的同学可能不太喜欢这种写法,那么用上 init() 后,会写成这样
func init() {
    initPc()
    initPc2()
    initPc3()
}

我觉得两种写法都说的过去吧,虽然我个人更倾向第一种写法。

参考链接:https://www.jb51.net/article/231599.htm

更多相关Go语言的技术文章或视频教程,请关注本公众号获取并查看,感谢你的支持与信任!


21520为什么不建议在go项目中使用init()

root

这个人很懒,什么都没留下

文章评论