C++模板与继承结合:CRTP、类型擦除与策略模式实战

发布时间:2026/7/23 5:40:29
C++模板与继承结合:CRTP、类型擦除与策略模式实战 1. 项目概述当模板遇上继承C的深度玩法在C的世界里模板和继承是构建复杂、灵活且高效代码的两大基石。很多朋友在初学阶段会把它们当作两个独立的章节来处理模板用来写泛型算法和容器继承用来构建类层次结构。但当你真正深入到项目开发尤其是需要设计可复用、高性能的库或框架时你会发现将模板与继承结合使用能迸发出惊人的能量。这不仅仅是语法上的简单叠加更是一种设计哲学和工程实践的进阶。简单来说模板进阶与继承的结合核心解决的是“如何在保持类型安全和高性能模板的优势的同时又能利用多态和代码复用继承的优势”这一经典矛盾。比如你想写一个能处理任意派生类对象的容器又不想损失静态类型检查的性能或者你想设计一个策略模式但策略的具体实现因类型而异。这时候单纯的模板或单纯的继承都显得力不从心而它们的组合技则成了最优解。这篇文章就是从一个多年C开发者的视角来拆解这个组合技的实战应用。我会避开教科书式的罗列聚焦于几个在真实项目中高频出现、且容易踩坑的场景通过具体的代码示例和背后的设计思考带你理解CRTP奇异递归模板模式、类型擦除的优雅实现、以及基于策略的模板设计等核心玩法。无论你是正在准备面试啃着“C八股文”还是在实际开发中遇到了设计瓶颈相信这些内容都能给你带来直接的启发和可落地的方案。2. 核心场景与设计思路拆解为什么我们需要把模板和继承拧在一起用直接看三个最典型的驱动力。2.1 场景一静态多态与性能优化动态多态通过虚函数和继承是C的经典特性它通过在运行时查找虚函数表vtable来决定调用哪个函数。这带来了巨大的灵活性但每次函数调用都有一层间接寻址的开销。在性能敏感的领域如游戏引擎、高频交易系统或数值计算库比如Eigen它大量使用了表达式模板这种开销可能是不可接受的。静态多态应运而生。它的核心思想是在编译期就确定调用的具体函数从而消除运行时开销。模板是实现静态多态的天然工具。但是如果只有模板我们很难构建一个统一的接口来操作不同的类型。这时继承就可以登场了但它不是用于动态派发而是用于定义统一的接口形式。最常见的模式就是CRTP。设计思路我们定义一个模板基类这个基类知道它的派生类类型。派生类通过继承这个以自身为模板参数的基类从而在编译期就将“接口”和“实现”绑定在一起。编译器能为每个不同的派生类生成一份独立的、完全去虚拟化的代码。// CRTP 模板基类 template typename Derived class Shape { public: void draw() const { // 静态向下转换在编译期确定调用哪个derived().draw_impl() static_castconst Derived*(this)-draw_impl(); } double area() const { return static_castconst Derived*(this)-area_impl(); } }; // 派生类 Circle class Circle : public ShapeCircle { private: double radius_; // 实现基类约定的接口 void draw_impl() const { std::cout Drawing a circle.\n; } double area_impl() const { return 3.14159 * radius_ * radius_; } public: Circle(double r) : radius_(r) {} }; // 派生类 Square class Square : public ShapeSquare { private: double side_; void draw_impl() const { std::cout Drawing a square.\n; } double area_impl() const { return side_ * side_; } public: Square(double s) : side_(s) {} }; // 使用静态多态无虚函数开销 template typename T void renderShape(const ShapeT shape) { shape.draw(); // 编译期决议调用对应的 draw_impl std::cout Area: shape.area() std::endl; }在这个设计中ShapeCircle和ShapeSquare是完全不同的类型renderShape函数模板会为它们实例化出不同的版本。shape.draw()的调用在编译期就绑定到了Circle::draw_impl或Square::draw_impl没有任何虚函数表查找。这就是继承用于定义模式和模板用于生成类型特定代码的完美配合。2.2 场景二类型擦除与统一接口有时候需求是反过来的我们有一个模板类它能处理各种类型但我们又希望将这些处理不同具体类型的模板实例放入同一个容器比如std::vector中管理。由于std::vectorMyTemplateint和std::vectorMyTemplatestd::string是不同类型直接存放是不可能的。动态多态可以做到基类指针容器但可能牺牲模板带来的性能或类型特性。这时我们需要一种技术能擦除模板参数的类型信息同时保留其可调用性或其他操作。这听起来有点像动态多态但我们可以用“继承模板”实现得更轻量、更定制化这就是**类型擦除Type Erasure**的一种常见实现手法std::function和std::any的内部原理就与此类似。设计思路定义一个非模板的抽象基类声明统一的接口如call。然后定义一个模板派生类继承自这个抽象基类并在内部持有模板参数类型的对象实现基类的接口。最后用一个外壳类如AnyCallable包裹这个抽象基类指针。这样外壳类的类型是统一的但内部通过多态可以操作任何类型的对象。// 1. 非模板抽象基类 class CallableBase { public: virtual ~CallableBase() default; virtual void operator()() const 0; // 统一接口 }; // 2. 模板派生类实现类型擦除 template typename F class CallableImpl : public CallableBase { private: F f_; // 持有任意可调用对象 public: CallableImpl(F f) : f_(std::forwardF(f)) {} void operator()() const override { f_(); } // 转发调用 }; // 3. 统一的外壳类 class AnyCallable { private: std::unique_ptrCallableBase pimpl_; // 桥接指针 public: template typename F AnyCallable(F f) // 构造函数是模板 : pimpl_(std::make_uniqueCallableImplstd::decay_tF(std::forwardF(f))) {} void operator()() const { if (pimpl_) (*pimpl_)(); } // 支持移动禁止拷贝简化示例 }; // 使用可以将lambda、函数指针、函数对象等放入同一容器 std::vectorAnyCallable tasks; tasks.emplace_back([]{ std::cout Hello from lambda\n; }); tasks.emplace_back(someGlobalFunction); for (const auto task : tasks) { task(); // 统一调用背后是多态分发 }这里继承CallableImpl继承CallableBase提供了统一接口和运行时多态的能力而模板CallableImpl的模板参数F使得我们可以包装任意类型。AnyCallable的类型是确定的但它内部通过继承链擦除了F的具体类型。这种模式在需要回调、访问者模式等场景中非常有用。2.3 场景三策略模式与编译期组合策略模式Strategy Pattern通常通过动态多态实现定义一个策略接口然后有多个具体策略类。客户端持有一个指向接口的指针可以在运行时替换策略。这很灵活但同样有虚函数开销。利用模板我们可以将策略的选择提前到编译期。每个不同的策略都是一个独立的类型可以是类也可以是模板参数。然后通过继承或者组合将主类与策略类“粘合”起来。这被称为基于策略的设计Policy-based Design是模板元编程和现代C设计中的重要概念。设计思路将主类设计为一个模板类它的模板参数就是各种策略如锁策略、分配器策略、序列化策略等。主类通过继承这些策略类通常是公有继承或者将它们作为成员来获得策略的行为。由于所有类型在编译期已知编译器可以进行充分的优化如内联。// 定义不同的锁策略空策略互斥锁策略 struct NoLockPolicy { void lock() {} void unlock() {} }; struct MutexLockPolicy { void lock() { /* pthread_mutex_lock... */ } void unlock() { /* pthread_mutex_unlock... */ } }; // 定义不同的分配器策略 template typename T struct MallocAllocator { T* allocate(size_t n) { return static_castT*(std::malloc(n * sizeof(T))); } void deallocate(T* p, size_t) { std::free(p); } }; template typename T struct NewAllocator { T* allocate(size_t n) { return new T[n]; } void deallocate(T* p, size_t) { delete[] p; } }; // 主模板类组合策略 template typename T, typename LockPolicy NoLockPolicy, typename AllocatorPolicy MallocAllocatorT class ThreadSafeVector : private LockPolicy, private AllocatorPolicy { private: T* data_; size_t size_, capacity_; // 使用策略 void maybeLock() { this-lock(); } // 继承自LockPolicy void maybeUnlock() { this-unlock(); } T* alloc(size_t n) { return this-allocate(n); } // 继承自AllocatorPolicy void dealloc(T* p, size_t n) { this-deallocate(p, n); } public: void push_back(const T value) { maybeLock(); // ... 实现扩容逻辑使用 alloc/dealloc maybeUnlock(); } // ... 其他接口 }; // 使用在编译期指定策略 ThreadSafeVectorint, MutexLockPolicy, NewAllocatorint vec_with_mutex; ThreadSafeVectordouble vec_no_lock; // 使用默认策略在这个例子中ThreadSafeVector私有继承了锁策略和分配器策略。私有继承意味着“以...实现”而不是“是一种”关系这符合策略是实现的细节。通过模板参数我们在编译期就完成了策略的“装配”。如果选择NoLockPolicy编译器会优化掉所有的lock/unlock调用生成无锁版本的代码。这种设计在STL的分配器std::allocator和Boost库中非常常见。注意基于策略的设计可能导致类型爆炸每个策略组合都产生新类型并让客户端代码的模板参数列表变得很长。通常需要仔细权衡并为常用组合提供别名模板using。3. 关键技术细节与避坑指南理解了为什么结合接下来看看怎么结合得更好、更安全。这里有几个关键的细节和容易踩的坑。3.1 CRTP中的对象切片与正确访问在CRTP模式中基类通过static_castDerived*(this)来获得派生类指针。这要求this指针确实指向一个Derived对象。一个经典的错误是对象切片Object Slicing。// 错误示例对象切片导致未定义行为 class Circle : public ShapeCircle { /* ... */ }; void badFunction(ShapeCircle s) { // 按值传递发生切片 s.draw(); // s 只是一个 ShapeCircle 基类子对象没有 Circle 的完整信息 } Circle c(5.0); badFunction(c); // 灾难static_castDerived* 将指向一个不完整的对象避坑指南永远避免CRTP基类的按值传递和拷贝。基类ShapeDerived通常不应该被独立实例化它只是提供一个接口框架。因此应将它的构造函数设为protected或 delete并避免定义拷贝操作。始终通过引用或指针来操作CRTP对象。在函数中使用const ShapeT或ShapeT*。考虑将CRTP基类的析构函数声明为虚函数这是一个有趣的问题。在CRTP中通常通过派生类来使用并且基类指针指向派生类对象的情况很常见尽管我们多用引用。如果可能通过基类指针删除对象就需要虚析构函数。但这也意味着引入了一个虚函数表部分牺牲了“静态”的特性。我的经验是如果这个CRTP层次结构有通过基类指针进行资源管理的可能性就加上virtual ~Shape() default;。如果纯粹是静态多态且能保证使用方式安全可以不加但要在文档中明确说明。3.2 模板派生类中名称的依赖性问题当你在模板派生类中引用基类的成员时可能会遇到编译错误因为编译器在解析模板时无法确定基类中是否有某个名称。这是因为基类本身依赖于模板参数是一个依赖基类。template typename T class Base { public: void baseFunc() {} int value; }; template typename T class Derived : public BaseT { public: void derivedFunc() { baseFunc(); // 错误编译器不知道BaseT中是否有baseFunc std::cout value; // 同样错误 } };解决方案有三种方式告诉编译器这个名称来自依赖基类。使用this-前缀this-baseFunc();this-value;。这明确表示成员是类成员。使用基类限定符BaseT::baseFunc();BaseT::value;。使用using声明对于类型名尤其必要在派生类中using BaseT::baseFunc;然后直接调用baseFunc();。实操心得我习惯使用this-前缀因为它最接近非模板类的写法意图清晰。对于类型别名如typedef或using在基类中定义的类型必须使用typename BaseT::SomeType或using声明来引入。3.3 虚函数与模板函数的共存与抉择一个常见的困惑是一个成员函数到底该设计成虚函数还是模板函数虚函数提供运行时多态允许通过基类指针/引用调用不同的派生类实现。接口固定但实现可变。模板函数提供编译时多态基于不同的参数类型生成不同的函数实例。接口和实现都可以随类型变化但不能通过基类接口统一调用。抉择指南行为是否因对象类型继承层次而异是 - 考虑虚函数。行为是否因参数类型而异且这些类型可能无关不属于同一继承树是 - 考虑模板函数。是否需要将不同类型的对象放入同一容器进行统一处理是 - 通常需要虚函数或类型擦除。性能是否极度敏感需要避免任何运行时开销是 - 优先考虑模板和静态多态如CRTP。共存示例有时它们可以协作。例如一个抽象基类定义了一个虚函数作为主要接口而这个虚函数的实现内部可以调用一个模板辅助函数来处理类型相关的细节。class Serializer { public: virtual ~Serializer() default; virtual std::string serialize() const 0; // 统一接口动态多态 }; template typename T class GenericSerializer : public Serializer { private: T data_; // 模板辅助函数处理T特有的序列化 template typename U std::string serializeImpl(const U val) const { // 可能是特化或重载 return std::to_string(val); } // std::string的特化 std::string serializeImpl(const std::string val) const { return \ val \; } public: std::string serialize() const override { // 实现虚函数 return serializeImpl(data_); // 委托给模板函数 } };3.4 菱形继承与虚基类在模板中的特例多重继承在模板场景下可能更复杂。如果多个模板基类可能从同一个非模板虚基类继承就会形成“模板菱形继承”。class CommonBase { int common; }; template typename T class Base1 : public virtual CommonBase { /* ... */ }; template typename U class Base2 : public virtual CommonBase { /* ... */ }; template typename T, typename U class Derived : public Base1T, public Base2U { /* ... */ };在这种情况下Derivedint, double对象中只有一个CommonBase子对象这通常是我们想要的。但是虚继承是有成本的通常通过指针实现可能会影响对象布局和访问成员的速度。建议在模板设计中谨慎使用虚继承。除非明确需要共享唯一的基础子对象否则优先使用组合或非虚继承。如果必须使用要清楚它对性能和对象大小的影响并在文档中说明。4. 实战演练构建一个简单的静态多态容器让我们综合运用上述知识实现一个简化版的std::function它支持静态多态编译期绑定并且能存储任意可调用对象。我们将使用类型擦除继承模板的技术。目标定义一个StaticFunctionR(Args...)类它可以被构造自函数指针、函数对象、lambda等并且调用开销尽可能小理想情况下应被编译器内联。4.1 第一步定义接口和可调用对象包装器首先定义一个非模板的抽象基类CallableBase它代表了一个可调用对象的抽象接口。// 前置声明 template typename class StaticFunction; // 抽象基类擦除类型的接口 template typename R, typename... Args class CallableBase { public: virtual ~CallableBase() default; virtual R invoke(Args... args) 0; virtual std::unique_ptrCallableBase clone() const 0; // 用于拷贝 };接下来实现模板派生类CallableImpl它负责存储具体类型的可调用对象F并实现接口。// 具体实现类存储任意可调用对象 F template typename F, typename R, typename... Args class CallableImpl : public CallableBaseR, Args... { private: F f_; // 存储的可调用对象 public: explicit CallableImpl(F f) : f_(std::move(f)) {} R invoke(Args... args) override { // 关键直接调用存储的f_。如果F的operator()很简单很可能被内联。 return f_(std::forwardArgs(args)...); } std::unique_ptrCallableBaseR, Args... clone() const override { // 假设F是可拷贝的。对于只移动类型这里需要特殊处理。 return std::make_uniqueCallableImpl(*this); } };4.2 第二步实现主类 StaticFunction现在实现主类StaticFunction。它内部持有一个CallableBase指针通过多态来管理实际的可调用对象。template typename R, typename... Args class StaticFunctionR(Args...) { private: std::unique_ptrCallableBaseR, Args... callable_; public: // 默认构造函数创建一个空函数对象 StaticFunction() noexcept default; // 模板构造函数接受任何可调用对象 template typename F, typename std::enable_if_t!std::is_same_vstd::decay_tF, StaticFunction StaticFunction(F f) : callable_(std::make_uniqueCallableImplstd::decay_tF, R, Args...(std::forwardF(f))) {} // 拷贝构造函数和赋值运算符需要深拷贝 StaticFunction(const StaticFunction other) : callable_(other.callable_ ? other.callable_-clone() : nullptr) {} StaticFunction operator(const StaticFunction other) { if (this ! other) { callable_ other.callable_ ? other.callable_-clone() : nullptr; } return *this; } // 移动操作 StaticFunction(StaticFunction) noexcept default; StaticFunction operator(StaticFunction) noexcept default; // 调用操作符 R operator()(Args... args) const { if (!callable_) { throw std::bad_function_call(); } return callable_-invoke(std::forwardArgs(args)...); } // 显式布尔转换检查是否为空 explicit operator bool() const noexcept { return static_castbool(callable_); } // 交换 void swap(StaticFunction other) noexcept { callable_.swap(other.callable_); } };4.3 第三步使用示例与性能分析现在我们可以使用这个StaticFunction了。#include iostream #include vector int add(int a, int b) { return a b; } struct Multiply { int factor; Multiply(int f) : factor(f) {} int operator()(int x) const { return x * factor; } }; int main() { // 存储自由函数 StaticFunctionint(int, int) func1 add; std::cout func1(2, 3) std::endl; // 输出 5 // 存储函数对象 StaticFunctionint(int) func2 Multiply{10}; std::cout func2(5) std::endl; // 输出 50 // 存储lambda StaticFunctionvoid() func3 []() { std::cout Hello Lambda\n; }; func3(); // 输出 Hello Lambda // 放入容器 std::vectorStaticFunctionvoid() tasks; tasks.push_back([](){ std::cout Task A\n; }); tasks.push_back([](){ std::cout Task B\n; }); for (const auto task : tasks) { task(); } // 输出: // Task A // Task B return 0; }性能分析优点StaticFunction本身类型是统一的可以放入标准容器。调用operator()时通过虚函数invoke进行一次动态派发。虽然有一次虚函数调用开销但实际的工作即f_(args...)是直接调用存储的可调用对象。如果F的operator()非常简单比如一个小的lambda并且被频繁调用编译器在优化时有可能将invoke和f_的调用内联到一起从而减少开销。这比纯动态多态虚函数调用实际工作通常要好。缺点由于使用了堆分配std::unique_ptr和虚函数创建和调用开销比直接使用函数指针或模板参数更大。它是对灵活性和性能的折中。对比std::function标准库的std::function实现原理与此类似但更加复杂和优化例如小对象优化避免堆分配。我们这个示例是一个教学简化版。实操心得在需要类型擦除的场景如果性能要求不是极端苛刻优先使用std::function。自己实现类似功能时务必考虑小对象优化Small Object Optimization, SOO即对于小的可调用对象比如捕获列表小的lambda直接将其存储在StaticFunction内部的缓冲区中避免堆分配这对性能提升巨大。这也是std::function和许多现代C库如Folly的Function的标准做法。5. 常见问题与排查技巧实录在实际项目中混用模板和继承总会遇到一些令人困惑的编译错误或运行时问题。这里记录几个典型问题及其排查思路。5.1 编译错误“不是模板”、“无效的模板参数”问题描述在CRTP或模板派生类中编译器报错提示基类不是模板或者模板参数无效。示例代码与错误template typename T class Base {}; class Derived : public BaseDerived { // 看起来没问题 public: void foo() { Base::someStaticMethod(); // 错误Base 不是模板 } };原因与排查Base是一个模板类它的完整名称是BaseT。在Derived的作用域内直接写Base编译器不会将其与模板BaseT关联。你需要写BaseDerived。修正BaseDerived::someStaticMethod();。如果someStaticMethod依赖于模板参数可能还需要template关键字如果它是模板成员函数。另一个相关错误template typename T class Outer { public: template typename U class Inner {}; }; template typename T class MyClass : public OuterT::InnerT { // 错误Inner 是依赖名称需要 typename };原因与排查因为OuterT依赖于模板参数T所以OuterT::Inner是一个依赖名称。编译器在解析时不知道Inner是一个类型还是一个静态成员。你需要用typename关键字告诉编译器它是一个类型。修正class MyClass : public typename OuterT::template InnerT { ... };。注意如果Inner本身也是模板还需要template关键字。5.2 链接错误未定义的引用Undefined Reference问题描述模板类成员函数的定义放在.cpp文件中导致链接时找不到定义。示例代码mytemplate.htemplate typename T class MyTemplate { public: void doSomething(T value); // 只有声明 };mytemplate.cpp#include mytemplate.h template typename T void MyTemplateT::doSomething(T value) { // 定义 // 实现... } // 显式实例化如果只针对特定类型 template class MyTemplateint;main.cpp#include mytemplate.h int main() { MyTemplatedouble obj; // 使用 double 实例化 obj.doSomething(3.14); // 链接错误找不到 MyTemplatedouble::doSomething 的定义 }原因与排查模板的实例化是在编译单元.cpp文件中进行的。mytemplate.cpp中只显式实例化了MyTemplateint所以编译器只为int版本生成了代码。main.cpp中使用了double版本编译器看到声明认为定义在其他地方但链接时在mytemplate.cpp中找不到double版本的实现。解决方案1推荐将模板类成员函数的定义也放在头文件.h或.hpp中。这样在每个包含该头文件的编译单元中编译器都能看到定义并根据需要实例化。解决方案2如果出于代码隐藏考虑必须将定义放在.cpp中则需要在.cpp文件中显式实例化所有你会用到的类型。例如在mytemplate.cpp末尾添加template class MyTemplatedouble;。但这失去了模板的泛型性不推荐用于通用库。5.3 运行时多态失效对象切片与错误转型问题描述使用基类引用或指针操作派生类对象时行为不符合预期特别是与dynamic_cast或typeid相关时在模板继承层次中可能更微妙。示例代码template typename Derived class Base { public: virtual ~Base() default; virtual void identify() const { std::cout Base\n; } }; class Derived1 : public BaseDerived1 { public: void identify() const override { std::cout Derived1\n; } void specific1() const { std::cout Specific1\n; } }; void process(BaseDerived1 ref) { ref.identify(); // 正确输出 Derived1 // 试图向下转型 auto* pd dynamic_castDerived1*(ref); if (pd) { pd-specific1(); // 可以调用 } // 但如果传入的是 BaseDerived1 的另一个派生类呢 } class EvilDerived : public BaseDerived1 { // 注意它继承自 BaseDerived1而不是 BaseEvilDerived public: void identify() const override { std::cout EvilDerived\n; } };问题分析EvilDerived也继承自BaseDerived1。process函数接受BaseDerived1所以EvilDerived对象也能传进去。在函数内部dynamic_castDerived1*会对EvilDerived对象失败返回nullptr因为Derived1不是EvilDerived的基类。这可能导致逻辑错误。排查技巧在设计CRTP时要警惕这种“错配”的继承。一个防御性做法是在CRTP基类中将派生类类型作为友元并将构造函数设为protected防止外部直接实例化基类但无法完全防止EvilDerived这种情况。更安全的做法如果确实需要运行时类型检查考虑在基类中添加一个virtual的type标识函数或者使用typeid。但更好的设计是重新审视是否需要这种“错配”的继承也许应该让EvilDerived继承自BaseEvilDerived。5.4 调试技巧打印类型信息与静态断言在模板元编程和复杂继承中搞清楚当前实例化的是什么类型至关重要。1. 使用typeid和__PRETTY_FUNCTION__GCC/Clang或__FUNCSIG__MSVCtemplate typename T void debugType(const T obj) { std::cout Typeid name: typeid(obj).name() std::endl; // 可能被修饰 #ifdef __GNUC__ std::cout Pretty function: __PRETTY_FUNCTION__ std::endl; #elif defined(_MSC_VER) std::cout Func signature: __FUNCSIG__ std::endl; #endif }__PRETTY_FUNCTION__会在编译时展开包含函数签名和模板参数是调试模板代码的利器。2. 使用static_assert进行编译期检查 在模板或继承设计中可以用static_assert确保类型符合某些约束。template typename Derived class Shape { static_assert(std::is_base_of_vShapeDerived, Derived, Derived must inherit from ShapeDerived (CRTP)); // 或者检查是否有某个成员函数 static_assert(std::is_invocable_r_vdouble, decltype(Derived::area_impl), const Derived*, Derived must have a const member function area_impl returning double); public: // ... };这能在编译早期捕获设计契约的违反比运行时错误友好得多。3. 使用概念C20进行约束 如果你在使用C20概念Concepts是更强大的工具。template typename T concept DrawableShape requires(const T t) { { t.draw_impl() } - std::same_asvoid; { t.area_impl() } - std::convertible_todouble; } std::is_base_of_vShapeT, T; template DrawableShape T void render(const ShapeT shape) { shape.draw(); }这使接口约束更加清晰和自文档化。结合模板和继承是C高级编程的标志之一。它要求开发者不仅理解语法更要理解其背后的设计意图和权衡。从静态多态的CRTP到灵活的类型擦除再到编译期策略组合每一种模式都是解决特定问题的利器。掌握它们的关键在于多实践、多思考“为什么这样设计”并善用编译器错误信息和调试工具来验证自己的理解。