跳转到内容

第 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):走起来像鸭子、叫起来像鸭子,就当它是鸭子,只是到运行时才去看。

同一个“有 area() 的东西”在两种兼容规则下

看两栏的箭头:上栏的 Rect 和 Circle 只是碰巧有 area(),就自动兼容 Shape;下栏的 Square 方法一个不少,只因没写 : IShape,编译器照样拒绝。

Python 是动态强类型:每个值都知道自己的类型,变量没有类型,运算时类型不对就抛 TypeError。函数标注的语法 3.0 就有,PEP 484(3.5)规定用它来写类型(type hints),语言本身不检查它,检查交给 mypy、pyright 这类外部工具,所以标注是原生语法,静态检查却靠工具。

import json
import math
from 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) 写的分支会悄悄走到另一边。

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" // 12
typeof 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 的类型是 string
const n: number = 19.9 + "19.90"; // 编译错误:不能将类型 string 分配给类型 number
const 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# 是静态名义类型:编译器在编译时检查一切,两个类型兼容的条件是一方在声明里写明继承或实现了另一方。

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 是静态类型,接口按结构隐式满足:类型有接口要求的全部方法,就自动实现了这个接口,不需要也没有 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 就自动满足 Shape
func (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 = 2
var f float64 = 1.5
fmt.Println(n + f) // 编译错误:mismatched types int and float64
fmt.Println(1 + "2") // 编译错误:mismatched types untyped int and untyped string
fmt.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(...)。

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 什么都不留。
  • 外部数据总要在运行时检查:静态类型只能保证进来以后的代码,进来的那一刻要靠显式转换和校验。