Skip to content

第 2 章:塑造 — 同一份现实,8 种形状

从订单说起

我们要做一个订单系统。很朴素的需求:一个订单有序号、金额、支付状态、优惠码。

现在,用 8 门语言把这件事写出来。你会得到什么呢?

你得到的不是 8 段 "语法不同" 的等价代码。你得到的是 8 个对现实的不同理解。


2.1 语法基础:8 种方式,定义同一个订单

Python

python
from dataclasses import dataclass
from typing import Optional

@dataclass(frozen=True)
class Order:
    order_id: str
    amount: float
    paid: bool
    coupon_code: Optional[str] = None  # 可以不存在的字段

# 使用
order = Order(order_id="A1001", amount=99.0, paid=True)
# order.amount = 150.0  # FrozenInstanceError: frozen=True 禁止修改

# 不用 dataclass 的手写版
class OrderManual:
    def __init__(self, order_id: str, amount: float, paid: bool,
                 coupon_code: Optional[str] = None):
        self.order_id = order_id
        self.amount = amount
        self.paid = paid
        self.coupon_code = coupon_code

Python 有几个关键事实

  • dataclass 替你生成 __init____eq____repr__,省掉大量样板代码
  • frozen=True 让实例不可变(类似 Rust 的理念,但 Python 中这是用户善意 + 类型检查器协助来维护的)
  • Optional[str] 是给类型检查器看的,运行时 Python 允许你把 None 传进去,也允许你不传
  • 金额用 float这是为示例简洁做的妥协。生产环境的金额应该用 Decimal

JavaScript / TypeScript

typescript
// TypeScript
interface Order {
    readonly orderId: string;
    readonly amount: number;
    readonly paid: boolean;
    readonly couponCode: string | null;  // 联合类型:要么 string 要么 null
}

function createOrder(id: string, amt: number, paid: boolean,
                     coupon: string | null = null): Order {
    if (amt < 0) throw new Error("amount must be >= 0");
    return { orderId: id, amount: amt, paid, couponCode: coupon };
}

TypeScript 的关键事实

  • interface 定义结构形状,不是类——这是结构类型(structural typing),和 Java 的名义类型(nominal typing)有本质区别
  • string | null 是类型系统级别的:不检查 couponCode 是否为 null 就直接用它的方法,TypeScript 编译器会拒绝
  • readonly 只在 TypeScript 编译期存在,运行时不产生任何代码——所以运行时没有东西阻止你改

纯 JavaScript 版本则是另一回事:

javascript
// 纯 JavaScript
class Order {
    constructor(orderId, amount, paid, couponCode = null) {
        if (amount < 0) throw new Error("amount must be >= 0");
        this.orderId = orderId;
        this.amount = amount;
        this.paid = paid;
        this.couponCode = couponCode;
    }
}
// 没有类型。couponCode 可以是 null、undefined、数字、或者另一个 Order 实例。
// 没有任何东西阻止这件事——除了团队约定和测试。

Java

java
// Java record(Java 16+):专门为 "数据载体" 设计的轻量级类型
public record Order(
    String orderId,
    double amount,
    boolean paid,
    String couponCode   // 可以为 null!Java 的 String 本身不保证非空
) {
    public Order {
        if (amount < 0) throw new IllegalArgumentException("amount must be >= 0");
    }
}

// 使用
var order = new Order("A1001", 99.0, true, null);
// order.amount = 150.0;  // 编译错误:record 组件隐式 final

Java 的关键事实

  • record 是从 Scala/Kotlin 的 data class 借鉴来的,专门解决 "Java 写一个 POJO 要 50 行" 的问题
  • record 自动生成 equalshashCodetoString、访问器方法(没有 setter)
  • 但 record 的所有组件隐式 final,你不能改
  • String 本身可为 null——这是 Java 最古老的设计债之一

如果没有 record(Java 15 及更早),你需要这样写:

java
// Java 传统 POJO
public final class Order {
    private final String orderId;
    private final double amount;
    private final boolean paid;
    private final String couponCode;

    public Order(String orderId, double amount, boolean paid, String couponCode) {
        if (amount < 0) throw new IllegalArgumentException("amount must be >= 0");
        this.orderId = orderId;
        this.amount = amount;
        this.paid = paid;
        this.couponCode = couponCode;
    }

    public String getOrderId() { return orderId; }
    public double getAmount() { return amount; }
    public boolean isPaid() { return paid; }
    public String getCouponCode() { return couponCode; }
    // 还需要手写 equals、hashCode、toString
}

从 50 行到 6 行——这就是为什么 record 是 Java 生态里最重要的语法进化之一。

C++

cpp
#include <optional>
#include <string>

struct Order {
    std::string order_id;
    double amount;
    bool paid;
    std::optional<std::string> coupon_code;

    Order(std::string id, double amt, bool is_paid,
          std::optional<std::string> coupon = std::nullopt)
        : order_id(std::move(id)), amount(amt), paid(is_paid), coupon_code(std::move(coupon)) {
        if (amount < 0) throw std::invalid_argument("amount must be >= 0");
    }
};

C++ 的关键事实

  • struct 在 C++ 中和 class 几乎没有区别(唯一的区别是默认访问权限:struct 默认 public,class 默认 private)
  • std::optional<std::string> 让 "没有优惠码" 这件事在类型层面可见——比用空字符串或哨兵值清晰得多
  • 构造函数的初始化列表 : order_id(...), amount(...) 是性能关键:直接在内存中构造,而不是先默认初始化再赋值
  • C++ 没有语言级的不可变支持——你可以 const Order order = ... 但这只保护这个变量,不保护别人那里同一个对象的修改

Rust

rust
#[derive(Debug, Clone)]
struct Order {
    order_id: String,
    amount: f64,
    paid: bool,
    coupon_code: Option<String>,
}

impl Order {
    fn new(order_id: String, amount: f64, paid: bool, coupon_code: Option<String>)
           -> Result<Self, String> {
        if amount < 0.0 {
            return Err("amount must be >= 0".into());
        }
        Ok(Self { order_id, amount, paid, coupon_code })
    }
}

// 使用
let order = Order::new(
    String::from("A1001"), 99.0, true, None
).unwrap();

Rust 的关键事实

  • struct 的字段默认不可变——let order = ... 之后不能 order.amount = 150.0
  • Option<String> 不是注解也不是语法糖——它是一个标准库的 enum,编译器原生知道怎么处理它
  • #[derive(Debug, Clone)] 是编译期代码生成——不像 Python dataclass 在运行时工作,Rust 的 derive 在编译期展开
  • 构造函数返回 Result<Self, String>:把验证失败通过类型系统传出去,而不是抛异常

Go

go
type Order struct {
    OrderID    string
    Amount     float64
    Paid       bool
    CouponCode *string   // *string = 可为 nil
}

func NewOrder(orderID string, amount float64, paid bool, couponCode *string) (Order, error) {
    if amount < 0 {
        return Order{}, fmt.Errorf("amount must be >= 0")
    }
    return Order{
        OrderID:    orderID,
        Amount:     amount,
        Paid:       paid,
        CouponCode: couponCode,
    }, nil
}

Go 的关键事实

  • *string 是 Golang 表达 "可空字符串" 的惯用方式:nil 表示没有,非 nil 指向一个值
  • 但这允许 CouponCode 为 nil——如果你不检查直接用 *order.CouponCode,会 panic
  • Go 没有构造器重载——通常用 NewXxx 工厂函数返回 (Xxx, error)
  • 没有 Optional<T> 类型——指针就是 Go 的可选值(这个设计选择在 Go 社区内部也一直有争议)

Swift

swift
struct Order {
    let orderId: String
    let amount: Double
    let paid: Bool
    let couponCode: String?

    init(orderId: String, amount: Double, paid: Bool, couponCode: String? = nil) throws {
        guard amount >= 0 else {
            throw ValidationError.invalidAmount
        }
        self.orderId = orderId
        self.amount = amount
        self.paid = paid
        self.couponCode = couponCode
    }
}

// 使用
let order = try Order(orderId: "A1001", amount: 99.0, paid: true)
// order.amount = 150.0  // 编译错误:let + struct 深度不可变

Swift 的关键事实

  • Swift 的 structclass 有本质区别:struct 是值类型,赋值时复制;class 是引用类型,赋值时共享
  • String?Optional<String> 的语法糖——和 Rust 的 Option<String> 是同一种设计思路
  • let + struct 提供深度不可变:即使把 orderId 声明为 var,只要 Order 实例是 let,整个对象都不能改
  • init 可以 throw——构造失败可以通过异常传播,这是 Swift 比很多语言更灵活的地方

Kotlin

kotlin
data class Order(
    val orderId: String,
    val amount: Double,
    val paid: Boolean,
    val couponCode: String? = null
) {
    init {
        require(amount >= 0) { "amount must be >= 0" }
    }
}

// 使用
val order = Order(orderId = "A1001", amount = 99.0, paid = true)
// order.amount = 150.0  // 编译错误:val 属性不可重新赋值

Kotlin 的关键事实

  • data class 是 Kotlin 对 "数据载体" 的一等支持——自动生成 equalshashCodetoStringcopycomponentN()
  • String?String 是完全不同的两个类型——编译器强制你处理可空性(这是 Kotlin 对 Java 空指针问题的最重要修正)
  • 命名参数 orderId = "A1001" 让构造调用更具可读性,这在参数多的场景尤其有价值
  • init 块在所有属性初始化之后、构造完成之前执行

语法速查:定义数据模型

能力PythonTypeScriptJavaC++RustGoSwiftKotlin
数据载体@dataclassinterface / typerecordstructstructstructstructdata class
自动生成方法__init__, __eq__, __repr__无(interface 只是类型)equals, hashCode, toString, 访问器无(手写或靠外部工具)#[derive] 可选无(最朴素)无(靠 protocol 扩展)equals, hashCode, toString, copy, componentN
深度不可变frozen=Truereadonly(编译期)record 字段隐式 finalconst 对象默认不可变无语言级支持let + structval + data class
可空类型Optional[T]T | null@Nullable 注解std::optional<T>Option<T>*T(指针)T?T?
构造函数失败raise 异常throw 异常throw 异常throw 异常Result<Self, E>(T, error) 返回init throwsinit + require

2.2 深度对照一:同一个 Order,为什么在 8 门语言里长得不一样

让我们把 8 个版本的 Order 放在一起,不只看语法差异,而是看一个更深的问题:

每门语言让你 "必须说清的" 和 "可以不说清的" 分别是什么?

Python 版本:类型标注是建议

python
@dataclass(frozen=True)
class Order:
    amount: float        # "建议"这里放 float
    coupon_code: Optional[str] = None  # "建议"这里可以是 None

你可以把 "hello" 放进 amount: float,Python 解释器不会阻止你。它相信你知道自己在做什么。

Java record 版本:类型是事实,但可空不保证

java
public record Order(String orderId, double amount, boolean paid, String couponCode) {}

amount 一定是 double(编译器会阻止你放 String 进去),但 couponCode 可以是 null(编译器不会阻止)。

Rust 版本:类型和可空都是事实

rust
struct Order {
    amount: f64,              // 一定是 64 位浮点数
    coupon_code: Option<String>, // 一定要么是 Some(String) 要么是 None
}

Rust 把所有东西都写成了正式契约。看起来啰嗦,但任何读这段代码的人——无论他用了 Rust 1 天还是 10 年——都得到完全一样的信息。

Kotlin 版本:介于 Java 和 Rust 之间

kotlin
data class Order(val amount: Double, val couponCode: String? = null)

amount 和 Rust 一样强——Double 就是 DoublecouponCode: String? 也让可空性类型化。但 Kotlin 还需要兼容 Java 互操作——从 Java 传来的值,编译器会标注为 "平台类型"(String!),意为 "我不确定这是不是 null,你最好自己检查"。


2.3 深度对照二:空值的四种制度

一个 couponCode 可能为空,这个事实在 8 门语言里产生了四种不同的制度回应。

制度一:无制度(纯 JavaScript、纯 Python 运行时)

javascript
// JavaScript
let couponCode = null;
couponCode.toUpperCase();  // 运行时炸:TypeError: Cannot read properties of null

语言不帮你,炸在线上是你的问题。靠约定、测试、lint 补位。

制度二:外部制度(Python + mypy、TypeScript)

typescript
// TypeScript
let couponCode: string | null = null;
couponCode.toUpperCase();  // 编译期报错:Object is possibly 'null'

语言上层加上类型检查器,在编译期/检查期拦住。但检查是可选的——你可以绕过,你也可以不跑。

制度三:类型级制度(Swift、Kotlin、Rust)

swift
// Swift
let couponCode: String? = nil
// couponCode.uppercased()  // 编译错误:必须解包
couponCode?.uppercased()     // 安全调用
rust
// Rust
let coupon_code: Option<String> = None;
// coupon_code.to_uppercase()  // 编译错误:Option 没有这个方法
coupon_code.map(|c| c.to_uppercase());  // 必须显式处理
kotlin
// Kotlin
val couponCode: String? = null
// couponCode.uppercase()  // 编译错误
couponCode?.uppercase()    // 安全调用

编译器强制你处理空值——要么用安全调用(?.),要么显式断言非空(!!unwrap()),要么提供默认值(??unwrap_or())。

制度四:哨兵值制度(Go)

go
var couponCode *string = nil  // 指针类型,nil 表示没有
// strings.ToUpper(*couponCode)  // 运行时 panic: nil pointer dereference
if couponCode != nil {
    strings.ToUpper(*couponCode)  // 必须自己检查
}

Go 没有 Optional<T>,用指针表示可空。这让你一眼能认出来(看 *),但语言不强制你检查——忘记 != nil 就会在运行时 panic。

空值制度的本质区别

制度谁负责检查什么时候发现写起来的感觉
无制度程序员运行时(线上)自由但有风险
外部制度工具链CI / IDE写的时候有提示但可以忽略
类型级制度编译器编译期每处都要处理但很安心
哨兵值制度程序员运行时朴素但容易漏

2.4 深度对照三:简单数值类型里的深海

我们用 amount 这个字段来讲一个细思极恐的事情。

python
amount: float = 99.0       # Python
amount: number = 99.0      # TypeScript
double amount = 99.0;      # Java
double amount = 99.0;      # C++
amount: f64 = 99.0;        # Rust
Amount float64 = 99.0      # Go
amount: Double = 99.0      # Swift, Kotlin

"浮点数 99.0" 这五个字,在 8 门语言里代表的是不同的事。

Python 的 float:IEEE 754 双精度,但只有一个浮点类型

python
amount = 0.1 + 0.2
print(amount)  # 0.30000000000000004

Python 的 float 永远是 64 位双精度。但 Python 没有单精度浮点、没有内置的定点小数(Decimal 需要从标准库导入)。当你写 float,你写的其实是 "IEEE 754 binary64"——这在一门以简洁著称的语言里是一个有意思的简化。

Java 的 double:和 float 并存,精度陷阱几十年来一直在坑人

java
double amount = 0.1 + 0.2;   // 0.30000000000000004
float  small  = 0.1f + 0.2f; // 0.3(巧合?不是——只是精度更低的四舍五入)

Java 同时有 float(32位)和 double(64位)。float 的存在有历史原因(早期内存受限),但在现代 Java 里几乎不应该用。问题是:0.1 字面量默认是 double,写 float f = 0.1 会编译错误(类型不匹配),新手经常为此困惑。

Rust 的 f64:类型名本身就说明了精度

rust
let amount: f64 = 99.0;  // "float 64" —— 名字就告诉你这是 64 位
let small: f32 = 99.0;   // "float 32" —— 32 位

Rust 把精度直接写进类型名。这不是审美差异——当你看到 f64,你不会误以为它是 32 位。当你看到 f32,你不会把它当 64 位传给期望 f64 的接口(编译器会报错)。把精度写进类型名,意味着精度差异不可能被 "看着差不多" 忽视。

Go 的 float64:同样直白

go
var amount float64 = 99.0

Go 和 Rust 一样把精度写进类型名。

金额到底该用什么?

全书示例为对比方便使用浮点数,但生产环境里金额绝对不应该用浮点数。原因:

python
# Python 生产环境
from decimal import Decimal
amount = Decimal("99.00")

# 或者在数据库里用最小货币单位(分为单位)存储为整数
amount_cents: int = 9900  # 99.00 元 = 9900 分
java
// Java:用 BigDecimal
BigDecimal amount = new BigDecimal("99.00");

// 或者用 Joda Money / JavaMoney 库
rust
// Rust:用整数表示最小单位,或者用 rust_decimal 等 crate
let amount_cents: i64 = 9900;

这不是偏好问题,而是浮点数不能精确表示十进制小数的数学事实。0.1 在二进制中是无限循环小数,就像 1/3 在十进制中无法精确表示一样。在涉及到钱的任何地方,这个事实会以各种令人不安的方式浮现。


2.5 边界探索:跨语言迁移最容易踩的类型坑

坑一:Python → Java:习惯了 float 什么都能存

Python 里 float 只有一种(64位),你不需要思考 "这个值是 32 位浮点还是 64 位"。切到 Java 后,floatdouble 是不同的类型,混用需要显式转换。更糟的是:0.1 在 Java 里默认是 double 字面量,0.1f 才是 float

坑二:JavaScript → Go/Rust:习惯了 number 大一统

JavaScript 的 number 只有一种——64 位双精度浮点数。没有整数类型。11.0 在 JavaScript 里是同一种东西。到了 Go/Rust 你会发现:11.0 是不同的类型(int vs float64),它们之间的运算需要显式转换。

go
// JavaScript 程序员写 Go
var a = 5
var b = 2
// c := a / b   // 2(整数除法)
var c = float64(a) / float64(b)  // 2.5——必须显式转换

坑三:Java → Kotlin:你以为 String 就是 String,其实还有 String!

kotlin
// Java 类返回的 String,Kotlin 编译器标注为 String!(平台类型)
val name = javaClass.getName()  // name 的类型是 String!
// name.length  // 如果 name 实际为 null,这里才报空指针

从 Java 互操作的返回值,Kotlin 编译器不知道 null 的可能性——因为 Java 代码没有标注。结果是一个灰色地带:你可以把它当 String 用,但它可能是 null。这就是为什么 Kotlin 最佳实践是 "给所有 Java 互操作返回值显式标注类型和可空性"。

坑四:Go → Rust:习惯了零值,不习惯显式处理

Go 的零值哲学(string 默认 ""int 默认 0*string 默认 nil)让你可以用零值表示 "未初始化"。Rust 不这样:String 不能是 None,你如果需要 "缺失" 语义,必须显式用 Option<String>。从 Go 切到 Rust 最常见的不适:"我就想先声明一个变量暂不赋值——Rust 说不行。"


2.6 洞察:类型塑造的不是代码,是你对现实的认知

如果你只用一门语言定义一个 Order,你不会发现:

你在选择 Optional[str]String?*string、还是 Option<String> 的时候,你不是在选择语法——你是在选择 "缺失" 在你的世界里是不是一个正式概念。

在 Python 里,"缺失" 是一个可以被忽略的细节。在 Kotlin 里,"缺失" 是一个你必须正视的存在。在 Rust 里,"缺失" 甚至不是缺失——它是 None,是 Option 枚举的一个变体,和其他值平起平坐。

同一个业务概念——"订单可能没有优惠码"——在强空值制度下,你不得不提前想好:没有优惠码的时候我返回什么?前端显示什么?数据库存什么?在弱空值制度下,这些决定可能被推迟到第一个空指针异常出现在日志里。

这不是语言让你多写几行的问题。这是语言在帮你做一件你自己不会主动做的事:把模糊变清晰。


本章小结

  • 同一份现实,在不同类型系统里被切成了不同的形状。 Order 在 Python 里是一个运行时可塑的容器,在 Rust 里是一个编译期验证的契约,在 Kotlin 里是两者之间的平衡。

  • 空值不只是语法糖。 T?(Swift/Kotlin)、Option<T>(Rust)、std::optional<T>(C++)、T | null(TypeScript)、*T(Go)——它们是四种不同的制度,决定 "缺失" 在你的代码里会被怎样对待。

  • 浮点数和金额不是一回事。 全书用浮点数是为了跨语言对比的简洁性。生产环境里,金额用定点数、Decimal 或分(最小货币单位整数)。

  • data class / record / struct 的差异不是语法差异。 它们背后是不同的自动生成策略(哪些方法被生成)、不同的不可变语义(浅还是深)、不同的哲学(数据优先还是行为优先)。


如果你只用一门语言,你不会学到这个:

当你定义了 amount: float,你不是在描述金额的类型。你是在宣布:这个系统可以承受小数点后第 16 位出现一个神秘的 4。如果你的系统承受不了,你的类型就没选对。