操作数据

计算机中的数据不是一成不变的。对于数据的一系列计算、变换、输出等操作,构成了计算机程序的主干。我们已经知道,数据类型规定了一类数据的表示方式,而对数据的操作,则都是针对一类数据中的具体实例进行的。数据类型的一个具体的实例,我们称为数据对象(object)。我们下面就从指定操作的对象开始,理解程序是如何表达对数据的操作的。

变量和作用域

在对数据对象做任何有意义的事情之前,首先都需要一种方式来指定它们。在 Rust 中,我们通过变量来指代程序中所用到的数据对象,每个变量绑定着一个固定的数据类型:这样我们可以通过变量名字指定操作对象,通过变量的类型指定这个对象是如何读取、操作和变化的。我们可以通过 let 语句定义新的变量:

fn main() {
    // 定义变量 a,指定为 i32 类型
    let a: i32 = 3;
    // 很多时候不需要指定类型,语言可以自行推断
    let b = 5;
    // 用变量表达计算
    let c = a + b;

    println!("a + b = {}", c);
}

和数据类型类似,变量也是一种思维模型,在思维层面和实现层面也有着不同作用。基于表达意图的需要,变量应当对应一个事物、一个概念的具体个体。而基于指定数据的需要,变量应当指代固定的数据对象、具有固定的类型。

如果程序中出现同一个概念的几种不同形式,这些不同形式可能对应不同类型,但是想要表达相同含义,那我们是否应当用同一个变量呢?基于这样的考虑,在 Rust 中,变量的名字是允许重复使用的。更具体而言,每个变量本身指代对象和类型是固定的,但一个名字在不同时间可以对应不同的变量。由于名字是对于数据对象的一个指代,像代数中的字母、或者自然语言中的代词一样,名字应当是可以重复使用、用来指代不同的数据对象的。这样一来,同一个名字可以对应思维层面的同一个概念,背后却对应着不同的变量。而对于变量本身,其类型则是固定的。

变量类型固定一般被称为静态类型(statically typed)。【静态类型的好处】

参考:type safety - What is the difference between a strongly typed language and a statically typed language? - Stack Overflow

允许名字重复使用的前提下,在程序的不同部分,没有歧义地确定每个名字所指代的变量具体是哪个,对程序正确表达意图而言十分关键。关于名字的规则,简单而言有两条:使用 let 语句把名字绑定在变量上;使用代码块临时盖过外部定义的名字绑定。在下面的例子中,我们可以看到这两种规则的体现:

fn main() {
    let a = 3;
    println!("a: {}", a);     // -> 3
    let a = "hello";
    println!("a: {}", a);     // -> hello
    {
        let a = 5;
        println!("a: {}", a); // -> 5
    }
    println!("a: {}", a);     // -> hello
}

表达式

有了变量,我们可以很方便地表达对数据最基本的操作,即数据变换:通过变量指定输入,通过表达式指定变换规则,将变换结果记为另一个变量。

我们首先考虑最基础的一种数据变换:数值计算。我们将能够计算结果的一段语法结构称为表达式,这里所说的“计算结果”更确切来讲就是对表达式求值。我们事实上已经见过几种比较基本的表达式形式:字面值表达式,算术表达式,以及方法调用。【解释字面值表达式】。算术表达式我们都比较熟悉,和数学上(或计算器上)的形式是一致的。方法调用(或函数调用)的形式也比较好理解,但其具体含义我们后面会深入探讨。【强调它们都可以求值】

除了数值计算类的操作之外,我们可以将一些逻辑操作也理解为数据变换,从而用表达式来表达。将更多的操作视为数据变换常常是很有好处的。我们对比下面三个例子:


#![allow(unused)]
fn main() {
if num % 2 == 0 {
    num /= 2;
} else {
    num = num * 3 + 1;
}
}

#![allow(unused)]
fn main() {
num = if num % 2 == 0 {
    num / 2
} else {
    num * 3 + 1
}
}

#![allow(unused)]
fn main() {
num = match num % 2 {
    0 => num / 2,
    _ => num * 3 + 1,
}
}

【解释:条件判断语句更关注操作细节;条件判断表达式更像是在表达控制流的拆分和聚合;match 更侧重直接地表达映射关系,类似数学上的分段函数,支持多个分支(我们后面还会看到 if 和 match 表达式在此理解基础上的进一步发展)。提供几种同等方便的选择,实际上对应不同的侧重、倾向、关注点,敦促我们想清楚我们想传达的到底是什么。】

也许你已经注意到三段程序中花括号和分号的不同使用。事实上,我们可以想象,更复杂的逻辑需要通过表达式的进一步组合实现,我们需要一种“打包”表达式的方式,而花括号和分号正是为了表达式的灵活组合而存在的。直观来讲,花括号组合一系列表达式为一个代码块表达式,代码块表达式的值等于最后一个表达式的值。【内部的表达式之间为什么需要分隔,可以用分号隔开,代码块表达式分号可省略】

分隔符的选择: 【回车作为分隔符,歧义,解决:明确表达式未结束,或自动推断。行内多表达式仍用分号分隔。Rust 采用无歧义的统一的方案:使用分号明确表达式已结束】

更严谨的理解需要引入语句的概念。首先,严格而言,花括号所标定的代码块中,包含的是按顺序执行的一系列语句(如前面提到过的 let 语句)。而表达式也可以转换为表达式语句放置在花括号中,只要在表达式末尾加分号即可。我们也可以把分号的作用理解为忽略表达式的值。其次,代码块的最后一项可以是一个表达式(不加分号,也不能是另一个代码块),整个代码块表达式的值就等于最后表达式的值。如果最后的表达式加了分号,则最后一个值被忽略,代码块求值得到空类型的空值 ()。总结而言,表达式加分号构成语句,顺序的语句外加花括号再构成表达式,如此嵌套即可实现复杂的逻辑组合。

需要注意的是,代码块同时起到限定名字作用域的作用,这一点我们在前面章节已经讨论过了。

函数和方法

【打包和函数】

【代码块的额外含义,代码块标记,break 和 continue】

【在没有返回值的表达式中做有意义的事:“状态”和状态的改变。】

【在引入状态之前,考虑是否需要状态,是否可以用 map 解决。举基本例子。更多关于闭包和函数式编程后面再讲。】

状态和赋值

通过表达式来表达程序逻辑,很多时候你是在建立数据间的映射或转换关系。类比数学上的计算:若算式本身是固定的,那么计算结果也是固定的、不可变的。这对应到计算机中,则意味着每个数据对象都是只读的,我们不断将表达式求出的值放置在新的数据对象里、而不会修改原有的对象。由这种方式构建的数据操作,为程序提供了一个最基本的保障:每个数据对象在它的整个生命周期中,都将不会被修改,这让这些数据操作能够保证一个可控、可预测的结果。

然而计算机的内存本身并不是不可变的。我们可以引入“状态”这一概念来更好地思考内存中的数据对象。相比前面的只读变量(虽然它们不可变,我们仍然沿用变量一词),状态额外增加了写入的操作。具体而言,作为状态的变量将不再是只读的、不可变的,你应当可以给变量写入新的值,直到下次写入前,变量将保持这一值不变。

只读变量和可读写的状态,是内存对象的两种思维模型,也对应了对数据理解和操作的两种思维模式。基于变量的思维模式下,我们只需要思考数据之间的变换关系,这个模型更加简单可控,能够更容易地在脑中推演,但也有一些限制。基于状态的思维模式下,我们能够对状态进行读写,从而表达更复杂的逻辑,但这也引入了更多的复杂性(很多时候会比想象的还要复杂)。更重要的是根据问题的需要选择合适的思维模型。

Rust 利用 mut 表示可写的变量(即状态),用赋值表达式来表达对状态的写入或改变。这是为了能够明确表达你确实需要这种更复杂的思维模型:当你需要在程序中引入一个可变的状态时,你需要通过 mut 明确提出要求。

fn main() {
    let a = 3; // a默认是不可变的
    let mut b = 4; // 明确b是可变的
    b += a; // 通过赋值语句修改b
    println!("{}", b); // -> 7
}

【写入操作的复杂性:赋值表达式为什么返回空值】

【赋值表达式的核心作用是修改状态。以 c = a + b 为例具体过程分为几步实现:丢弃原有值,计算新值,将新值移动到变量中。注意和 let c = a + b; 的区别。】

【实际上,数据的传递(不论是复制还是移动),在赋值表达式、函数调用等数据操作中十分重要。正确理解这些操作中发生的数据传递,需要我们明确数据所在的位置、以及程序如何在不同位置间传递数据。】