鉴于本人经常与朋友们进行编程语言的“高谈阔论”,思来想去就写了一篇随笔,这样下次再谈的时候就可以直接拿出来了。本文有很多喜闻乐见的暴论,其中一部分可能掺杂了作者的情绪因素,读者不必过于较真。

当然,笔者撰写本文的目的也不全是情绪上的发泄或者无意义的拉踩。人各有所爱,或许我与读者可以借此思考一下,我们各自是建立在一个什么样的角度去评价一门编程语言,又是因为哪些地方的关注点不同而产生了分歧。

当今程序语言之盛况

我们直接略过程序语言的历史部分,概因这部分内容稍显陈旧和 trivial,很多当今语言流行的特性在当时还都没有被提出。笔者接触过的语言,按照喜爱程度、熟练程度和语言本身的能力综合评价,从高到底依次是

  1. T0: Rust
  2. T1: CJavaScript/TypeScriptPython
  3. T2: HaskellGo
  4. T3: AgdaCoq
  5. T: C++

个人的视角终归有局限性,因此我所谓盛况的讨论主要围绕上述我接触过的语言展开。相信点开本文的读者至少已经接触了一门图灵完备的编程语言,因此我们略去基础的计算概论部分。接下来我们花一些时间简单介绍一下上面提到的语言。

程序语言中的设计范式有很多,但大致可以分为两个分支:指令式编程和函数式编程。但是就现在来看这种分法也有些过于粗暴了,因为很多语言已经逐渐开始兼容这两种范式。

  • 指令式编程的代表有 C 以及在其基础上发展的 C++;JavaScript 和它的静态类型抽象 TypeScript;还有与人工智能密切相关的 Python;另一个是 Google 开发维护的 Go 语言,可以视为对 C++ 的删繁就简;Rust 的语义设计表达了部分函数式编程的思想,但其对函数的处理(全局函数和闭包)仍然没有足够丝滑地将函数视作 first-class citizen,并且也没有显式地处理函数的副作用,因此我们仍将其视作指令式编程语言。
  • 其余的语言归为函数式编程的范畴。事实上这部分语言是非常纯种的函数式编程语言,不带一点指令式编程语言的气息。

不同语言的爱好者想必认为上述天梯榜有失偏颇,对此我先给出如下的初步解释。在后文的随想中我们会随时 callback。

  • 首先是垫底的 C++,即便笔者是退役的 OI 选手,对 C++ 十分熟悉,它仍然是笔者心中当之无愧的倒数第一。C++ 的致命扣分点是四不像,该语言的标准从 17 年开始到现在变得越来越臃肿,一滩烂泥,不禁让人想质问标准制定者 “Are you ok?”。依赖管理混乱固然是一个问题,但是它不算致命。例如 C 语言的依赖管理也称得上毫无章法,但我仍然把它排在 T1。
  • T3 的语言主要是因为笔者不从事相关的研究方向,并且对这类学术语言的实用价值表示担忧,但总体来说是正面的评价。
  • T2 的语言中,Haskell 处在一个零界点的位置。笔者常说,如果一个学术界的语言能够比 Haskell 还出圈(指语言的知名度和生态都能超过 Haskell),那它大概率能大规模投入生产使用。Go 语言则是处在一个尴尬的位置。它是 C++ 的一个非常好的替代品,并且拥有成熟的依赖管理,但是它恰巧在热度上升疲软乏力的时候遇到了火速崛起的 Rust,就变成了现在尴尬的地位。
  • T1 的语言在各自擅长的领域都是霸主的地位。这些语言不在 T0 只是因为笔者对 Rust 的极端推崇。C 语言的地位自不必多说,它可以说是绝大多数现代编程语言的祖宗和基石,它的工具链也是很多现代语言编译过程中的一环。JavaScript/TypeScript 做为前端几乎唯一指定语言,除了给浏览网页提供更好服务,配合 NodeJS、deno 等运行时环境,以及异军突起的 WebAssembly,在系统原生应用、网页游戏移植、网站后端、等方面也大行其道,具备相当成熟和丰富的生态系统。Python 的地位随着如今人工智能大模型的高歌猛进自不必多说。说实话,Python 属于入门门槛低但隐性门槛高的语言,并且它的依赖管理也比较混乱。若不是人工智能如今的热度让我不得不给它面子,它得排 T2。
  • T0 的语言,它的地位无需解释。它就是唯一真神。所有没有接触过 Rust 语言的读者都形同顽固不化的野蛮人,你们需要神的光辉来指引方向;而对于那些对 Rust 颇有微词的异教徒,你悔改吧(无慈悲)。

「草稿」我如何看待一门程序语言

世界上使用人数最多的语言,不一定是实质上的全球通用语言。但最通用的语言却不一定具备最高的信息密度。我们评价一门自然语言是从多方面综合评价的。

同理,一门程序语言的评价角度也很多。但与自然语言不同的是,自然语言的目的是实现信息的传递和共享,而程序语言的目的是表达计算的过程。这其中最基本的要素包括

  • 实体的指代:各类符号代表的变量,代表计算的结果(简单值)或者计算的过程(函数)
  • 对于指令式语言,我们会考虑使用流程控制描述计算的过程。三大基本的流程控制包括顺序结构,分支结构和循环结构。
  • 对于函数式语言,我们直接将函数视作 first-class citizen,通过高阶函数来定义计算结果。两者最直接的区别是,指令式语言中可能会存在某种时间感,因为计算是一个机器运作的过程。而函数式语言中没有所谓的时间感,只有逻辑上的因果联系。

上述程序语言的本身特质,对于现代编程语言而言已经是基础中的基础,因此没有比较的余地。函数式语言的通病是不够快,不够直观,因此可以看到 T0 T1 中均没有函数式语言一席之地。它们可能具备较高的理论价值,但是笔者对此并不了解。而对于指令式编程语言,在基础的要素完全具备之后,考虑的一个重要因素就是如何高效、安全地表达复杂的,大规模的计算过程,在这其中诞生的范式有

  • 模块化:将计算过程拆分成若干个可复用的单元,并隐藏内部琐碎的逻辑;
  • 构建模式优化:通过引入依赖管理的机制,在降低编译成本的同时提升软件的可移植性;
  • 宏与语法糖:在基础语法的基础上补充方便使用的简化语法,提高开发效率;
  • 代码生成:在编译前通过简洁,容易编写的源文件生成复杂但功能重复的源代码,降低代码耦合度;
  • 自动检查:提供代码检查工具,通过一些预定义的规则检查项目中是否存在不可靠的代码。

除此之外,程序语言与自然语言的一个重要区别是,程序语言在任何意义上都不是赖以生存的。它需要具备一定的迭代更新机制,配合一定的宣传,来保持它的生命力,否则它就会渐渐被人遗忘。

有了上面的基础逻辑,我们就可以详细地讨论这些程序语言的特点了。

C++ 之原罪与 C 语言之功成

首先便拿我最恨铁不成钢的 C++ 开刀。C++ 的原罪正是 “++”。这个语言的标准制定者铁了心似的想把所有特性都加到 C++ 里去(补充:一门程序语言的标准是指人为规定的若干语法规则和语义,描述这个程序语言的行为。而语言的编译器则是指遵循语言的标准将源代码翻译成机器指令的程序)。

以下是我在粗略浏览了 C++ 近年来的标准更新内容后,结合自身的使用情况作出的总结:

  • C++98 和 C++03 是在 C 语言基础上引入了基本的面向对象和标准库;

  • C++11 引入了引用类型的变量、lambda 表达式、range for、可变参数模板和自动类型推导,这些特性在当时来说都是十分前沿的设计,因此也算实打实的一个阶梯;

  • C++14 是对 C++11 的小改进,可以接受。事实上到目前为止,C++ 都是一个欣欣向荣,日趋完善的现代语言。

  • C++17 开始,一系列冗余,或者看似冗余的特性被加入了进来,包括 std::variant, std::optional, typename。这些特性往往是从别的现代语言中照搬过来,也许它们的出发点没有问题,但是它们传达给开发者的是标准逐渐变得混乱。给我这么多特性,我到底用哪个?有时候开发者甚至因为这些无聊的喜好吵起来,可真正的问题是 C++ 正在逐渐变得像一个 CISC 指令集。

  • C++20 引入了一些更加摸不着头脑的特性,比如 concepts,以及一些完全冗余的特性,比如 modules。更搞笑的是 reference 原文中写道:“Modules are orthogonal to namespaces.” 现在 C++ 的模块化系统里除了有继承自 C 的头文件模块化,还有 namespace 命名模块,以及这个不长眼的 modules 模块。顺带一提这个 modules 的示例代码是这样的

    // helloworld.cpp
    export module helloworld; // module declaration
    
    import <iostream>;        // import declaration
     
    export void hello() {     // export declaration
        std::cout << "Hello world!\n";
    }
    
    // main.cpp
    import helloworld; // import declaration
     
    int main() {
        hello();
    }

    在它新加入的标准库中,出现了一些诸如 <compare><numbers> 的标准库,让人不禁搞不懂它和现有的重载比较运算符和 <cmath> 的关系;<ranges> 里引入的大量函数和标准库 container 的成员函数之间又有什么纠葛。这一系列天才的设计让 C++ 逐渐成为一个非常合格的没有明确语言标准的造屎语言。你真的很难不写出屎山代码。

  • C++23 继续高歌猛进,它引入的 Multidimensional subscript operator 还算合理,紧随其后的 Explicit object parameters, Explicit object member functions, Monadic operations, std::unreachable, std::expected 企图将 Go、Rust 等语言的特性抄过来,可表现出的只是某种拙劣的模仿。新引入的 <print> 似乎是在对古老的 <cstdio> 和坚挺的 <iostream> 的另一种形式的宣战。一切都是一如既往的混乱,C++ 的标准早就不再统一。

  • 还有即将到来的 C++26。

这其中还有一个巨大的笑点:从 C++20 开始(注意,这已经是 4 年前的标准),没有任何一个编译器实现了 C++ 标准的完整的所有内容,没有任何一个。这还是在我们放过了 C++11 中关于垃圾回收的标准的前提下(这个特性甚至没有一个编译器实现过)。按常理来说,一门语言的编译器应当是紧跟这门语言的标准,或者说一门语言的标准应当结合其编译器的运作机理来设计。C++ 编译器与其语言标准之间的距离给这门语言蒙上了一个巨大的不稳定性,因为你永远不知道未来 C++ 的语言标准会变得多么抽象。

这里我需要纠正一些读者的驳斥:有的读者在讨论 C++ 时只以 C++ 语言特性的一个子集为代表而谈论 C++ 如何好,对这门语言的糟粕则冠以 “你自己不选择好的写法,非得用这些垃圾的写法,怪谁”,将责任推在开发者上。如果是十年前 C++ 尚如日中天的时候,这么说倒也罢了。但如今众多程序语言早已弯道超车,C++ 早就被超麻了,你还玩这一套,就显得没有眼力见了。

C++ 这门语言上出现的诸多不合理的现象,其实可以归结为标准制定者自身的维护不善。这所谓的不善是相对于其他现代编程语言而言。要知道 C 语言早就没有更新了,但是它做为一个上世纪基本完工的产物到现在依然屹立不倒,除了如今的 linux 系统是用 C 语言编写外,另一个重要原因是它没有胡乱地扩充语言特性。如今的 C 语言更像是从现代编程语言到汇编的一个中间表示,它不具备泛型、闭包、垃圾回收、异常捕获、反射、依赖类型、Monad、元编程等等现代语言要素,但是它快,它的工具链足够成熟可靠。而 C++ 的维护者匆匆忙忙把这些现代特性往语言上面堆砌,却根本没有好好思考各个特性之间的配合和用途,最终造成了现在尴尬的局面。

即便 C++ 做得这么差,它仍然是许多现代程序语言的大哥。倒不如说它就像是一个试验田,给后来者提供了非常好的错误示范。即便我对这门语言如此失望,也不能否认其重要的历史意义。

Python 与人工智能

我对于 Python 的观感相对复杂。Python 有很多令人深恶痛绝的屎点,但也有不少亮点。但是这样的观感一定是建立在如今人工智能的高速发展的基础上的。从这个角度,Python 更像是一个实验性的语言。理想情况下,它的易用性可以大大提高科研项目的工程效率,即使它的运行效率不高。但事实上,Python 如今的立足点已经和易用性毫无关系。

如今人工智能的研究项目中 Python 代码早已不是几行代码构成的脚本,而是在一个庞大的软件生态系统中的一个工程项目。也因此,缺乏强力的静态类型检查反而成为 Python 的痛点。Python 的立足点仅仅只是若干个核心的人工智能和数据处理库,例如 PyTorch、Tensorflow、NumPy、Scipy 等等。Python 当然也有后端框架,有游戏引擎,但是这些只能支撑一些玩具项目的开发,不能代表 Python 的核心生产力。另外,与其说这是 Python 的亮点,不如说它是 Python 目前还能苟延残喘的救命稻草。例如 Mojo 就存在完全替代 Python 的可能性,只要它能将这些核心库移植过来。

让 Python 的易用性变差的另一个主要原因是,它的依赖管理仍然称得上混乱。不仅官方提供的软件包构建工具就有很多,让人不知道如何下手;而且软件包在指定依赖时可以完全不指定版本,导致软件的可靠性极差。为了解决这些问题,许多虚拟环境管理软件应运而生,诸如 conda,pyenv,venv 等等,让 Python 软件的安装步骤越发多样,给大家提供了各种正面或负面意义上的情绪价值。复现过 AI 方向的 paper 的同学想必很了解这一点,正所谓配环境是 AI 最屎的一步,而这一切的苦难都得感谢 Python。

不要瞧不起 Python 的依赖管理,事实上我之所以不谈 C++ 的依赖管理,是因为它没有依赖管理。Python 比 C++ 好的地方主要在于,抛开它的社区生态不谈,Python 语言本身以及其官方库都是比较科学的。没有冗余的特性,没有过度的抽象。就程序语言本身的特质而言,它属于十分合格,标准明确的语言,算是唯一让我安心的点了。

如何评价 Rust

熟悉我的读者一定被我强推过 Rust 编程语言。首先,我们需要承认,Rust 语言的发展是建立在前辈们的基础上的,毕竟它应当是天梯榜里最年轻的语言之一。但它绝不止是简单地把前辈们的优秀特性融合提炼。

Rust 最让人陌生,也就是最创新的部分,是引入了前无古人(就笔者的认知而言)的生命周期管理和所有权约定,以一种令人意外的优雅的方式近乎解决了内存泄漏的问题。但是,若只有如此,Rust 仍然称不上惊艳。毕竟在编程语言的研究领域,我相信有许多具有更加深刻的理论背景的程序语言,具备安全的类型和内存管理机制;另外我也相信,若是取 C++、Go 或者甚至 Python 的一个子集,配合精心编写的依赖库,仍然能达到同样的内存安全效果。

Rust 在目前我接触,或者未接触的编程语言中一骑绝尘,最超前的理念,我将之总结为:类型约束式的行为控制。这并不是一个非常明显的编程技巧,而是 Rust 在降低了诸多方面的编程成本之后,形成的独特范式。举一个简单的例子:我们希望定义一个邮箱地址的类型,要求其保存的邮箱地址必须是合法的邮箱地址。在 Rust 语言中你可以写成

struct EmailAddress(String);

impl EmailAddress {
  pub fn create(s: &str) -> Option<EmailAddress> {
    if /* the address is valid */ {
      Some(s.to_string())
    } else {
      None
    }
  }
}

create 如果返回的不是 None,那么这个邮箱地址必然合法。Option<T> 是一个类型,其中的值要么是 Some(T),要么是 None。可以将其等价为一个动态分配的 T*,其中 null 指针对应 None 的情况。对于 C++,可以通过将构造函数私有化,然后定义另外一个公共的构造函数来调用构造函数实现:

class EmailAddress {
  string s;
  EmailAddress(string s) : s(s) {}

public:
  static optional<EmailAddress> create(string s) {
    if (/* the address is valid */) {
      return make_optional(EmailAddress(s));
    } else {
      return nullopt;
    }
  }
};

这种范式在别的编程语言中也可以使用类似的技巧实现。但是你会注意到,Rust 语言中使用这个技巧是在降低编程成本,但在 C++/Go/Java/Python 里使用这个技巧是在提高编程成本。原因很简单:Option 是 Rust 的标准类型,它的标准库里大量使用了 Option,因此你可以丝滑地结合标准库和语法糖处理 create 的返回值。但是其他语言则不是,需要你自己实现相应的 utils 和 helper。

这时有读者就要问了:为什么我非得用这个 Option<T> 类型?这里我想通过举例的方式让大家理解这种编程范式的好处。以 C++ 为例:

  1. 一个显然的想法是,create 函数返回一个动态分配的地址 string*。如果邮箱字符串不合法,就返回一个空指针。这个方法弊端很显然:如果你不小心忘了做空指针检查,那么程序就会直接 UB。我不确定现代的 C++ 编译器是否会在运行时拒绝空指针寻址的操作,但是这种不负责任的行为始终是不保险的。
  2. 很多指令式编程语言都有异常处理机制,例如 Python 的 try-raise-except,C++/JavaScript 的 try-throw-catch。因此我们可以直接让 create 的返回值类型为 string,或者一个具有公共成员变量的 struct。而如果邮箱地址不合法,我们可以直接在 create 函数里抛出错误。这样的编程范式固然可以避免内存泄漏和 UB,但也存在一个明显的缺陷:你的程序总是有可能在莫名其妙的地方暴毙,因为每一个函数都可能抛出错误,而你一旦忘记写 try-catch,程序就有可能在关键的地方暴毙,导致整个程序终止(多线程的程序可能好点,比如网站后端)。内存泄漏的确是不会发生了,但是程序的行为却有些脱离了控制,带来的后果是开发成本和后期运维的成本较高。
  3. 事实上一些现代编程语言也的确没有引入异常处理,例如 Go 语言。Go 语言的处理方式也十分粗暴:create 的返回值设为一个二元组 (string, appError),并且手动写 if 判断 appError 是否为 nil,如果不是则说明出现了错误。编辑器插件会检查是否存在没有使用的变量,算是变相提供了一种不太强的错误处理约束。1
  4. Rust 也没有引入异常处理,但依仗 Option 是基础类型,它提供了非常方便的语法糖来代替类似 Go 语言的 if 判断。2

类型约束式的行为控制,在很大程度上提升了代码编写的门槛,并且它的学习成本和理解成本也不低,或许有些读者也没有想明白为什么非得这么干,希望这个例子能提供一些启发。

举一反三,Rust 中的互斥锁是一个泛化类型 Mutex<T>,例如 Mutex<AppData> 将 AppData 用互斥锁保护起来,必须使用成员函数 .lock() 获取数据。而在其他编程语言中,往往是将 mutex 作为一个成员变量,每次都手动 lock(这里的手动是指,你需要记得这个类型得先 lock 再访问,写代码时需要格外小心;而在 Rust 中,你没有办法在不 lock 的情况下访问到内部数据),等访问完了再释放。要模仿 Rust 的范式并非不可,但正如前文所言,它会增加代码编写的成本。比如 C++ 里面你就得手动定义一个抽象类,写一坨又臭又长的 template 和类型约束。

促成这一范式的形成也离不开 Rust 的枚举类型和模式匹配,它们与上文的 Optional 均借鉴自函数式编程语言。函数式编程语言没有促成这一范式,是因为它们的约束太强了,这类语言本身就不太能高效地表达循环和指针,根本就没什么复杂的行为能够表达,因此也用不着类型去约束行为。

1. https://go.dev/blog/error-handling-and-go

2. https://doc.rust-lang.org/rust-by-example/error/option_unwrap/question_mark.html