第 24 章 · 换语言最容易踩的坑:从 X 到 Y 要忘掉什么
本章回答什么:从 Python、JS/TS、C#、Go 中的一种转到另一种时,哪些顺手的旧习惯会悄悄出错,各自该换成什么新习惯。
一句话结论:最贵的坑很少出在语法上,多半出在四个默认假设上:类型在什么时候检查、失败怎样传给调用方、同一时刻有几条线程在跑你的代码、赋值是复制数据还是共享对象;前面各章讲过的机制,这里按迁移方向重新排一遍。
怎么用这一章
Section titled “怎么用这一章”先在下面十二节里找到自己的方向,读那段话和那张表;只想快速对照,直接翻到末尾的总表,表里先读的章节按优先级排列。每张表的左列是旧语言里顺手、换过来会出错的习惯,右列是新语言里该养成的习惯,括号里是讲清这条机制的章节。
坎大致分四类。类型:静态还是动态,检查发生在编译时还是运行时。错误:用异常还是用错误值,失败是沿调用栈自动上跳,还是要每一层显式返回。并发:事件循环、线程还是 goroutine,同一时刻有几条线程在执行你的代码。内存:值还是引用,赋值时复制数据还是共享同一个对象。
看每个框里两端的对照:换语言时如果从框的一端跳到另一端,这一类的旧习惯就要重新检查;并发框里四种语言各占一种模型,任何两种之间迁移都要重学。
从 Python 到 JS/TS
Section titled “从 Python 到 JS/TS”最大的坎是值的规则变了。Python 遇到类型不匹配就抛 TypeError,JS 却乐于隐式转换,"3" * 2 得 6,错误变成怪值传到很远的地方;TS 能拦下大部分,但类型在编译后全部擦除,运行时和 Python 的标注一样不管事。“没有值”也从一个 None 变成 undefined 和 null 两个。并发上,不开 Worker 时只有一条主线程执行 JS,同步的耗时代码会让所有回调一起等着。
| 要忘掉的习惯 | 要新建的习惯 |
|---|---|
类型不对时运行时会抛 TypeError(第 3 章) |
JS 会悄悄转换,靠 TS 在编译期拦下(第 3 章) |
None 是唯一的“没有”(第 6 章) |
同时防 undefined 和 null,用 ?? 兜底(第 6 章) |
async def 函数不 await 就不执行(第 19 章) |
Promise 一创建就开始执行(第 19 章) |
| 同步阻塞调用随处可写(第 19 章) | 主线程上阻塞,所有回调都要等(第 19 章) |
len 数的是码点(第 14 章) |
length 数 UTF-16 码元,超出 U+FFFF 的字符占两个(第 14 章) |
从 Python 到 C#
Section titled “从 Python 到 C#”最大的坎是类型检查挪到了编译期,而且按声明判断兼容:一个类碰巧有 Send 方法,并不等于它实现了 ISender,必须写在声明里。第二道坎是内存:Python 里一切都是对象引用,C# 的 struct 在赋值、传参、从 List<T> 取出时都整块复制,改了副本,原物纹丝不动。第三道坎是并发:C# 的线程本来就能同时跑在多个核上,Python 标准构建里被 GIL 掩盖的竞争,在这里会直接丢数。
| 要忘掉的习惯 | 要新建的习惯 |
|---|---|
| 鸭子类型,有这个方法就能用(第 8 章) | 实现接口要在类型声明里写出(第 8 章) |
| 赋值一律共享同一个对象(第 5 章) | struct 赋值即复制,改副本不改原物(第 5 章) |
| 靠 GIL 侥幸不丢数(第 17 章) | 线程真并行,共享数据用 lock(第 17 章) |
| 子类随时能覆盖父类方法(第 8 章) | 父类写 virtual,子类写 override(第 8 章) |
用 with 关闭文件和连接(第 10 章) |
用 using,编译成 try/finally(第 10 章) |
从 Python 到 Go
Section titled “从 Python 到 Go”最大的坎是错误处理。Go 没有异常,失败作为最后一个返回值交回来,每一层都要写 if err != nil,决定返回、包装还是处理;写了检查却忘了 return,程序就带着零值往下走,编译器不会提醒。第二道坎是 Go 没有类和继承,代码靠结构体、嵌入和隐式接口组织。第三道坎在字符串和“没有值”:len 数的是字节;映射查不到键不报错,而是返回零值。
| 要忘掉的习惯 | 要新建的习惯 |
|---|---|
出错就 raise,上层统一 except(第 10 章) |
返回 error,每层检查并用 %w 包装(第 10 章) |
| 子类继承父类(第 8 章) | 嵌入只是字段,外层不是内层的子类型(第 8 章) |
len 数码点(第 14 章) |
len 数字节,range 按 rune 遍历(第 14 章) |
查不到键抛 KeyError(第 10 章) |
查不到得零值,用 comma ok 区分(第 6 章) |
I/O 并发惯用 async/await(第 19 章) |
没有 async,等网络 I/O 只挂起 goroutine(第 19 章) |
从 JS/TS 到 Python
Section titled “从 JS/TS 到 Python”最大的坎是异步的默认行为反过来了。JS 的 Promise 一创建就开始执行;Python 调用 async def 函数只得到一个协程对象,不 await 或不交给 create_task,函数体一行也不执行,只留下一条“从未被等待”的运行时警告。第二道坎在函数:默认参数只在定义时求值一次,for 循环里创建的闭包共用同一个变量。第三,Python 不做隐式转换,1 + "2" 直接抛 TypeError;字典查不到键抛 KeyError,而不是返回空值。
| 要忘掉的习惯 | 要新建的习惯 |
|---|---|
| Promise 一创建就开始执行(第 19 章) | 协程要 await 或 create_task 才运行(第 19 章) |
| 默认参数每次调用都重新求值(第 7 章) | 默认值定义时只求一次,可变的用 None(第 7 章) |
let 让循环闭包每轮一个变量(第 7 章) |
for 的循环变量被所有闭包共用(第 7 章) |
读不存在的属性得到 undefined(第 6 章) |
字典查不到键抛 KeyError(第 10 章) |
| 字符串和数字相加会自动转换(第 3 章) | 类型不匹配直接抛 TypeError(第 3 章) |
从 JS/TS 到 C#
Section titled “从 JS/TS 到 C#”最大的坎是兼容规则从结构换成名义:TS 里形状对上就能传,C# 只认声明过的继承和接口。第二道坎是运行时保留类型:TS 编译后类型信息什么都不剩,C# 连泛型实参都在,typeof(T) 能用。第三道坎是线程:控制台程序和 ASP.NET Core 里,await 之后的代码可能换到线程池的另一条线程上,JS 里用不着的锁在这里要加,而且 lock 里不能 await。
| 要忘掉的习惯 | 要新建的习惯 |
|---|---|
| 形状对上就能赋值(第 3 章) | 只认声明过的类和接口(第 3 章) |
| 泛型编译后擦除(第 9 章) | 泛型实参运行时还在,typeof(T) 可用(第 9 章) |
await 后仍在同一条主线程(第 19 章) |
延续可能换到线程池的别的线程(第 19 章) |
| 不共享内存,从不加锁(第 17 章) | 共享数据用 lock,锁里不能 await(第 17 章) |
| 对象一律按引用共享(第 5 章) | struct 赋值即复制,宜小而不可变(第 5 章) |
从 JS/TS 到 Go
Section titled “从 JS/TS 到 Go”最大的坎是并发模型整个换掉。没有 async/await 和 Promise,阻塞写法直接放进 goroutine,结果经通道或 WaitGroup 收回;goroutine 分布在多条系统线程上,共享变量会真的发生数据竞争,多个 goroutine 同时写 map,运行时检测到会直接终止进程(第 12 章)。第二道坎是 try/catch 没有了,错误是普通返回值,panic 留给程序缺陷。第三道坎是 undefined 不见了:变量总有零值,查不到的键也返回零值。
| 要忘掉的习惯 | 要新建的习惯 |
|---|---|
用 async/await 等 I/O(第 19 章) |
开 goroutine,用通道收结果(第 20 章) |
| 单线程,共享对象不会竞争(第 17 章) | 加 sync.Mutex,测试时开 -race(第 17 章) |
throw 后由外层 catch(第 10 章) |
返回 error,调用方紧接着检查(第 10 章) |
查不到得 undefined(第 6 章) |
得到零值,用 v, ok := m[k] 区分(第 6 章) |
length 数 UTF-16 码元(第 14 章) |
len 数字节,range 按 rune 遍历(第 14 章) |
从 C# 到 Python
Section titled “从 C# 到 Python”最大的坎是运行前没人把关:标注写错了解释器照样运行,类型错误要等执行到那一行才以 TypeError 出现,不跑 mypy 或 pyright 的项目,标注只是注释。第二道坎是并发:标准构建里同一时刻只有一条线程执行 Python 字节码,C# 里多开几条线程就能吃满多核的计算,在 Python 里要换成多进程,或者改用另装的无 GIL 构建。第三道坎是封装:_name 只是约定,没有编译器拦着。
| 要忘掉的习惯 | 要新建的习惯 |
|---|---|
| 编译器替你检查类型(第 3 章) | 标注不检查,持续集成里跑 mypy(第 3 章) |
| 多开线程就能并行计算(第 16 章) | 标准构建下 CPU 密集工作用多进程(第 18 章) |
Task 一调用就开始执行(第 19 章) |
协程要 await 才运行(第 19 章) |
private 由编译器保证(第 11 章) |
_name 只是约定(第 11 章) |
struct 赋值即复制(第 5 章) |
只有引用语义,复制要显式造新对象(第 5 章) |
从 C# 到 JS/TS
Section titled “从 C# 到 JS/TS”最大的坎是 TS 的类型只活在编译期。strictNullChecks 在生成的 JS 里不留任何检查,泛型被擦除,接口在运行时根本不存在;外部数据进来的那一刻要自己校验。第二道坎是“没有值”有两种,undefined 和 null。第三道坎是只有一条主线程执行 JS:C# 里放到线程池上的重计算,在 Node 里会把所有回调都卡住,要挪到 Worker。
| 要忘掉的习惯 | 要新建的习惯 |
|---|---|
| 按声明判断类型兼容(第 3 章) | 形状对上就兼容(第 3 章) |
运行时能拿到 typeof(T)(第 9 章) |
类型全部擦除,外部数据自己校验(第 3 章) |
可空只有一种 null(第 6 章) |
undefined 和 null 都要防(第 6 章) |
| 计算交给线程池并行(第 16 章) | 主线程只有一条,重计算放进 Worker(第 16 章) |
record 按内容比较相等(第 5 章) |
=== 对对象比的是引用(第 5 章) |
从 C# 到 Go
Section titled “从 C# 到 Go”最大的坎同样是错误:没有异常,try/catch/finally 换成错误返回值加 defer。第二道坎是类型组织:没有类和继承,接口不用声明实现,方法对上就满足。第三道坎是取消:C# 一路传 CancellationToken,Go 一路传 context.Context,派生出的子 context 要记得调用 cancel。另有一处空值陷阱:装着 nil 指针的接口不等于 nil,err != nil 会意外为真。
| 要忘掉的习惯 | 要新建的习惯 |
|---|---|
throw/catch 传递失败(第 10 章) |
返回 error,用 errors.Is 判断种类(第 10 章) |
用 : IFoo 声明实现接口(第 8 章) |
方法对上即实现,不用声明(第 8 章) |
用 async/await 等 I/O(第 19 章) |
goroutine 里直接写阻塞调用(第 19 章) |
一路传 CancellationToken(第 21 章) |
一路传 ctx,派生后记得 cancel(第 21 章) |
和 null 比一次就够(第 6 章) |
装着 nil 指针的接口不等于 nil(第 6 章) |
从 Go 到 Python
Section titled “从 Go 到 Python”最大的坎是错误会自己跳。Python 用异常,函数签名上看不出会抛什么,中间层不写任何代码,失败就穿过去;defer 的工作由 with 接管。第二道坎是并发:goroutine 能在多核上真正并行,Python 标准构建的线程受 GIL 约束,计算密集的工作要用进程池;I/O 并发惯用 asyncio,函数分出颜色,协程里一次阻塞调用就卡住整个事件循环。第三道坎是内存:Python 没有值类型,赋值永远共享同一个对象,[[0] * 3] * 3 造出的三行是同一个列表。
| 要忘掉的习惯 | 要新建的习惯 |
|---|---|
每层检查返回的 err(第 10 章) |
异常自动上跳,在能处理处 except(第 10 章) |
用 defer 释放资源(第 10 章) |
用 with,由 __exit__ 释放(第 10 章) |
| 开 goroutine 并行计算(第 16 章) | 标准构建下 CPU 密集工作用多进程(第 18 章) |
| 阻塞多半只挂起 goroutine(第 19 章) | 协程里阻塞会卡住事件循环(第 19 章) |
| 结构体赋值即复制(第 5 章) | 赋值只复制引用,嵌套数据要深拷贝(第 5 章) |
从 Go 到 JS/TS
Section titled “从 Go 到 JS/TS”最大的坎是阻塞写法不能再用。不开 Worker 时只有一条主线程执行 JS,等待必须写成 await,而一个函数改成异步,调用链上一路都要跟着改。第二道坎是错误:异步函数的失败表现为被拒绝的 Promise,没人 await 也没挂 .catch 时,Node 15 起默认打印错误后退出进程。第三道坎是零值没了:没赋值的变量、读不存在的属性都得到 undefined;对象和数组赋值都是共享引用,展开语法只做浅复制。
| 要忘掉的习惯 | 要新建的习惯 |
|---|---|
| 阻塞多半只挂起 goroutine(第 19 章) | 阻塞会停住主线程,等待写 await(第 19 章) |
| 函数没有颜色(第 19 章) | 调用异步函数的函数也得变成异步(第 19 章) |
| 错误作为返回值检查(第 10 章) | 每个 Promise 都要 await 或 .catch(第 19 章) |
| 变量总有零值(第 6 章) | 没赋值是 undefined,用 ?? 兜底(第 6 章) |
| 结构体赋值即复制(第 5 章) | 对象赋值共享,{ ...a } 只浅复制(第 5 章) |
从 Go 到 C#
Section titled “从 Go 到 C#”最大的坎是从错误值回到异常:失败可以从任何一层调用里冒出来,签名上看不出;常见的可预期失败另有 TryParse 这类不抛异常的写法。第二道坎是接口要显式声明,方法默认不是虚的。第三道坎是并发:等 I/O 要写 async/await,函数从此分出颜色;async void 的异常没人接得住。值和引用在 C# 里要在定义类型时选定:class 是引用,struct 才是值。
| 要忘掉的习惯 | 要新建的习惯 |
|---|---|
返回 error 逐层检查(第 10 章) |
异常自动上跳,常见失败用 TryParse(第 10 章) |
| 方法对上即实现接口(第 8 章) | 在声明里写 : IFoo(第 8 章) |
| goroutine 里直接阻塞(第 19 章) | 等 I/O 写 await,不写 async void(第 19 章) |
| 结构体都是值(第 5 章) | class 是引用,struct 才是值(第 5 章) |
用 defer 释放资源(第 10 章) |
用 using,编译成 try/finally(第 10 章) |
| 方向 | 最大的一道坎 | 先读哪几章 |
|---|---|---|
| Python → JS/TS | 隐式转换和两种空值 | 第 3、6、19 章 |
| Python → C# | 编译期名义类型和值类型 | 第 3、5、8、17 章 |
| Python → Go | 异常换成错误值 | 第 10、8、14 章 |
| JS/TS → Python | 协程不 await 不执行 |
第 19、7、3 章 |
| JS/TS → C# | 名义类型和多线程 | 第 3、9、17、19 章 |
| JS/TS → Go | goroutine 真并行,要防竞争 | 第 19、20、17、10 章 |
| C# → Python | 标注不检查,标准构建的线程受 GIL 约束 | 第 3、16、18 章 |
| C# → JS/TS | 类型只在编译期,单线程 | 第 3、6、16 章 |
| C# → Go | 异常换成错误值 | 第 10、8、21 章 |
| Go → Python | 错误值换成异常 | 第 10、16、5 章 |
| Go → JS/TS | 阻塞写法换成 await |
第 19、10、6 章 |
| Go → C# | 错误值换成异常 | 第 10、8、19 章 |
- 换语言最常见的坎分四类:类型检查的时机、失败的传递方式、执行代码的线程数、赋值是复制还是共享;先确认新旧语言在这四处各站哪一边。
- 换到 Go 时,坎多在错误值和 goroutine 并发上:每层要检查
err,共享数据会真的发生竞争;离开 Go 时则要重新习惯异常自动上跳和异步的颜色。 - 难点随方向而变:从 Python 或 JS/TS 转 C#,难在名义类型和值类型;转 Go,三种来源都要改用错误值,从 JS/TS 来还要学防数据竞争;从 C# 转 Python,难在标注不检查和标准构建的 GIL;从 C# 转 TS,要记得类型只在编译期,外部数据得自己校验。
- 并发是四类里差别最大的:JS 单线程事件循环,Python 标准构建受 GIL 约束,C# 用线程池,Go 用 goroutine,从单线程换到多线程要学加锁,反过来要学不阻塞。
- 每条习惯背后的机制都在括号里的章节,踩了坑再回去读那一章,比背清单记得牢。