第二章:让数据流起来!依赖节点管理的实现

在本章中,我们将实现 ObserverNodeObserverGraph,从而能够正确传播数据源的调用链。

我们的目标是让如下代码成功编译并通过测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
TEST(ReactionTest, TestCommonUse) {
auto a = reaction::var(1);
auto b = reaction::var(3.14);
EXPECT_EQ(a.get(), 1);
EXPECT_EQ(b.get(), 3.14);

auto ds = reaction::calc([](int aa, double bb) { return aa + bb; }, a, b);
auto dds = reaction::calc([](auto aa, auto dsds) { return std::to_string(aa) + std::to_string(dsds); }, a, ds);

ASSERT_FLOAT_EQ(ds.get(), 4.14);
EXPECT_EQ(dds.get(), "14.140000");

a.value(2);
ASSERT_FLOAT_EQ(ds.get(), 5.14);
EXPECT_EQ(dds.get(), "25.140000");
}

众所周知,设计模式中有一个赫赫有名的观察者模式,而我们本章要做的,实际上就是一个观察者模式的实现。

观察者模式需要一个观察容器,但问题在于——我们的数据源是任意类型的,那么,如何把这些不同类型的数据源放进同一个容器中呢?

这就需要用到**类型擦除(Type Erasure)**的技术。

类型擦除是一种常见的编程技术,它允许你在保持类型安全的前提下,处理多种不同类型的数据,而无需在编译时知道这些类型的具体信息。

在 C++ 中,类型擦除是实现 “鸭子类型(Duck Typing)” 的核心手段之一。

所谓鸭子类型,是动态类型系统中的一种编程思想,其名字源自那句经典谚语:

“当看到一只鸟走起来像鸭子、游泳起来像鸭子、叫起来也像鸭子,那它就可以被认为是鸭子。”

在鸭子类型的语义中,我们关心的是对象“能做什么”,而不是“它是什么”

  • 不关心它是不是 Duck 类;
  • 只关心它能不能 swim()walk()quack()

换句话说,鸭子类型强调的是行为匹配而非类型匹配,关注点在于对象的接口与能力,而非其所属类型

具体地说,类型擦除是一种运行时多态技术,C++中有几种常见的类型擦除实现方式:

  • 虚函数
  • std::function
  • std::any

我们今天用虚函数std::function来实现类型擦除,并做一下性能对比。

虚函数实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class ObserverNode {
public:
virtual void valueChanged() {}

template <typename... Args>
void updateObservers(Args &&...args) {
(void)(..., args.addObserver(this));
}

void notify() {
for (auto &observer : m_observers) {
observer->valueChanged();
}
}

private:
std::vector<ObserverNode*> m_observers;
};

折叠表达式解释:

(..., args.addObserver(this)) 这里是一个折叠表达式,属于一元左折叠,展开为一个逗号表达式:

1
(arg1.addObserver(this), arg2.addObserver(this), arg3.addObserver(this), ...)

逗号表达式的特点是:会依次执行前面所有项的语句,并返回最后一项的值。当然,这里也可以写成右折叠 (args.addObserver(this), ...),由于逗号操作符本身是顺序执行的,其顺序不影响结果。但如果使用的是 -/ 等非交换的操作符,折叠方向就会影响结果。

为什么前面要加 (void)

这是一个强制类型转换,将整个逗号表达式的结果转为 void 类型,这就形成了一个弃置表达式(discarded-value expression)。这意味着我们不关心表达式的返回值,只关心它的副作用(即每次调用 addObserver(this) 的效果)。

如果 addObserver 有返回值,使用 (void) 明确告知编译器“我不关心这个返回值”,可以避免可能出现的 “未使用返回值”警告,这是一种典型的防御性编程技巧。

在 C++17 之前,还不能直接使用折叠表达式,因此通常借助 std::initializer_list 实现类似功能的 workaround,写法如下:

1
(void)std::initializer_list<int>{(args.addObserver(this), 0)...};

借助初始化列表的展开能力,(args.addObserver(this), 0)... 会生成:

1
{(arg1.addObserver(this), 0), (arg2.addObserver(this), 0), (arg3.addObserver(this), 0)}

我在学习现代 C++ 的时候,曾把 折叠表达式包展开(parameter pack expansion) 搞混。其实两者有明显的差别,下面是几种常见的包展开形式:

场景 示例表达式 说明
递归函数调用 print_all(args...) 递归展开参数包
初始化容器 std::tuple{args...} 直接展开到初始化列表
模板参数列表 std::is_same_v<Args, T>... 展开到模板参数
完美转发 std::forward<Args>(args)... 保持值类别进行转发

C++ 的包展开规则要求:展开必须发生在明确的模板或初始化上下文中,比如函数调用、初始化列表、模板参数等。
但对于需要运算符参与的场景,包展开本身就无法单独使用,这时就要使用折叠表达式。

DataSource 的 value 方法实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
template <typename Type, typename... Args>
class DataSource : public Expression<Type, Args...> {
public:
using ValueType = Expression<Type, Args...>::ValueType;
using ExprType = Expression<Type, Args...>::ExprType;
using Expression<Type, Args...>::Expression;

auto get() const {
return this->getValue();
}

template<typename T>
requires (std::is_convertible_v<std::decay_t<T>, ValueType> &&
std::is_same_v<ExprType, VarExpr>)
void value(T &&t) {
this->updateValue(std::forward<T>(t));
this->notify();
}
};

类型萃取与别名传递

至于最后提到的 ValueType,它是模板元编程中最经典的惯用技法之一,核心思想是一种:

类型萃取(type extraction) + 别名传递(alias propagation)

举例来说,在 STL 标准模板库中,迭代器类型通常通过如下方式萃取:

1
std::iterator_traits<Iter>::value_type

这样可以统一地提取出某个迭代器的值类型。然后把这个 value_type 传递到容器或算法中,在执行算法时,就能根据容器中不同的 value_type 做出相应优化:

  • std::vector 是随机访问迭代器,可以直接跳跃;
  • std::list 是单向或双向迭代器,只能一步一步前进。

这种机制让 C++ 在泛型编程中具备了强大的类型适配能力。

requires可以用concept封装一下,这样可以提高可读性和复用性。

1
2
3
4
5
template<typename T, typename U>
concept ConvertCC = std::is_convertible_v<std::decay_t<T>, std::decay_t<U>>;

template<typename T>
concept VarExprCC = std::is_same_v<T, VarExpr>;

性能压力测试

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
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
struct ProcessedData {
std::string info;
int checksum;
};

TEST(ReactionTest, StressTest) {
using namespace reaction;
using namespace std::chrono;

// Create var-data sources
auto base1 = var(1); // Integer source
auto base2 = var(2.0); // Double source
auto base3 = var(true); // Boolean source
auto base4 = var(std::string{"3"}); // String source
auto base5 = var(4); // Integer source

// Layer 1: Add integer and double
auto layer1 = calc([](int a, double b) {
return a + b;
}, base1, base2);

// Layer 2: Multiply or divide based on the flag
auto layer2 = calc([](double val, bool flag) {
return flag ? val * 2 : val / 2;
}, layer1, base3);

// Layer 3: Convert double value to a string
auto layer3 = calc([](double val) {
return "Value:" + std::to_string(val);
}, layer2);

// Layer 4: Append integer to string
auto layer4 = calc([](const std::string &s, const std::string &s4) {
return s + "_" + s4;
}, layer3, base4);

// Layer 5: Get the length of the string
auto layer5 = calc([](const std::string &s) {
return s.length();
}, layer4);

// Layer 6: Create a vector of double values
auto layer6 = calc([](size_t len, int b5) {
return std::vector<int>(len, b5);
}, layer5, base5);

// Layer 7: Sum all elements in the vector
auto layer7 = calc([](const std::vector<int> &vec) {
return std::accumulate(vec.begin(), vec.end(), 0);
}, layer6);

// Layer 8: Create a ProcessedData object with checksum and info
auto layer8 = calc([](int sum) {
return ProcessedData{"ProcessedData", static_cast<int>(sum)};
}, layer7);

// Layer 9: Combine info and checksum into a string
auto layer9 = calc([](const ProcessedData &calc) {
return calc.info + "|" + std::to_string(calc.checksum);
}, layer8);

// Final layer: Add "Final:" prefix to the result
auto finalLayer = calc([](const std::string &s) {
return "Final:" + s;
}, layer9);

const int ITERATIONS = 100000;
auto start = steady_clock::now(); // Start measuring time
// Perform stress test for the given number of iterations
for (int i = 0; i < ITERATIONS; ++i) {
// Update base sources with new values
*base1 = i % 100;
*base2 = (i % 100) * 0.1;
*base3 = i % 2 == 0;

// Calculate the expected result for the given input
std::string expected = [&]() {
double l1 = base1.get() + base2.get(); // Add base1 and base2
double l2 = base3.get() ? l1 * 2 : l1 / 2; // Multiply or divide based on base3
std::string l3 = "Value:" + std::to_string(l2); // Convert to string
std::string l4 = l3 + "_" + base4.get(); // Append base1
size_t l5 = l4.length(); // Get string length
std::vector<int> l6(l5, base5.get()); // Create vector of length 'l5'
int l7 = std::accumulate(l6.begin(), l6.end(), 0); // Sum vector values
ProcessedData l8{"ProcessedData", static_cast<int>(l7)}; // Create ProcessedData object
std::string l9 = l8.info + "|" + std::to_string(l8.checksum); // Combine info and checksum
return "Final:" + l9; // Add final prefix
}();

// Print progress every 10,000 iterations
if (i % 10000 == 0 && finalLayer.get() == expected) {
auto dur = duration_cast<milliseconds>(steady_clock::now() - start);
std::cout << "Progress: " << i << "/" << ITERATIONS
<< " (" << dur.count() << "ms)\n";
}
}

// Output the final results of the stress test
auto duration = duration_cast<milliseconds>(steady_clock::now() - start);
std::cout << "=== Stress Test Results ===\n"
<< "Iterations: " << ITERATIONS << "\n"
<< "Total time: " << duration.count() << "ms\n"
<< "Avg time per update: "
<< duration.count() / static_cast<double>(ITERATIONS) << "ms\n";
}

std::function实现类型擦除:

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
class ObserverNode
{
public:
void addObserver(const std::function<void()> &f)
{
m_observers.emplace_back(f);
}

template <typename... Args>
void updateObservers(const std::function<void()> &f, Args &&...args)
{
(..., args.addObserver(f));
}

void notify()
{
for (auto &observer : m_observers)
{
observer();
}
}

private:
std::vector<std::function<void()>> m_observers;
};