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

前言

goinit函数给人的感觉怪怪的,我想不明白聪明的 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 依赖 bcb 依赖 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 函数的作用数计算数字中等于 1bit 的数量。例如 :

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 数中 bit1 的数量,最后累加即可。

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

var pc = []byte{0, 1, 1,...}

这种形式给出。

一个可以替代 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()
}

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

使用 init()的时机,仅仅有一个例外,后面说。

不使用 init 函数的时机

init()除了初始化变量,不应该干其他任何事!

有两个原则:

  • 一个包的 init() 不应该依赖包外的环境
  • 一个包的 init() 不应该对包外的环境造成影响

设置这两个原则的理由是:任何对外部有依赖或者对外部有影响的代码都有义务显式的让程序员知晓,不应该自己悄咪咪地去做,最好是显式地让程序员自己去调用。

init() 的活动范围就应该仅仅被局限在包内,自己和自己玩,不要影响了其他小朋友的游戏体验。

如下几条行为就踩了红线:

  • 读取配置(依赖于外部的配置文件,且一般读取配置得到的 obj 会被其他包访问,违反了第一条和第二条)
  • 注册路由(因为修改了 http 包中的 routeMap,会对 http 包造成影响,违反了第二条)
  • 连接数据库(连接数据库后一般会得到一个 db 对象给业务层去curd吧?违反了第二条)
  • etc... 我暂时只能想到这么多了

一个反面教材 https://github.com/go-sql-driver/mysql

反面教材就是:https://github.com/go-sql-driver/mysql 这个大名鼎鼎的包

当使用这个包时,一个必不可少的语句是:

import (
	_ "github.com/go-sql-driver/mysql"
)

原因是它里面有个 init函数,会把自己注册到 sql 包里。

func init() {
	sql.Register("mysql", &MySQLDriver{})
}

按照之前的标准,此处明显不符合规范,因为它影响了标准库的 sql 包。

我认为一个更好的方法是,创建一个 export 的专门用来做初始化工作的方法:

// Package mysql
func Init() {
	sql.Register("mysql", &MySQLDriver{})
}

然后在 main 包中显式的调用它:

// Package main
func main(){
    mysql.Init();
    // other logic
}

来比较一下两种方式吧。

  1. 使用 Init()
  • 是否需要告诉开发者额外的信息?

需要。

需要告诉开发者:使用这个库时,记得一定要调用 Init() 哦,我在里面做了一些工作。

开发者,点进 Init(),瞬间了然。

  • 是否能够阻止开发者不正确的调用?

不能。

因为是 export 的,所以开发者可以想到哪儿调用就到哪儿调用,想调用多少次就调用多少次。

因此需要额外告诉开发者:请您务必只调用一次,之后就不要调用了。且必须在用到 sql 包之前调用,一般而言都是在 main() 的第一句调用。

  1. 使用 init()
  • 是否需要告诉开发者额外的信息?

需要

依旧需要告诉开发者,一定要用 _ "github.com/go-sql-driver/mysql"这个语句显式的导入包哦,因为我利用init()在里面做一些工作。

开发者:那你做了什么工作

库:亲,请您点进 mysql 包,在目录下搜索 init() 关键字,慢慢找哦。

开发者:......

  • 是否能够阻止开发者不正确的调用?

勉强可以吧。

因为 init() 只会被调用一次,不可能被调用多次,这从根本上杜绝了开发者调用多次的可能性。

可你管不了开发者的 import 时机,假设开发者在其他地方 import 了,导致你在 sql.Open()时,mysqldriver 没有被正常注册,你还是拿开发者没有办法。只能哀叹一声:我累了,毁灭吧。

我觉得作为库的提供者,最主要的是提供完善的机制,在用户使用你的库时,能利用你提供的机制,写出无bug 的代码。而不是像保姆一样,想方设法避免用户出错。

所以可能使用 init() 为了的优势就是减少了代码量吧。

使用 Init() 时,需要两句代码

import (
	"github.com/go-sql-driver/mysql"	// 这句
)

func main(){
    mysql.Init()				  // 这句
}

但是使用 init 时,却只需要一句代码

import (
	_ "github.com/go-sql-driver/mysql"	// 这句
)

oh yeah,足足少写了一句代码!

一个例外 单元测试

可能使用 init 的唯一例外就是写单元测试的时候了吧。

假设我现在需要需要对 dao 层的增删改查逻辑的写一个单元测试。

func TestCURDPlayer(t *testing.T) {
	// 测试 curd 玩家信息
}

func TestCURDStore(t *testing.T) {
	// 测试 curd 商店信息
}

func TestCURDMail(t *testing.T) {
	// 测试 curd 邮件信息
}

很显然,这些测试都是依赖数据库的,因此为了正常的测试,必须初始化数据库

func TestCURDPlayer(t *testing.T) {
	// 测试 curd 玩家信息
    initdb()
    // balabala
}

func TestCURDStore(t *testing.T) {
	// 测试 curd 商店信息
    initdb()
    // balabala
}

func TestCURDMail(t *testing.T) {
	// 测试 curd 邮件信息
    initdb()
    // balabala
}

func initdb(){
    // sql.Open()...
}

难道我每次新增一个单元测试,都要在单元测试的代码中加一个 initdb() 么,这也太麻烦了吧。

这个时候 init() 就派上用场了。可以这样

func TestCURDPlayer(t *testing.T) {
	// 测试 curd 玩家信息
    // balabala
}

func TestCURDStore(t *testing.T) {
	// 测试 curd 商店信息
    // balabala
}

func TestCURDMail(t *testing.T) {
	// 测试 curd 邮件信息
    // balabala
}

func init(){
    initdb()
}

func initdb(){
    // sql.Open()...
}

这样,当对这个文件进行单元测试时,可以确保在执行每个 TestXXX 函数时,db 肯定是被正确初始化了的。

那为什么这个地方可以利用 init() 来初始化数据库呢?

理由之一是它的影响范围很小,仅仅在 xxx_test.go 文件中生效,在 go run 时不会起作用,在 go test 时才会起作用。

理由之二是我懒。。。

总结

init 更像是一个语法糖,它会让开发者对代码的追踪能力变弱,所以能不用就最好不用。

到此这篇关于为什么不建议在go项目中使用用init()的文章就介绍到这了,更多相关go init内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!

(0)

相关推荐

  • asp.net中引用同一个项目中的类库 避免goToDefinition时不能到达真正的定义类

    新建一个解决方案: Api 添加类库 APi.Data APi.Data 新建一个 Entity 复制代码 代码如下: public class Entity { private int id; public int Id { get { return id; } set { id = value; } } private string name; public string Name { get { return name; } set { name = value; } } } 添加类库 A

  • 详解Angular之constructor和ngOnInit差异及适用场景

    Angular中根据适用场景定义了很多生命周期函数,其本质上是事件的响应函数,其中最常用的就是ngOnInit.但在TypeScript或ES6中还存在着名为constructor的构造函数,开发过程中经常会混淆二者,毕竟它们的含义有某些重复部分,那ngOnInit和constructor之间有什么区别呢?它们各自的适用场景又是什么呢? 区别 constructor是ES6引入类的概念后新出现的东东,是类的自身属性,并不属于Angular的范畴,所以Angular没有办法控制constructo

  • 详解Angular 中 ngOnInit 和 constructor 使用场景

    1. constructor constructor应该是ES6中明确使用constructor来表示构造函数的,构造函数使用在class中,用来做初始化操作.当包含constructor的类被实例化时,构造函数将被调用. 来看例子: class AppComponent { public name: string; constructor(name) { console.log('Constructor initialization'); this.name = name; } } let a

  • go语言的初始化顺序,包,变量,init详解

    依次见例子代码: p1.go package p1 import "fmt" //1.1 var x float32 = 1.2 //1.2 func init() { //1.3 fmt.Printf("p1 package, x:%f\n", x) //1.4 } func Donothing() { fmt.Println("do nothing.\n") } a.go: package main import "fmt"

  • Go语言init函数详解

    Go init函数详解 init()函数会在每个包完成初始化后自动执行,并且执行优先级比main函数高.init 函数通常被用来: 对变量进行初始化 检查/修复程序的状态 注册 运行一次计算 包的初始化 为了使用导入的包,首先必须将其初始化.初始化总是以单线程执行,并且按照包的依赖关系顺序执行.这通过Golang的运行时系统控制,如下图所示: 初始化导入的包(递归导入) 对包块中声明的变量进行计算和分配初始值 执行包中的init函数 initial.go package main import

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

    前言 go的 init函数给人的感觉怪怪的,我想不明白聪明的 google团队为何要设计出这么一个"鸡肋"的机制.实际编码中,我主张尽量不要使用init函数. 首先来看看 init函数的作用吧. init() 介绍 init()与包的初始化顺序息息相关,所以先介绍一个go中包的初始化顺序吧.(下面的内容部分摘自<The go programinng language>) 大体而言,顺序如下: 首先初始化包内声明的变量 之后调用 init 函数 最后调用 main 函数 变量

  • vue项目中添加单元测试的方法

    从网上找了很多例子关于单元测试,都是如何新建项目的时候的添加单元测试,用vue-cli中怎么添加,但是我的项目已经生成了,不能再一次重新初始化,这时如何添加单元测试,这里面遇到了好多坑,写在这里记录一下心得. 1.用vue-cli生成一个新的项目,把单元测试需要的文件直接复制到你的项目中 vue init webpack vuetest 2.安装Karma+Mocha模块,这个模块依赖比较多,我在遇到了坑,解决问题半天发现缺少了某个模块,在这里全部列出需要的模块 复制代码 代码如下: npm i

  • SpringBoot项目中建议关闭Open-EntityManager-in-view原因

    目录 前言 问题背景 OPEN-ENTITYMANAGER-IN-VIEW的前世今生 问题的真实原因 解决方案 建议关闭OPEN-ENTITYMANAGER-IN-VIEW 结语 前言 一天,开发突然找过来说KLock分布式锁失效了,高并发情况下没有锁住请求,导致数据库抛乐观锁的异常.一开始我是不信的,KLock是经过线上大量验证的,怎么会出现这么低级的问题呢?然后,协助开发一起排查了一下午,最后经过不懈努力和一探到底的摸索精神最终查明不是KLock锁的问题,问题出在Spring Data Jp

  • 浅谈在JAVA项目中LOG4J的使用

    一.直接使用: //输出到项目文件夹下output1.txt文件中 ////////////////////////////// // DEBUG - Here is some DEBUG // INFO - Here is some INFO // WARN - Here is some WARN // ERROR - Here is some ERROR // FATAL - Here is some FATAL ////////////////////////////// package

  • Java EE项目中的异常处理总结(一篇不得不看的文章)

    为什么要在J2EE项目中谈异常处理呢?可能许多java初学者都想说:"异常处理不就是try-.catch-finally吗?这谁都会啊!".笔者在初学java时也是这样认为的.如何在一个多层的j2ee项目中定义相应的异常类?在项目中的每一层如何进行异常处理?异常何时被抛出?异常何时被记录?异常该怎么记录?何时需要把checked Exception转化成unchecked Exception ,何时需要把unChecked Exception转化成checked Exception?异

  • HttpClient 在Java项目中的使用详解

    Http协议的重要性相信不用我多说了,HttpClient相比传统JDK自带的URLConnection,增加了易用性和灵活性(具体区别,日后我们再讨论),它不仅是客户端发送Http请求变得容易,而且也方便了开发人员测试接口(基于Http协议的),即提高了开发的效率,也方便提高代码的健壮性.因此熟练掌握HttpClient是很重要的必修内容,掌握HttpClient后,相信对于Http协议的了解会更加深入. 一.简介 HttpClient是Apache Jakarta Common下的子项目,用

  • ejsExcel模板在Vue.js项目中的实际运用

    什么是ejsExcel? ejsExcel是一款国人开发的.在Node.js应用程序中使用我们预先设置好的Excel模板导出Excel表格的模板引擎. Excel模板 导出后 Github地址 ejsExcel 如果因为众(ni)所(dong)周(de)知的原因打不开github,没有关系,它的语法很简单,都是一些对Excel模板格式的定义: 这篇文章是我在工作中因为业务需要,用到了ejsExcel这个模板引擎,觉得很不错,但是坑也不少.而网上相关的资料又太少,所以趁此机会总结了一下我的踩坑经历

  • 如何去除vue项目中的#及其ie9兼容性

    一.如何去除vue项目中访问地址的# vue2中在路由配置中添加mode(vue-cli创建的项目在src/router/index.js) export default new Router({ mode: 'history', routes: [ { path: '/', name: 'menu', component: menu, children: [ { path: 'organization', component: organization, children: [ { path:

  • Vue CLI3搭建的项目中路径相关问题的解决

    这是开头 最近在试水 Vue CLI 3,并且尝试配置一个多页面(多应用)项目出来,期间又遇到各种路径问题,于是...于是有了下面的唠叨. 以下都是基于 Vue CLI 3 来举例说明的,使用 2.x 版本的其实也类似 首先,参考 官方文档对静态资源处理的说明,并通过自己的实践,可以总结出以下内容 静态资源可以通过两种方式进行处理: 1.以下情况下,资源不会被 webpack 处理,而是被直接拷贝: 放置在 public 目录下,即使未被使用. 通过绝对路径被引用,即以 / 开头的路径. 2.以

  • 详解如何在vue项目中使用layui框架及采坑

    根据官网的文档,要在一个html文件下使用layui里面的组件库其实很简单,但是在vue项目中使用该ui库却存在着很多坑,下面我们就详细讲解一下如何在vue-cli搭建的项目下使用layui 1.第一个坑:vue项目中使用npm安装引入时报错(目前并没有找到引入的合适的方式,知道的可以留言探讨) 在官网中我们可以看到他提到支持npm安装,但是当我们安装成功后,在main.js中引入时却报并不能找到该模块的错. 所以,我们先下载文件包,然后在html文件中用link和script标签的方式引入 2

随机推荐