Go语言不简单

阅读 2084
收藏 58
2018-02-08
原文链接:www.techug.com

出于好奇,我最近开始接触一些 Go 的代码。我之前对它有一些了解,但是从来没有尝试去写(没有需求)。但是现在我们团队选择使用 Go 来开发一个项目,所以我觉得这是一个获得实际经验的好机会。

到目前为止,关于这门语言我已经学习了很长时间。在这个博文的末尾,我会写更多关于 Go 的干货。

社区实际上并不那么令人愉快,特别是那些因为它的简单性而主张使用 Go 的人。似乎简单已经成为 Go 社区中的一个流行语,许多人反复重复提到这点,却没有给出太多实际的想法。

这对我来说似乎很不幸,因为在我看来,Go 是一个“极其简单的语言”:

  1. 不应该作为考虑使用 Go 的主要原因
  2. 从他们的关注点中找到其他更有利的推荐理由
  3. 甚至不是真的(不是真的简单)

在这篇文章中,我想围绕 Go 来分析一些简单观点。

在深入之前,我想强调一件事情:这篇文章并不是对 Go 的批评,而是一种对 Go 的宣传和倡导的方式。有时候,我可能会批评这个语言的某个方面,但这不是我们关注的重点,我只会试图用一种非正式的、事实的,每种语言都会涉及的方式来讲述。

我来自哪里

出于工作和业余爱好,我同时使用多种编程语言。我不赞成有“最喜欢的语言”的概念。过去我曾经有过一些最喜欢的语言,但这种认识往往是一时的情感,随着时间推移,会发生变化。

在我的工作中,我使用 C++Python 写大型服务的后端代码。过去我曾经在一个你可能知道的操作系统上工作,而且我也做了嵌入式工作。在业余项目中,我做了其他各种事情。

我并不是夸耀什么(我不是一个专家),我只是想表明,我在编程的许多领域至少有一些见解,而且我一直努力保持开放的心态。

所以,不要着急,让我们开始讨论正题,看看几个观点。

1. “与主流语言相比,Go 的关键字非常少”

我从一个最常见的例子开始。当推广 Go 时,这会是大家的口头禅。

首先,即使它是真实的,我不知道为什么关键字数量会是判断一个语言的学习曲线或复杂性的重要依据。当然,如果有成千上万的关键字,这可能是一个问题。但是大多数语言最多只有几十个关键字,这种规模下,关键字的多少是无关紧要的。

我还没有听到有人因为关键字的数量而抱怨某门语言

其次,Go 所谓的“很少”的关键字实际上只不过是一个聪明律师的伎俩(也许,我甚至会认为这是 Go 的虚假广告)。Go 规范 列出了 25 个关键字,这的确比大多数语言要少些。但在我看来,Go 并没有比其他语言关键字表示更少的概念,Go 虽然没有这些关键字,但相应的概念依然是语言的一部分(即实际的复杂性保持不变)。

为了说明我的意思,请考虑一个 while 循环。 Go 没有这个关键字,这是真的,但它仍然有一个 while 循环,文档甚至是这样说的,它的目的只是重用其他关键字。

另一个这样的例子是 privatepublic。 Go 没有这些关键字,但它仍然有 privatepublic,它只是使用字母大小写而不是关键字。

用来删减关键字的另一个技巧叫 预定义标识符(Predeclared identifiers),在技术上它不是关键字,但是在实践中仍然需要它们,创建一个和它同名的变量仍然不是一个好主意,因此,最后看来…它们基本上是关键字。此外,其中一些预定义标识符是其他语言的关键字,因此仅将它们与 Go 的关键字列表进行比较是非常不公平的。就像苹果和桔子。

2. 接收者参数

接受者参数对我来说有些古怪。看起来 Go 似乎并不建议使用 thisself,但是仍然需要方法,所以就存在 “接收者参数”,除了方法签名看上去很奇怪之外,它们基本上是一样的。

接收者参数有一个问题,当访问一个方法时,我需要知道接收者参数(这是任意的)的名称,以明确这个方法的作用。因为缺少关键字(译注:如 this),语法高亮成为一个问题。(看吧?这是如何减少关键字实际上使事情变得更加复杂的例子。)这有点像 C++ 中的隐式 this

这里有一个新人容易混淆的例子

恕我直言,最简单、最直接的方式来表达一个接收器是 UFCS,而不是 C++ 或 Go 的方式。但就像我说的,我不是在抱怨 Go,我真的不介意接受者参数的观点(如果我忍受不了 C++ 的怪异,我可以忍受 Go 的)。

3. 函数返回值

如果接收参数不够,函数甚至能够通过各种形式的返回值来声明。通常语言允许你通过 return 语句返回函数中的一个值。而在 Go 语言中,你可以返回多个值(我认为可以用更优雅的方式通过元组来解决,但是就这样吧)。除此之外,还有命名返回值。在我看来,并不是一个好主意,因为它允许我们在那些很难找到返回值的地方写上晕头转向的代码。结合接收方参数,您可以创建这样的函数签名:

func (f Foobar) Something(a int, b int, c int) (foo int, bar int) {
    // ...
}

这是有效的 Go 代码。如您所见,有三个参数。我真的不希望任何人试图选择这个“简单”,因为这个语法除了简单,什么也不是。

4. “没有继承”

Go(或许只是社区)似乎很反对“传统的 OOP”(不管这是指哪个,可能是 Java 或者 C++),我记得有人说 Go 没有继承是一件好事。

除此之外,Go 有一个功能叫做嵌入,这个文档以及一些博客文章声称 Go 没有继承。我试着用各种方式使用它,我没法认为 Go 反对继承。上面链接的文档说:

还有种区分内嵌与子类的重要手段。当内嵌一个类型时,该类型的方法会成为外部类型的方法,但当它们被调用时,该方法的接收者是内部类型,而非外部的。

有差别吗?继承通常以相同的方式工作,继承的方法也对内部类型起作用。

在我看来,在 Go 中,真正唯一不同的是,多态性从结构中解耦。你需要使用接口来使用多态性。但一旦你做了,做的事情和传统的 OOP 非常相似,包括方法覆盖 - 这里是个演示

关于 Go,有件事令我很惊讶 —— 这门所谓简单的语言 —— 你甚至可以实现多重继承。确实很糟糕。 golang-nut 的邮件列表中,有人提到,Go 并不能很好的处理继承的歧义。我已经调整了其中提及的代码,以便它展示了著名的“可怕的钻石问题”(Dreaded diamond problem):

package main

import "fmt"

type T1 struct {
    T2
    T3
}

type T2 struct {
    T4
    foo int
}

type T3 struct {
    T4
}

type T4 struct {
    foo int
}

func main() {
    t2 := T2{ T4{ 9000 }, 2 }
    t3 := T3{ T4{ 3 } }
    fmt.Printf("foo=%d\n", t2.foo)
    fmt.Printf("foo=%d\n", t3.foo)
    t1 := T1{
        t2,
        t3,
    }
    fmt.Printf("foo=%d\n", t1.foo)
}

在线运行以上代码

上面的代码没有任何编译时警告或者错误。这是 C++ 的类似的代码,你可以看到,它编译不通过,因为存在歧义。

结果会如何?首先,我认为具有多重继承功能,几乎不能在描述该编程语言时使用“简单”一词。在我看到上面的代码后,没有人能说服我,Go 是最简单的语言之一,甚至连简单语言都不算。甚至没有其他一些你可以用嵌入来做的事情,比如通过指针嵌入或者通过指针嵌入接口。 (我甚至不确定这些功能的真正含义。)

其次,我想做一个简短、对 Go 语言本身的批评。不处理这样的歧义似乎是一个设计或者实现错误。甚至连 C++ 都没有如此疯狂,让这种代码编译通过。这足以告诉你一些事情。

5. 错误处理

各种错误处理通常会导致一个巨大的口水战。我不想谈那件事。我曾经在不同的语言中使用过所有常见的错误处理风格(我认为),我也不喜欢所有这些语言。我认为,错误处理无论什么一直是一个 PITA(译注:应该是国外的一种比喻)。把一种风格换成另一种风格,你只需把一套问题换成另一套。没有好的方法。

回到简单的话题:Go 让我选择不使用异常,这使事情更简单了。多个返回值的特征不能使事情变得简单,这意味着不能返回一个错误或成功的结果,你可以返回所有值或者都不返回(CS 术语,你可以说这个问题是一个产品类型而不是总和式的用法)。事实上,我看过的许多对于新人的代码审查。

如果 Go 不允许多个返回值,而有一些合适的或者喜欢的类型,在我看来,这会使事情变得更简单。出于同样的原因,在 Go 中忽略错误或者不向向调用者或其他适当目的地报告错误是相当容易的。

另一不简单的是 panic。不要误解我的意思,我理解它在 Go 中存在的原因以及它的用处,事实上,其他语言也有类似的处理。我只是提出来作为反对简单性的一个论据。恕我直言,对于一个新人,很可能会混淆 error 和 panic 之间的区别,以及什么时候适合用什么。

6. 泛型

这个主题和错误处理比起来,可能是一个更大的蠕虫。

和 errors 一样,我只想考虑一下这里的复杂性或者简单性。Go 社区的许多人似乎认为,泛型的本质上是复杂的(=坏,嗯嗯嗯咳),有这样或那样的巨大开销。这在某种程度上是事实,但我不认为它像有些人描述的那么糟糕。似乎那些人已经经历了 C++ 模板的痛苦,从那以后,无论何时提及泛型,都会遭受 PTSD(创伤后应激障碍) 的攻击。

看到这里的人,泛型不是一个怪物。它们当然绝对不应该像 C++ 那样复杂(或者其他一些奇怪的语言)。我的意思是,甚至前端的人都用泛型工作了一段时间(TypeScript, Flow, …),如果他们不害怕泛型,其他程序员应该是没有理由害怕:)(对不起,前端开发者,只是开个玩笑。)

人们还没有意识到,如果正确地使用泛型,它可以使许多类型和函数的使用更加简单。例如,考虑 Go 中的堆接口。这就是从一个堆中声明这个接口的方式:

popped := heap.Pop(&someheap)
myfoo := popped.(*Foo)       // ZOMG what just happened here?

对新人解释这些,包括 panic 的问题。也许可以考虑一下,如果他们没有真正把整个 interface{} 搞对,那么会发生什么。相比之下:

myfoo := heap.Pop(&someheap) // myfoo has the correct type

这更容易阅读、更容易解释(你解释它,就像你将解释 map 类型已经存在于 Go!)。而且在编写代码时也更难弄乱。

缺乏泛型是造成额外复杂性的原因,它在 Go 的其他部分也会造成相当多的复杂性,主要是需要存在各种“神奇”的函数/类型。mapslicechannel 类型的魔法,以及伴随的 make() 功能,这是它们三个的构造函数。slice 类型既可以作为数组的引用,也可以作为动态数组。(不管发生什么事,“做一件事,并做好它”?)

(只是为了提醒大家,我并不介意这些,只是为了不简单的争论而提及它。)

7. 其他

我想我已经把主要的简单违反者排除在外了。我的单子上只剩下几个简单的:

  1. <--> 操作符。这些可能只是 channel 类型的方法。
  2. iota – 基本一样,但奇怪的枚举。
  3. 内置的复数。
  4. if 支持短语句(有时可能有用,但 if 语法比其他语言中更复杂)

我想就是这样。可能忘记了什么,但我想已经足够了。

那么,我觉得如果不是简单的话,Go 实际上会带来什么呢?

任务 - “goroutines”

这可能看起来有点显而易见,因为 goroutines 是一个经常被提及的特性,就像“简单”一样,所以我觉得需要区分下:我认为这不是通常意义上的并发性,它不能认为是 Go 的优势。不要误解我的意思,Go 的并发性是没问题的。只是说这没有什么特别的。你有 channel,这肯定是好的,但基本上,它们只是像我在别处常用的并发队列。然后你有常规的并发原语,像 mutex,读写锁,条件变量等。你可以同步你的代码,你可能会遇到像许多其他语言一样的竞争条件和死锁。

我喜欢 goroutines(除了明显的事实,它们是轻量级的用户空间线程)是它们可以使用 I/O 的方式 – 调度连接到主机操作系统的低级 I/O API 的方式(如 epoll、kqueue、IOCP…)。这对于程序员来说通常很难做出令人愉快和有用的东西,特别是在编译本地语言的时候。我仍然在这里了解细节,但在我看来,这是一个很好的做法,也是为什么我认为 Go 是未来工程的一个亮点。

正如已经暗示的,我也喜欢 Go 这种编译为本地代码的语言。看到新的语言使用垃圾收集来保持这种不可思议的效果真是太好了。(或其他形式的自动内存管理 – Swift 中有提及)

结论

所以,读者们,为什么所有这些都离开了你呢?是 Go 复杂还是其他什么原因?

当然不是,绝对不像 C++Haskell 那样复杂。相比之下,Go 的确很简单。另一方面,比较 Go 和其他常见语言(如JavaJavaScriptPython 等)的复杂性时,情况就不太清楚了,正如我希望的那样。 (此外,这是一个很难,没有明确定义的任务。)

我可以提供类似的例子。在某些方面,Go 可能比这些语言更简单,有些则不是…大致上我会说它和其他常用语言的平均差不多。我也不认为简单,无论是感觉上还是实际使用中,最终的体验很重要。

最后,这篇文章从哪里来,作者是谁?我不肯定。我还不知道 Go 是否会在我的日常工作中被选为一个(子)项目,或者我是否可能将它用于兴趣爱好项目。我想避免像本文提到的那种教条的社区推广的一份子。有没有意识形态导向的地方呢? 大家可以随意就此提出建议。

我和 Rust 社区有同样的问题,请不要介意,我也知道离开那些更狂热的支持者会更好。 (Q:“你能否在 Rust 重写你的项目?”A:“迷失了”)也许这就是这些新语言的性质,以及他们为激励人们如此激励阳光的争斗。

评论

查看更多 >