谈谈我最近的编程语言选择

最后更新日期:2023-08-13

  说起来你可能不信,我最近在做个人项目的时候,放弃了 Ruby 和 F# ,而选择 Go 语言。下面我将说明这样做的理由。

缘起

  首先我确实需要一门带 GC 的语言来做一些 fast prototyping 式的开发。我对这门语言的期望是,可以快速开发,有一定表达力。

  之前我的快速开发首选语言是 Ruby 。但我最近越来越发现 Ruby 的问题:

  1. 社区越来越不活跃了,似乎人们在慢慢离开 Ruby ,很多 gems 不怎么维护了
  2. 作为动态类型语言,写出来的代码的长期可维护性不太好

  所以我希望找一门静态语言。我一开始选择了 F# 。因为:

  1. 函数式,有强大的类型系统,非常强的表达力
  2. 背靠 .net 社区,再不济还有微软撑着,标准库不会差到哪里去

  当时当我真正开始写程序的时候,我查了 .net 的文档,尤其是实现 http server 的 HttpListener 。我发现 F# 的问题是:标准库跟 C# 共享一套,而 C# 的标准库基本上是学 Java ,充斥着 OOP 设计。他们并没有利用 F# 的函数式特性专门为 F# 设计一套至少涵盖 IO 、 HTTP 、 JSON 的标准库。所以我开始转向 Go 。

重要的是标准库啊,混蛋!

  如果我写 F# ,我要忍受的就是这充斥着面向对象遗毒的标准库。而如果我写 Go ,我要忍受的是这简陋不堪的语法。

  两相其害取其轻,我更愿意忍受 Go 的语法(何况 Go 1.18 还支持了泛型)。Go 的语法虽然简陋,但够用。

  对我来说,Go 的加分项是它的标准库采用可组合接口(composable interfaces)的设计,我认为非常漂亮,我自己写 C 代码的时候,常常参考 Go 标准库的设计。

为什么面向对象有毒(或:我为什么更喜欢胖指针)

  任何实用的程序不可能没有抽象,动态多态(polymorphism)的抽象方法一般有两种:

  1. 面向对象、继承、封装。一个类必须声明自己实现了哪些接口。实现上采用虚表(vtable)。代表语言有 C++、Java、Python、C#
  2. 可组合的接口。一个类不需要声明自己实现了哪些接口。类实现某个接口的代码跟类的代码可以是分开的。实现上采用胖指针(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 类:

  1. 用户输入错误(包括 URL 参数、配置文件等输入),这类错误需要返回、展示给用户。这类错误最好不要用编程语言内置的错误类型来表示,而是用自定义的类型,比如一个 struct ,或者最简单的一个 string ,又或者用类型中的空值来表示错误,例如 ““, -1, nil 等

  2. 程序错误。又可以分成 2 类:

    1. 意料之外的、不可恢复的错误。这类错误最好的处理方法是 fail-fast ,打印一个调用栈之后退出
    2. 程序员意料之内的,可以恢复或者重试的错误。比如网络错误。这类错误用语言自带的机制(如 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 写这类运维脚本的优势在于:可以很容易地利用多核并行。

相关链接

相关文章