Java程序员不喜欢Golang的地方 - Gavin

22-06-25 banq

我爱Go。从我开始使用这种语言的第一天起,我就迅速爱上了它。它提供了令人难以置信的简单性,同时保持了出色的类型安全和快如闪电的编译。它的执行速度非常快,并发性是一流的(这是一种轻描淡写的说法),标准库有大量的高级接口,可以用很少的依赖性来启动任何应用程序,它可以直接编译成可执行文件,我可以继续说下去。尽管与其他C语言相比,Go的语法文字主义需要一些时间来适应,但在使用了一段时间后,感觉非常直观。

我的背景是Java,但对C、C++、JavaScript、TypeScript和Python都有丰富的经验。Go是我学习的第一种语言,我希望在任何地方都能用它来做任何事情。虽然我讨厌 "产品杀手 "这种陈词滥调的概念,但作为一个曾经是专业的Java开发者的人,Go感觉就像是一个Java杀手。我认为Java会消失吗?可能不会。我认为Go会在流行程度上超过Java吗?不太可能。然而,对我个人来说,我无法想象在任何情况下(除了维护大到无法重写的传统产品),我宁愿使用Java而不是Go。

说到这里,你可能会想,"这篇文章不是应该讲你不喜欢Go的地方吗?" 这是一个完全公平的问题,我正要回答这个问题,但重要的是要了解我有多喜欢Go,才能理解我为什么要抱怨它。那么就不多说了,到底有什么可批评的呢?

库函数会修改其参数
我对Go的第一个抱怨是我马上就注意到的。许多内置的库函数修改它们的参数而不是返回新的结果。修改函数参数模糊了输入和输出之间的界限,最终破坏了代码的表现力。一个函数的表现力是指它通过其签名清楚地传达意义和意图的能力;意义传达得越清楚,这个函数的表现力就越强。表达性是可维护代码的最重要方面。显然,有些时候性能比表现力更有利于程序,但从可维护性的角度来看,表现力应该始终是优先考虑的。

那为什么Go会这样做呢?通过修改参数而不是返回新数据,Go编译器可以更好地跟踪特定变量的生命周期。Go是一种垃圾收集(GC)语言,所以任何时候函数返回一个指针或由指针支持的类型(例如一个片断),都会增加内存需要在堆而不是栈上分配的可能性。堆分配需要垃圾收集,而垃圾收集会占用你程序中宝贵的CPU周期。

避免堆分配--从而减少垃圾收集--绝对可以提高一个应用程序的性能。这些优化可能有利于渲染高FPS图形的软件,但对于大多数企业应用和日常服务来说,很可能对终端用户的好处几乎是无法察觉的。
一个合理的论点是,一种语言应该尽量减少其自身库的开销,但是当速度和易用性是相互竞争的目标时,需要优先考虑一个目标。已经有很多语言为高性能的使用场景提供了显式内存管理(C、C++、Rust等),那么Go真的应该为了提供更少的GC周期而牺牲其最大的优势之一(易用性)吗?

作为一个开发者,我希望至少能有一些更有表现力、更直观的替代品,在功能上与优化的API相当。尽管有这样的烦恼,Go远不是唯一犯了这样错误的语言,事实上,许多违规的Go函数都有几乎相同的Java和C++对应物。然而,仅仅因为有其他语言的先例,并不意味着Go应该无可指责,最终,这是我不喜欢这种语言的地方。

为了挽回一些分数,可以说要求用指针作为参数是Go语言请求修改的一种表达方式。对于这一点,我将承认几点,尽管我仍然没有找到一个令人信服的方法来用slice这么做(而slice往往是这个模式用的最多的)。

泛型
Go 1.18引入了泛型,所以我有点被宠坏了,因为我只需要等待几个月就可以得到这个功能¹,而许多资深的Go开发者已经等待了好几年。Go的泛型实现感觉有点像TypeScript的,在大多数情况下,这是件好事。与TS一样,开发者可以很容易地约束泛型,使其符合几种可能的已知类型或接口。但与TS不同的是,Go不必处理JavaScript的包袱,特别是围绕着未定义和null。

说白了,我喜欢泛型作为一种语言特性。如果使用得当,它们可以提高代码的可重用性,从而提高一致性并降低错误的风险。当我说我不喜欢Go中的泛型时,我的意思有两点:第一,Go对泛型的实现还有很多需要改进的地方;第二,长期以来语言中缺乏泛型,导致许多丑陋的反模式埋藏在许多库的表面,包括Go的标准库。

解读第一点,Go目前不支持方法或结构域的泛型。对方法的不支持有点令人费解,因为在引擎盖下,Go将方法视为以接收者为第一参数的函数。如果函数支持泛型,为什么方法不支持呢?Go对嵌入的支持降低了泛型结构字段的关键性,因为很多泛型的用途都可以用嵌入来模仿:只要把 "非泛型 "字段放在一个单独的结构中,然后把同一个结构嵌入几次就好了。然而,嵌入并不是一个完美的替代品,因为操作和方法需要针对外部结构的每一种变化进行重新实现。Go可以通过允许泛型结构字段来将这一责任转移给编译器,而不是开发人员,但现在我们还只能复制和粘贴。

由于Go中目前缺少泛型的地方,许多数据结构的实现不得不使用反射、类型检查和铸造等黑客的变通方法,以提供对不同类型的广泛支持。这让我想到了第二个抱怨。Go作出了类型安全的承诺,然后立即在其标准库中通过使用伪通用的变通方法:interface{}来破坏它。Go的空接口用法不仅是一种反模式的缩影,而且类型检查和反射往往是较慢的操作(讽刺的是,这与我之前抱怨的表达能力对速度的权衡是不一致的)。最糟糕的是,第三方库也大量采用了空接口的反模式,所以即使Go最终将其所有库迁移到泛型,这种模式也可能在许多代码库中存在相当长一段时间。

make()函数
make()函数是Go的 "原始类型 "初始化解决方案。大多数基元都有一个合理的零值,但在Go中,map、slices和channel都是受益于动态初始化的基元类型。使用map和slices的零值是完全可能的,有时甚至是合理的(例如JSON操作和避免nil返回),但对于大多数情况,make()是最好的选择。我对make()有异议的地方是,它存在我已经说过的两个问题。

首先,make()没有表现力。它的完整签名是func make(t Type, size ...IntegerSize) Type,这让我对如何正确使用它知之甚少。尽管它在技术上只是一个函数,但在Go编译器对它的特殊处理以及它对创建通道的必要性之间,make()就像for-loop一样是Go的一个重要组成部分。采用这种思路可以部分地原谅它的签名,但是提供NewMap()、NewSlice()和NewChan()函数也同样容易,甚至更容易,这样就不会产生歧义了。我不打算深入讨论这些替代方案,因为我相信对于这些选择为什么会有问题,有很多强烈的意见。但我要深入探讨的是,make()的错误是多么容易发生(看到我做了什么了吗)。

m := make(map[int]int, 10) 创建一个空的地图,分配足够的空间来存储10个条目;len(m) 返回0。看到问题所在了吗?无论是在写代码时,还是在审查代码时,都很容易不小心忽略这个重要的区别。要获得你所期望的切片行为,需要一个额外的参数:s := make([]int, 0, 10)。在这种情况下,len(s)实际上会返回0。因此,Go并没有为这些数据结构提供更具表现力的、不同的初始化器,而是提供了一个具有更大模糊性的单一函数,因此具有更大的误用风险。

在我对make()的看法上,我对它的第二个问题是它的伪通用性。Go通常不允许函数重载,但make()得到了一个特殊的通行证来假装重载。由于这个特殊的传递,make()的第一个参数可以是几种类型中的一种。它的返回类型也是如此。对于一个十年来一直声称不需要泛型的语言来说,Go不得不打破很多自己的规则,让它最核心的一个函数在没有泛型的情况下工作。对我来说,这让我感觉很草率。

扁平化的包结构
我来自Java的世界。Java应用程序往往有很多很多的包。在这个世界上,父包对于一个类的上下文来说往往和类的名字本身一样重要,所以对于Go这个 "Java杀手 "来说,拥有这样一个扁平的包结构是有点刺耳的。这并不是Go的独特之处。许多面向脚本的语言,如Python,倾向于采用更多的广度而不是深度。尽管这是一种相对普遍的做法,但我想让Go成为Java的 "直接替代品 "的梦想似乎已经破灭了。

扁平化的包结构本身并没有什么问题。一层层的空目录(或包含单个文件的目录)在没有明确设计的语言中很少提供价值--诚然,这适用于大多数不是面向对象的语言。然而,如果扁平语言声称要解决与嵌套语言相同的问题,那么扁平语言应该提供语义上相等的机制来管理标识符的可见性和范围。

Go通过大写字母导出标识符的简单方法非常好。在我的团队的风格公约会议上,我又少了一件要争论的事情。玩笑归玩笑,尽管我很喜欢Go开发者的这一选择,但包的语义和扁平结构的惯例削弱了这一功能对应用程序代码的潜在价值。在库代码中,导出或不导出的简单概念对于定义公共API是完美的。对于大型应用,尤其是网络服务器,这通常是不够的。

网络服务器必然会有一些从来没有被其他代码明确消费的包(除了测试),而是被外部客户和其他服务器通过HTTP等协议调用。这些包中的代码将和其他代码一样从抽象中受益,但在扁平化的包结构中,未导出的抽象将不可避免地被包中无权使用的其他区域看到。这导致了一个难题:我们应该违反扁平化包结构的惯例,牺牲可读性和重用性来换取更少的抽象性,还是干脆让未导出的标识符在它们不应该出现的地方被访问?这个问题的存在就是承认Go有问题。
当然,扁平结构是惯例而不是法律,但惯例在Go的发展过程中具有巨大的影响力,它决定了许多新的功能,并将其纳入语言。所以是的,这可能不是Go的一个明确特征,但它仍然是我不喜欢的东西,因为Go社区把它作为一个最佳实践而大力推动。

缺少lambda函数
这个问题绝对是吹毛求疵的,所以我就直奔主题了。Go并没有λ函数的简写方式。我知道有人提议使用λ函数,也有人争论为什么不需要λ函数,但尽管有这些考虑,事实是我喜欢速记λ,而Go没有。

Go的函数语法恰好是短而精的。此外,函数在Go中是类型,可以分配给变量,这对我这个喜欢滥用Java 8中引入的 "方法引用 "的人来说很熟悉。即使如此,当我用Go写作时,有时内联一个函数是解决一个问题的最合适的方法,然而即使是单行的,所产生的代码也往往是笨拙的,特别是当需要返回语句时。我不认为有人能说服我说func(x, y int) int { return x+y }比(x, y) => (x+y)更漂亮或更易读。随你怎么争论强类型或明确性,我还是会怀念速记的lambdas。

总结
对于那些还没有尝试过Go,但正在考虑使用它的人来说,不要让这些劝阻你;它是一个神奇的工具,几乎肯定会改善你的开发生活。对于现有的Gophers,我希望你能同情我的抱怨,但仍然像我一样喜欢这门语言。

1
猜你喜欢