

谈谈我最近的编程语言选择
source link: https://blog.henix.info/blog/why-choose-golang/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

谈谈我最近的编程语言选择
最后更新日期:2023-08-13
说起来你可能不信,我最近在做个人项目的时候,放弃了 Ruby 和 F# ,而选择 Go 语言。下面我将说明这样做的理由。
首先我确实需要一门带 GC 的语言来做一些 fast prototyping 式的开发。我对这门语言的期望是,可以快速开发,有一定表达力。
之前我的快速开发首选语言是 Ruby 。但我最近越来越发现 Ruby 的问题:
- 社区越来越不活跃了,似乎人们在慢慢离开 Ruby ,很多 gems 不怎么维护了
- 作为动态类型语言,写出来的代码的长期可维护性不太好
所以我希望找一门静态语言。我一开始选择了 F# 。因为:
- 函数式,有强大的类型系统,非常强的表达力
- 背靠 .net 社区,再不济还有微软撑着,标准库不会差到哪里去
当时当我真正开始写程序的时候,我查了 .net 的文档,尤其是实现 http server 的 HttpListener 。我发现 F# 的问题是:标准库跟 C# 共享一套,而 C# 的标准库基本上是学 Java ,充斥着 OOP 设计。他们并没有利用 F# 的函数式特性专门为 F# 设计一套至少涵盖 IO 、 HTTP 、 JSON 的标准库。所以我开始转向 Go 。
重要的是标准库啊,混蛋!
如果我写 F# ,我要忍受的就是这充斥着面向对象遗毒的标准库。而如果我写 Go ,我要忍受的是这简陋不堪的语法。
两相其害取其轻,我更愿意忍受 Go 的语法(何况 Go 1.18 还支持了泛型)。Go 的语法虽然简陋,但够用。
对我来说,Go 的加分项是它的标准库采用可组合接口(composalbe interfaces)的设计,我认为非常漂亮,我自己写 C 代码的时候,常常参考 Go 标准库的设计。
为什么面向对象有毒(或:我为什么更喜欢胖指针)
任何实用的程序不可能没有抽象,动态多态(polymorphism)的抽象方法一般有两种:
- 面向对象、继承、封装。一个类必须声明自己实现了哪些接口。实现上采用虚表(vtable)。代表语言有 C++、Java、Python、C#
- 可组合的接口。一个类不需要声明自己实现了哪些接口。类实现某个接口的代码跟类的代码可以是分开的。实现上采用胖指针(fat pointer)。代表语言有 Rust、Go、Haskell(不知道类型类算不算)
还有一个比较特殊的 C 语言,这两种范式都能支持,不过都要自己模拟实现。
如果你的程序需要动态分派(dynamic dispatch),你必然需要为抽象付出某种代价。重点是,这个代价是由哪部分代码来支付?
在面向对象的语言中,代价由实现接口的类定义来支付。其形式就是虚表。例如,C++ 中的每个对象,如果继承了某个带 virtual 方法的基类,就会在对象的开头多出一个指向虚表的指针。
而在可组合接口的语言(范式)中,代价是由接口的使用方来支付的。使用方需要两个指针,一个指向对象,一个指向虚表。
举个例子,很多时候我们都会遇到一个任务:如何将任意对象转为字符串?
假设我们定义了一个接口 Stringable 表示可以转为 String:
interface Stringable {
string toString();
}
在传统的面向对象的编程语言中,每一个我们希望它可以转为字符串的类,比如 Integer 、Date 等,都需要实现这个接口。所以我们不如搞出一个超级基类,然后在这个超级基类上定义一个 toString 方法(想想 Java 的 Object.toString)。
但问题在于,这种设计不具备可扩展性。如果以后出现了 JSON 、 BSON 或者其他某种序列化格式,难道每种格式都要往这个基类上添加一个方法吗?很多时候基类定义在基础库中,我们不可能修改它的定义。
这个问题的另一种解决方案是,不要折腾定义方了,我们折腾使用方。
比如我定义了一个 Date 类,上面根本没有什么 toString:
class Date {
int year, month, day;
}
但是在使用这个 Date 的地方,传入两个指针:一个是 Date 对象本身,另一个是 Date_toString ,即将这个对象转为 String 的函数(指针)。
void printDate(Date d, func dateToString);
这里的 dateToString 是一个函数,它的使用方法是,传入一个 Date 对象,返回一个 String 。
这样,对象和接口就分离开了,以后如果需要 toJSON 、 toBSON ,也不用修改原始的 Date 对象的源代码,可以把这些代码放在新的模块。
面向对象的核心理念是:数据和相关的操作应该绑定在一起。但从这个例子我们可以看到,在很多情况下,数据和操作应该是分离的,强行绑定在一起会增加不必要的耦合。从我自己的编程实践来看,对于一些高层次的模块,数据和操作分离的话可以让代码更容易复用。
也许有人会说:用 Java 也可以这样写程序啊。但是 Java 的整个标准库都是围绕面向对象来设计的,已经积重难返。而 Go 语言没有历史包袱,标准库是完全围绕可组合接口来设计的,所以我认为 Go 的标准库非常值得学习。
为什么我认为 Go 的错误处理不难用
网上经常能看到的对 Go 的另一个抱怨是错误处理不好用。我认为 Go 的错误处理相比异常,使用体验上其实差不多。
而且在使用 Go 的过程中,我对错误处理又有一些新理解。
我们可以把错误分成 3 类:
用户输入错误(包括 URL 参数、配置文件等输入),这类错误需要返回、展示给用户。这类错误最好不要用编程语言内置的错误类型来表示,而是用自定义的类型,比如一个 struct ,或者最简单的一个 string ,又或者用类型中的空值来表示错误,例如 ““, -1, nil 等
程序错误。又可以分成 2 类:
- 意料之外的、不可恢复的错误。这类错误最好的处理方法是 fail-fast ,打印一个调用栈之后退出
- 程序员意料之内的,可以恢复或者重试的错误。比如网络错误。这类错误用语言自带的机制(如 Go 的 error 或其他语言的异常)来表示,可以在函数之间返回、传递、保存
看到了吗,我认为只有最后那种错误才适合用 error ,其他错误要么用自定义类型,要么直接 fail-fast 。如果用这种思路来处理错误,我认为 Go 的“将错误作为值”的方式并不难用。
你要做的是更多地使用如下代码片段来 fail-fast:
func Ok(err error) {
if (err != nil) {
panic(err)
}
}
选择个人项目的语言其实是很私人的事情,重点是,你能从使用这门语言的过程中学习到什么。我从 Go 语言学习到的主要是如何使用可组合的接口设计标准库,这对我来说很有帮助,这就足够了。
P.S. 因为同样的原因,我的个人项目的“重型语言”也从 C++ 转向 C 了。
P.S.2 现在甚至我的一些 bash 脚本都开始用 Go 写了,用 Go 写这类运维脚本的优势在于:可以很容易地利用多核并行。
请按照如下格式发邮件:
收件人[email protected]
标题[复制]
正文评论 / 回复内容,只支持纯文本
说明评论用户名为你在邮箱中设置的用户名
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK