不可变性
本课程讲解了不可变性及不可变变量的概念。
什么是不可变性?
我们已经看到,变量在程序中代表各种概念。这些概念之间的交互通过表达式来实现,而这些表达式会改变变量的值:
1// 支付账单 2totalPrice = calculateAmount(itemPrices); 3moneyInWallet -= totalPrice; 4moneyAtMerchant += totalPrice;
-
有些概念从定义上就是不可变的。例如,一周总是有七天,数学常数 π 永远不会改变,一个程序支持的自然语言列表可能是固定且数量较少的(比如仅限英语和土耳其语)等。
-
如果所有变量都可以被修改,那么程序中的任何代码都可能意外地更改它们。即使某个变量在逻辑上不需要被修改,也无法保证它不会被无意中改动。
当程序缺乏不可变性保障时,代码将变得难以阅读和维护。例如,考虑一个名为 retire(office, worker) 的函数,用于让某办公室的员工退休。仅从函数调用本身,我们无法判断哪些参数变量会被该函数修改,哪些保持不变。
不可变性的概念有助于理解程序的某些部分,因为它保证了某些操作不会改变特定变量。同时,它也降低了某些类型编程错误的发生风险。
在 D 语言中,这种不可变性通过 const 和 immutable 关键字来表达。尽管这两个词在含义上相近,但它们在程序中的职责不同,有时甚至不兼容。
const 和 immutable 都是类型限定符。我们将在后续章节中学习 inout 和 shared。
不可变变量
如果严格按“变量”一词的字面意思——即会变化的东西——那么“不可变变量”或“常量变量”这样的说法本身就是矛盾的。但在更广泛的意义上,“变量”通常被理解为程序中可能可变也可能不可变的概念。
有三种方式可以定义永远不会被修改的变量:enum 常量、immutable 变量和 const 变量。
enum 常量
我们在前面的 enum 课程 中已经了解过,enum 用于定义命名的常量值:
1enum fileName = "list.txt";
只要其值可以在编译时确定,enum 变量也可以用函数的返回值进行初始化:
1import std.stdio; 2 3int totalLines() { 4 return 42; 5} 6 7int totalColumns() { 8 return 7; 9} 10 11string name() { 12 return "list"; 13} 14 15void main() { 16 enum fileName = name() ~ ".txt"; 17 enum totalSquares = totalLines() * totalColumns(); 18 19 writeln(fileName); 20 writeln(totalSquares); 21}
D 语言支持这种初始化的功能称为编译时常量函数执行(CTFE)。
如预期一样,enum 常量的值不能被修改,尽管这是一种非常有效的表示不可变值的方式:
1++totalSquares; // ← 编译错误
enum 常量是一种显式常量,意味着程序在编译时就像把每个对这个常量的引用都替换为其值一样处理。举个例子,考虑以下 enum 定义及两个使用它的表达式:
1enum i = 42; 2writeln(i); 3foo(i);
上述代码与下面这段代码完全等价,其中我们将 i 的每次使用都替换为它的值 42:
1writeln(42); 2foo(42);
虽然对于像 int 这样的简单类型来说,这种替换在语义上合理,且对最终程序无影响,但当 enum 常量用于数组或关联数组时,可能会带来隐藏的开销:
1enum a = [ 42, 100 ]; 2writeln(a); 3foo(a);
在将 a 替换为其值后,编译器实际编译的等效代码如下:
1writeln([ 42, 100 ]); // 在运行时创建一个数组 2foo([ 42, 100 ]); // 在运行时创建另一个数组
这里的隐藏代价是:上述两个表达式分别创建了两个独立的数组。因此,如果这类数组或关联数组在程序中会被多次使用,更合理的做法是将其定义为 immutable 变量。
enum 只能用于编译时常量。
immutable 变量
与 enum 类似,immutable 关键字也表示变量的值永远不会改变。但不同于 enum,immutable 变量是一个真正的变量,拥有内存地址,这意味着我们可以在程序运行期间为其赋值,并且可以引用它的内存位置。
下面的程序对比了 enum 和 immutable 的用法。程序要求用户猜测一个随机生成的数字。由于随机数无法在编译时确定,因此不能定义为 enum。但一旦选定,该数值必须始终保持不变,因此适合将其声明为 immutable。
程序利用了前一章定义的 readInt() 函数:
1import std.stdio; 2import std.random; 3 4int readInt(string message) { 5 int result; 6 write(message, "? "); 7 readf(" %s", &result); 8 return result; 9} 10 11void main() { 12 enum min = 1; 13 enum max = 10; 14 15 immutable number = uniform(min, max + 1); 16 17 writefln("我正在想一个介于 %s 和 %s 之间的数字。", 18 min, max); 19 20 auto isCorrect = false; 21 while (!isCorrect) { 22 immutable guess = readInt("你的猜测是什么"); 23 isCorrect = (guess == number); 24 } 25 26 writeln("正确!"); 27}
观察点:
-
min和max是本程序行为的重要组成部分,它们的值在编译时已知,因此被定义为enum常量。 -
number被声明为immutable,因为一旦在运行时初始化后就不应再被修改。同样,每次读取用户的猜测后,guess也不应再被修改。 -
注意,这些变量的类型并未显式写出。与
auto和enum一样,immutable变量的类型可以从右侧表达式的值推断出来。
虽然不必完整写出类型,但 immutable 通常需要在括号中写出实际类型,例如 immutable(int)。以下程序的输出表明,这三个变量的实际类型名称确实相同:
1import std.stdio; 2 3void main() { 4 immutable inferredType = 0; 5 immutable int explicitType = 1; 6 immutable(int) wholeType = 2; 7 8 writeln(typeof(inferredType).stringof); 9 writeln(typeof(explicitType).stringof); 10 writeln(typeof(wholeType).stringof); 11}
括号的使用具有重要意义,它指明了类型中哪些部分是 immutable 的。这一点将在讨论 切片与其元素的不可变性 时进一步说明。
const 变量
在定义变量时,const 关键字的效果与 immutable 相同:const 变量也不能被修改:
1import std.stdio; 2 3void main() { 4 int total = 100; 5 6 const half = total / 2; 7 half = 10; // ← 编译错误 8}
我建议在定义变量时优先使用 immutable 而不是 const。原因是 immutable 变量可以传递给接受 immutable 参数的函数。
在下一课中,我们将学习如何在函数中使用 immutable 变量。