第五章:纯洁性--避免可变状态

可变状态带来的问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class movie_t {
public:
double average_score() const;
...
private:
std::string name;
std::list<int> scores;
};

double movie_t::average_score() const
{
return std::accumulate(scores.begin(), scores.end(), 0)
/ (double) scores.size();
}

计算某个电影的平均值,看似没有问题,但是由于 scores 容器没有大小限制,会出现如下两个问题:

  1. 当你在求平均值的时候,可能有新的数据添加,它可能被计入总和,也可能没有被计入总和
  2. 你不知道 scores.size() 是不是会先求值,导致 后面新添加的元素没有被视为 size 大小的一部分

那我们可以添加一个成员变量 scores_size 来记录链表中到底有多少数据(要加锁),到时候遍历的时候按照这个数量去遍历和求平均数。但是作者认为我们可能会忘记更新 scores_size 大小,那这属于代码逻辑层面的问题了,不意味着我前面这个想法有问题。

作者的意思是如果 scores 和 scores_size 变量都不可变,即声明为 const,还会遇到这些问题吗?

  • 第一个问题在于计算得分的平均值时,有人可能改变得分列表。如果列表是不可改变的,那么在使用它时,就不会有人改变它,这个问题就不复存在
  • 第二个问题在于如果这些变量都是不可变的,那么就需要在创建 movie_t 类时初始化它们。变量 scores_size 可能初始化为一个错误的值,但这种错误应该比较明显。如果某人忘记了更新它的值–那一定是计算代码出了问题。另外,设置了错误的值以后,它就一直是错的,但这并不难调试出来

我完全没有想到作者居然要把 scores 和 scores_size 变量都不可变,这样做的意义何在?难道我的这个 movie_t 类不能添加元素?作者究竟在想什么?那就视为作者只是想表达可变状态存在的问题吧。

纯函数和引用透明

所有这些问题都源自一个设计缺陷:软件系统中多个组件负责相同的数据,而不知道另外的组件何时更改数据。修改这些问题最简单的办法就是禁止修改任何数据,所有问题都会迎刃而解。但事实是不可能没有数据被修改。

现在通过称为引用透明的概念,定义更加纯洁的函数。引用透明是表达式的一个特征,而不仅仅是函数。表达式是定义了一种计算并返回结果的任何东西。如果用表达式的结果替换整个表达式,而程序不会出现不同的行为,那么就说这个表达式是引用透明的。如果表达式是引用透明的,那它就没有任何可见的副作用,因此表达式中的所有函数都是纯函数。

查找和记录最大值.png

max 是 引用透明的吗?如果用 max 返回值替换 max 调用,发现程序的行为没有变化,即计算出结果依旧是 6。

max返回值替换.png

尽管依旧有些不同,因为 max 里面会通过 cerr 输出计算的结果,但是我们可以宽松引用透明的概念。如果非要严苛一点,下面的这个就完全符合了。

引用透明.png

无副作用编程

在纯函数式编程中,不是去改变一个值,而是创建一个新的(值)。如果要改变对象的一个属性,就创建这个对象的副本,只不过属性的值要改变为新值。如果这样设计软件,就不会出现前面例子中的问题–当处理电影得分时,其他人改变了它。这时没人能够改变电影得分的列表,只能创建一个新的列表,插入新值。

大型系统有许多可改变的部分。需要创建一个巨大的、包罗万象的结构,并在每次需要改变时,重新创建它。这将产生巨大的性能开销(即使使用针对函数式编程优化的数据结构,这将在第8章介绍),并大幅增加软件的复杂性。

通常会有一些可变状态,如果经常复制和传递,将影响程序的效率。可以将函数设计为返回如何改变的语句,而不是返回改变后状态的复制。这种方式带来的是系统可变部分与 pure 部分进行了明确分离。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#include <iostream>
#include <functional>

class State {
public:
int value1;
int value2;

State(int v1, int v2) : value1(v1), value2(v2) {}

// 用于应用状态变化的函数
void apply(const std::function<void(State&)>& change) {
change(*this);
}

void print() const {
std::cout << "State(value1: " << value1 << ", value2: " << value2 << ")\n";
}
};

// 纯函数:返回一个表示状态变化的操作,而不是直接改变状态
std::function<void(State&)> incrementValue1(int amount) {
return [amount](State& state) {
state.value1 += amount;
};
}

std::function<void(State&)> incrementValue2(int amount) {
return [amount](State& state) {
state.value2 += amount;
};
}

int main() {
State state(10, 20);

std::cout << "Initial state: ";
state.print();

// 获取状态变化操作
auto change1 = incrementValue1(5);
auto change2 = incrementValue2(10);

// 应用状态变化
state.apply(change1);
state.apply(change2);

std::cout << "Modified state: ";
state.print();

return 0;
}

代码解释:

  1. State 类:包含两个可变的成员变量 value1value2apply 函数接受一个 std::function<void(State&)> 类型的参数,表示对状态的某种操作。
  2. 纯函数 incrementValue1incrementValue2:这些函数不直接改变状态,而是返回一个 lambda 表达式,该表达式描述了如何修改状态。这些函数是纯函数,因为它们不改变任何外部状态,只生成描述状态变化的操作。
  3. 应用状态变化:在 main 函数中,我们创建了一个 State 对象,并通过 apply 函数来应用状态变化。apply 函数调用传入的 lambda 表达式,从而修改 State 对象的状态。

并发环境中的可变状态和不可变状态

并发问题只有在并行的进程间共享可变数据才会出现。因此,一种解决方案就是不使用并行,另一种方案就是不使用可变数据。但还有第三种选择:使用可变数据,但不共享它。如果不共享数据,则不会发生在不知情的情况下数据被修改的事情。

const 的重要性

如果希望某个对象不被修改,可以使用 const 关键字。

逻辑 const 与内部 const

如果不希望修改内部类成员的数据,可以把成员变量加上 const ,甚至不需要创建访问函数,只需要加上 public 即可。

1
2
3
4
5
class Person {
public:
const string name_;
const int age_;
}

这种方法有个缺陷:有些编译器的优化功能将停止工作。一旦声明成员常量,就会丢失移动构造函数和移动赋值操作符。

1
2
3
4
5
6
7
8
class Person {
public:
void name() const{}
void age() const{}
private:
string name_;
int age_;
}

虽然这种方式中,数据成员没有声明为 const,但类的用户却不能修改它们,因为在实现的所有成员函数中,this 都指向一个 Person 的 const 实例。这不但提供了逻辑上的 const(不能修改对象中的用户可见数据),还实现了内部const(不会修改对象的内部数据)。同时,还不会丢失任何优化机会,因为编译器会产生任何必需的移动操作。

如果需要修改,但又想保证数据的不可变性(至少对外部调用者是这样的),那就用并发编程相关的技术来做了。

对于临时值优化成员函数

如果类设计为不可变的,则创建 setter 函数时,需要创建一个返回对象副本的函数,以保存特定变量修改后的值。创建对象副本保存修改后的对象是十分低效的(尽管编译器在某些场合下可以对此进行优化)。对于原对象不再需要的情况,这一点也是适用的。

普通值和临时值.png

现在声明了两个 with_name 的重载函数。正如 const 修饰符一样,给成员函数指定引用类型修饰符(&)只影响this 指针的类型。第二个重载函数只适用于借用数据的对象的调用–临时对象或其他右值引用。

当第一个重载函数被调用时,创建一个新的 persont 对象的副本,并由 this 指向它,设置新的 name,并返回新创建的对象实例。在第二个重载函数中,创建一个新的 persont 对象实例,并把 this 所指对象的数据移动(不是复制)到新对象实例中。

注意第二个重载函数没有声明为 const。如果是,就不能把当前对象中的数据移动到新的对象中,就需要像第一个重载函数一样复制数据。

const缺陷

  1. const 禁止对象移动
  2. const 可以被破坏
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>

void modifyConst(const int& x) {
int& nonConstX = const_cast<int&>(x); // 通过 const_cast 移除 const 性质
nonConstX = 42; // 修改了原始的 const 对象
}

int main() {
int value = 10;
const int& constRef = value;

modifyConst(constRef);

std::cout << "Modified value: " << value << std::endl; // 输出 42

return 0;
}

const_cast 可以移除 const 性质,实际上还有很多方式可以移除 const 性质,就不逐一列举了,只是想说有 const 关键字并不一定始终跟随定义的对象。