我学习go的五个感悟(译)

原文

5 things about programming I learned with Go By MICHAŁ KONARSKI

Go在最近一段时间内开始变得十分流行。语言相关的论文和博客每天都在更新,新的golang相关的项目在github中也层出不穷。Go语言的会议也吸引了越来越多的开发者的关注。Go语言的时代已经来临,并且当选了TIOBE的2016年度语言,并一度进入流行度前十。

我一年前开始接触golang,然后决定试一试。经过一段时间的接触,我发现这绝对是一个值得学习的语言。即使你不打算长期使用,学习一段时间也会是你的编程技巧有很大的提升。接下来我会告诉大家我学习golang的过5点感悟,而且这五点感悟对其他编程语言也有用。

1. 具有动态类型特性的静态类型安全语言

我日常使用的编程语言是Ruby,我非常喜欢动态类型语言。这一特性使得语言非常容易学习、使用,并且开发效率非常高。随着项目越来越多、越来越复杂,代码变得不像其他静态类型安全语言那样安全可靠。及时我十分谨慎的测试代码,仍不能覆盖到所有的边缘状况,因此经常出现不希望出现的状况。那么,有没有哪种语言既有着动态语言的特性又有静态类型安全语言的可靠性。答案是肯定的,我们来讲一讲Go!

现在有一些争论是关于golang到底是不是面相对象的编程语言[1] [2] 。但是golang有个面相对象语言的特性--接口。格式上来看,和面相对象的语言Java比较相似,一个包含很多方法的结构体:

type Animal interface {
  Speak() string
}

当然,golang也有类的等价实现--结构体。结构体也可以是数据和方法的封装:

type Dog struct {
  name string
}

然后我们可以使用该结构体作为方法接收器--receiver,类似于类的成员方法:

func (d Dog) Speak() string {
  return "Woof!"
}

这不就是面相对象的三大特性之一--封装么。

和其他面相对象语言不同的是,方法声明在结构体外。golang的作者希望给结构体的使用者更多的灵活性。即使 你不是结构体的作者,你也可以自由的为它加上新的“成员方法”。

那我们怎么做到类似多态呢?很简单:

func SaySomething(a Animal) {
  fmt.Println(a.Speak())
}

dog := Dog{name: "Charlie"}
SaySomething(dog)

Dog实现了接口Animal的所有方法,就可以作为Anmial来使用,不需要主动的声明。这种行为被称为a statically typed duck typing

“If it quacks like a duck, then it probably is a duck”.

正是因为接口的这种特性,可以让我们像使用动态类型语言一样使用golang,却同时得到类型安全的保障。

2. 比继承更好用的组合

在之前的blog中我描述过一个问题,如果过度的使用面向对象的特性,我们会让自己陷进去。举个例子,一个需求最初可以用一个类来建模,然后逐渐扩展,在某种程度上,继承似乎是不断增长的需求的完美答案。不幸的是,这样做导致我们有了一棵紧密相关的大树,在那里添加新的逻辑的同时想要保持简单性和避免代码重复是非常困难的。

我对这个故事的结论是,如果我们想要减少在代码复杂性中迷失的风险,我们需要避免继承而选择组合。改变观念非常困难,而使用一种不支持继承的语言能够帮得上忙,你猜的对,就是Go。

Go的结构体设计的时候没有继承的概念。Go语言设计者是想保持语言的简单和清爽。他们发现继承不是必须的,但是他们保留组合的特性。举个例子,汽车包含引擎和车身,使用两个interface来表示:

type Engine interface {
  Refill()
}

type Body interface {
  Load()
}

现在,我们需要创建一个结构体Vechicle组合上述接口:

type Vehicle struct {
  Engine
  Body
}

发现什么奇怪的地方了么?我故意省略了接口类型的字段名。因此,我使用了叫做嵌入(embedding)的特性。这样,我们使用Vehicle的实体可以直接调用接口中的方法。我们可以方便的使用组合。代码如下:

vehicle := Vehicle{Engine: PetrolEngine{}, Body: TruckBody{}}
vehicle.refill()
vehicle.load()

3. 解决并发问题的利器-- channels and goroutines

channels 和 goroutines 是非常酷的工具帮助我们解决并发问题。

Goroutines 是Go的 green threads 由go自行管理和调度,而且占用非常少的系统资源。

Channel 是一个管道,可以用作协程间的通信。它可以让协程间方便的进行异步通信。

这里给出一个 Goroutine 和 Channel 共同工作的例子。假设我们有个方法执行一个耗时的计算任务,我们不希望它阻塞进程,我们可以这样做:

func HeavyComputation(ch chan int32) {
  // long, serious math stuff

  ch <- result
}

正如你看到的,这个方法接受一个channel类型的参数,一旦计算出结果,就将结果放到channel中即可。那我们怎么调用这个方法呢:

ch := make(chan int32)
go HeavyComputation(ch)

这里的go关键字可以非常方便的进行异步处理。Go会新建一个协程执行HeavyComputation(ch),然后程序可以不阻塞的执行其他任务。获取结果也非常简单:

result := <-ch

ch里有计算结果的时候,可以直接读出,否则将阻塞直到计算协程放入结果。

channels 和 goroutines 是非常简单但是非常有效的并发处理机制。

4. Don’t communicate by sharing memory, share memory by communicating

(这里标题没有翻译,因为英文大家更熟悉。)

传统的编程语言一般在标准库里提供多个线程访问同一块共享内存的方法。为了同步和避免同时访问一般采用加锁的方法。但是由于Go有 goroutines 和 channels 可以使用其他的方法。与加锁的方式不同,Go可以方便的使用channel来实现,保证了同时只有一个协程能够改变其内容。Go 的官方文档给出了解释:

One way to think about this model is to consider a typical single-threaded program running on one CPU. It has no need for synchronization primitives. Now run another such instance; it too needs no synchronization. Now let those two communicate; if the communication is the synchronizer, there’s still no need for other synchronization.

这绝对不是一个新概念,但是对于很多人来说,对于任何并发问题,加锁仍然是首选的解决方案。当然,这并不意味着锁是无效的。它可以用来实现简单的东西,比如原子计数器。但是对于更高层次的抽象,最好考虑不同的东西。

注:我认为作者的意思是,更复杂的场景下,如果能更关心业务而不是加锁解锁的逻辑,系统会更加可靠。实际上channel的实现就是帮我们集成了加锁和解锁的过程,当一个协程操作channel的时候,都会伴随的加锁和解锁的过程。想详细的了解,可以参考Go语言中channel的实现。

5. Go的异常处理

一般语言都有异常捕获和异常处理的概念。Go不同,Go在设计的时候没有异常的概念。这仿佛把缺少一个特性当成了Go的特性。但是仔细想,它是有用的。当出现错误的时候,我们没有办法确定到底是那种错误--磁盘空间不足?IO网络问题?如果是捕获异常可能需要包含所有类型的异常。Go给出了不同的解决方案,将错误当做返回值。

f, err := os.Open("filename.ext").
if err != nil {
  fmt.Println(err)
  return err
}

// do something with the file

坦白说,这不一定是最优雅的解决方案,但这是最有效的方法鼓励开发者处理错误。

总结

Go是一种有趣的语言,它提供了一种不同编写代码方法。它丢弃了一些我们从其他语言中了解的一些特性,比如继承或异常。相反,它鼓励用户使用自己的工具集来解决问题。因此,如果您想要编写可维护的、干净的、健壮的代码,您不妨以一种不同的、类似于Go的方式开始思考。这是一件好事,因为您在这里学到的技能可以在其他语言中成功使用。你的年龄可能会有所不同,但我认为一旦你开始接触Go,你很快就会发现它能够帮助你成为一个更好的程序员。