第一章:让代码跑起来!构建响应式框架的起点

在本章中,我们将从零开始搭建 Reaction 响应式框架的基本工程结构,并实现几个关键的核心组件:DataSourceExpressionResource。这些组件将构成我们响应式框架的计算骨架。

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

1
2
3
4
5
6
7
8
9
10
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");

1. 项目结构搭建

使用 CMake 构建项目:

1
2
3
4
5
6
7
8
9
reaction/
├── include/
│ └── reaction/
│ ├── dataSource.h
│ ├── expression.h
│ └── resource.h
├── test/
│ └── test.cpp
├── CMakeLists.txt

1.1 CMakeLists.txt 示例

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
cmake_minimum_required(VERSION 3.10)

project(reaction VERSION 1.0 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

if(MSVC)
add_compile_options(/W4 /GR-)
else()
add_compile_options(-Wall -Wextra -pedantic -fno-rtti -std=c++2a)
endif()

if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Release CACHE STRING "Choose the type of build." FORCE)
endif()

add_library(${PROJECT_NAME} INTERFACE)
add_library(${PROJECT_NAME}::${PROJECT_NAME} ALIAS ${PROJECT_NAME})

target_include_directories(${PROJECT_NAME} INTERFACE
${CMAKE_CURRENT_SOURCE_DIR}/include
)

find_package(GTest)
if(GTest_FOUND)
enable_testing()
file(GLOB TEST_SOURCES ${PROJECT_SOURCE_DIR}/test/*.cpp)
add_executable(runTests ${TEST_SOURCES})
target_link_libraries(runTests PRIVATE GTest::GTest GTest::Main ${PROJECT_NAME})
add_test(NAME reactionTest COMMAND runTests)
else()
message(WARNING "GTest not found, skipping tests.")
endif()
  • MSVC (Windows):
    • /W4: 高警告级别
    • /GR-: 禁用 RTTI (运行时类型信息)
  • 其他编译器:
    • -Wall -Wextra -pedantic: 严格警告
    • -fno-rtti: 禁用 RTTI
    • -std=c++2a: 指定 C++20 标准 (GCC/Clang 的旧名称)
1
2
add_library(${PROJECT_NAME} INTERFACE)
add_library(${PROJECT_NAME}::${PROJECT_NAME} ALIAS ${PROJECT_NAME})
  • 创建一个 INTERFACE 库 (不构建实际二进制文件)
  • 为库创建别名,符合现代 CMake 目标命名规范

2. 实现核心组件

2.1 DataSource:数据源管理器

DataSource 是用户操作响应式变量的入口,暴露出 get()等各种接口

1
2
3
4
5
template<typename T, typename... Args>
class DataSource : public Expression<T, Args...> {
public:
auto get() const { return this->getValue(); }
};

为什么要采用这种继承的设计模式

  1. 数据源本身是一个表达式,这里符合is-a的关系
  2. 解耦逻辑,将不同类型的数据源的处理逻辑放在Expression类中处理,DataSource本身只负责用户交互的部分
  3. 空基类优化(EBO)
    当基类为空时(如只有类型定义无数据成员),继承可完全消除存储开销
    组合方式会因C++对象布局规则(每个对象必须有唯一地址)产生至少1个指针的开销

为什么此处必须要 this->getValue(),否则编译不通过

要解释这个问题,首先要从 C++ 模板的编译机制说起——
两阶段查找(Two-Phase Lookup)延迟实例化(Delayed Instantiation) 机制:


示例一

1
2
3
4
5
6
7
8
9
10
11
template<typename T>
void bar(T* ptr) {
ptr->memberFunc(); // ✅ 编译通过(待决名)
}

void foo() {
// 假设前面没有 IncompleteType 的定义
IncompleteType* ptr = nullptr;
ptr->memberFunc(); // ❌ 编译错误:
// IncompleteType 是非待决名,且是不完整类型,无法调用成员函数
}
  1. 第一阶段(语法分析阶段)

    • 编译器只检查模板本身的语法(如 ptr->memberFunc() 是否符合基本语法规则)。
    • 不检查 T 是否完整,也不检查 memberFunc 是否存在(因为 T 可能是任何类型)。
  2. 第二阶段(模板实例化阶段)

    • bar<IncompleteType>(ptr) 被调用时,编译器尝试实例化 bar
    • 如果 IncompleteType 仍然不完整,或者没有 memberFunc,则实例化失败,报错。

在模板中,如果一个名称(变量、函数、类型等)的解析依赖于模板参数,则称为 待决名(Dependent Name)
编译器在第一阶段(模板定义阶段)不会立即解析它,而是在第二阶段(模板实例化阶段)再确定其含义。


示例二

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template<typename T>
class Base {
public:
void memberFunc() {}
};

template<typename T>
class MyClass : public Base<T> {
public:
void foo() {
memberFunc(); // ❌ 非待决名,立即查找失败
this->memberFunc(); // ✅ 待决名,延迟到实例化阶段再查找
Base<T>::memberFunc(); // 另一种正确写法
}
};
  • memberFunc()

    • 由于不依赖任何模板参数,被视为非待决名,会在编译时的语义分析阶段立即查找。
    • 此时 Base<T> 尚未实例化,查找失败,编译报错。
  • this->memberFunc()

    • 等价于 MyClass<T> * -> memberFunc(),依赖于模板类型参数 T,属于待决名
    • 编译器在实例化 MyClass<T> 时,再去查找 memberFunc(),此时 Base<T> 已实例化,可正常调用。

2.2 Expression:表达式封装器

用于管理依赖计算逻辑:

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

template <typename T>
struct ExpressionTraits {
using Type = T;
};
template <typename T>
struct ExpressionTraits<DataSource<T>> {
using Type = T;
};
template <typename Fun, typename... Args>
struct ExpressionTraits<DataSource<Fun, Args...>> {
using Type = std::invoke_result_t<Fun, typename ExpressionTraits<Args>::Type...>;
};
template <typename Fun, typename... Args>
using ReturnType = typename ExpressionTraits<DataSource<Fun, Args...>>::Type;

template<typename Fun, typename... Args>
class Expression : public Resource<ReturnType<Fun, Args...>> {
public:
template <typename F, typename... A>
Expression(F &&f, A &&...args) : m_func(std::forward<F>(f)), m_args(std::forward<Args>(args)...) {}

protected:
auto evaluate() {
auto result = [&]<std::size_t... I>(std::index_sequence<I...>) {
return std::invoke(m_func, std::get<I>(m_args).get()...);
}(std::make_index_sequence<std::tuple_size_v<std::decay_t<decltype(m_args)>>>{});

this->updateValue(result);
}

private:
Fun m_func;
std::tuple<Args...> m_args;
};

// 特化版本
template<typename Type>
class Expression<Type> : public Resource<Type>{
public:
template <typename T>
Expression(T &&t) : Resource<Type>(std::forward<T>(t)) {}

// using Resource<Type>::Resource; //或者用C++11的继承构造函数
};

这里的 std::invoke_result_t有个坑,我们后面会修改它

使用了 C++20 的 lambda 模板参数[&]<std::size_t... I>(...),这种语法被称为 “immediate lambda”

为什么类模板的类型参数不能是万能引用

我学习现代C++的时候,思考过这个问题,函数模板和类模板既然都可以推导,为什么函数模板可以引用折叠推导为万能引用,而类模板不行?

函数模板参数推导(Function Template Argument Deduction)(C++98就已经支持)
类模板参数推导(CTAD, Class Template Argument Deduction)(C++17才开始支持)

为什么CTAD需要单独引入?
技术复杂度不同

  • 函数模板推导仅涉及单次调用
  • 类模板推导需要协调所有构造函数的参数组合(需要Deduction Guides)

如果允许类模板参数直接作为万能引用,会导致哪些严重的类型安全问题和技术陷阱。

假设C++允许这样的语法(实际非法):

1
2
3
4
5
6
template<typename T>
class DangerousHolder {
T resource; // 成员是引用类型(可能是左值或右值引用)
public:
DangerousHolder(T&& r) : resource(std::forward<T>(r)) {}
};

类型系统崩溃

1
2
3
4
5
6
7
// 案例2:类型意外变为引用
DangerousHolder holder1(42); // 假设T=int,则成员类型为int&&
int x = 10;
DangerousHolder holder2(x); // 假设T=int&,则成员类型为int&

static_assert(std::is_same_v<decltype(holder1.resource), int&&>); // 通过
static_assert(std::is_same_v<decltype(holder2.resource), int&>); // 通过

问题

  • 相同模板实例化DangerousHolder<int>会产生两种完全不同的成员类型
  • 违反类模板”生成确定类型”的基本原则

对比安全实现

1
2
SafeHolder<int> s1(42);  // 成员永远是int类型
SafeHolder<int> s2(x); // 同上,即使传入左值

引用折叠规则不适合贯穿整个对象生命周期:

C++ 的引用折叠规则(& & → &、&& & → &、&& && → && 等)设计初衷是为了函数参数推导时临时使用。
如果把它用到类模板参数层面,就得让编译器在模板定义、实例化、特化时都执行复杂的折叠,极易引入混乱和歧义。


而函数模板的万能引用之所以能避免类模板参数的问题,关键在于其临时性作用域局限性。这种设计差异源于两者在C++中的根本角色不同。以下是具体分析:

核心差异:生存周期与作用域

特性 函数模板万能引用 类模板成员引用
作用时间 仅在函数调用期间有效 伴随整个对象生命周期
存储要求 不涉及长期存储 可能成为对象状态的一部分
类型确定性 每次调用独立推导 必须与类定义统一

1. 无法形成长期持有

临时性与作用域限制

  • 函数内部传入的右值或左值引用,只在函数执行期间存在,之后立即销毁或恢复。
  • 类成员一旦持有引用,就要长期管理它的生命周期,容易出错。

2. 类型推导隔离性

推导隔离性

  • 每次函数调用都是独立的模板推导:foo(x)foo(3.14) 分别会推导为 T=int&T=double,互不影响。
  • 类模板在同一次实例化内,必须保证“相同模板参数→相同类型布局”,否则就打破了类型安全。
1
2
3
4
5
6
template<typename T>
void foo(T&& arg) {}

int x = 42;
foo(x); // 本次T=int&
foo(3.14); // 本次T=double

不会出现类模板中”一个实例化对应多种成员类型”的矛盾

  • 函数模板和类模板服务于不同场景:函数模板强调“临时、一次性”的推导与转发;类模板强调“持久、稳定”的类型定义。
  • 语言设计者为了保证语法简洁、一致、可维护,特意将“引用折叠”机制局限在了函数模板参数中,不允许在类模板参数列表出现。

2.3 Resource:值资源管理器

用于底层数据的读写:

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
template<typename Type>
class Resource {
public:
ResourceBase() :
m_ptr(nullptr) {
}

template <typename T>
ResourceBase(T &&t) :
m_ptr(std::make_unique<Type>(std::forward<T>(t))) {
}

auto getValue() const {
if (!m_ptr) {
throw std::runtime_error("Attempt to get a null pointer");
}
return *m_ptr;
}

template <typename T>
void updateValue(T &&val) {
if (!m_ptr) {
m_ptr = std::make_unique<Type>(std::forward<T>(t));
} else {
*m_ptr = std::forward<T>(t);
}
}

private:
std::unique<Type> m_ptr;
};

八股文整理:为什么要make_shared, make_unique而不直接使用构造函数

在 C++ 中,std::make_sharedstd::make_unique 是创建智能指针的推荐方式,相较于直接使用构造函数(如 std::shared_ptr<T>(new T)std::unique_ptr<T>(new T)),它们具有以下优势:


1. 代码简洁性

  • make_sharedmake_unique 通过模板参数推导,允许一行代码完成对象的构造和智能指针的初始化,避免重复书写类型。
  • 示例:
    1
    2
    auto ptr1 = std::make_shared<Foo>();  // 简洁
    std::shared_ptr<Foo> ptr2(new Foo); // 需要重复写 Foo

2. 异常安全

  • 如果直接使用 new 并将裸指针传递给智能指针构造函数,可能在内存分配成功但智能指针构造未完成时发生异常,导致内存泄漏。
  • make_sharedmake_unique原子操作(分配内存和构造智能指针),避免了这一问题。
  • 示例(不安全的情况):
    1
    auto ptr = std::shared_ptr<Foo>(new Foo(arg1, arg2)); // 可能泄漏
    改用 make_shared 后:
    1
    auto ptr = std::make_shared<Foo>(arg1, arg2); // 更安全

3. 性能优化(仅针对 make_shared

  • std::shared_ptr 需要维护两块内存:
    1. 对象本身的内存(存储 Foo 的数据)。
    2. 控制块的内存(存储引用计数、弱计数等)。
  • 直接使用 new 时,这两块内存是分开分配的(两次内存操作)。
  • make_shared合并这两块内存,通过单次分配完成,减少了内存开销和分配时间。

3. 封装 API:reaction::var 和 reaction::calc

为了方便用户使用,我们提供统一入口函数:

1
2
3
4
5
6
7
8
9
10
11
12
namespace reaction {

template<typename T>
auto var(T &&val) {
return DataSource<T>(std::forward<T>(val));
}

template<typename F, typename... Args>
auto calc(F f, Args... args) {
return DataSource<std::decay_t<F>, std::decay_t<Args>...>(std::forward<F>(f), std::forward<Args>(args)...);
}
}

注意这里的decay_t,类模板应该生成常规类型(regular types),而引用类型会破坏值语义的完整性,影响后面的类型推导、特化


4. 编写测试:test/test_var.cpp

我们用 GTest 验证功能是否正常:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include "reaction/dataSource.h"
#include <gtest/gtest.h>
#include <string>

TEST(ReactionTest, TestCalc) {
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");
}

5. 小结

通过本章内容,我们实现了 Reaction 框架的第一个可运行版本,包括基本的数据源管理、表达式求值和测试验证,下一章我们开始实现数据源的依赖传播