数据的表示

数据类型

为了能够在计算机中表示一个确定的事物或概念,我们要规定事物在计算机中的存在方式,规定事物如何表示为数据、数据又如何理解为事物。这也就是规定了数据的读写方式,从而让数据尽可能接近、模拟、还原我们想要表示的概念。这种近似,让我们在编程时,能够在思维层面认定数据代表了这个概念,于是在数据上所做的操作、产生的结论,也都能再次还原到真实世界中,对应真实事物的操作和结论。

将现实的概念表示为数据,最简单也是最直观的例子是数学上的。比如,我们可以将一个整数转换为二进制表示保存在计算机中,也可以将一段二进制数据理解和还原为整数。在此基础上,小数也可以通过科学记数法来记录:我们可以用表示整数的方式分别记录有效数字和幂次。

然而,一段数据的长度,在计算机中是有限的,能够表达的信息也是有限的。这意味着,并不是所有的数都能在计算机中被准确表示。可以说,不管我们设计怎样的数据表示方式,它都将是对真实概念的一种近似,且想要达到越高的近似程度、就必须使用越长的数据。这样一来,计算机中需要用不同的数据表示方式来适应不同场合也就不足为奇了。为了明确程序中的数据对应什么概念、采用哪种程度的近似,我们在程序中往往需要通过数据类型来指明和区分数据的不同表示方式。

我们可以通过整数的数据表示,来直观地理解数据类型的意义。我们已经知道,不存在一种表示方法能够表示所有的整数。最简单的近似表示策略是限定一个固定的范围:我们设定一个固定的数据位数,使用这些位来表示一个固定范围内的整数。设定的数据位数越多,能够表示的范围越大,但占用的空间也越大。为了适应不同需要,程序语言往往提供多种预设的范围供程序选择使用,这些不同的范围对应不同的整数类型,也即规定了整数的不同表示方式。实数的表示也是类似的:我们按照科学记数法的原理,规定几位用来表示有效数字、几位用来表示幂次,设定几种不同大小的位数组合,即是规定了不同范围和精度的实数类型。在 Rust 中,语言依照这样的基本思想设置了几种原生的数值类型,并且通过类型的名称指示了数值数据的表示方式和范围。我们可以从下表中更直观地看到每种类型是如何对应不同数据表示规则的:

类型名占用位数字节数数值范围
u8810~255
u161620~65535
i32324-2147483648~2147483647
f32324TODO
f64648TODO

可以观察到 Rust 中有两类整数。非负整数(自然数)在计算机中一般称为无符号(unsigned)整数;若支持负数,则称为有符号整数、或直接称整数(integer)。这也是 Rust 整数类型 ui 前缀的由来。区分这两种整数类型是有意义的,因为他们实际上对应不同的现实概念:无符号整数用来表示计数、长度等概念,这些概念中负数是没有意义的,使用有符号整数不仅浪费了一半的表示范围,还存在无意间使用了没有意义的负数值的风险;而有符号整数则用来表示更一般的整数概念、进行整数上常规的数学计算。混淆两者的使用不仅仅在思想上混淆了两种不同的事物,更容易导致难以预料的逻辑错误发生。实际上,在 Rust 中,不同类型的数值是不能够直接进行比较或计算的:

fn main() {
    let a: i32 = -4;
    let b: u32 = 128;
    // 直接计算 a + b 会报错
    println!("{}", a + b as i32); // -> 124

    let c: i8 = 5;
    // 直接计算 a + c 也会报错
    println!("{}", a + c as i32); // -> 1

}

语法解释:

  1. 我们通过 let a: i32 = -4; 语句指定一个类型为 i32、值为 -4 的变量 a
  2. println! 是一个宏,用于向控制台打印文本。宏的具体定义在很久之后才会提到,你现在只要把宏当作一些方便的工具来使用即可。

对于有符号整数,除了要考虑多少位以外,还需要考虑负数如何表示的问题。具体参考补码表示。而实数的表示更加复杂,需要考虑无穷、不存在等情况如何表示,具体细节参考 IEEE 754 标准。

类型的范围和边界

类型实际上是我们遇到的第一种思维模型。抽象来说,类型连接了我们的思维和计算机的具体实现:在思维层面,数据类型用来表示一类事物,是对真实事物的某种简化的模型;而在实现层面,数据类型规定了计算机如何读写一段二进制数据。和所有思维模型一样,在一定范围内,简化的模型是成立的,我们可以利用这一点更简单地思考问题;而这个成立的范围往往是由实现决定的。在编程时,我们常常需要选择范围合适的思维模型,也需要更加有意识地思考这个范围的边界:能否保证不会超出这个边界?如果超出了该怎么办呢?

对于数值类型和数值计算而言,计算的结果超出类型能够表示的范围,我们一般称为越界。比如,如果两个 u8 类型的数相加,结果超过了 u8 能够表示的范围,就发生了越界。正因为思维模型是一种近似,对于越界这种超过近似能够表示范围的情况,我们是需要谨慎行事的。因此,我们下面介绍几种越界的常见处理方式。

对于两个 u8 类型的数相加越界的情况,最简单的处理方式可能是直接禁止超过范围的结果。如果超过类型能够表示的范围,程序直接报错。这样的方案最为保守。我们也可以把结算结果超过类型范围的高位直接忽略,这个方案一般称为溢出加法。这样也相当于实现了模为 256 的同余加法(比如 128+129=1 (mod 256),等价于忽略高位的结果),因此也叫模加法。我们有时也可以认为范围内最大值表示了“最大”这个概念,我们会把过大的结果用范围内能表示的最大值表示(这样 128+129 会得到 u8 类型所能表示的最大值 u8::MAX,即 255),这种方案称为饱和加法。在 Rust 中做计算时,你可以通过不同的语法指定使用不同的处理方案:

fn main() {
    let a: u8 = 128;
    let b: u8 = 129;
    // 直接计算 a + b 会报错
    assert!(a.wrapping_add(b) == 1);
    assert!(a.saturating_add(b) == u8::MAX);
}

语法解释:

  1. 除了最常规的计算和操作,其余对数据的操作大多是通过方法表达的(即 .method() 语法)。数也可以有方法,这里即通过不同名字的方法来指定不同的计算方式。
  2. assert! 是另一个宏,用来验证(或断言)表达式的成立:如果表达式不成立,这一行会报错,程序也会终止;如果表达式成立,则什么都不做。

在加法最简单的形式中,发生越界会直接报错,这实际上意味着默认情况下,数值计算是不允许越界的。这样看似有些严格,然而却是减少程序出错的重要手段。将最简单、最保守的情况作为默认情况,能够保证我们所做的选择是经过思考的,促使我们对于自己的选择更加更加有意识(conscious):存在多个解决方案时,程序不直接替我们解决问题,如果发生问题,程序应该直接告诉我们,让我们去选择一个需要的解决方案;而如果我们最终选择了某种解决方案,程序中应当能够把我们的选择清晰地表达出来。这样一来,每个选择都会是知情的、有意的、明确的。我们之后还会在 Rust 中看到很多类似的例子。

组合类型

尽管语言可以提供更多的数据类型,但要能够表示更复杂的数据,仅仅依靠增加一些固定的数据类型是不现实的。于是,程序设计语言一般会提供由现有类型组合形成新数据类型的方式,开放了数据表示的可能性,让程序能够表示和处理任意的数据。直观上看,类型的组合方式很接近集合的运算,因此,我们下面通过与集合的类比来理解类型的组合。我们从结构体(struct)这一概念出发,它是一种最基本的组合产生新数据类型的方式:一个新的结构体数据类型,可以用来表示原有几种类型数据的所有可能组合。这在思想上很接近集合之间的笛卡尔积。我们带着这一类比,看一个具体的例子。在 Rust 中,我们定义一个新数据类型 Point 如下:


#![allow(unused)]
fn main() {
struct Point(f64, f64);
}

新定义的 Point 类型由两个 f64 类型组合而成,能够用来表示二维空间中的一个点。如果考虑每个类型能够表示的所有数据构成的集合,我们还能注意到,新类型所能表示的所有数据的集合,是两个 f64 能够表示的数据集合的笛卡尔积。

对于结构体类型,Rust 中还提供了几种不同的语法形式供不同场合使用,但它们的本质是一致的,区别只在于是否为类型本身和类型中的元素命名:


#![allow(unused)]
fn main() {
// (f64, f64) 是一个匿名元组(tuple)
let a: (f64, f64);
// Point 是一个元组结构体(tuple struct)
struct Point(f64, f64);
// NamedPoint 是一个常规的结构体(struct)
// 常规的结构体中,每个成员(member)都有自己的名称
struct NamedPoint { x: f64, y: f64 }
}

我们还可以组合多个相同类型,类似集合幂集的概念。比如我们想表示一个 \(N\) 维向量(对应幂集 \(\mathbb{R}^N\)),在 Rust 中可以写 [f64; N](N 是固定的)。

这些产生新数据类型的方式,直觉上来看其实都对应着集合的乘积。也许因为这种方式更加直观、或实现上更加容易,这种方式在几乎所有程序设计语言中都有体现。然而我们将知道,这并不是唯一可行的组合方式。实际上,组合产生新数据类型的方式,很大程度决定了一个语言类型系统的表达能力。我们将在第 4 章深入探讨类型系统的概念和意义,为什么我们需要一个强大的类型系统,以及我们能用一个强大的类型系统来干什么。