标签 Golang 下的文章

通过实例理解Go内联优化

本文永久链接 – https://tonybai.com/2022/10/17/understand-go-inlining-optimisations-by-example


移动互联网时代,直面C端用户的业务系统规模一般都很庞大,系统消耗的机器资源也很可观,系统使用的CPU核数、内存都是在消耗公司的真金白银。在服务水平不下降的前提下尽量降低单服务实例的资源消耗,即我们俗称的“少吃草多产奶”,一直是各个公司经营人员的目标,有些公司每降低1%的CPU核数使用,每年都能节省几十万的开销。

在编程语言选择不变的情况下,要想持续降低服务资源消耗,一方面要靠开发人员对代码性能持续地打磨,另一方面依靠编程语言编译器在编译优化方面提升带来的效果则更为自然和直接。不过,这两方面也是相辅相成的,开发人员如果能对编译器的优化场景和手段理解更为透彻的话,就能写出对编译优化更为友好的代码,从而获得更好的性能优化效果。

Go核心团队在Go编译器优化方面一直在持续投入并取得了不俗的效果,虽然和老牌的GCCllvm的代码优化功力相比还有不小的空间。近期看到的一篇文章“字节大规模微服务语言发展之路”中也有提到:字节内部通过修改Go编译器的内联优化(收益最大的改动),从而让字节内部服务的Go代码获得了更多的优化机会,实现了线上服务10-20%的性能提升以及内存资源使用的下降,节约了大概了十几万个核。

看到这么明显的效果,想必各位读者都很想了解一下Go编译器的内联优化了。别急,在这一篇文章中,我就和大家一起来学习和理解一下Go编译器的内联优化。希望通过本文的学习,能让大家掌握如下内容:

  • 什么是内联优化以及它的好处是什么
  • 内联优化在Go编译过程中所处的环节和实现原理
  • 哪些代码能被内联优化,哪些还不能被内联优化
  • 如何控制Go编译器的内联优化
  • 内联优化的弊端有哪些

下面我们就先来了解一下什么是内联优化。


1. 什么是编译器的内联优化

内联(inlining)是编程语言编译器常用的优化手段,其优化的对象为函数,也称为函数内联。如果某函数F支持内联,则意味着编译器可以用F的函数体/函数定义替换掉对函数F进行调用的代码,以消除函数调用带来的额外开销,这个过程如下图所示:

我们知道Go从1.17版本才改为基于寄存器的调用规约,之前的版本一直是基于栈传递参数与返回值,函数调用的开销更大,在这样的情况下,内联优化的效果也就更为显著。

除此之外,内联优化之后,编译器的优化决策可以不局限在每个单独的函数(比如上图中的函数g)上下文中做出,而是可以在函数调用链上做出了(内联替换后,代码变得更平(flat)了)。比如上图中对g后续执行的优化将不局限在g上下文,由于f的内联,让编译器可以在g->f这个调用链的上下文上决策后续要执行的优化手段,即内联让编译器可以看得更广更远了

我们来看一个简单的例子:

// github.com/bigwhite/experiments/tree/master/inlining-optimisations/add/add.go

//go:noinline
func add(a, b int) int {
    return a + b
}

func main() {
    var a, b = 5, 6
    c := add(a, b)
    println(c)
}

这个例子中,我们的关注点是add函数,在add函数定义上方,我们用//go:noinline告知编译器对add函数关闭inline,我们构建该程序,得到可执行文件:add-without-inline;然后去掉//go:noinline这一行,再进行一次程序构建,得到可执行文件add,我们用lensm工具以图形化的方式查看一下这两个可执行文件的汇编代码,并做以下对比:

我们看到:非内联优化的版本add-without-inline如我们预期那样,在main函数中通过CALL指令调用了add函数;但在内联优化版本中,add函数的函数体并没有替换掉main函数中调用add函数位置上的代码,main函数调用add函数的位置上对应的是一个NOPL的汇编指令,这是一条不执行任何操作的空指令。那么add函数实现的汇编代码哪去了呢?

// add函数实现的汇编代码
ADDQ BX, AX
RET

结论是:被优化掉了!这就是前面说的内联为后续的优化提供更多的机会。add函数调用被替换为add函数的实现后,Go编译器直接可以确定调用结果为11,于是连加法运算都省略了,直接将add函数的结果换成了一个常数11(0xb),然后直接将常量11传给了println内置函数(MOVL 0xb, AX)。

通过一个简单的benchmark,也可以看出内联与非内联add的性能差异:

// 开启内联优化
$go test -bench .
goos: darwin
goarch: amd64
pkg: github.com/bigwhite/experiments/inlining-optimisations/add
BenchmarkAdd-8      1000000000           0.2720 ns/op
PASS
ok      github.com/bigwhite/experiments/inlining-optimisations/add  0.307s

// 关闭内联优化
$go test -bench .
goos: darwin
goarch: amd64
pkg: github.com/bigwhite/experiments/inlining-optimisations/add
BenchmarkAdd-8      818820634            1.357 ns/op
PASS
ok      github.com/bigwhite/experiments/inlining-optimisations/add  1.268s

我们看到:内联版本是非内联版本性能的5倍左右

到这里,很多朋友可能会问:既然内联优化的效果这么好,为什么不将Go程序内部的所有函数都内联了,这样整个Go程序就变成了一个大函数,中间再没有任何函数调用了,这样性能是不是可以变得更高呢?虽然理论上可能是这种情况,但内联优化不是没有开销的,并且针对不同复杂性的函数,内联的效果也是不同的。下面我就和大家一起先来看看内联优化的开销!

2. 内联优化的“开销”

在真正理解内联优化的开销之前,我们先来看看内联优化在Go编译过程中的位置,即处于哪个环节。

Go编译过程

和所有静态语言编译器一样,Go编译过程大致分为如下几个阶段:

  • 编译前端

Go团队并没有刻意将Go编译过程分为我们常识中的前后端,如果非要这么分,源码分析(包括词法和语法分析)、类型检查和中间表示(Intermediate Representation)构建可以归为逻辑上的编译前端,后面的其余环节都划归为后端。

源码分析形成抽象语法树,然后是基于抽象语法树的类型检查,待类型检查通过后,Go编译器将AST转换为一个与目标平台无关的中间代码表示。

目前Go有两种IR实现方式,一种是irgen(又名”-G=3″或是”noder2″),irgen是从Go 1.18版本开始使用的实现(这也是一种类似AST的结构);另外一种是unified IR,在Go 1.19版本中,我们可以使用GOEXPERIMENT=unified启用它,根据最新消息,unified IR将在Go 1.20版本落地。

注:现代编程语言编译过程多数会多次生成中间代码(IR),比如下面要提到的静态单赋值形式(SSA)也是一种IR形式。针对每种IR,编译器都会有一些优化动作:


图:编译优化过程(图来自https://www.slideserve.com/heidi-farmer/ssa-static-single-assignment-form)

  • 编译后端

编译后端的第一步是一个被Go团队称为中端(middle end)的环节,在这个环节中,Go编译器将基于上面的中间代码进行多轮(pass)的优化,包括死代码消除、内联优化、方法调用实体化(devirtualization)和逃逸分析等。

注:devirtualization是指将通过接口变量调用的方法转换为接口的动态类型变量直接调用该方法,消除通过接口进行方法表查找的过程。

接下来是中间代码遍历(walk),这个环节是基于上述IR表示的最后一轮优化,它主要是将复杂的语句分解成单独的、更简单的语句,引入临时变量并重新评估执行顺序,同时在这个环节,它还会将一些高层次的Go结构转换为更底层、更基础的操作结构,比如将switch语句转换为二分查找或跳表,将对map和channel的操作替换为运行时的调用(如mapaccess)等。

接下来是编译后端的最后两个环节,首先是将IR转换为SSA(静态单一赋值)形式,并再次基于SSA做多轮优化,最后针对目标架构,基于SSA的最终形式生成机器相关的汇编指令,然后交给汇编器生成可重定位的目标机器码。

注: 编译器(go compiler)产生的可重定位的目标机器码最终提供给链接器(linker)生成可执行文件。

我们看到Go内联发生在中端环节,是基于IR中间代码的一种优化手段,在IR层面上实现函数是否可内联的决策,以及对可内联函数在其调用处的函数体替换

一旦了解了Go内联所处环节,我们就能大致判断出Go内联优化带来的开销了。

Go内联优化的开销

我们用一个实例来看一下Go内联优化的开销。reviewdog是一个纯Go实现的支持github、gitlab等主流代码托管平台的代码评审工具,它的规模大约有12k行(使用loccount统计):

// reviewdog代码行数统计结果:

$loccount .
all          SLOC=14903   (100.00%) LLOC=4613    in 141 files
Go           SLOC=12456   (83.58%)  LLOC=4584    in 106 files
... ...

我们在开启内联优化和关闭内联优化的情况下分别对reviewdog进行构建,采集其构建时间与构建出的二进制文件的size,结果如下:

// 开启内联优化(默认)
$time go build -o reviewdog-inline -a github.com/reviewdog/reviewdog/cmd/reviewdog
go build -o reviewdog-inline -a github.com/reviewdog/reviewdog/cmd/reviewdog  53.87s user 9.55s system 567% cpu 11.181 total

// 关闭内联优化
$time go build -o reviewdog-noinline -gcflags=all="-l" -a github.com/reviewdog/reviewdog/cmd/reviewdog
go build -o reviewdog-noinline -gcflags=all="-l" -a   43.25s user 8.09s system 566% cpu 9.069 total

$ ls -l
-rwxrwxr-x  1 tonybai tonybai 23080429 Oct 13 12:05 reviewdog-inline*
-rwxrwxr-x  1 tonybai tonybai 20745006 Oct 13 12:04 reviewdog-noinline*
... ...

我们看到开启内联优化的版本,其编译消耗时间比关闭内联优化版本的编译时间多出24%左右,并且生成的二进制文件size要大出11%左右 – 这就是内联优化带来的开销!即会拖慢编译器并导致生成的二进制文件size变大。

注:hello world级别的程序是否开启内联优化大多数情况是看不出来太多差异的,无论是编译时间,还是二进制文件的size。

由于我们知道了内联优化所处的环节,因此这种开销就可以很好地给予解释:根据内联优化的定义,一旦某个函数被决策为可内联,那么程序中所有调用该函数的位置的代码就会被替换为该函数的实现,从而消除掉函数调用带来的运行时开销,同时这也导致了在IR(中间代码)层面出现一定的代码“膨胀”。前面也说过,代码膨胀后的“副作用”是编译器可以以更广更远的视角看待代码,从而可能实施的优化手段会更多。可实施的优化轮次越多,编译器执行的就越慢,这进一步增加了编译器的耗时;同时膨胀的代码让编译器需要在后面环节处理和生成更多代码,不仅增加耗时,还增加了最终二进制文件的size。

Go向来对编译速度和binary size较为敏感,所以Go采用了相对保守的内联优化策略。那么到底Go编译器是如何决策一个函数是否可以内联呢?下面我们就来简单看看Go编译器是如何决策哪些函数可以实施内联优化的。

3. 函数内联的决策原理

前面说过,内联优化是编译中端多轮(pass)优化中的一轮,因此它的逻辑相对独立,它基于IR代码进行,改变的也是IR代码。我们可以在Go源码的$GOROOT/src/cmd/compile/internal/inline/inl.go中找到Go编译器进行内联优化的主要代码。

注:Go编译器内联优化部分的代码的位置和逻辑在以前的版本以及在未来的版本中可能有变化,目前本文提到的是代码是Go 1.19.1中的源码。

内联优化IR优化环节会做两件事:第一遍历IR中所有函数,通过CanInline判断某个函数是否可以内联,对于可内联的函数,保存相应信息,比如函数body等,供后续做内联函数替换使用;第二呢,则是对函数中调用的所有内联函数进行替换。 我们重点关注CanInline,即Go编译器究竟是如何决策一个函数是否可以内联的

内联优化过程的“驱动逻辑”在$GOROOT/src/cmd/compile/internal/gc/main.go的Main函数中:

// $GOROOT/src/cmd/compile/internal/gc/main.go
func Main(archInit func(*ssagen.ArchInfo)) {
    base.Timer.Start("fe", "init")

    defer handlePanic()

    archInit(&ssagen.Arch)
    ... ...

    // Enable inlining (after RecordFlags, to avoid recording the rewritten -l).  For now:
    //  default: inlining on.  (Flag.LowerL == 1)
    //  -l: inlining off  (Flag.LowerL == 0)
    //  -l=2, -l=3: inlining on again, with extra debugging (Flag.LowerL > 1)
    if base.Flag.LowerL <= 1 {
        base.Flag.LowerL = 1 - base.Flag.LowerL
    }
    ... ...

    // Inlining
    base.Timer.Start("fe", "inlining")
    if base.Flag.LowerL != 0 {
        inline.InlinePackage()
    }
    noder.MakeWrappers(typecheck.Target) // must happen after inlining
    ... ...
}

从代码中我们看到:如果没有全局关闭内联优化(base.Flag.LowerL != 0),那么Main就会调用inline包的InlinePackage函数执行内联优化。InlinePackage的代码如下:

// $GOROOT/src/cmd/compile/internal/inline/inl.go
func InlinePackage() {
    ir.VisitFuncsBottomUp(typecheck.Target.Decls, func(list []*ir.Func, recursive bool) {
        numfns := numNonClosures(list)
        for _, n := range list {
            if !recursive || numfns > 1 {
                // We allow inlining if there is no
                // recursion, or the recursion cycle is
                // across more than one function.
                CanInline(n)
            } else {
                if base.Flag.LowerM > 1 {
                    fmt.Printf("%v: cannot inline %v: recursive\n", ir.Line(n), n.Nname)
                }
            }
            InlineCalls(n)
        }
    })
}

InlinePackage遍历每个顶层声明的函数,对于非递归函数或递归前跨越一个以上函数的递归函数,通过调用CanInline函数判断其是否可以内联,无论是否可以内联,接下来都会调用InlineCalls函数对其函数定义中调用的内联函数进行替换。

VisitFuncsBottomUp是根据函数调用图从底向上遍历的,这样可以保证每次在调用analyze时,列表中的每个函数都只调用列表中的其他函数,或者是在之前的调用中已经analyze过(在这里就是被内联函数体替换过)的函数。

什么是递归前跨越一个以上函数的递归函数,看下面这个例子就懂了:

// github.com/bigwhite/experiments/tree/master/inlining-optimisations/recursion/recursion1.go
func main() {
    f(100)
}

func f(x int) {
    if x < 0 {
        return
    }
    g(x - 1)
}
func g(x int) {
    h(x - 1)
}
func h(x int) {
    f(x - 1)
}

f是一个递归函数,但并非自己调用自己,而是通过g -> h这个函数链最终又调回自己,而这个函数链长度>1,所以f是可以内联的:

$go build -gcflags '-m=2'  recursion1.go
./recursion1.go:7:6: can inline f with cost 67 as: func(int) { if x < 0 { return  }; g(x - 1) }

我们继续看CanInline函数。CanInline函数有100多行代码,其主要逻辑分为三个部分。

首先是对一些//go:xxx指示符(directive)的判定,当该函数包含下面指示符时,则该函数不能内联:

  • //go:noinline
  • //go:norace或构建命令行中包含-race选项
  • //go:nocheckptr
  • //go:cgo_unsafe_args
  • //go:uintptrkeepalive
  • //go:uintptrescapes
  • … …

其次会对该函数的状态做判定,比如如果函数体为空,则不能内联;如果未做类型检查(typecheck),则不能内联等。

最后调用visitor.tooHairy对函数的复杂性做判定。判定方法就是先为此次遍历(visitor)设置一个初始最大预算(budget),这个初始最大预算值为一个常量(inlineMaxBudget),目前其值为80:

// $GOROOT/src/cmd/compile/internal/inline/inl.go
const (
    inlineMaxBudget       = 80
)

然后在visitor.tooHairy函数中遍历该函数实现中的各个语法元素:

// $GOROOT/src/cmd/compile/internal/inline/inl.go
func CanInline(fn *ir.Func) {
    ... ...
    visitor := hairyVisitor{
        budget:        inlineMaxBudget,
        extraCallCost: cc,
    }
    if visitor.tooHairy(fn) {
        reason = visitor.reason
        return
    }
    ... ...
}

不同元素对预算的消耗都有不同,比如调用一次append,visitor预算值就要减去inlineExtraAppendCost,再比如如果该函数是中间函数(而非叶子函数),那么visitor预算值也要减去v.extraCallCost,即57。就这样一路下来,如果预算被用光,即v.budget < 0,则说明这个函数过于复杂,不能被内联;相反,如果一路下来,预算依然有,那么说明这个函数相对简单,可以被内联优化。

注:为什么inlineExtraCallCost的值是57?这是一个经验值,是通过一个benchmark得出来的

一旦确定可以被内联,那么Go编译器就会将一些信息保存下来,保存到IR中该函数节点的Inl字段中:

// $GOROOT/src/cmd/compile/internal/inline/inl.go
func CanInline(fn *ir.Func) {
    ... ...
    n.Func.Inl = &ir.Inline{
        Cost: inlineMaxBudget - visitor.budget,
        Dcl:  pruneUnusedAutos(n.Defn.(*ir.Func).Dcl, &visitor),
        Body: inlcopylist(fn.Body),

        CanDelayResults: canDelayResults(fn),
    }
    ... ...
}

Go编译器设置budget值为80,显然是不想让过于复杂的函数被内联优化,这是为什么呢?主要是权衡内联优化带来的收益与其开销。让更复杂的函数内联,开销会增大,但收益却可能不会有明显增加,即所谓的“投入产出比”不足。

从上面的原理描述可知,对那些size不大(复杂性较低)、被反复调用的函数施以内联的效果可能更好。而对于那些过于复杂的函数,函数调用的开销占其执行开销的比重已经十分小了,甚至可忽略不计,这样内联效果就会较差。

很多人会说:内联后不是还有更多编译器优化机会么?问题在于究竟是否有优化机会以及会实施哪些更多的优化,这是无法预测的事情。

4. 对Go编译器的内联优化进行干预

最后我们再来看看如何对Go编译器的内联优化进行干预。Go编译器默认是开启全局内联优化的,并按照上面inl.go中CanInline的决策流程来确定一个函数是否可以内联。

不过Go也给了我们控制内联的一些手段,比如我们可以在某个函数上显式告知编译器不要对该函数进行内联,我们以上面示例中的add.go为例:

//go:noinline
func add(a, b int) int {
    return a + b
}

通过//go:noinline指示符,我们可以禁止对add的内联:

$go build -gcflags '-m=2' add.go
./add.go:4:6: cannot inline add: marked go:noinline

注:禁止某个函数内联不会影响InlineCalls函数对该函数内部调用的内联函数的函数体替换。

我们也可以在更大范围关闭内联优化,借助-gcflags ‘-l’选项,我们可以在全局范围关闭优化,即Flag.LowerL == 0,Go编译器的InlinePackage将不会执行。

我们以前面提到过的reviewdog来验证一下:

// 默认开启内联
$go build -o reviewdog-inline github.com/reviewdog/reviewdog/cmd/reviewdog

// 关闭内联
$go build -o reviewdog-noinline -gcflags '-l' github.com/reviewdog/reviewdog/cmd/reviewdog

之后我们查看一下生成的binary文件size:

$ls -l |grep reviewdog
-rwxrwxr-x  1 tonybai tonybai 23080346 Oct 13 20:28 reviewdog-inline*
-rwxrwxr-x  1 tonybai tonybai 23087867 Oct 13 20:28 reviewdog-noinline*

我们发现noinline版本居然比inline版本的size还要略大!这是为什么呢?这与-gcflags参数的传递方式有关,如果只是像上面命令行那样传入-gcflags ‘-l’,关闭内联仅适用于当前package,即cmd/reviewdog,而该package的依赖等都不会受到影响。-gcflags支持pattern匹配:

-gcflags '[pattern=]arg list'
    arguments to pass on each go tool compile invocation.

我们可以通过设置不同pattern来匹配更多包,比如all这个模式就可以包括当前包的所有依赖,我们再来试试:

$go build -o reviewdog-noinline-all -gcflags='all=-l' github.com/reviewdog/reviewdog/cmd/reviewdog
$ls -l |grep reviewdog
-rw-rw-r--  1 tonybai tonybai     3154 Sep  2 10:56 reviewdog.go
-rwxrwxr-x  1 tonybai tonybai 23080346 Oct 13 20:28 reviewdog-inline*
-rwxrwxr-x  1 tonybai tonybai 23087867 Oct 13 20:28 reviewdog-noinline*
-rwxrwxr-x  1 tonybai tonybai 20745006 Oct 13 20:30 reviewdog-noinline-all*

这回我们看到reviewdog-noinline-all要比reviewdog-inline的size小了不少,这是因为all将reviewdog依赖的各个包的内联也都关闭了。

5. 小结

在这篇文章中,我带大家一起了解了Go内联相关的知识,包括内联的概念、内联的作用、内联优化的“开销”以及Go编译器进行函数内联决策的原理,最后我还给出控制Go编译器内联优化的手段。

内联优化是一种重要的优化手段,使用得当将会给你的系统带来不小的性能改善。Go编译器组也在对Go内联优化做持续改善,从之前仅支持叶子函数的内联,到现在支持非叶子节点函数的内联,相信Go开发者在未来还会继续得到这方面带来的性能红利。

本文涉及的源码可以在这里下载。

6. 参考资料

  • Introduction to the Go compiler – https://go.dev/src/cmd/compile/README
  • Proposal: Mid-stack inlining in the Go compiler – https://github.com/golang/proposal/blob/master/design/19348-midstack-inlining.md
  • Mid-stack inlining in the Go compiler – https://golang.org/s/go19inliningtalk
  • Inlining optimisations in Go – https://dave.cheney.net/2020/04/25/inlining-optimisations-in-go
  • Mid-stack inlining in Go – https://dave.cheney.net/2020/05/02/mid-stack-inlining-in-go
  • cmd/compile: relax recursive restriction while inlining – https://github.com/golang/go/issues/29737

“Gopher部落”知识星球旨在打造一个精品Go学习和进阶社群!高品质首发Go技术文章,“三天”首发阅读权,每年两期Go语言发展现状分析,每天提前1小时阅读到新鲜的Gopher日报,网课、技术专栏、图书内容前瞻,六小时内必答保证等满足你关于Go语言生态的所有需求!2022年,Gopher部落全面改版,将持续分享Go语言与Go应用领域的知识、技巧与实践,并增加诸多互动形式。欢迎大家加入!

img{512x368}
img{512x368}

img{512x368}
img{512x368}

我爱发短信:企业级短信平台定制开发专家 https://tonybai.com/。smspush : 可部署在企业内部的定制化短信平台,三网覆盖,不惧大并发接入,可定制扩展; 短信内容你来定,不再受约束, 接口丰富,支持长短信,签名可选。2020年4月8日,中国三大电信运营商联合发布《5G消息白皮书》,51短信平台也会全新升级到“51商用消息平台”,全面支持5G RCS消息。

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻)归档仓库 – https://github.com/bigwhite/gopherdaily

我的联系方式:

  • 微博:https://weibo.com/bigwhite20xx
  • 博客:tonybai.com
  • github: https://github.com/bigwhite

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

Go语言之道[译]

本文永久链接 – https://tonybai.com/2022/09/25/the-tao-of-go

近期阅读了John Arundel的文章《The Tao of Go》,看完后我都有心去阅读一遍《道德经》了:)。作者将Go语言设计哲学与惯例与“道”学三宝有机的联系到一起,给了我不小的启发。这里译成中文,供大家参考。


你可以让水牛去任何地方,只要它们想去 – 杰拉尔德・温伯格 《咨询的奥秘》(译注:原文似乎将Gerald M. Weinberg 写成了Jerry Weinberg,应该是笔误)

“道”是指事物的内在本质或自然趋势。例如,水向低处流:这就是水的“道”。你可以筑坝、疏导、抽水,或以其他方式干扰它,但尽管你做了种种努力,它最终可能还是会流向它要去的地方。

你遵循“道”并将其作为你的处事原则,就是要对事物的自然趋势保持敏感,不浪费精力与之抗争,而是顺势而为,而非逆势而为

扩展一下水的比喻,一个泳技糟糕的游泳者在水中乱扑腾,制造了很多噪音和骚动,但却没有真正前进。而道家,则是在水中冲浪(译注:借助波浪,顺势而为)。

那么什么是Go语言之道呢?如果我们以一种敏感的、聪明的方式来进行Go的软件开发,遵循语言和问题的自然轮廓,而不是试图用推土机把它们推开,那会是什么样子呢?让我们试着建立一些一般原则

一. 仁慈(Kindness)

我有三宝,持而保之。一曰慈(kindness),二曰俭(simplicity),三曰不敢为天下先(humility,谦逊)。- 《道德经》

用仁慈(译注:最大程度的善意)和同情心编写程序是什么意思呢?它意味着我们为人而不是为计算机写代码。人容易犯错,没有耐心,缺乏经验,注意力不集中,而且在其他方面也不完美。我们可以通过在Go代码的设计和细节上花些心思,使大家的生活(和工作)变得更加轻松。

我们可以善待我们的用户:给我们的库起一个描述性的名字(译注:比如像net、os这样的名字),让它们容易导入,提供良好的文档,并为它们提供宽松的开源许可。我们可以设计深度抽象,让用户利用小而简单的API来访问强大而有用的行为。

我们可以善待那些运行我们程序的人,使他们易于安装和更新,要求最少的配置和依赖,捕捉最常见的使用错误和运行时的错误,给用户提供有用的、准确的和友好的信息,告诉他们出现了什么错误以及如何解决。

聪明是一种天赋,仁慈是一种选择。 – 杰夫・贝佐斯

我们可以通过尽可能的清晰(clear)、简单(simple)和明确(explicit)来善待那些不得不阅读我们代码的人。我们可以给类型和函数起一个在上下文中有意义的名字,让用户用我们的抽象概念在直接的、逻辑的组合中创建自己的程序。我们可以通过遵守惯例、实现标准接口和以明显的方式做明显的事情来消除认知上的障碍和速度上的障碍。

在代码审查中,我们保持温和并多用鼓励性的语言。我们在别人的工作中找到值得称赞的地方,我们不会因为别人犯错或忽略细节而把他们当成傻瓜。如果我们对自己坦诚,我们会承认我们也会犯同样的错误。我们也知道,接受批评对我们来说是痛苦和困难的,但我们可以用仁慈来调节以应对对自己的批评。

最后,我们也要对自己仁慈,通过编写优秀的测试,使我们的程序在未来容易理解、修正和改进,当我们发现错误或设计缺陷时,不要对自己生气。

一旦代码仓库变成了意大利面条,就几乎不可能修复。 – John Ousterhout, 《软件设计的哲学》

另一个对我们未来的自己和我们的继任者仁慈的方法是,对程序的结构和整体设计进行小的、持续的改进。好的程序能活很久(当然,有一些糟糕的程序也能活很久),许多小改动的累积效应通常会使代码库变得混乱、复杂和笨拙。我们可以花一点额外的时间来帮助避免这种情况的发生,每当我们为某些事情访问代码库时,我们就可以重构和清理它。因为我们很少有机会从头开始重写系统,所以长期投入少量时间进行微改进是保持系统健康的唯一实用方法。

二. 简单(Simplicity)

“道”教给我们的第二种美德是节俭(frugality)、谦虚(modesty)、简单(simplicity):以小博大,消除杂乱。Go本身就是一种“节俭”的语言,拥有较少的语法和表面积(译注:可能是较少的API的意思)。它并不试图做所有的事情,或者取悦所有人

我们的生活被琐碎的细节浪费掉了。简化,再简化”— 亨利・大卫・梭罗《瓦尔登湖》

我们也应该这样做,使我们的程序小而聚焦,不混乱,做好一件事。深度抽象为强大的机器提供了一个简单的接口。我们不会让用户为了获得调用我们库的特权而做大量的文书工作。只要我们能够为最常见的情况提供一个简单的API和合理的默认值,我们就会这么做。

灵活性是一件好事,但我们不应该试图处理每一种情况,或提供每一种功能。可扩展性是很好的,但我们不应该为了我们目前还不需要的东西做出妥协而损害一个简单的设计。事实上,一个简单的程序比一个复杂的程序更容易扩展。

我有最简单的口味。我总是对最好的东西感到满意。 – 奥斯卡-王尔德,引自Edgar Saltus,”奥斯卡-王尔德,一个闲人的印象”

我们不会用函数、类型、接口、回调、参数和功能选项(options)来压垮用户。最小的API是最好的,因为它需要最少的知识来使用。我们不会用几十个包和子文件夹的子文件夹来将我们的module复杂化。我们不会采用无休止的命令行标志或要求用户编写冗长的配置文件。

我们满足于重复大块的代码,而不是纯粹为了满足我们保持代码不重复的愿望而发明不必要的抽象概念。如果我们可以通过为几种不同的类型实现相同的函数来解决问题,我们就不写复杂的代码生成器或泛型函数(译注:Go 1.18泛型落地后,有些时候使用泛型函数感官上代码更为简洁)。如果一个方法自然有点长,我们会让它长,而不是积极地把它重构为不必要的子函数,只是为了让每个子函数都能有几行长。

如果一个就够了,我们就不写十个测试。如果只需要一个函数,我们就不创建一个接口。我们不要让用户实现我们的接口,而是要实现他们的接口。

谁能出不由户,何莫由斯道也? – 孔子

我们是明确的/显式的(explicit);我们避免魔法。我们不在没有帮助的地方使用并发性。我们让包自成一体,与其他包解耦,我们避免让一个包或API的类型泄漏到我们代码库的其他部分。我们设定明显的内部和外部界限,并加以执行。

我们节约资源;我们避免泄漏,并在必要时使用尽可能少的内存或CPU。我们高效地处理数据流,而不是将其放入大块的内存中。我们产生的垃圾越少,需要收集的就越少。我们不在不需要的地方传递Context。

我们不纠结于性能问题。Go是高性能的。但我们的代码可能不需要这么快;至少,不需要以牺牲简单性为代价来换取高性能。

正如Go谚语所说:我们接受接口值。这样我们就需要对它们是什么做出最少的假设,但我们会返回具体的值(结构),这样用户就不用为它们编写大量的类型断言。

三. 谦逊(Humility)

第三件宝物是谦逊。像水一样,道家寻求低调,不争,不比,不试图打动别人。Go本身是谦逊和务实的:它没有其他语言的所拥有高科技功能特性和理论优势。事实上,它故意忽略了许多其他语言的重要卖点。它的设计者对创造一种令人印象深刻的编程语言或在人气调查中名列前茅并不感兴趣,而是想为人们提供一种小型而简单的工具,以最实用和最直接的方式完成有用的工作。

Go认识到我们很容易犯错,因此它提供了很多方法来保护我们不犯错。它负责分配内存,清理我们已经用完的东西,并警告我们有未使用的包导入或变量。它是为那些知道自己不是什么都懂,并且了解自己的错误倾向的人(换句话说,是谦虚的人)设计的语言。

最危险的错误是没有认识到我们自己的错误倾向。 – 巴塞尔-利德尔-哈特

作为Go程序员,我们写代码不要显得过于聪明,我们通过这种方式来保持谦逊。我们写代码并不是为了给大家留下我们是多么了不起的程序员的印象:相反,我们满足于做明显的事情。我们清楚而直截了当地表达自己,而不觉得有必要把自己的个性强加在代码中。

当标准库能解决问题时,我们就使用它,而只有在它不能解决问题时才使用第三方库。如果有一个堪称事实标准的包,我们就使用它:如果它对别人足够好,对我们也足够好。

我们避免通过panic或调用os.Exit或log.Fatal来意外地终止用户的程序,因为我们认识到,我们没有足够的智慧来事先确定问题是否真的是致命的。相反,我们会在问题发生时处理一切可以处理的事情,而当我们不能处理时,我们会谦逊地返回一个错误,并提供有用的上下文信息,让我们的用户来决定如何处理。

我们可以认识到,我们并不了解所有的事情,我们也无法做出非常准确的预测(尤其是对未来的预测),所以我们不应该浪费时间和精力来预先设计我们可能永远不需要的东西。我们不认为我们最了解其他的软件是否会想和我们的软件一起使用,所以我们不会硬性规定对它的依赖。

我们假设我们写的任何东西都会包含bug,所以我们写了详尽的测试代码,试图引出意外的行为或不正确的结果。我们明白不可避免地会有一些我们不知道或无法正确预测的重要事情,所以我们不会为了现状而过多地优化代码,因为很多工作最终都会被浪费掉。

以学生的姿态(不断学习),永远不要觉得已经长大而不问问题,永远不要觉得知道得太多而拒绝学习新事物。- 奥格·曼狄诺, 《世界上最伟大的销售员》的作者

当我们审查别人的代码时,我们不会自动假设我们是最了解的:我们很乐意向任何有东西可以教我们的人学习。如果有些东西看起来很奇怪或不对,我们就会问:”从什么角度看这是有意义的?我没有什么信息可以解释为什么这是必要的?”

我们把我们的评论当作问题,以真诚而不是讽刺的方式提出:这有必要吗?如果……会怎样?你有没有考虑过……?如果……会不会更好?我们尊重别人的时间,就像尊重自己的时间一样,所以我们不要求他们提供不必要的信息,或者只为了符合我们喜欢的风格而做出微小的改变,或者坐在走形式的会议中浪费时间,或者写多余的状态报告。

我们知道我们并不总是正确的。明智的人可以以一种文明和建设性的方式对事情提出异议。如果我们把人们当做白痴,那么当他们反应不好的时候就不应该感到惊讶。相反,我们一开始就假设对方是理性的、体面的,并根据他们对情况的最佳理解,真诚地行事。有时情况并非如此,但这仍然是正确的默认假设,直到他们最终证明不是这样。

在要求别人审查自己的代码之前,我们会谦虚地审查自己的代码,因为如果我们这都懒得做,他们为什么要做?我们花时间逐行阅读,像一个新的用户或开发者那样阅读,遵循逻辑逐一论证:从哪里开始阅读是否很清楚?程序是否在一开始就引入了关键的类型或常量并说明它们是如何使用的?命名是否清楚并准确地指明了它们的作用,或者它们在十几次重构中是否变得混乱和过时了?程序是否整齐自然地融入了它的结构,或者它在某些部分过度堆砌,而在其他部分奇怪地留下了空白?

因为我们没有被自己的聪明和优雅所束缚,我们不需要把三四个不同的想法塞进一行代码中。相反,我们把逻辑清晰、简单、显式地列出,一步一步地,一个一个地陈述,一点一点地,以读者所期望的方式准确地做必要的事情。在无法做到这一点的地方,我们会不厌其烦地向读者解释他们需要知道什么才能理解发生了什么。

因为我们知道我们不是天才,我们不可能把程序写得那么出色,不言自明,所以我们在解释上花了一些功夫。我们为代码提供文档,不仅说明程序的作用,而且说明如何用它完成用户可能想要做的事情。文档还包含了详细的使用例子,准确地显示出需要做什么,从头开始,以执行现实的任务,当它完成时,用户应该期望看到什么,以及他们接下来应该做什么,并且我们严格地定期检查这些例子,以确保它们仍然有效。

四. 无为(Not Striving)

我们已经谈到了一些将仁慈、简单和谦逊这三件宝物应用于用Go编写软件的方法。这些品质已经存在于每个人身上,即使它们在一些人身上隐藏得很好。同样地,每个人都已经知道如何在编程和生活中遵循“道”。的确,他们不能不这样做。但是,一旦你理解了这个事实,并停止在所有事情上做这样的挣扎,生活就会变得更加有趣。

道给我们的最后一个教导是无为(not striving)。这有时会被误解为懒惰、退缩或被动;恰恰相反。努力工作并不总是意味着工作出色。我们都知道那些长期忙碌的人,总是匆匆忙忙,手忙脚乱,活动频繁,但他们似乎从未真正取得过什么成就。而且,他们过得很痛苦,因为他们也知道这一点。

相反,我们经常在别人看来我们什么都没做的时候以最佳状态完成我们的工作:在一个美丽的日子里在河边散步,或者坐在门廊上看蜘蛛结网。如果我们能聪明地从忙碌中停下来,只需一分钟,正确的想法往往就会直接出现在我们的头脑中。

与其把每个问题都当作要攻击的敌人、要攀登的山峰或要拆毁的墙壁,我们可以使用“无为”原则(有时“不强迫(not forcing)”会是更好的翻译)。我们可能都有过这样的尴尬经历:无果地推一扇顽固的门,最后才意识到这扇门对拉动的反应更好。在我们的日常工作中,我们忽略了哪些小迹象表明我们应该拉而不是推?

解决问题的心态是好的,但消除问题则更好。我们怎样才能重新规划这个问题,使其消失?对需求的重述会使解决方案变得微不足道,甚至显而易见?是否有一个简单而优雅的设计是我们没有看到的,因为我们专注于一些被证明是不相关的细节?我们是否可以不用尝试解决这个问题?最好的优化是根本不做这件事。

把编程和打字混为一谈是一个常见的错误。如果有人只是坐在那里盯着空间,那就不像是在做什么有用的事情。但是,如果他们在键盘上疯狂地敲击,我们就会认为他们在做什么。事实上,真正的编程发生在打字之前,有时甚至代替了打字。当我们构思了一段非常好的程序,我们唯一需要按的键往往是删除键。

真正有趣的是,你不需要相信我的话来证明道家原则在编程中的有效性,或者在生活的其他领域。世界本身会教你什么是有效的,什么是无效的,以及如何分辨它们。在行使你的仁慈、简单和谦逊方面做一些小实验,看看会发生什么,感觉如何。

你不必称它为“道”,如果这让你感到恼火。这只是一个人为编造的词。如果你一直知道做事情有正确的方法和错误的方法,而且你认为我只是用了一个花哨的中文名字而没有说什么新的或有价值的东西,你是对的。

下次你遇到问题时,试一次不努力或不强迫,看看是否可以温和地鼓励问题自己解决。如果你发现自己在努力把水牛送到你想去的地方,就不要再挣扎了。问问自己,你是否能找到水牛想去的地方,也许那可能不是它的最佳位置。


“Gopher部落”知识星球旨在打造一个精品Go学习和进阶社群!高品质首发Go技术文章,“三天”首发阅读权,每年两期Go语言发展现状分析,每天提前1小时阅读到新鲜的Gopher日报,网课、技术专栏、图书内容前瞻,六小时内必答保证等满足你关于Go语言生态的所有需求!2022年,Gopher部落全面改版,将持续分享Go语言与Go应用领域的知识、技巧与实践,并增加诸多互动形式。欢迎大家加入!

img{512x368}
img{512x368}

img{512x368}
img{512x368}

我爱发短信:企业级短信平台定制开发专家 https://tonybai.com/。smspush : 可部署在企业内部的定制化短信平台,三网覆盖,不惧大并发接入,可定制扩展; 短信内容你来定,不再受约束, 接口丰富,支持长短信,签名可选。2020年4月8日,中国三大电信运营商联合发布《5G消息白皮书》,51短信平台也会全新升级到“51商用消息平台”,全面支持5G RCS消息。

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻)归档仓库 – https://github.com/bigwhite/gopherdaily

我的联系方式:

  • 微博:https://weibo.com/bigwhite20xx
  • 博客:tonybai.com
  • github: https://github.com/bigwhite

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 Go语言编程指南
商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats