第 3 章 · 类型系统
解决什么问题:在程序真正出错之前,尽早发现“把不该放在一起的值放在了一起”,并说清楚什么样的值可以交给什么样的函数。
四种语言的态度:Python 和 JS 在运行时检查,TS、C#、Go 在编译时检查;Python 不肯隐式转换,JS 什么都愿意转;TS、Go 按形状判断兼容,C# 按声明判断。
订单服务要算两样东西。一是一批图形的面积合计:一个 3×4 的矩形和一个半径为 1 的圆,计算函数对参数只有一个要求,“有 area() 方法”。二是订单金额合计:金额来自外部系统发来的 JSON,其中一项写成了字符串 "19.90",另一项是数字 19.9。每种语言都先把字符串金额显式转换成数字再相加,最后打印两行,保留两位小数。本章看四种类型系统在两件事上的差别:问题在什么时候被发现,靠什么判断两个类型兼容。
类型(type)说的是一个值能做什么:数字能相乘,字符串能拼接,矩形能算面积。把数字当成函数去调用,结果没有意义,程序迟早要出错;类型系统(type system)的任务就是找出这种错误,问题只在于什么时候找、找得多严。
第一条轴是检查发生的时间。静态类型(static typing)在编译时检查,变量、参数、返回值都有类型,类型不对的程序跑不起来;动态类型(dynamic typing)把类型挂在值上,运行到那一行才检查,变量本身可以先后装不同类型的值。第二条轴是会不会隐式类型转换(implicit type coercion):强类型(strong typing)遇到类型不匹配的运算就报错,弱类型(weak typing)会悄悄把一边转成另一边再算。“强”和“弱”没有公认的精确定义,更像一条连续的轴,下图里各语言的位置只是大致的。
横轴看检查时机,纵轴看隐式转换。JS 在左下,运行时才检查且乐于转换;Python 在左上,同样运行时检查,却拒绝把字符串和数字相加;C# 和 Go 在右上。TS 静态检查,但运行时语义就是 JS,编译器也照样放行 1 + "2",所以落在右下靠中间。Python 和 TS 都标着渐进类型(gradual typing):同一个程序里可以一部分写类型、一部分不写,Any 或 any 是两个世界的接口,检查器对它既不检查也不报错。
类型推断(type inference)让编译器从初始值和用法里算出类型,程序员不必处处写出来:写 x := 3 就知道 x 是整数。本章四种语言都只做局部推断,具名函数的参数仍要写类型。
最后是兼容规则:函数要一个 Shape,什么样的值可以传进去?名义类型(nominal typing)看名字和声明,类型必须写明“我实现了 Shape“才算数;结构类型(structural typing)看形状,只要有 Shape 要求的全部成员就算兼容,不必事先声明。动态语言里与之相应的说法是鸭子类型(duck typing):走起来像鸭子、叫起来像鸭子,就当它是鸭子,只是到运行时才去看。
看两栏的箭头:上栏的 Rect 和 Circle 只是碰巧有 area(),就自动兼容 Shape;下栏的 Square 方法一个不少,只因没写 : IShape,编译器照样拒绝。
Python 怎么做
Section titled “Python 怎么做”Python 是动态强类型:每个值都知道自己的类型,变量没有类型,运算时类型不对就抛 TypeError。函数标注的语法 3.0 就有,PEP 484(3.5)规定用它来写类型(type hints),语言本身不检查它,检查交给 mypy、pyright 这类外部工具,所以标注是原生语法,静态检查却靠工具。
import jsonimport mathfrom typing import Protocol
class Shape(Protocol): def area(self) -> float: ...
class Rect: # 没有继承 Shape def __init__(self, w: float, h: float) -> None: self.w, self.h = w, h
def area(self) -> float: return self.w * self.h
class Circle: def __init__(self, r: float) -> None: self.r = r
def area(self) -> float: return math.pi * self.r**2
def total_area(shapes: list[Shape]) -> float: # 类型检查器只看有没有 area() return sum(s.area() for s in shapes)
def total_amount(raw: str) -> float: items = json.loads(raw) # 返回 Any,类型检查器不再过问 return sum(float(item["amount"]) for item in items) # 显式转换,"19.90" 变成 19.9
ORDER_JSON = '[{"amount": "19.90"}, {"amount": 19.9}]'print(f"面积合计:{total_area([Rect(3, 4), Circle(1)]):.2f}")print(f"订单合计:{total_amount(ORDER_JSON):.2f}")
# 输出:# 面积合计:15.14# 订单合计:39.80运行:python totals.py。少了 float(...) 会怎样?sum 从 0 开始加,第一步就是 0 + "19.90",立即抛出异常:
>>> 1 + "2"TypeError: unsupported operand type(s) for +: 'int' and 'str'这就是“强”:Python 宁可停下,也不猜你想要拼接还是相加。例外只在数值之间,1 + 2.0 得到 3.0。
Shape 继承 typing.Protocol(PEP 544,3.8 起),它是结构类型:Rect 和 Circle 没有提到 Shape,mypy 照样认为它们可以放进 list[Shape]。另一条路是抽象基类 abc.ABC,那是名义类型,类必须显式继承它(或用 register 登记)才算数,好处是忘了实现抽象方法时,实例化那一刻就报错。json.loads 的返回类型是 Any,从这里流出的值检查器一概放行,渐进类型的接口就在这里。
底层怎么实现。 CPython 里每个对象的头部都有一个指向其类型对象的指针,变量只是指向对象的名字,所以类型属于值而不属于变量。a + b 编译成一条字节码,执行时先调用左边类型的加法;int 的加法发现对方是 str,返回特殊值 NotImplemented,解释器再去找 str 的反向加法 __radd__,找不到,于是抛 TypeError。标注只是存进函数 __annotations__ 的数据(3.14 起改为用到时才求值),解释器执行时从不读它,所以标注不影响运行速度,也不提供任何保护。
- 标注写错不会报错:
def area(self) -> str实际返回浮点数,程序照常运行,直到有人信了标注去调用字符串方法;不跑 mypy 的项目,标注只是注释。 float标注接受int:PEP 484 规定写float的地方可以传int,Rect(3, 4)的宽高其实是整数,检查器不吭声,可按isinstance(x, float)写的分支会悄悄走到另一边。
JS/TS 怎么做
Section titled “JS/TS 怎么做”JavaScript 是动态弱类型:值带类型,变量不带,运算时类型不匹配会先尝试转换,很少报错。
class Rect { constructor(w, h) { this.w = w; this.h = h; } area() { return this.w * this.h; }}
class Circle { constructor(r) { this.r = r; } area() { return Math.PI * this.r ** 2; }}
function totalArea(shapes) { return shapes.reduce((sum, s) => sum + s.area(), 0); // 没有 area() 的话,运行到这里才抛 TypeError}
function totalAmount(raw) { const items = JSON.parse(raw); return items.reduce((sum, item) => sum + Number(item.amount), 0); // 少了 Number,+ 就成了字符串拼接}
const ORDER_JSON = '[{"amount": "19.90"}, {"amount": 19.9}]';console.log(`面积合计:${totalArea([new Rect(3, 4), new Circle(1)]).toFixed(2)}`);console.log(`订单合计:${totalAmount(ORDER_JSON).toFixed(2)}`);
// 输出:// 面积合计:15.14// 订单合计:39.80运行:node totals.js。+ 的规则是:两边先转成原始值,只要有一边是字符串,就把另一边也转成字符串拼接,否则按数字相加。-、* 没有拼接的含义,总是转成数字:
1 + "2" // "12""3" * "4" // 12typeof null // "object"typeof NaN // "number"typeof null 得 "object" 是第一版实现留下的错误:当时值的低位存类型标记,对象的标记是 0,而 null 是全零的空指针,于是被认成对象;网页早已依赖这个结果,再也改不回来。totalArea 不检查参数,鸭子类型完全发生在运行时:传进一个没有 area 的对象,要到调用那一刻才抛 TypeError。
底层怎么实现。 V8 里每个值是一个带标记的机器字:最低位区分小整数(Smi)和堆对象指针;堆对象的第一个字段指向它的隐藏类(V8 叫 Map),记录对象有哪些属性、排在什么位置。执行 a + b 时,字节码处理器先看两边的标记和隐藏类,再按规范的步骤决定转换方向;解释器记下这里见过哪些类型(类型反馈),优化编译器据此为常见组合生成快路径。所以 JS 里类型检查一直存在,只是发生在每次运算时,结果是转换而不是报错。
- 字符串金额相加变成拼接:去掉
Number(...),reduce先算出0 + "19.90"得"019.90",再加19.9得"019.9019.9",最后字符串没有toFixed方法,抛TypeError;要是直接打印,就是一个荒唐的金额。 Number和parseFloat对坏数据的反应不同:Number("19.90元")得NaN,parseFloat("19.90元")得19.9;NaN参与运算不报错,一路传染到合计。
TypeScript 在 JS 之上加了一层编译期的静态类型,兼容规则是结构类型。它只检查,不改变运行时行为:类型检查通过后,编译器把类型标注删掉,输出普通的 JS。
interface Shape { area(): number;}
class Rect { // 没有写 implements Shape w: number; h: number; constructor(w: number, h: number) { this.w = w; this.h = h; } area(): number { return this.w * this.h; }}
class Circle { r: number; constructor(r: number) { this.r = r; } area(): number { return Math.PI * this.r ** 2; }}
function totalArea(shapes: Shape[]): number { return shapes.reduce((sum, s) => sum + s.area(), 0);}
function toAmount(value: unknown): number { if (typeof value === "number") return value; // 收窄成 number 之后才能当数字用 if (typeof value === "string") return Number(value); throw new Error(`金额格式不对:${String(value)}`);}
function totalAmount(raw: string): number { const items: unknown = JSON.parse(raw); // JSON.parse 返回 any,先存成 unknown if (!Array.isArray(items)) throw new Error("订单应该是数组"); return items.reduce((sum: number, item: { amount: unknown }) => sum + toAmount(item.amount), 0);}
console.log(`面积合计:${totalArea([new Rect(3, 4), new Circle(1)]).toFixed(2)}`);console.log(`订单合计:${totalAmount('[{"amount": "19.90"}, {"amount": 19.9}]').toFixed(2)}`);
// 输出:// 面积合计:15.14// 订单合计:39.80运行:tsx totals.ts。注意 TS 并不拒绝 1 + "2":拼接在 JS 里是正当用法,编译器把结果的类型定为 string。它拒绝的是把这个结果当数字用,以及没有拼接含义的算术:
const s = 1 + "2"; // 通过,s 的类型是 stringconst n: number = 19.9 + "19.90"; // 编译错误:不能将类型 string 分配给类型 numberconst m = 1 * "2"; // 编译错误:算术运算的右侧必须是 number 等类型any 关掉检查,对它做什么都行,结果也是 any,错误会顺着它扩散;unknown 也能装任何值,但不先用 typeof、Array.isArray 这类判断收窄(narrowing),就什么都不能做。JSON.parse 返回 any,代码把结果立刻存成 unknown,逼自己逐项检查。
底层怎么实现。 编译器 tsc 先算出每个表达式的类型,判断赋值是否兼容时逐个成员比较形状,Rect 有 Shape 要的 area(): number,就算兼容;然后做类型擦除(type erasure),interface 整个消失,标注被删掉,输出的 JS 和手写的几乎一样。运行时没有任何 TS 类型信息,x instanceof Shape 写不出来,因为 Shape 根本不存在。新版 Node 能直接运行 .ts 文件,做法同样是把标注抹成空白,所以参数属性(constructor(public w: number))这类要生成代码的语法它默认不认,本章的类因此写出了字段。
TS 的设计目标里明确写着不追求可靠的(sound)类型系统,而在正确和好用之间取平衡。下面的程序在 TS 5 的 strict 模式下没有任何编译错误,运行时却会崩溃:
class Dog { bark() { return "汪"; } }class Cat { meow() { return "喵"; } }const dogs: Dog[] = [];const pets: (Dog | Cat)[] = dogs; // 数组按协变处理:Dog[] 可以当 (Dog | Cat)[] 用pets.push(new Cat()); // 通过 pets 放进一只猫dogs[0].bark(); // 运行时 TypeError:dogs[0].bark is not a function数组被当成协变(covariant)的,push 又是方法,而 strictFunctionTypes 只严格检查函数类型的属性、对方法参数网开一面,于是一只猫进了狗的数组。
- 外部 JSON 用类型断言骗过编译器:写成
JSON.parse(raw) as { amount: number }[],编译器信了,"19.90"照样进来,sum + item.amount在运行时又变成拼接,正是 JS 那个坑,只是多了一层“类型没问题”的假象。断言只改编译期的看法,不做任何运行时检查。 Array.isArray把unknown收窄成any[],元素又回到不检查的状态;item: { amount: unknown }只是一句声明,元素是null时item.amount照样在运行时抛错。
C# 怎么做
Section titled “C# 怎么做”C# 是静态名义类型:编译器在编译时检查一切,两个类型兼容的条件是一方在声明里写明继承或实现了另一方。
using System;using System.Collections.Generic;using System.Globalization;using System.Linq;using System.Text.Json;
const string OrderJson = """[{"amount": "19.90"}, {"amount": 19.9}]""";var shapes = new List<IShape> { new Rect(3, 4), new Circle(1) };Console.WriteLine($"面积合计:{TotalArea(shapes).ToString("F2", CultureInfo.InvariantCulture)}");Console.WriteLine($"订单合计:{TotalAmount(OrderJson).ToString("F2", CultureInfo.InvariantCulture)}");
static double TotalArea(IEnumerable<IShape> shapes) => shapes.Sum(s => s.Area());
static double TotalAmount(string json){ using var doc = JsonDocument.Parse(json); double sum = 0; foreach (var item in doc.RootElement.EnumerateArray()) { var amount = item.GetProperty("amount"); sum += amount.ValueKind == JsonValueKind.String ? double.Parse(amount.GetString()!, CultureInfo.InvariantCulture) // 显式解析字符串金额 : amount.GetDouble(); } return sum;}
interface IShape { double Area(); }
// 必须写 : IShape,光有 Area() 方法不够record Rect(double W, double H) : IShape { public double Area() => W * H; }record Circle(double R) : IShape { public double Area() => Math.PI * R * R; }
// 输出:// 面积合计:15.14// 订单合计:39.80运行:dotnet run totals.cs。var 让编译器从初始值推断局部变量的类型,shapes 的类型就是 List<IShape>,和写全了一样,只是不用重复;字段、参数和方法返回值仍要写明。在基本类型之间,C# 只做数值放宽的隐式转换(如 int 到 double),另外 + 对 string 有重载,任何值都能和字符串拼接:
var s = 1 + "2"; // 通过,s 是 string "12"double d = 19.9 + "19.90"; // 编译错误 CS0029:无法将类型 string 隐式转换为 double可空引用类型(nullable reference types,C# 8 起)让 string 和 string? 有了区别,新项目模板和 dotnet run 单文件默认开启;但它只产生编译期警告,string s = null; 照样能编译运行,到 s.Length 才抛 NullReferenceException。逃生口是 dynamic:对它的成员访问推迟到运行时,由动态语言运行时(DLR)去查找。
底层怎么实现。 编译器把类型写进 IL 和元数据(metadata),运行时一直带着这些信息;每个对象头部都有一个指向方法表(method table)的指针,所以 is、as 和强制转换在运行时也能检查,转换失败抛 InvalidCastException。泛型在运行时保留:List<int> 和 List<string> 是两个不同的运行时类型,反射能看到类型实参;JIT 为每种值类型(value type)实参生成专门的机器码,所有引用类型(reference type)实参共享一份。类型实现了哪些接口记在它的元数据里,调用接口方法时,运行时按这份记录找到具体的实现,这正是名义类型的物质基础:没有声明,就没有记录。
- 两个形状一样的类不能互换:再写一个
class Square { public double Area() => 4; },它放不进List<IShape>,square is IShape也是“否”,必须加上: IShape;第三方库里的类改不了声明,只能另写一个适配器类包住它。 - 可空引用类型的警告不是错误:忽略警告或用
!压掉(本例的GetString()!就是),编译器不再过问,运行时的空值照样崩溃。 dynamic把错误推迟到运行时:方法名拼错要到运行那一行才抛RuntimeBinderException。
Go 怎么做
Section titled “Go 怎么做”Go 是静态类型,接口按结构隐式满足:类型有接口要求的全部方法,就自动实现了这个接口,不需要也没有 implements 关键字。
package main
import ( "encoding/json" "fmt" "math" "strconv")
type Shape interface{ Area() float64 }type Rect struct{ W, H float64 }type Circle struct{ R float64 }
// 不写 implements:方法集里有 Area() float64 就自动满足 Shapefunc (r Rect) Area() float64 { return r.W * r.H }func (c Circle) Area() float64 { return math.Pi * c.R * c.R }
func totalArea(shapes []Shape) float64 { sum := 0.0 for _, s := range shapes { sum += s.Area() } return sum}
func totalAmount(raw string) float64 { var items []struct{ Amount any } // amount 可能是字符串也可能是数字,先收成空接口 if err := json.Unmarshal([]byte(raw), &items); err != nil { panic(err) } sum := 0.0 for _, item := range items { switch v := item.Amount.(type) { case float64: // JSON 数字一律解成 float64 sum += v case string: f, err := strconv.ParseFloat(v, 64) // 显式解析字符串金额 if err != nil { panic(err) } sum += f default: panic(fmt.Sprintf("金额格式不对:%v", v)) } } return sum}
func main() { fmt.Printf("面积合计:%.2f\n", totalArea([]Shape{Rect{3, 4}, Circle{1}})) fmt.Printf("订单合计:%.2f\n", totalAmount(`[{"amount": "19.90"}, {"amount": 19.9}]`))}
// 输出:// 面积合计:15.14// 订单合计:39.80运行:go run totals.go。:= 从右边推断类型,只能用在函数内部。any 是空接口 interface{} 的别名(Go 1.18 起),任何值都满足它;取出来要用类型开关(type switch)或类型断言 v.(string) 判断实际类型。encoding/json 匹配字段名不分大小写,所以 "amount" 能落进 Amount。Go 没有任何隐式数值转换,连 int 加 float64 都不行:
var n int = 2var f float64 = 1.5fmt.Println(n + f) // 编译错误:mismatched types int and float64fmt.Println(1 + "2") // 编译错误:mismatched types untyped int and untyped stringfmt.Println(float64(n) + f) // 3.5接口是结构的,具名类型却是名义的:type Celsius float64 和 float64 底层一样,相互赋值仍要显式转换。
底层怎么实现。 编译器在编译时检查 Rect 的方法集是否覆盖 Shape,覆盖了才允许把 Rect{3, 4} 放进 []Shape。接口值在运行时占两个字:一个指向 itab(记录具体类型,以及这个接口要的各方法的函数指针),一个指向数据;空接口没有方法,第一个字直接存类型指针。编译时已知的转换由编译器静态生成 itab,其余的由运行时在第一次转换时构造并缓存,所以隐式实现不需要任何声明,定义类型时也不必知道将来有哪些接口。类型开关和类型断言就是拿接口值里的类型信息做比较。
- 数值字面量是无类型常量(untyped constant),赋给变量后才定型:
n * 3.0能编译,因为3.0能精确表示成int;n * 1.5就是编译错误,必须写float64(n) * 1.5。同样是“整数乘小数”,能不能编译取决于字面量的值。 - 隐式满足也意味着偶然不满足:方法签名差一点(比如
Area() float32)就不再实现接口,报错出现在使用的地方,而不是定义类型的地方;想在定义处检查,惯用写法是var _ Shape = Rect{}。 - Go 里装着
nil指针的接口不等于nil,详见第 6 章。
| 维度 | Python | JS/TS | C# | Go |
|---|---|---|---|---|
| 支持程度 | 原生动态类型;静态检查靠外部工具 | JS 原生动态;TS 编译期静态 | 原生静态 | 原生静态 |
| 实现机制 | 对象头的类型指针 + 运行时分派 | JS:带标记的值 + 隐藏类;TS:检查后擦除 | 编译期检查 + CLR 方法表 | 编译期检查 + 接口值里的 itab |
| 检查时机 | 运行时;标注由 mypy 等离线检查 | JS 运行时;TS 编译时 | 编译时,转换在运行时再查 | 编译时,类型断言在运行时查 |
| 隐式转换 | 只有数值提升(1 + 2.0) |
JS 很多;TS 拦下大部分,1 + "2" 仍放行 |
基本类型间只有数值放宽;string 的 + 重载 |
没有 |
| 推断范围 | 检查器推断局部变量 | TS:局部变量、返回值、泛型实参 | var 局部变量、lambda 参数、泛型实参 |
:= 局部变量、泛型实参 |
| 兼容规则 | Protocol 结构,ABC 名义 |
结构 | 名义 | 接口结构,具名类型名义 |
| 运行时是否保留类型 | 值的类型保留,标注不参与执行 | JS 值带类型;TS 类型全部擦除 | 全部保留,含泛型实参 | 保留,反射可见 |
| 逃生口 | Any |
any |
dynamic |
any(空接口) |
差别最大的是“运行时是否保留类型”一行:同样是静态类型,TS 编译完什么都不剩,C# 连 List<int> 的类型实参都留着。“兼容规则”一行则决定了别人写的类型能不能直接传给你的函数。
JavaScript 诞生于 1995 年,要让不懂编程的网页作者也能写几行脚本,而网页出了错,浏览器最好别罢工,尽量把页面显示下去。宽松的隐式转换就是这种选择的产物:表单里读出来的都是字符串,"3" * 2 直接得 6,省掉了转换。代价是错误不在出错处暴露,而是变成怪值传到很远的地方;规范要兼容已有的网页,这些规则一条也改不了。
Python 同样选择动态类型,好让代码短、写得快,却按“显式优于隐式”的格言拒绝隐式转换,类型错误在运行时尽早暴露成 TypeError。PEP 484 的标注是后来补上的,为了不破坏已有代码,被设计成可选的、运行时不管的,给大代码库一条逐步加类型的路。后果是 Python 有两套“类型”:解释器认的值类型,和检查器认的标注,两者可能不一致。
TypeScript 的首要约束是兼容所有现存的 JS:JS 程序到处在传对象字面量、靠鸭子类型工作,名义类型会让这些代码全部报错,只能选结构类型;输出又必须是能在任何引擎上运行的普通 JS,类型就只能在编译后擦除。于是 TS 能做的只有编译期检查,一旦数据来自 JSON.parse、网络请求、as 断言,类型就成了承诺而不是事实,外部数据要靠程序员在边界上自己校验。为了让大量既有写法通过,它还有意保留了数组协变这类不可靠之处。
C# 跟着 .NET 运行时走:CLR 从一开始就把类型写进元数据、在运行时使用,C# 2.0 加泛型时,CLR 2.0 也一起改成在运行时保留类型实参;同期的 Java 为了兼容旧虚拟机,选了擦除。保留类型换来了反射、运行时的 is 检查和值类型泛型不装箱(boxing)的性能;名义类型的代价是第三方类型必须事先声明实现了你的接口,否则只能包一层。
Go 的设计者想要动态语言那样轻快的书写,又要 C 那样在编译期抓住错误。隐式接口让小接口可以由使用方定义,标准库的 io.Reader 只有一个方法,任何有 Read 的类型都自动满足它;:= 省掉了大部分类型名;禁止隐式数值转换则来自 C 的教训:有符号与无符号、不同宽度的整数混算,是 C 程序里一大类难找的错误。后果是重构时编译器能找出每一处类型不符,数值代码里却满是 float64(...)。
旁注:别的语言
Section titled “旁注:别的语言”Haskell 和 OCaml 用 Hindley–Milner 类型推断,能推断整个程序,函数参数也不用写类型,编译器从函数体里的用法反推,检查仍然完全是静态的。OCaml 的整数加法 + 和浮点加法 +. 是两个运算符,所以下面的函数被推断为只接受整数:
let add x y = x + y(* 推断出的类型:val add : int -> int -> int *)- 外部数据进来的地方就是类型系统管不到的地方:JSON、表单、数据库行一律当作
Any、unknown、JsonElement、any处理,在边界上检查并转换一次,之后才交给有类型的代码。 - 想让“有某个方法的东西”都能传进来:Python 用
Protocol,TS 用interface,Go 用小接口;C# 里要么让类型实现接口,要么为改不了的类写适配器。 - 涉及金额时,二进制浮点数会带来舍入误差:Python 的
decimal.Decimal、C# 的decimal更合适;JS/TS 和 Go 的标准库没有十进制小数类型,常见做法是以分为单位用整数计算。
用 Python 和 TS 各写一个结构类型的例子:定义一个“有 name 属性的东西”的类型(Python 用 Protocol,TS 用 interface),写一个函数接受这种东西并打印它的 name。再写两个互不相干的类,比如用户和服务,都有 name 属性,却都不声明自己实现了那个类型,把两个类的实例各传进去一次,打印出两个名字。
提示:Python 的 Protocol 里怎么声明一个属性而不是方法?TS 里如果其中一个类的 name 是 number,编译器会在哪一行报错?两个类都没提到那个类型,运行时有没有任何地方记得它们“兼容”?
- 类型系统有两条轴:静态与动态说的是什么时候检查,强与弱说的是会不会隐式转换;JS 动态弱,Python 动态强,C# 和 Go 静态强,TS 静态但运行时语义同 JS。
- Python 和 TS 是渐进类型:
Any、any连接有类型和没类型的代码;Python 的标注运行时不检查,TS 的类型编译后擦除。 - 兼容规则分结构和名义:TS、Go 接口、Python
Protocol看形状,C# 看声明;名义类型的兼容关系写在声明里,结构类型不必事先约定。 - 运行时保留多少类型信息由实现决定:C# 连泛型实参都保留,Go 靠接口值里的类型信息,TS 什么都不留。
- 外部数据总要在运行时检查:静态类型只能保证进来以后的代码,进来的那一刻要靠显式转换和校验。