第一章:让代码跑起来!构建响应式框架的起点 在本章中,我们将从零开始搭建 Reaction 响应式框架的基本工程结构,并实现几个关键的核心组件:DataSource
、Expression
和 Resource
。这些组件将构成我们响应式框架的计算骨架。
我们的目标是让如下代码成功编译并通过测试:
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++2 a) 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 (); } };
为什么要采用这种继承的设计模式
数据源本身是一个表达式,这里符合is-a 的关系
解耦逻辑 ,将不同类型的数据源的处理逻辑放在Expression类中处理,DataSource本身只负责用户交互的部分
空基类优化(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* ptr = nullptr ; ptr->memberFunc (); }
第一阶段(语法分析阶段)
编译器只检查模板本身的语法 (如 ptr->memberFunc()
是否符合基本语法规则)。
不检查 T
是否完整,也不检查 memberFunc
是否存在(因为 T
可能是任何类型)。
第二阶段(模板实例化阶段)
当 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)) {} };
这里的 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 DangerousHolder holder1 (42 ) ; int x = 10 ;DangerousHolder holder2 (x) ; 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 ) ; 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); foo (3.14 );
不会出现类模板中”一个实例化对应多种成员类型”的矛盾
函数模板和类模板服务于不同场景:函数模板强调“临时、一次性”的推导与转发;类模板强调“持久、稳定”的类型定义。
语言设计者为了保证语法简洁、一致、可维护,特意将“引用折叠”机制局限在了函数模板参数中,不允许在类模板参数列表出现。
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_shared
和 std::make_unique
是创建智能指针的推荐方式,相较于直接使用构造函数(如 std::shared_ptr<T>(new T)
或 std::unique_ptr<T>(new T)
),它们具有以下优势:
1. 代码简洁性
make_shared
和 make_unique
通过模板参数推导,允许一行代码完成对象的构造和智能指针的初始化 ,避免重复书写类型。
示例:1 2 auto ptr1 = std::make_shared <Foo>(); std::shared_ptr<Foo> ptr2 (new Foo) ;
2. 异常安全
如果直接使用 new
并将裸指针传递给智能指针构造函数,可能在内存分配成功但智能指针构造未完成时 发生异常,导致内存泄漏。
make_shared
和 make_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
需要维护两块内存:
对象本身的内存 (存储 Foo
的数据)。
控制块的内存 (存储引用计数、弱计数等)。
直接使用 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 框架的第一个可运行版本,包括基本的数据源管理、表达式求值和测试验证,下一章我们开始实现数据源的依赖传播