量纲分析——让编译器检查你的单位 🟢
你将学到: newtype 包装器和
uomcrate 如何将编译器变成单位检查引擎,防止摧毁 3.28 亿美元航天器的那类 bug。
火星气候轨道器
1999 年,NASA 的火星气候轨道器丢失了,因为一个团队发送的推力数据单位是磅力秒,而导航团队期望的是牛顿秒。航天器以 57 公里而不是 226 公里的高度进入大气层并解体。损失:3.276 亿美元。
根本原因:两个值都是 double。编译器无法区分它们。
同类 bug 潜伏在每个处理物理量的硬件诊断中:
// C — 都是 double,没有单位检查
double read_temperature(int sensor_id); // Celsius?Fahrenheit?Kelvin?
double read_voltage(int channel); // Volts?Millivolts?
double read_fan_speed(int fan_id); // RPM?弧度每秒?
// Bug:比较 Celsius 和 Fahrenheit
if (read_temperature(0) > read_temperature(1)) { ... } // 单位可能不同!
物理量的 Newtype
最简单的构造正确性方法:将每个单位包装在它自己的类型中。
use std::fmt;
/// 摄氏温度。
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct Celsius(pub f64);
/// 华氏温度。
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct Fahrenheit(pub f64);
/// 电压(伏特)。
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct Volts(pub f64);
/// 电压(毫伏)。
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct Millivolts(pub f64);
/// 风扇转速(RPM)。
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct Rpm(pub f64);
// 转换是显式的:
impl From<Celsius> for Fahrenheit {
fn from(c: Celsius) -> Self {
Fahrenheit(c.0 * 9.0 / 5.0 + 32.0)
}
}
impl From<Fahrenheit> for Celsius {
fn from(f: Fahrenheit) -> Self {
Celsius((f.0 - 32.0) * 5.0 / 9.0)
}
}
impl From<Volts> for Millivolts {
fn from(v: Volts) -> Self {
Millivolts(v.0 * 1000.0)
}
}
impl From<Millivolts> for Volts {
fn from(mv: Millivolts) -> Self {
Volts(mv.0 / 1000.0)
}
}
impl fmt::Display for Celsius {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:.1}°C", self.0)
}
}
impl fmt::Display for Rpm {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:.0} RPM", self.0)
}
}
现在编译器捕获单位不匹配:
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct Celsius(pub f64);
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct Volts(pub f64);
fn check_thermal_limit(temp: Celsius, limit: Celsius) -> bool {
temp > limit // ✅ 相同单位——编译通过
}
// fn bad_comparison(temp: Celsius, voltage: Volts) -> bool {
// temp > voltage // ❌ 错误:类型不匹配——Celsius vs Volts
// }
零运行时成本——newtype 编译为原始 f64 值。包装器纯粹是类型级别的概念。
硬件量纲的 Newtype 宏
手动编写 newtype 会变得重复。宏消除了样板代码:
/// 为物理量生成 newtype。
macro_rules! quantity {
($Name:ident, $unit:expr) => {
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct $Name(pub f64);
impl $Name {
pub fn new(value: f64) -> Self { $Name(value) }
pub fn value(self) -> f64 { self.0 }
}
impl std::fmt::Display for $Name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:.2} {}", self.0, $unit)
}
}
impl std::ops::Add for $Name {
type Output = Self;
fn add(self, rhs: Self) -> Self { $Name(self.0 + rhs.0) }
}
impl std::ops::Sub for $Name {
type Output = Self;
fn sub(self, rhs: Self) -> Self { $Name(self.0 - rhs.0) }
}
};
}
// 用法:
quantity!(Celsius, "°C");
quantity!(Fahrenheit, "°F");
quantity!(Volts, "V");
quantity!(Millivolts, "mV");
quantity!(Rpm, "RPM");
quantity!(Watts, "W");
quantity!(Amperes, "A");
quantity!(Pascals, "Pa");
quantity!(Hertz, "Hz");
quantity!(Bytes, "B");
每一行生成一个完整的类型,带有 Display、Add、Sub 和比较运算符。全部零运行时成本。
物理注意事项: 宏为所有量生成
Add,包括Celsius。添加绝对温度(25°C + 30°C = 55°C)在物理上没有意义——你需要一个独立的TemperatureDelta类型来表示差值。uomcrate(稍后展示)正确处理这一点。对于简单的传感器诊断(只需比较和显示),你可以从温度类型中省略Add/Sub,并将它们保留给加法有意义的量(Watts、Volts、Bytes)。如果你需要差值算术,定义一个CelsiusDelta(f64)newtype 并实现impl Add<CelsiusDelta> for Celsius。
应用示例:传感器流水线
典型的诊断读取原始 ADC 值,将其转换为物理单位,并与阈值比较。使用量纲类型,每个步骤都进行类型检查:
macro_rules! quantity {
($Name:ident, $unit:expr) => {
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct $Name(pub f64);
impl $Name {
pub fn new(value: f64) -> Self { $Name(value) }
pub fn value(self) -> f64 { self.0 }
}
impl std::fmt::Display for $Name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:.2} {}", self.0, $unit)
}
}
};
}
quantity!(Celsius, "°C");
quantity!(Volts, "V");
quantity!(Rpm, "RPM");
/// 原始 ADC 读数——还不是物理量。
#[derive(Debug, Clone, Copy)]
pub struct AdcReading {
pub channel: u8,
pub raw: u16, // 12 位 ADC 值(0–4095)
}
/// 用于将 ADC 转换为物理单位的校准系数。
pub struct TemperatureCalibration {
pub offset: f64,
pub scale: f64, // 每 ADC 计数的°C
}
pub struct VoltageCalibration {
pub reference_mv: f64,
pub divider_ratio: f64,
}
impl TemperatureCalibration {
/// 将原始 ADC 转换为 Celsius。返回类型保证输出是 Celsius。
pub fn convert(&self, adc: AdcReading) -> Celsius {
Celsius::new(adc.raw as f64 * self.scale + self.offset)
}
}
impl VoltageCalibration {
/// 将原始 ADC 转换为 Volts。返回类型保证输出是 Volts。
pub fn convert(&self, adc: AdcReading) -> Volts {
Volts::new(adc.raw as f64 * self.reference_mv / 4096.0 / self.divider_ratio / 1000.0)
}
}
/// 阈值检查——只有在单位匹配时才编译。
pub struct Threshold<T: PartialOrd> {
pub warning: T,
pub critical: T,
}
#[derive(Debug, PartialEq)]
pub enum ThresholdResult {
Normal,
Warning,
Critical,
}
impl<T: PartialOrd> Threshold<T> {
pub fn check(&self, value: &T) -> ThresholdResult {
if *value >= self.critical {
ThresholdResult::Critical
} else if *value >= self.warning {
ThresholdResult::Warning
} else {
ThresholdResult::Normal
}
}
}
fn sensor_pipeline_example() {
let temp_cal = TemperatureCalibration { offset: -50.0, scale: 0.0625 };
let temp_threshold = Threshold {
warning: Celsius::new(85.0),
critical: Celsius::new(100.0),
};
let adc = AdcReading { channel: 0, raw: 2048 };
let temp: Celsius = temp_cal.convert(adc);
let result = temp_threshold.check(&temp);
println!("Temperature: {temp}, Status: {result:?}");
// 这无法编译——不能用 Celsius 读数检查 Volts 阈值:
// let volt_threshold = Threshold {
// warning: Volts::new(11.4),
// critical: Volts::new(10.8),
// };
// volt_threshold.check(&temp); // ❌ 错误:期望 &Volts,找到 &Celsius
}
整个流水线都是静态类型检查的:
- ADC 读数是原始计数(不是单位)
- 校准生成类型化的量(Celsius、Volts)
- 阈值对量类型是泛型的
- 将 Celsius 与 Volts 比较是编译错误
uom Crate
对于生产用途,uom crate 提供了全面的量纲分析系统,具有数百个单位、自动转换和零运行时开销:
// Cargo.toml: uom = { version = "0.36", features = ["f64"] }
//
// use uom::si::f64::*;
// use uom::si::thermodynamic_temperature::degree_celsius;
// use uom::si::electric_potential::volt;
// use uom::si::power::watt;
//
// let temp = ThermodynamicTemperature::new::<degree_celsius>(85.0);
// let voltage = ElectricPotential::new::<volt>(12.0);
// let power = Power::new::<watt>(250.0);
//
// // temp + voltage; // ❌ 编译错误——不能将温度与电压相加
// // power > temp; // ❌ 编译错误——不能将功率与温度比较
当你需要自动导出单位支持(例如,Watts = Volts × Amperes)时使用 uom。当你只需要简单的量而不需要导出单位算术时使用手工编写的 newtype。
何时使用量纲类型
| 场景 | 推荐 |
|---|---|
| 传感器读数(温度、电压、风扇) | ✅ 总是——防止单位混淆 |
| 阈值比较 | ✅ 总是——泛型 Threshold<T> |
| 跨子系统数据交换 | ✅ 总是——在 API 边界强制执行契约 |
| 内部计算(全程相同单位) | ⚠️ 可选——较少 bug |
| 字符串/显示格式化 | ❌ 在量类型上使用 Display impl |
传感器流水线类型流程
flowchart LR
RAW["raw: &[u8]"] -->|解析 | C["Celsius(f64)"]
RAW -->|解析 | R["Rpm(u32)"]
RAW -->|解析 | V["Volts(f64)"]
C -->|阈值检查 | TC["Threshold<Celsius>"]
R -->|阈值检查 | TR["Threshold<Rpm>"]
C -.->|"C + R"| ERR["❌ 类型不匹配"]
style RAW fill:#e1f5fe,color:#000
style C fill:#c8e6c9,color:#000
style R fill:#fff3e0,color:#000
style V fill:#e8eaf6,color:#000
style TC fill:#c8e6c9,color:#000
style TR fill:#fff3e0,color:#000
style ERR fill:#ffcdd2,color:#000
练习:功率预算计算器
创建 Watts(f64) 和 Amperes(f64) newtype。实现:
Watts::from_vi(volts: Volts, amps: Amperes) -> Watts(P = V × I)- 一个
PowerBudget跟踪总瓦特数并拒绝超过配置限制的添加。 - 尝试
Watts + Celsius应该是编译错误。
解决方案
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct Watts(pub f64);
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct Amperes(pub f64);
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct Volts(pub f64);
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct Celsius(pub f64);
impl Watts {
pub fn from_vi(volts: Volts, amps: Amperes) -> Self {
Watts(volts.0 * amps.0)
}
}
impl std::ops::Add for Watts {
type Output = Watts;
fn add(self, rhs: Watts) -> Watts {
Watts(self.0 + rhs.0)
}
}
pub struct PowerBudget {
total: Watts,
limit: Watts,
}
impl PowerBudget {
pub fn new(limit: Watts) -> Self {
PowerBudget { total: Watts(0.0), limit }
}
pub fn add(&mut self, w: Watts) -> Result<(), String> {
let new_total = Watts(self.total.0 + w.0);
if new_total > self.limit {
return Err(format!("预算超出:{:?} > {:?}", new_total, self.limit));
}
self.total = new_total;
Ok(())
}
}
// ❌ 编译错误:Watts + Celsius → "类型不匹配"
// let bad = Watts(100.0) + Celsius(50.0);
关键要点
- Newtype 以零成本防止单位混淆——
Celsius和Rpm内部都是f64,但编译器将它们视为不同的类型。 - 火星气候轨道器 bug 是不可能的——在期望
Newtons的地方传递Pounds是编译错误。 quantity!宏减少样板代码——为每个单位生成 Display、算术和阈值逻辑。uomcrate 处理导出单位——当你需要Watts = Volts × Amperes自动计算时使用它。- Threshold 对量是泛型的——
Threshold<Celsius>不能意外地与Threshold<Rpm>比较。