十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C++模板编程核心:从隐式接口到编译期多态与内存管理

C++模板编程核心:从隐式接口到编译期多态与内存管理 1. 项目概述为什么《Effective C》的模板与泛型编程章节如此重要如果你写过一段时间的C尤其是接触过标准库STL或者一些现代C库那你一定绕不开模板。刚开始你可能觉得模板就是个“类型占位符”写个vectorT或者max(T a, T b)就差不多了。但当你真正想写出灵活、高效、安全的泛型代码时会发现坑一个接一个为什么我的编译错误信息长得像天书为什么这个模板特化没被调用为什么移动语义在模板里好像失效了《Effective C》的条款41到条款52这整整12个条款就是Scott Meyers为你准备的、穿越这片“模板雷区”的详细排雷手册。这不仅仅是关于语法更是关于思维模式的转变。从面向对象编程OOP到泛型编程GP你需要从“类型层次结构”和“运行时多态”的思维切换到“编译时多态”和“隐式接口”的思维。这12个条款覆盖了从最基础的隐式接口与编译期多态到高级的模板元编程、类型推导、移动语义与完美转发在模板中的微妙之处最后以如何定制化new和delete来管理模板类内存作结。掌握它们意味着你能写出不仅正确而且具备工业级健壮性和效率的泛型组件。对于任何希望深入理解现代C、参与基础库开发或优化高性能计算代码的工程师来说这部分内容是必须啃下的硬骨头。2. 核心概念解析隐式接口与编译期多态2.1 条款41理解隐式接口和编译期多态面向对象编程的世界围绕显式接口Explicit Interface和运行时多态Runtime Polymorphism。一个类通过public成员函数声明了它的显式接口当你用基类的指针或引用调用一个虚函数时具体调用哪个函数是在运行时根据对象的动态类型决定的。模板则将我们带入另一个维度。在模板中多态是通过模板具现化Instantiation和函数重载解析Overloading Resolution在编译期发生的因此称为编译期多态Compile-Time Polymorphism。而接口是隐式的Implicit Interface它基于模板参数必须支持的有效表达式Valid Expressions。举个例子考虑一个简单的模板函数templatetypename T void process(T w) { if (w.size() 10 w ! someNastyWidget) { T temp(w); temp.normalize(); temp.swap(w); } }对于类型T它的隐式接口要求是什么呢它必须提供一个名为size的成员函数或存在一个可用的size(T)非成员函数且该函数返回一个能与int10比较的类型。支持operator!操作能与someNastyWidget其类型可能是T或其他可比较类型进行比较。具有一个可访问的拷贝构造函数用于T temp(w)。提供一个名为normalize的成员函数或非成员函数。提供一个名为swap的成员函数或存在一个针对T的重载swap非成员函数。这些要求并没有在代码中显式声明但编译器会在具现化模板时检查所有表达式对于给定的具体类型T是否有效。如果无效编译就会失败并且错误信息通常会指向表达式失效的那一行。实操心得理解隐式接口是阅读和编写模板代码的关键。当你看到一个模板函数时不要只看函数签名要像编译器一样逐行分析代码对模板类型参数施加了哪些操作约束。这能帮助你快速预判一个类型是否适用于某个模板。2.2 条款42了解typename的双重意义typename这个关键字在模板中有两个看似无关、实则至关重要的用途。用途一声明模板类型参数。这是最常见的用法与class完全等价。templateclass T class Widget; // 使用class templatetypename T class Widget; // 使用typename 更清晰在这个上下文中两者没有区别。但业界更倾向于使用typename因为它更能清晰地表达“这是一个类型参数”而不是“这是一个类”。用途二标识嵌套从属类型名称Nested Dependent Type Name。这是typename不可被class替代的关键场景。考虑以下代码templatetypename C void print2nd(const C container) { C::const_iterator * x; // 这行代码想做什么 // ... }编译器在解析这段模板代码时尚未具现化它不知道C::const_iterator是什么。因为C是一个模板参数const_iterator是一个依赖于C的名称称为“从属名称”Dependent Name。如果C内部有一个静态成员变量也叫const_iterator那么C::const_iterator * x;就可能被解释为乘法运算为了避免这种歧义C标准规定除非通过前面特定的关键字如typename明确指出否则编译器假定从属名称不是类型。因此我们必须使用typename来告诉编译器C::const_iterator是一个类型templatetypename C void print2nd(const C container) { typename C::const_iterator iter(container.begin()); // 正确声明一个迭代器 // ... }例外情况typename不能在基类列表Base Class List和成员初始化列表Member Initialization List中作为基类修饰符使用。例如templatetypename T class Derived: public BaseT::Nested { // 基类列表 不能加typename public: explicit Derived(int x): BaseT::Nested(x) { // 成员初始化列表 不能加typename typename BaseT::Nested temp; // 这里必须加typename } };避坑指南这是一个常见的编译错误来源。经验法则是在模板中对于任何嵌套在模板参数内部的、且表示一个类型的名称除非它出现在基类列表或成员初始化列表中否则前面必须加上typename。处理标准库容器迭代器时尤其要注意比如typename std::vectorT::iterator。3. 模板代码的编写与优化核心3.1 条款43学习处理模板化基类内的名称继承和模板结合时会有一个反直觉的问题。假设我们有一个模板化基类和一个派生类templatetypename Company class MsgSender { public: void sendClear(const MsgInfo info) { Company c; c.sendCleartext(info); } void sendSecret(const MsgInfo info) { /* ... */ } // 同sendClear }; templatetypename Company class LoggingMsgSender: public MsgSenderCompany { public: void sendClearMsg(const MsgInfo info) { // 记录日志... sendClear(info); // 调用基类函数 编译错误 // 记录日志... } };编译LoggingMsgSender::sendClearMsg时编译器不知道MsgSenderCompany是什么因为Company是一个模板参数。它可能被特化而特化版本可能不提供sendClear函数。因此C编译器在解析模板化派生类时拒绝在模板化基类中寻找继承而来的名称。它认为sendClear是一个非从属名称在当前作用域或外围作用域中查找找不到就报错。有三种方法解决这个问题使用this-前缀明确指出成员在基类中。this-sendClear(info);使用using声明式将基类名称引入派生类作用域。using MsgSenderCompany::sendClear; // 告诉编译器 请假设sendClear在基类中 void sendClearMsg(...) { sendClear(info); // 现在可以了 }显式指定基类作用域明确指出函数位于基类中。MsgSenderCompany::sendClear(info);这种方法最不推荐因为如果sendClear是虚函数这种写法会关闭虚绑定Virtual Binding。注意事项这是模板与继承交叉领域的一个经典陷阱。当你在派生类模板中调用基类模板的成员时如果编译器报错“未找到该名称”首先就应该想到这个条款。三种方法中this-通常是最简洁明了的选择。3.2 条款44将与参数无关的代码抽离模板模板会引发代码膨胀Code Blasting因为每个不同的模板参数都会导致编译器生成一份对应的代码。条款44的核心思想是进行共性与变性分析Commonality and Variability Analysis将模板中与模板参数无关的部分抽取出来避免重复。非类型参数Non-Type Parameters导致的膨胀templatetypename T, std::size_t n class SquareMatrix { // 计算n x n矩阵的逆 public: void invert(); }; SquareMatrixdouble, 5 sm1; SquareMatrixdouble, 10 sm2; sm1.invert(); // 生成SquareMatrixdouble, 5::invert sm2.invert(); // 生成SquareMatrixdouble, 10::invert两份invert函数除了常量5和10其他操作逻辑完全一样。解决方案是创建一个接受矩阵大小作为函数参数的基类templatetypename T class SquareMatrixBase { protected: void invert(std::size_t matrixSize); // ... }; templatetypename T, std::size_t n class SquareMatrix: private SquareMatrixBaseT { private: using SquareMatrixBaseT::invert; // 避免隐藏基类名称 public: void invert() { this-invert(n); } // 调用基类版本 并传入大小 };类型参数导致的膨胀例如vectorint和vectorlong在许多平台上其成员函数可能生成完全相同的代码因为int和long大小和表现可能相同。同样listint*和listconst int*的代码也几乎一样。对于指针类型可以通过让它们共享同一个、对void*进行操作的底层实现来消除膨胀。实操心得代码膨胀会增加编译时间、目标文件大小并可能影响指令缓存命中率。但不要过度优化。首先确保代码正确和清晰然后在性能分析表明代码膨胀确实是瓶颈时再应用此条款的技术。抽取基类可能会降低封装性并可能使代码更复杂。3.3 条款45运用成员函数模板接受所有兼容类型智能指针是“运用成员函数模板”的绝佳例子。一个SmartPtrBase应该能隐式转换为SmartPtrDerived吗不这不符合逻辑基类指针能指向派生类对象反之则不行。但是SmartPtrDerived应该能隐式转换为SmartPtrBase吗是的这模仿了原始指针的行为。如何让模板化的智能指针模仿这种转换关系我们需要为每个SmartPtrT定义拷贝构造函数和拷贝赋值运算符但它们不能是普通的、固定类型的函数因为SmartPtrDerived和SmartPtrBase是不同的类型。解决方案是使用成员函数模板Member Function Templates来生成“泛化拷贝构造函数”和“泛化赋值运算符”templatetypename T class SmartPtr { public: templatetypename U SmartPtr(const SmartPtrU other) // 泛化拷贝构造函数 : heldPtr(other.get()) { ... } // 用U*初始化T* templatetypename U SmartPtr operator(const SmartPtrU other) { ... } // 泛化赋值运算符 T* get() const { return heldPtr; } // ... private: T* heldPtr; };注意在构造函数初始化列表中我们用other.get()返回的U*来初始化T* heldPtr。这只有在U*可以隐式转换为T*时即U是T的派生类或U就是T才是合法的。编译器会在编译期为我们执行这个安全检查。这正是我们想要的允许从SmartPtrDerived构造SmartPtrBase但禁止从SmartPtrBase构造SmartPtrDerived。注意事项成员函数模板不会改变语言规则。即使声明了泛化拷贝构造函数编译器仍然会为我们生成正常的非模板拷贝构造函数。如果你需要控制拷贝行为的方方面面两者都需要声明。3.4 条款46需要类型转换时请为模板定义非成员函数条款24告诉我们对于普通类支持混合类型算术运算的正确方法是将操作符定义为非成员函数。例如让Rational类支持Rational * int和int * Rational。但当Rational变成类模板时事情就复杂了。templatetypename T class Rational { public: Rational(const T numerator 0, const T denominator 1); const T numerator() const; const T denominator() const; // ... }; templatetypename T const RationalT operator*(const RationalT lhs, const RationalT rhs) { ... }我们希望这样使用Rationalint oneHalf(1, 2); Rationalint result oneHalf * 2; // 错误无法编译。编译失败的原因在于模板实参推导Template Argument Deduction。在oneHalf * 2中编译器知道oneHalf的类型是Rationalint因此它能推导出第一个参数T是int。但对于第二个参数2它是int类型编译器需要推导出RationalT中的T是什么以便知道整个第二个参数的类型。这个过程从int推导出RationalT中的T无法完成因为不存在从int到Rationalint的隐式类型转换在模板实参推导阶段这种转换不被考虑。解决方案是将运算符声明为模板类的友元函数。这利用了友元函数可以在类内定义从而在模板具现化时被具体化的特性。templatetypename T class Rational { public: // ... // 声明友元函数。注意 这不是一个函数模板 而是每个RationalT具现化时生成的一个普通函数。 friend const Rational operator*(const Rational lhs, const Rational rhs) { return Rational(lhs.numerator() * rhs.numerator(), lhs.denominator() * rhs.denominator()); // 内联定义 } };现在当编译器看到oneHalf * 2时它知道oneHalf的类型是Rationalint这导致Rationalint类被具现化。作为这个过程的一部分一个接受两个Rationalint参数的operator*友元函数也被具体生成出来。这个生成的函数是一个普通的非成员函数而不是模板。因此当编译器进行函数调用解析时它可以将2通过Rationalint的非显式构造函数隐式转换为Rationalint从而调用成功。避坑指南为了让这个友元函数在类外可见以便能被链接到我们通常会在类内定义它如上述代码。如果函数体很复杂也可以在类内声明并调用一个定义在类外的辅助模板函数但最简洁的方式还是直接在类内实现。这是模板编程中一个经典的、违反直觉的模式。4. 模板元编程与编译期计算4.1 条款47请使用traits classes表现类型信息Traits是一种技术它允许你在编译期间获取与某个类型相关联的信息。标准库中充斥着traits的应用比如iterator_traits、numeric_limits。假设我们正在实现一个advance函数它将迭代器移动指定的距离。对于随机访问迭代器如vector的迭代器我们可以用iter d这是O(1)操作。对于双向迭代器如list的迭代器我们只能用iter或--iter循环d次这是O(n)操作。我们需要在编译期知道迭代器的种类。首先我们为每种迭代器类型定义一个标签结构Tag Structstruct input_iterator_tag {}; struct output_iterator_tag {}; struct forward_iterator_tag: public input_iterator_tag {}; struct bidirectional_iterator_tag: public forward_iterator_tag {}; struct random_access_iterator_tag: public bidirectional_iterator_tag {};然后每个迭代器类如listT::iterator会在其内部通过typedef声明自己的迭代器类别例如typedef bidirectional_iterator_tag iterator_category;。iterator_traits模板的作用就是统一地提取这个信息templatetypename IterT struct iterator_traits { typedef typename IterT::iterator_category iterator_category; // ... 其他信息 如value_type, difference_type等 }; // 针对原生指针的特化版本 templatetypename T struct iterator_traitsT* { typedef random_access_iterator_tag iterator_category; // ... };现在我们可以实现advance了templatetypename IterT, typename DistT void advance(IterT iter, DistT d) { doAdvance(iter, d, typename std::iterator_traitsIterT::iterator_category()); } // 针对随机访问迭代器的重载 templatetypename IterT, typename DistT void doAdvance(IterT iter, DistT d, std::random_access_iterator_tag) { iter d; } // 针对双向迭代器的重载 templatetypename IterT, typename DistT void doAdvance(IterT iter, DistT d, std::bidirectional_iterator_tag) { if (d 0) { while (d--) iter; } else { while (d) --iter; } } // 针对输入迭代器的重载forward迭代器继承自input 所以也会匹配这个 templatetypename IterT, typename DistT void doAdvance(IterT iter, DistT d, std::input_iterator_tag) { if (d 0) { throw std::out_of_range(Negative distance); } while (d--) iter; }整个过程在编译期完成编译器根据IterT的类型通过iterator_traits获取其iterator_category然后根据这个标签类型选择最匹配的doAdvance重载版本。实操心得Traits是C模板元编程的基石之一。它通过模板特化和嵌套类型定义将类型信息“绑定”到类型本身上。设计自己的泛型库时如果需要根据类型的不同采取不同的策略traits是一个非常强大的工具。记住其标准模式一个主模板、一系列特化包括针对指针的特化以及通过typedef暴露出的信息。4.2 条款48认识模板元编程模板元编程Template Metaprogramming, TMP是编写基于模板的、在编译期执行的程序。TMP有两个强大的特性第一它使得某些事情变得容易比如上面traits实现的编译期分派第二它将工作从运行时转移到了编译时这通常能以更小的可执行文件、更短的运行时间、更少的内存需求为代价换取更长的编译时间。一个经典的TMP例子是编译期计算阶乘templateunsigned n struct Factorial { enum { value n * Factorialn-1::value }; }; template struct Factorial0 { enum { value 1 }; }; int main() { std::cout Factorial5::value; // 输出120 在编译期计算 }TMP是图灵完备的Turing-complete这意味着理论上它可以用来执行任何计算。在实际中TMP常被用于确保量纲正确在科学计算中编译期检查物理单位如米、秒的运算是否正确。优化矩阵运算比如根据矩阵尺寸和是否转置等特性在编译期选择最优的循环顺序和算法。生成自定义的设计模式实现如基于策略Policy-Based的设计。注意事项TMP代码可能非常复杂、难以理解和调试编译错误信息也极其晦涩。它还会显著增加编译时间。因此除非有明确的性能或安全性收益否则应谨慎使用。对于大多数应用开发者理解标准库中如何使用TMP如type_traits比亲自编写复杂的TMP更重要。5. 现代C特性在模板中的关键应用5.1 条款49了解new-handler的行为new-handler是当operator new无法满足内存分配请求时调用的错误处理函数。通过std::set_new_handler()可以设置它。理解new-handler对于编写自定义的、特别是模板化的内存管理组件至关重要。一个设计良好的new-handler应该做以下几件事之一让更多内存可用例如程序启动时分配一大块内存在new-handler中释放它。安装另一个new-handler如果当前的new-handler无法做得更好可以安装一个功能更强或更弱的处理函数。卸载new-handler将nullptr传给set_new_handler这样operator new在分配失败时会直接抛出std::bad_alloc异常。抛出std::bad_alloc或派生自它的异常。不返回调用abort()或exit()。对于类特定的new-handler经典做法是class Widget { public: static std::new_handler set_new_handler(std::new_handler p) throw(); static void* operator new(std::size_t size) throw(std::bad_alloc); private: static std::new_handler currentHandler; }; // 在.cpp文件中初始化静态成员 std::new_handler Widget::currentHandler nullptr; std::new_handler Widget::set_new_handler(std::new_handler p) throw() { std::new_handler oldHandler currentHandler; currentHandler p; return oldHandler; } void* Widget::operator new(std::size_t size) throw(std::bad_alloc) { // 安装Widget的new-handler 保存全局的 NewHandlerHolder h(std::set_new_handler(currentHandler)); return ::operator new(size); // 调用全局operator new // 退出作用域时 NewHandlerHolder的析构函数会恢复全局new-handler }这里NewHandlerHolder是一个RAII类在其构造函数中保存当前的全局new-handler并设置新的在析构函数中恢复。当我们将这个模式模板化以便任何类都能轻松拥有自己的new-handler时就得到了条款49的核心std::nothrow new并不能保证不抛出异常它只保证在分配失败时返回nullptr但构造函数本身仍可能抛出异常。因此new (std::nothrow) Widget这种形式并不提供强大的异常安全保证。避坑指南自定义new-handler主要用于需要严格控制内存分配行为的场景如嵌入式系统或高频交易系统。对于一般应用使用默认的全局new-handler或异常处理通常就够了。理解这个机制更多的是为了理解C内存管理模型的深度。5.2 条款50了解new和delete的合理替换时机替换编译器提供的全局operator new和operator delete或它们的数组版本operator new[]和operator delete[]通常出于以下三个原因检测运用错误例如自定义版本可以在分配的内存块前后加入签名signatures在delete时检查签名是否被破坏以检测缓冲区溢出或重复释放。收集使用统计数据在分配和释放时记录信息用于分析内存使用模式、定位内存泄漏或优化内存布局。增加分配和归还的速度通用分配器为了处理各种大小的分配请求可能比较慢。专属分配器如为小对象设计的内存池可以大幅提升性能。降低缺省内存管理器带来的空间额外开销通用分配器为了管理内存每个内存块可能带有额外的簿记信息overhead。对于某些尺寸的对象自定义分配器可以减少这种开销。弥补缺省分配器中的非最佳对齐Suboptimal Alignment某些硬件平台对特定类型数据的访问有严格的对齐要求自定义分配器可以保证分配的内存满足特定对齐。将相关对象成簇集中如果知道某些数据结构总是一起使用可以定制new和delete将它们分配在相邻的内存页上以提高缓存命中率。获得非传统行为例如在共享内存中分配或者进行垃圾收集。替换时你需要提供operator new和operator delete的标准签名版本。operator new应该在无法满足请求时循环调用new-handler或抛出std::bad_alloc并处理零内存请求。operator delete在收到空指针时应该什么也不做。实操心得替换全局new/delete影响巨大必须非常小心。一个常见的折中方案是只为特定的类重载operator new和operator delete通过类的静态成员函数而不是替换全局版本。标准库容器如std::vector,std::list的分配器Allocator参数就是为这种定制化内存管理而设计的更灵活、侵入性更小的机制。5.3 条款51编写new和delete时需固守常规当你决定自定义operator new必须遵守一些约定俗成的规则operator new应内含一个无限循环尝试分配内存如果无法满足需求就调用new-handler。它也应该处理零字节请求将其视为1字节请求。operator delete应在收到nullptr时什么也不做。对于类专属的operator new还需要注意继承问题。如果你为Base类定义了专属operator new那么派生类Derived的对象默认也会使用它除非Derived自己也定义了。这可能导致问题因为Base::operator new设计时可能只考虑了Base对象的大小。因此类专属operator new应该对“错误大小”的分配请求转交给全局的::operator new来处理。class Base { public: static void* operator new(std::size_t size) throw(std::bad_alloc) { if (size ! sizeof(Base)) { return ::operator new(size); // 如果大小不对 转交全局版本 } // ... 否则处理Base对象的分配 } static void operator delete(void* rawMemory, std::size_t size) throw() { if (rawMemory nullptr) return; if (size ! sizeof(Base)) { ::operator delete(rawMemory); // 转交全局版本 return; } // ... 否则归还Base对象的内存 } };注意operator delete的size参数它表示将要删除的内存块的大小。只有当delete一个派生类对象而该派生类没有虚析构函数时这个size值可能与sizeof(Base)不同可能更大。这就是我们检查size的原因。注意事项自定义内存管理是C中最容易出错的地方之一。除非有非常充分的理由并且经过了严格的性能剖析证明否则不要轻易替换全局的new/delete。即使要替换也强烈建议参考成熟的开源内存分配器如jemalloc,tcmalloc的实现或者使用它们。5.4 条款52写了placement new也要写placement delete“Placement new”通常指除了size_t之外还接受其他参数的operator new版本。最常用的标准库版本是void* operator new(std::size_t, void* pMemory) throw();它允许在已分配的内存地址pMemory上构造对象。当你自定义了一个带额外参数的operator new即placement new例如void* operator new(std::size_t size, std::ostream logStream) throw(std::bad_alloc); Widget* pw new (std::cerr) Widget; // 调用placement new你必须同时提供对应的、参数列表完全相同的“placement delete”void operator delete(void* pMemory, std::ostream logStream) throw();为什么考虑对象构造过程new表达式先调用operator new分配内存然后调用对象的构造函数。如果构造函数抛出异常运行时系统Runtime System有责任将已分配的内存回收以避免内存泄漏。为了回收内存运行时系统会寻找一个参数个数和类型都与调用的operator new相同的operator delete。如果找不到它就不会释放内存导致内存泄漏。在上面的例子中如果Widget的构造函数在new (std::cerr) Widget执行时抛出异常运行时系统会尝试调用operator delete(void* pMemory, std::ostream logStream)来匹配之前调用的operator new。如果你没有提供这个placement delete运行时系统将找不到匹配的版本于是什么也不做内存就泄漏了。请注意placement delete只有在伴随placement new调用而触发的构造函数抛出异常时才会被调用。如果你直接调用operator delete来释放一个用placement new创建的对象你必须调用普通的operator delete。避坑指南这是一个非常隐蔽的陷阱。简单的规则是operator new和operator delete必须成对出现并且具有匹配的签名。当你为类定义任何非标准的operator new时请务必同时定义对应的operator delete。同样如果你重载了全局的placement new也必须重载对应的全局placement delete。
返回列表