第 2 章:塑造 — 同一份现实,8 种形状
从订单说起
我们要做一个订单系统。很朴素的需求:一个订单有序号、金额、支付状态、优惠码。
现在,用 8 门语言把这件事写出来。你会得到什么呢?
你得到的不是 8 段 "语法不同" 的等价代码。你得到的是 8 个对现实的不同理解。
2.1 语法基础:8 种方式,定义同一个订单
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_codePython 有几个关键事实:
dataclass替你生成__init__、__eq__、__repr__,省掉大量样板代码frozen=True让实例不可变(类似 Rust 的理念,但 Python 中这是用户善意 + 类型检查器协助来维护的)Optional[str]是给类型检查器看的,运行时 Python 允许你把None传进去,也允许你不传- 金额用
float:这是为示例简洁做的妥协。生产环境的金额应该用Decimal。
JavaScript / 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
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 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 组件隐式 finalJava 的关键事实:
record是从 Scala/Kotlin 的 data class 借鉴来的,专门解决 "Java 写一个 POJO 要 50 行" 的问题record自动生成equals、hashCode、toString、访问器方法(没有 setter)- 但 record 的所有组件隐式 final,你不能改
String本身可为 null——这是 Java 最古老的设计债之一
如果没有 record(Java 15 及更早),你需要这样写:
// 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++
#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
#[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.0Option<String>不是注解也不是语法糖——它是一个标准库的 enum,编译器原生知道怎么处理它#[derive(Debug, Clone)]是编译期代码生成——不像 Python dataclass 在运行时工作,Rust 的 derive 在编译期展开- 构造函数返回
Result<Self, String>:把验证失败通过类型系统传出去,而不是抛异常
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
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 的
struct和class有本质区别:struct 是值类型,赋值时复制;class 是引用类型,赋值时共享 String?是Optional<String>的语法糖——和 Rust 的Option<String>是同一种设计思路let+ struct 提供深度不可变:即使把orderId声明为var,只要 Order 实例是let,整个对象都不能改init可以throw——构造失败可以通过异常传播,这是 Swift 比很多语言更灵活的地方
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 对 "数据载体" 的一等支持——自动生成equals、hashCode、toString、copy、componentN()String?和String是完全不同的两个类型——编译器强制你处理可空性(这是 Kotlin 对 Java 空指针问题的最重要修正)- 命名参数
orderId = "A1001"让构造调用更具可读性,这在参数多的场景尤其有价值 init块在所有属性初始化之后、构造完成之前执行
语法速查:定义数据模型
| 能力 | Python | TypeScript | Java | C++ | Rust | Go | Swift | Kotlin |
|---|---|---|---|---|---|---|---|---|
| 数据载体 | @dataclass | interface / type | record | struct | struct | struct | struct | data class |
| 自动生成方法 | __init__, __eq__, __repr__ | 无(interface 只是类型) | equals, hashCode, toString, 访问器 | 无(手写或靠外部工具) | #[derive] 可选 | 无(最朴素) | 无(靠 protocol 扩展) | equals, hashCode, toString, copy, componentN |
| 深度不可变 | frozen=True | readonly(编译期) | record 字段隐式 final | const 对象 | 默认不可变 | 无语言级支持 | let + struct | val + 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 throws | init + require |
2.2 深度对照一:同一个 Order,为什么在 8 门语言里长得不一样
让我们把 8 个版本的 Order 放在一起,不只看语法差异,而是看一个更深的问题:
每门语言让你 "必须说清的" 和 "可以不说清的" 分别是什么?
Python 版本:类型标注是建议
@dataclass(frozen=True)
class Order:
amount: float # "建议"这里放 float
coupon_code: Optional[str] = None # "建议"这里可以是 None你可以把 "hello" 放进 amount: float,Python 解释器不会阻止你。它相信你知道自己在做什么。
Java record 版本:类型是事实,但可空不保证
public record Order(String orderId, double amount, boolean paid, String couponCode) {}amount 一定是 double(编译器会阻止你放 String 进去),但 couponCode 可以是 null(编译器不会阻止)。
Rust 版本:类型和可空都是事实
struct Order {
amount: f64, // 一定是 64 位浮点数
coupon_code: Option<String>, // 一定要么是 Some(String) 要么是 None
}Rust 把所有东西都写成了正式契约。看起来啰嗦,但任何读这段代码的人——无论他用了 Rust 1 天还是 10 年——都得到完全一样的信息。
Kotlin 版本:介于 Java 和 Rust 之间
data class Order(val amount: Double, val couponCode: String? = null)amount 和 Rust 一样强——Double 就是 Double。couponCode: String? 也让可空性类型化。但 Kotlin 还需要兼容 Java 互操作——从 Java 传来的值,编译器会标注为 "平台类型"(String!),意为 "我不确定这是不是 null,你最好自己检查"。
2.3 深度对照二:空值的四种制度
一个 couponCode 可能为空,这个事实在 8 门语言里产生了四种不同的制度回应。
制度一:无制度(纯 JavaScript、纯 Python 运行时)
// JavaScript
let couponCode = null;
couponCode.toUpperCase(); // 运行时炸:TypeError: Cannot read properties of null语言不帮你,炸在线上是你的问题。靠约定、测试、lint 补位。
制度二:外部制度(Python + mypy、TypeScript)
// TypeScript
let couponCode: string | null = null;
couponCode.toUpperCase(); // 编译期报错:Object is possibly 'null'语言上层加上类型检查器,在编译期/检查期拦住。但检查是可选的——你可以绕过,你也可以不跑。
制度三:类型级制度(Swift、Kotlin、Rust)
// Swift
let couponCode: String? = nil
// couponCode.uppercased() // 编译错误:必须解包
couponCode?.uppercased() // 安全调用// Rust
let coupon_code: Option<String> = None;
// coupon_code.to_uppercase() // 编译错误:Option 没有这个方法
coupon_code.map(|c| c.to_uppercase()); // 必须显式处理// Kotlin
val couponCode: String? = null
// couponCode.uppercase() // 编译错误
couponCode?.uppercase() // 安全调用编译器强制你处理空值——要么用安全调用(?.),要么显式断言非空(!! 或 unwrap()),要么提供默认值(?? 或 unwrap_or())。
制度四:哨兵值制度(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 这个字段来讲一个细思极恐的事情。
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 双精度,但只有一个浮点类型
amount = 0.1 + 0.2
print(amount) # 0.30000000000000004Python 的 float 永远是 64 位双精度。但 Python 没有单精度浮点、没有内置的定点小数(Decimal 需要从标准库导入)。当你写 float,你写的其实是 "IEEE 754 binary64"——这在一门以简洁著称的语言里是一个有意思的简化。
Java 的 double:和 float 并存,精度陷阱几十年来一直在坑人
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:类型名本身就说明了精度
let amount: f64 = 99.0; // "float 64" —— 名字就告诉你这是 64 位
let small: f32 = 99.0; // "float 32" —— 32 位Rust 把精度直接写进类型名。这不是审美差异——当你看到 f64,你不会误以为它是 32 位。当你看到 f32,你不会把它当 64 位传给期望 f64 的接口(编译器会报错)。把精度写进类型名,意味着精度差异不可能被 "看着差不多" 忽视。
Go 的 float64:同样直白
var amount float64 = 99.0Go 和 Rust 一样把精度写进类型名。
金额到底该用什么?
全书示例为对比方便使用浮点数,但生产环境里金额绝对不应该用浮点数。原因:
# Python 生产环境
from decimal import Decimal
amount = Decimal("99.00")
# 或者在数据库里用最小货币单位(分为单位)存储为整数
amount_cents: int = 9900 # 99.00 元 = 9900 分// Java:用 BigDecimal
BigDecimal amount = new BigDecimal("99.00");
// 或者用 Joda Money / JavaMoney 库// Rust:用整数表示最小单位,或者用 rust_decimal 等 crate
let amount_cents: i64 = 9900;这不是偏好问题,而是浮点数不能精确表示十进制小数的数学事实。0.1 在二进制中是无限循环小数,就像 1/3 在十进制中无法精确表示一样。在涉及到钱的任何地方,这个事实会以各种令人不安的方式浮现。
2.5 边界探索:跨语言迁移最容易踩的类型坑
坑一:Python → Java:习惯了 float 什么都能存
Python 里 float 只有一种(64位),你不需要思考 "这个值是 32 位浮点还是 64 位"。切到 Java 后,float 和 double 是不同的类型,混用需要显式转换。更糟的是:0.1 在 Java 里默认是 double 字面量,0.1f 才是 float。
坑二:JavaScript → Go/Rust:习惯了 number 大一统
JavaScript 的 number 只有一种——64 位双精度浮点数。没有整数类型。1 和 1.0 在 JavaScript 里是同一种东西。到了 Go/Rust 你会发现:1 和 1.0 是不同的类型(int vs float64),它们之间的运算需要显式转换。
// 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!
// 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。如果你的系统承受不了,你的类型就没选对。