关于Go语言,你可能会讨厌的五件事

作者 | Christoph Engelbert

译者 | 无明

关于 Go 语言,你有什么要吐槽的?

近年来,Go 从新出现的编程语言中脱颖而出。不过要把 Go 称为“新晋者”似乎并不合适,因为谷歌早在 2009 年就推出了 Go,并于 2012 年发布了第一个最终版(Go 1.0)。到现在为止,Go 已经发展到了 1.10 版本,这个版本令人印象深刻,而且还在不断添加新的特性。

为什么它被称为 eGOtistic(自大狂)……

大家都知道,Go 在实现或语法方面喜欢“我行我素”。在英语中,这种情况被描述为“自以为是”。很多来自其他编程语言的概念在 Go 中并不存在,或者即使存在,它们的行为也变得“面目全非”。后一种情况可能会导致意想不到的错误,甚至让开发人员感到疑惑。

严格的 Go 语法通常会让开发人员感到疲倦。Go 编译器不允许出现未使用的导入和变量,并竭尽所能将它们拦截下来,甚至让花括号另起一行都不行。Go 强制使用相对固定且几乎统一的编程风格。只要 Go 编译器不喜欢某些东西,到最后都变成了编译错误。

Go 提供了非常严格的类型安全。因为太过严格,我们甚至可以通过它来实现一些特殊效果和编程错误,其中一些我们稍后会在文中讨论。不过,我们很少有必要在 Go 中显式地声明类型,因为类型通常可以从赋值中获得,也就是类型推断。

我不是要提供问答!

一年多以前,我开始在工作中大量使用 Go。Go 算不上是我最喜欢的编程语言,但我承认,Go 在提升开发效率方面起到了一定作用。事实上,我已经使用 Go 完成了几个小项目,主要是一些嵌入式应用。Go Toolchain 的跨平台编译功能(编译后可用于其他操作系统或 CPU 平台)非常棒,已经遥遥领先于它的竞争对手。

现在让我们来看看 Go 的一些比较特别的特性。入门 Go 其实很容易,可能只需要一个周末来了解它的基础知识。但当你开始用 Go 做一些更复杂的事情时,各种奇奇怪怪的事件开始浮出水面。

有时候,这些特性非常奇怪,谷歌为此提供了问题解答,用于解释类似“为什么 X 的行为是这样或者那样的”这类问题。Go 在很多方面都表现得与其他语言不太一样,感觉好像程序员在某个时候一定会被某些陷阱绊倒一样。gopher Slack 频道已经证实了这种情况的存在,其中就有这样的描述:“现在你真的应该好好了解一下 Go 了,因为每个开发人员在他们的 Go 职业生涯中都会问到这个问题”。通常情况下,我们的直觉与 Go 的特性并不相符。例如,在谷歌的 C 语言变种中,公开类型、函数、常量等都以大写字母作为开头来表示它们是公开的,而标识符开头的小写字母表示它们是私有的。

尽管如此,有关 Go 的很多决策都是在邮件列表或提案文件中经过了长时间的讨论,因此还是得到了肯定。然而,讨论所使用的用例都非常特殊,以至于很多开发人员仍然不清楚这与他们要解决的问题究竟有什么关系。

我个人最喜欢的部分是 Go 没有提供可重入锁,即同一线程或 Goroutine(Coroutine 或 Green Thread 的变体)可递归获取的锁。如果不通过 hack 的方式就无法自行实现这样的功能,因为线程在 Go 中不可用,而 Goroutine 也并没有提供可用于递归识别相同 Coroutine 的标识符。

在这篇文章中,我想介绍 Go 的五个特性及其语法,这些特性都很隐晦。

1. 疯狂的影子

让我们从最简单的事情开始:每个优秀的开发人员都听说过 Shadowing,它通常会发生在变量的上下文中。下面是只包含两个作用域的简单示例:

foo("foo")
func foo(var1 string) {
  for {
    var1 := "bar"
    fmt.Println(var1)
    break
  }

我们通过:=赋值符号创建了一个变量,并通过所赋的值(类型引用)来推断变量的类型。在这里,它是一个字符串。因此,我们在内部作用域(for 循环)中创建了一个与函数参数名称相同的变量。我们覆盖(shadow)了输入参数,并输出“bar”。

到现在为止还挺好。但是,在 Go 中,需要为其他包的属性指定包名(即结构体、方法、函数等),这个可以在提供 Println 函数的 fmt 包中看到。

所以我们对之前的例子稍微做一下重构:

foo("foo")
func foo(var1 string) {
  for {
    fmt := "bar"
    fmt.Println(var1)
    break
  }
}

这一次,我们遇到了编译错误,我们试图在一个字符串上调用 Println 函数。但这种情况并不总是这么明显。当代码突然停止编译时,即使只有几行代码也会给我们带来“惊喜”。

如果结构体发生重叠,就会很麻烦。让我们举一个奇怪的例子:

type task struct {
}

func main() {
  task := &task{}
}

我们创建了一个叫作 task 的结构体和它的一个实例。我们有意使用小写 task 作为结构体的名称,因为如前所述,Go 使用第一个字母来确定可见性,所以 task 在这里是私有的。

到目前为止,它看起来很不错,Go 编译了我们创建的 task。但是,当我们尝试添加另一行代码时,情况突然发生了变化。

type task struct {
}

func main() {
  task := &task{}
  task = &task{}
}

现在无法通过编译,并显示 task 不是一个类型。此时,Go 分不清类型和变量之间的区别。也许有人会说,在 JavaScript 中,变量 task 可以是对类型的引用,但这在 Go 中是不可能的,因为类型不可以作为值赋给变量。

现在的问题是:这算不算是悲剧?一般来说不算,但它却经常在我没有意识到的情况下发生。后面可能还会有一些代码尝试访问相同名称的结构体或包,而每次都需要花几分钟时间才能找到问题所在。

说到类型问题,让我们看看另外一个例子。

2. 类型还是无类型,这是个问题!

我们已经知道如何创建结构体和函数。有时候,我们会偶尔“重命名”一下类型,比如:type handle int ,这将创建一个叫作 handle 的类型,它的行为类似 int。通常,这个特性被称为类型别名。你可能也想到过这个特性,但不是在 Go 中。不过从 Go 1.9 开始,已经完全支持这个特性了。

让我们看看可以用 Go 做哪些好玩的事情:

type handle int

func main() {
  var var1 int = 1
  var var2 handle = 2
  types(var1)
  types(var2)
}

func types(val interface{}) {
  switch v := val.(type) {
  case int:
    fmt.Println(fmt.Sprintf("I am an int: %d", v))
  case handle:
    fmt.Println(fmt.Sprintf("I am an handle: %d", v))
  }
}

I am an int: 1
I am an handle: 2

在这个例子中,我们使用了 Go 的几个非常酷的特性。switch-type-case 语句是一种类型模式匹配,类似于 Java 的 instanceof 或 JavaScript 的 typeof。我们把 interface{}与 Java 中的 Object 等同起来,因为它是一个空的接口,每个 Go 类都会自动实现它。

有趣的是,Java 开发人员希望handle也是一个int,这样就会匹配到第一个 case。但事实并非如此,因为面向对象中的类型继承在 Go 中并不适用。

另一种可能的情况是,handle 是 int 的别名,就像 C/C++ 中的typedef一样,但事实也并非如此。Go 编译器会创建一个新的 TypeSpec,可以说是原始类型的克隆。因此,它们之间是完全独立的。

不过,从 Go 1.9 开始,支持真正的类型别名。下面的例子只稍微做了点修改。

type handle = int

func main() {
  var var1 int = 1
  var var2 handle = 2
  types(var1)
  types(var2)
}

func types(val interface{}) {
  switch v := val.(type) {
  case int:
    fmt.Println(fmt.Sprintf("I am an int: %d", v))
  }
  switch v := val.(type) {
  case handle:
    fmt.Println(fmt.Sprintf("I am an handle: %d", v))
  }
}

I am an int: 1
I am an int: 2
I am an handle: 1
I am an handle: 2

你有没有注意到它们的区别?实际上,我们现在不使用type handle int,而是使用type handle=intint创建一个额外的名称(别名),即handle。这意味着 switch 语句也必须做出修改,因为这个时候,int 和 handle 对于编译器来说是完全相同的类型,除非你有另一个 double case,否则会出现编译错误。由于类型别名实在 Go 1.9 中引入的,很多人会认为上述的类型克隆就是类别别名。

为了方便演示,让我们定义一个名为Callable的类型,它由一个没有参数和返回值的简单函数组成。

type Callable func()

现在创建一个相应的函数。

func main() {
  myCallable := func() {
    fmt.Println("callable")
  }
  test(myCallable)
}

func test(callable Callable) {
  callable()
}

看,很简单。由于 Go 的类型推断机制,编译器自动识别出myCallable应该对应Callable的函数签名。编译器因此能够隐式地将myCallable转换为Callable。随后,myCallable被传递给test函数。这是执行隐式转换的少数例外之一,通常情况下,所有形式的转换必须全部明确地指出。

现在我们已经到了不得不使用Reflection的地步。与其他语言一样,Reflection提供了在运行时分析或改变行为的能力。类型信息通常被用于根据值的数据类型来改变运行时行为。

type Callable func()

func main() {
  callable1 := func() {
    fmt.Println("callable1")
  }

  var callable2 Callable
  callable2 = func() {
    fmt.Println("callable2")
  }

  test(callable1)
  test(callable2)
}

func test(val interface{}) {
  switch v := val.(type) {
  case func():
    v()
  default:
    fmt.Println("wrong type")
  }
}

callable1
wrong type

callable1现在是函数类型func(),而callable2被显式声明为CallableCallable是一个单独的TypeSpec,因此与func()的类型不一样。这两种情况现在都必须由我们的Reflection处理程序单独拦截处理。不过这些问题可以通过在 Go 1.9 中引入的类型别名来解决。

type Callable=func()

3. 懒惰是囊地鼠的天性!

Go 语言萌萌哒的 logo 囊地鼠生性懒散,选这个 logo 也是有一定的代表意义的。

我最喜欢的 Go 特性之一是惰性求值(Lazy Evaluation),即延迟执行代码。自从 Java 推出 Stream API 以来,Java 开发人员对该特性也所了解。

我们来看看下面的代码片段:

func main() {
  functions := make([]func(), 3)
    for i := 0; i < 3; i++ {
      functions[i] = func() {
      fmt.Println(fmt.Sprintf("iterator value: %d", i))
      }
    }

  functions[0]()
  functions[1]()
  functions[2]()
}

这里有一个包含三个元素的数组、一个循环和闭包,而结果会是什么?

iterator value: 3
iterator value: 3
iterator value: 3

我们会认为是 0,1,2,但实际上却是 3,3,3。没错!

在其他编程语言(如 Java)中,在创建闭包时会捕获变量的值,而 Go 仅捕获指向变量本身的指针。问题是,在迭代期间,变量的值不断变化。循环完成后,我们执行闭包,只看到最后的值。我们知道我们只拥有指针,所以也就可以理解这种行为,但确实不是很直观。

如果我们想保存这个值,需要知道在创建闭包时如何计算这个值。

func main() {
  functions := make([]func(), 3)
  for i := 0; i < 3; i++ {
    functions[i] = func(y int) func() {
      return func() {
        fmt.Println(fmt.Sprintf("iterator value: %d", y))
      }
    }(i)
  }

  functions[0]()
  functions[1]()
  functions[2]()
}

我们创建了一个临时函数,它将变量作为参数并返回闭包。我们立即调用这个函数。由于在调用外部函数时必须先计算变量的值,所以内部闭包就可以捕获到正确的值。我们得到的是 0,1,2。

在写这篇文章不久之前,我找到了另一种方式。我们可以在循环中创建一个具有相同名称的变量,并为其分配实际值。这样也可以捕获到变量的值,因为这个方法在循环的每次迭代中都会创建一个新的变量(因此是一个新的指针)。

func main() {
  functions := make([]func(), 3)
  for i := 0; i < 3; i++ {
    i := i // Trick mit neuer Variable
    functions[i] = func() {
      fmt.Println(fmt.Sprintf("iterator value: %d", i))
    }
  }

  functions[0]()
  functions[1]()
  functions[2]()
}

从执行速度来看,懒求值通常是一个有趣的话题。毕竟,我可以在不使用它的情况下创建闭包。既然这样,为什么还要求值?在我看来,这也是非常不直观的。

4. 我们是不是都有点像囊地鼠?

我们已经知道,Go 中的interface{}就像 Java 中的Object——Go 中的每个类型都会自动实现这个空接口。不过,自动实现接口不仅适用于空接口,每一个实现了某个接口所有方法的结构体或类型也会自动实现这个接口。

为了更好地说明这个问题,让我们来看看下面的例子:

type Sortable interface {
  Sort(other Sortable)
}

定义了这个方法的结构体会自动成为 Sortable。

type MyStruct struct{}
func (m MyStruct) Sort(other Sortable){}

除了接收器类型的语法,它用于将函数绑定到类型(在本例中为结构体),我们已经实现了Sortable接口的所有方法。我们现在是一个 Sortable!

var sortable Sortable = &MyStruct{}

自动实现接口乍一看似乎很有用,但这样会让事情变得复杂,特别是在大型应用中,如果有几个接口拥有相同的方法,那么就会点让人摸不着头脑。开发者实际想要实现哪个接口?或许他们应该在代码的注释中写清楚!

Go 还有一个解决方案用于确保一个类型实现了一个接口,就像 Java 的implements关键字一样,这实在是太简单了。

type MyStruct struct{}
func (m MyStruct) Sort(other Sortable){}
var _ Sortable = MyStruct{}
var _ Sortable = (*MyStruct)(nil)

5. nil 和 nothing

现在我们都知道,“null”和“nil”之间有很大的差别,但可能不是所有人都知道,“nothing”并不总是意味着“什么都没有”。为了证明这点,我们定义了自己的错误类型(异常)。

type MyError string
func (m MyError) Error() string {
  return string(m)
}

我们创建了一个新的类型,它是从字符串类型克隆过来的。我们只是想要一个错误消息,所以这样做就足够了。要实现error接口(是的,小写,理论上它不应该是公开的,但 Go 无所不能),就必须实现Error方法。

接下来,我们需要另一个总是返回 Nil 的函数。

func test(v bool) error {
  var e *MyError = nil
  if v {
    return nil
  }
  return e
}

无论我们传进去的是true还是false,这个函数总是返回nil,是这样的吗?

func main() {
  fmt.Println(nil == test(true))
  fmt.Println(nil == test(false))
}

true
false

在返回e时,*MyError指针指向接口error的一个实例,它不是nil!这样合逻辑吗?当你知道接口在 Go 中的表示方式,你就会知道这是合乎逻辑的。

在 Go 内部,接口是一个结构体,包含了实际目标实例(这里为nil)和接口类型(在这里是error),而且根据 Go 语言规范,只有在这个结构体的两个值都为nil时,接口实例才为nil。因此,如果真想要返回nil,那就显式地返回吧。

特别之处

还有一点是值得一提的,如前所述,Go 根据名称来推断出类型和功能的可见性。如果第一个字母是大写字母(如Foo),则该函数或类型是公开的,如果第一个字母是小写字母(如foo),那么就是私有的。不过,在 Java 中有 private,而在 Go 中只有package-private

一般来说,除了在 Go 中使用驼峰式命名法,我们都可以使用这种可见性规则,无论是函数、结构体还是常量,但我们的 IDE 有语法突出显示,所以谁会在乎这个!

有趣的是,Go 支持 Unicode 的标识符。因此,日本语(Nihongo 是日语的意思)是完全合法的标识符,但通常被认为是私有的。为什么?因为日文字符没有大写字母。

“GO 斯拉”发来问候

某种程度上,Go 是一门非常独特的语言。在日常工作中,你可以享受 Go 带来的乐趣。如果你已经知道我们在这里所提到的陷阱(还有更多),那么即使开发再大型的应用程序也不成问题。尽管如此,还是会不断出现各种提醒,说这门语言有问题。

Go 在近几年发生了很多事情,除了增加新特性,Go 2 中还列出了很多需要改进的地方,包括一些语法和运行时行为的不一致性。不过 Go 2 的推出时间还不得而知,还没有清晰的路线图。

如果你想要用 Go,那么就用吧,尽管存在很多坑。不过你要为此做好准备:有时候你会感到困惑,需要长时间的调试,或通过阅读 FAQ 或访问 Gopher Slack 频道来解决问题。

你也许感兴趣的:

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注