跳转到内容

第 1 章 · 五种语言的全景

本书讲四种语言:Python、JavaScript/TypeScript、C# 和 Go。JavaScript 和 TypeScript 运行时是同一种东西,书里通常合为一列,但写法上 TypeScript 多了一整套类型,所以本章的小程序给它们各写一份,一共五份,这就是标题里“五种”的来历。在进入具体特性之前,本章先交代四件事:这几门语言从哪里来、各自信奉什么、运行时长什么样,以及同一个小程序在它们手里分别是什么样子。

Python 由荷兰程序员 Guido van Rossum 设计,1991 年首次公开发布。他此前参与过一门叫 ABC 的教学语言,Python 继承了其中不少想法,目标是让代码读起来接近伪代码:初学者能看懂,老手写起来也不啰嗦。这个对可读性的执念贯穿了它后来三十多年的演化。今天 Python 主要用在数据分析、机器学习和 AI、自动化脚本,以及 Web 后端;它也是很多编程入门课选用的第一门语言。

JavaScript 由 Brendan Eich 于 1995 年在 Netscape 公司设计,据他本人回忆,最初的版本只用了大约十天。它要解决的问题很朴素:让网页能响应点击、校验表单,不必每次都把数据送回服务器。仓促的出身留下了一些至今还在的怪癖,但浏览器只认它,于是它成了所有前端开发者都绕不开的语言;2009 年 Node.js 出现后,它又进入了服务端。TypeScript 由微软在 2012 年发布,主导设计的是 Anders Hejlsberg。它是 JavaScript 的超集,给 JavaScript 加上静态类型(static typing),让几十万行的大型前端项目在编译时就能发现拼错的属性名和传错的参数。今天两者是 Web 前端和 Node 后端的事实标准,新的大型项目大多直接用 TypeScript。

C# 同样出自 Anders Hejlsberg 之手,微软在 2000 年公布了它,2002 年随 .NET Framework 1.0 正式发布。当时微软需要一门为自家托管平台量身定做的面向对象(object-oriented)语言,既要有 C++ 程序员熟悉的语法,又要有垃圾回收(garbage collection)和类型安全带来的省心。此后 C# 几乎每一两年发布一个大版本,不断吸收泛型、LINQ、async/await、模式匹配等特性。今天 C# 主要用在企业应用和 Web 服务、Unity 游戏开发,以及 Windows 桌面程序;.NET 也早已开源并支持 Linux 和 macOS。

Go 由 Google 的 Robert Griesemer、Rob Pike 和 Ken Thompson 设计,2009 年公开发布。他们当时面对的是 Google 内部庞大的 C++ 服务端代码:一次完整编译要等很久,依赖关系错综复杂,写高并发程序又容易出错。Go 的回应是一门编译极快、语法很少、并发原语直接内建在语言里的语言。今天 Go 主要用在云基础设施(Docker、Kubernetes 都是用 Go 写的)、命令行工具和各种网络服务。

每门语言都有一句话概括的取向,这个取向往往能在一个很小的语法细节里看出来。其中 Python 之禅和 Go 的“少即是多”是有出处的原话,JavaScript 的“先能跑再说”和 C# 的“全功能的工业语言”则是本书自己的概括。

Python:应该有一种、最好只有一种明显的做法。 这句话出自 Python 之禅(The Zen of Python)。最能体现它的细节是缩进即语法:代码块用缩进表示,不用花括号。别的语言里缩进只是风格,各团队可以各有一套;在 Python 里缩进写错就是语法错误,于是所有人写出来的代码长得差不多,读别人的代码时少了一层翻译。

JavaScript:先能跑再说。 JavaScript 尽量不让程序因为小问题停下来,代价是很多错误被悄悄吞掉。典型的细节是隐式类型转换(implicit type coercion):"5" - 1 得到 4,"5" + 1 却得到 "51",减号把字符串转成了数字,加号却把数字转成了字符串。程序不会报错,只是结果可能不是你以为的那样。

TypeScript:渐进地加类型。 TypeScript 不要求一次把整个项目都标上类型,你可以从一个文件、一个函数开始。最能说明这一点的是 any 类型的存在:标成 any 的值,编译器完全放行,等于告诉它“这里先别管”。这让旧的 JavaScript 项目可以一点点迁移,而不必停工重写。

C#:全功能的工业语言。 C# 的态度是:常见的需求,语言都应该直接提供工具。从查询集合的 LINQ、到 async/await、到模式匹配、到记录类型和可空引用类型,你在别的语言里需要第三方库或手写的东西,C# 大多有语法级的支持。代价是语言本身越来越大,同一件事往往有好几种写法。

Go:少即是多。 Go 刻意保持很小的语法:没有 try/catch 式的异常,错误作为普通返回值逐层返回;没有类和继承,只有结构体、方法和接口;连三元表达式 a ? b : c 都没有,必须老老实实写 if。设计者的理由是:代码被读的次数远多于被写的次数,语法越少,读的人越不用猜。

下面这张表把四种语言的运行方式并排放在一起。格子里写的是最常见的实现:Python 指官方的 CPython,JavaScript 以 Node 和 Chrome 使用的 V8 引擎为例,C# 指 .NET 的 CLR,Go 指官方工具链。

维度 Python JS/TS C# Go
出现年份 1991 JS 1995,TS 2012 2000 2009
编译还是解释 先编译成字节码(bytecode),再由 CPython 解释执行;3.13 起有实验性即时编译(JIT,just-in-time) V8 先解释再用 JIT 编译热点代码;TS 先擦除类型编译成 JS 编译成 IL,由 CLR 即时编译(JIT);也可提前编译(AOT,ahead-of-time)成本机代码 直接编译成本机代码
类型检查时机 运行时;类型标注可选,由 mypy、pyright 等工具静态检查 JS 在运行时;TS 在编译时,运行时类型已擦除 编译时 编译时
内存管理 引用计数(reference counting),加专门回收循环引用的垃圾回收器 分代垃圾回收(generational GC) 分代垃圾回收 并发标记清除垃圾回收
并发模型 线程(受 GIL 限制)、多进程、asyncio 单线程事件循环(event loop),加 Worker 线程池,加 Task 与 async/await goroutine,加 channel
能不能用多核 线程同一时刻只有一个执行字节码,要用多核得开多进程;3.13 起有可选的无 GIL 构建 主线程单线程;Worker 是独立隔离的环境,靠消息传递 能,线程直接跑在多核上 能,运行时按 GOMAXPROCS 把 goroutine 分到多核
有没有生成器 有,yield 有,function* 有,yield return 没有;1.23 起用 range-over-func 迭代器补位
典型用途 数据、AI、脚本、Web 后端 前端、Node 后端 企业应用、Unity 游戏、Windows 桌面 云基础设施、命令行工具、网络服务

这张表里有几处需要多说一句。“编译还是解释”一行说明,四种语言其实都有编译这一步,只是编译的产物和时机不同:Python 编译成字节码,再由 CPython 的解释器逐条执行;JavaScript 由 V8 在运行时先编译成字节码解释执行,再把反复执行的热点代码用 JIT 编译成机器码;C# 编译成 IL,运行时由 CLR 在方法第一次被调用时即时编译成机器码,选择 AOT 发布时则在构建阶段就把 IL 全部编译成机器码,运行时不再需要 JIT;Go 一步到位编译成 CPU 直接能跑的机器码;TypeScript 的编译只做一件事,把类型标注删掉,得到普通的 JavaScript。“能不能用多核”一行的差别最大:Python 的线程是真正的操作系统线程,但全局解释器锁(Global Interpreter Lock,GIL)让同一时刻只有一个线程在执行 Python 字节码,所以纯计算的任务开再多线程也只用得上一个核;JavaScript 的主线程只有一个,想并行就开 Worker,而 Worker 之间不共享普通对象,只能互发消息(只有 SharedArrayBuffer 这类专门的对象能共享内存)。

四种语言从源码到运行的链路

这张图从上往下看每一栏从源码走到机器码要经过几步:Go 最短,C# 可以走 JIT 也可以走 AOT,TypeScript 比 JavaScript 多出最前面的“擦除类型”一步,Python 的虚线表示 JIT 目前还是可选的。

表里的每一行,本书都有专门的章节展开。“编译还是解释”是第 2 章的主题,那一章会讲清字节码、JIT 和 AOT 分别是什么,以及它们怎样影响启动速度、运行速度和发布方式。“类型检查时机”对应第 3 章类型系统,静态与动态、渐进类型、TypeScript 的类型擦除都在那里。“内存管理”对应第 5 章值与引用、相等性、内存与垃圾回收,引用计数和分代回收的差别会在那里用具体的内存布局图讲。“有没有生成器”对应第 13 章迭代与惰性,Go 在没有 yield 的情况下怎样用 range-over-func 做到同样的事,也放在那一章。

“并发模型”和“能不能用多核”两行是整个第四部分的内容。第 16 章先讲并发与并行的区别和四种运行时模型;第 17 章讲多线程,GIL 的后果在那里有具体的例子;第 18 章讲多进程,也就是 Python 用多核的主要办法;第 19 章讲异步,比较 async/await 在 Python、JavaScript、C# 里各自怎么实现,以及 Go 为什么不需要它;第 20 章讲 Go 的 channel 和其他语言的消息队列;第 21 章讲取消和超时。最后一行“典型用途”不对应某一章,但第 24 章讲从一种语言换到另一种时要忘掉什么、新建什么,其中很多坑正是这些不同的用途环境养成的习惯。

下面用一个很小的任务让你看到五种写法的长相:统计一句英文里每个词出现的次数,按次数从多到少排序,次数相同的按字母顺序排,打印出现最多的前三个词。输入是固定的一句话 the quick brown fox jumps over the lazy dog the fox,其中 the 出现三次,fox 两次,其余各一次;排在第三位的应该是一次的词里字母序最小的 brown。五份代码的输出完全相同。

from collections import Counter
TEXT = "the quick brown fox jumps over the lazy dog the fox"
def count_words(text: str) -> Counter[str]:
return Counter(text.split())
def top_words(counts: Counter[str], n: int) -> list[tuple[str, int]]:
return sorted(counts.items(), key=lambda item: (-item[1], item[0]))[:n]
for word, count in top_words(count_words(TEXT), 3):
print(f"{word}: {count}")
# 输出:
# the: 3
# fox: 2
# brown: 1

运行:python wordcount.py。计数只用了一行:标准库的 Counter 接收一串词,直接返回“词 → 次数”的字典。排序靠 sorted 的 key 参数,(-item[1], item[0]) 这个元组先按次数取负(即降序),次数相同再按词本身升序,Python 会逐个元素比较元组,所以不需要写比较函数。类型标注 Counter[str]、list[tuple[str, int]] 运行时不检查,只给读者和 mypy 这类工具看。

const TEXT = "the quick brown fox jumps over the lazy dog the fox";
function countWords(text) {
const counts = new Map();
for (const word of text.split(/\s+/)) {
counts.set(word, (counts.get(word) ?? 0) + 1);
}
return counts;
}
function topWords(counts, n) {
return [...counts]
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.slice(0, n);
}
for (const [word, count] of topWords(countWords(TEXT), 3)) {
console.log(`${word}: ${count}`);
}
// 输出:
// the: 3
// fox: 2
// brown: 1

运行:node wordcount.js。这里用 Map 而不是普通对象当字典:Map 的键不会和对象自带的属性名冲突,遍历顺序也就是插入顺序。counts.get(word) ?? 0 处理第一次遇到某个词的情况,因为不存在的键返回 undefined。排序用比较函数,b[1] - a[1] || a[0].localeCompare(b[0]) 利用了“差为 0 时取 || 右边”这一点,把两级排序写进一个表达式。

const TEXT = "the quick brown fox jumps over the lazy dog the fox";
function countWords(text: string): Map<string, number> {
const counts = new Map<string, number>();
for (const word of text.split(/\s+/)) {
counts.set(word, (counts.get(word) ?? 0) + 1);
}
return counts;
}
function topWords(counts: Map<string, number>, n: number): [string, number][] {
return [...counts]
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.slice(0, n);
}
for (const [word, count] of topWords(countWords(TEXT), 3)) {
console.log(`${word}: ${count}`);
}
// 输出:
// the: 3
// fox: 2
// brown: 1

运行:tsx wordcount.ts。和 JavaScript 版逐行对照,你会发现唯一的区别是类型标注:参数写成 text: string,返回值写成 Map<string, number>,topWords 返回 [string, number][],也就是“由词和次数组成的二元组的数组”。把这些标注删掉,剩下的就是上面那份 JavaScript,这正是 tsx 运行前做的事。标注的价值在编译时:如果把 count 当字符串用,或者给 topWords 传了个数组,编辑器在你保存前就会标红。

using System;
using System.Collections.Generic;
using System.Linq;
const string Text = "the quick brown fox jumps over the lazy dog the fox";
foreach (var (word, count) in TopWords(CountWords(Text), 3))
{
Console.WriteLine($"{word}: {count}");
}
static Dictionary<string, int> CountWords(string text)
{
var counts = new Dictionary<string, int>();
foreach (var word in text.Split(' ', StringSplitOptions.RemoveEmptyEntries))
{
counts[word] = counts.GetValueOrDefault(word) + 1;
}
return counts;
}
static IEnumerable<(string Word, int Count)> TopWords(Dictionary<string, int> counts, int n) =>
counts.OrderByDescending(p => p.Value)
.ThenBy(p => p.Key, StringComparer.Ordinal)
.Take(n)
.Select(p => (p.Key, p.Value));
// 输出:
// the: 3
// fox: 2
// brown: 1

运行:dotnet run wordcount.cs(第一次运行要先编译,会慢几秒到几十秒)。程序用了顶级语句(top-level statements),不必写 class Program 和 Main 方法,两个本地函数可以写在调用它们的代码之后。排序是一条 LINQ 链:OrderByDescending 按次数降序,ThenBy 按词升序,Take 取前 n 个,Select 转成带名字的元组,读起来像一句英文描述。ThenBy 如果不传比较器,比较字符串时默认按当前系统的区域设置(culture)来比,不同机器上的结果可能不同,而且不会有任何提示;这里传入 StringComparer.Ordinal,就是主动选择按字符编码逐个比较,这一步得你自己想到。前面 JavaScript 版用的 localeCompare 恰好是另一种选择:它也按区域设置比较,只是对这几个全小写的英文单词,两种比较的结果一样。

package main
import (
"fmt"
"sort"
"strings"
)
const text = "the quick brown fox jumps over the lazy dog the fox"
type pair struct {
word string
count int
}
func countWords(s string) map[string]int {
counts := map[string]int{}
for _, w := range strings.Fields(s) {
counts[w]++
}
return counts
}
func topWords(counts map[string]int, n int) []pair {
pairs := make([]pair, 0, len(counts))
for w, c := range counts {
pairs = append(pairs, pair{w, c})
}
sort.Slice(pairs, func(i, j int) bool {
if pairs[i].count != pairs[j].count {
return pairs[i].count > pairs[j].count
}
return pairs[i].word < pairs[j].word
})
if len(pairs) > n {
pairs = pairs[:n]
}
return pairs
}
func main() {
for _, p := range topWords(countWords(text), 3) {
fmt.Printf("%s: %d\n", p.word, p.count)
}
}
// 输出:
// the: 3
// fox: 2
// brown: 1

运行:go run wordcount.go。Go 版最长,因为几样东西要自己写:先定义一个 pair 结构体存放词和次数,再把 map 里的内容逐个搬进切片,然后给 sort.Slice 写一个完整的比较函数,最后手动截取前 n 个。计数那一行 counts[w]++ 倒是很短,因为 map 里不存在的键读出来是零值 0,不需要像 JavaScript 那样写默认值。另外 Go 的 map 遍历顺序是故意随机的,每次运行都可能不同,所以排序必须把次数和词两级都比较到,否则输出就不稳定。

五份代码做的是同一件事,长度和“要自己写多少”却差得很远。Python 把计数和排序都交给了标准库里现成的工具,Counter 计数,sorted 加一个 key 排序;C# 的排序交给了 LINQ,计数却和 JavaScript 一样是自己写的循环,靠 GetValueOrDefault 处理第一次遇到的词;JavaScript 和 TypeScript 有 Map 和排序,但默认值、比较规则要自己写;Go 只给最基本的 map、切片和排序函数,结构和比较全部由你写明。这不是谁比谁好的问题:Go 版虽然长,但每一步发生了什么都摆在眼前,没有任何隐藏的调用;Python 版虽然短,但你得事先知道 Counter 和元组比较的规则。这些差异从哪里来、在更大的程序里会带来什么后果,就是本书接下来二十多章要讲的内容。

各章基本独立,不必从头读到尾。前面运行时一览表里的每一行都给出了对应的章号:你对哪一行的差异最好奇,就可以直接跳到那一章。比如你主要写 Python,最近要接手一个 Go 服务,最想弄清楚的多半是并发,那就可以直接读第四部分,从第 16 章开始。

正文和附录的速查表是配合使用的。速查表四份,Python、JS/TS、C#、Go 各一份,用完全相同的小节顺序排列,从“安装与版本”“运行与编译”一直到“常见坑”,所以你可以在两份速查表之间平行地对照同一节。速查表只讲怎么写,每条一行说明加一个代码片段;需要讲原理的条目会写“详见第 N 章”,指回正文。正文则反过来,只讲为什么、差在哪,不会停下来教某一门语言的基础语法。

所以,读特性章节时如果遇到看不懂的语法,比如你熟悉 Python 却看不懂 Go 代码里的 := 或 range,不必停下来另找教程,翻到附录 D Go 速查表的对应小节查一下就能继续读。反过来,在速查表里看到一个写法却不明白它为什么要这么写,就顺着“详见第 N 章”回到正文。