尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Effective C++核心准则解析:从语言联邦到RAII资源管理

Effective C++核心准则解析:从语言联邦到RAII资源管理 1. 项目概述为什么我们需要重读《Effective C》如果你在C这条路上已经摸爬滚打了一段时间手头可能已经堆满了各种“从入门到精通”的厚书也写过不少能跑起来的代码。但有没有那么一瞬间你看着自己写的类或者review同事的代码时心里会犯嘀咕“这代码功能是实现了但总觉得哪里不对劲好像不够‘C’” 或者当你面对一个看似简单的设计选择——比如该用const还是非const成员函数该传值、传引用还是传智能指针——时会感到一丝犹豫不确定哪种才是“正确”的、更高效、更安全的选择。这种感觉恰恰是《Effective C》这本书试图为你驱散的迷雾。它不教你语法那是入门书的事它教你的是“idiom”惯用法是经过千锤百炼的最佳实践是让你从“能用C”到“用好C”的关键一跃。我最初接触这本书时已经自认为是个合格的C程序员了。直到被书中第一条准则“视C为一个语言联邦”点醒才意识到自己过去可能一直在用写C或者带类的C的风格在写C。这次“万字详解”系列就是希望能结合我这些年在工业级项目开发、性能调优以及面试他人时积累的经验把Scott Meyers大师的智慧掰开了、揉碎了再佐以实际的代码案例和踩坑教训呈现给你。我们不仅要看懂条款说了什么更要深挖背后的设计哲学、性能考量以及编译器可能的行为让你知其然更知其所以然。无论你是正在准备技术面试被“C八股文”所困扰还是希望在日常开发中写出更健壮、更高效的代码这个系列都将是一份值得你反复查阅的实战指南。2. 核心思想解析C的多范式本质与心智模型2.1 视C为一个语言联邦这是《Effective C》的开篇第一条也是奠定正确C世界观的基础。Scott Meyers将C分解为四个主要的次语言SublanguageCC的基础包括区块、语句、预处理器、内置数据类型、数组、指针等。但记住C的C部分并非完全兼容C89/C99且有更严格的类型检查。Object-Oriented C面向对象部分包括类、封装、继承、多态、虚函数等。这是很多初学者认为的“C核心”。Template C泛型编程部分这是C威力巨大的领域带来了STL和编译期多态。STL标准模板库一个包含容器、迭代器、算法和函数对象的特殊模板库它有自己的一套约定和用法。为什么这个观念如此重要因为每个次语言都有自己的规约。当你切换“语言”时高效编程的准则也需要切换。例如在C部分内置类型传值通常比传引用更高效。在**Object-Oriented C**部分通过引用传递用户自定义类型以避免切片和拷贝开销是常态。在**Template C**部分你面对的是未知类型typename和模板推导规则成为主角。在STL部分你必须遵循迭代器和函数对象的约定否则代码无法与算法协作。实操心得我见过很多代码的混乱根源在于没有建立这种“联邦”心智模型。比如在应该使用STL算法和Lambda表达式的现代C场景却用C风格的循环和函数指针硬套导致代码冗长且易错。建立这种模型后你在阅读或编写代码时会本能地识别当前代码主要属于哪个“联邦”并应用相应的最佳实践代码会立刻显得更清晰、更专业。2.2 用const,enum,inline替换#define这条准则关乎的是“宁可用编译器也不用预处理器”。#define是预处理器指令它在编译前进行简单的文本替换这带来了诸多问题调试困难#define定义的符号不会被编译器看到如果出现编译错误错误信息指向的是替换后的值而不是符号名。例如#define ASPECT_RATIO 1.653如果这个值在某个地方引发错误编译器报错信息是1.653而不是ASPECT_RATIO你不得不去头文件里查找这个魔数。作用域和封装性差#define不尊重作用域一旦定义在其后的编译单元中都有效除非#undef。这容易导致命名污染。无法定义类专属常量#define不能用来定义类作用域的常量。解决方案对于常量使用const或constexpr。// 替代 #define PI 3.14159 const double Pi 3.14159; // 更推荐使用constexpr constexpr double Pi 3.14159; // C11起编译期常量对于类专属常量使用static const或static constexpr成员。class GamePlayer { private: static const int NumTurns 5; // 常量声明式 int scores[NumTurns]; // 使用该常量 // ... }; // 必要时在实现文件中定义如果取了地址等 // const int GamePlayer::NumTurns;注意老的编译器可能不支持static成员在声明时获得初始值in-class initialization此时可以用“the enum hack”。the enum hack一个实用的技巧特别是当你需要数组大小而编译器不支持类内初始化static整型常量时。class GamePlayer { private: enum { NumTurns 5 }; // “the enum hack” - 令NumTurns成为5的一个记号名称 int scores[NumTurns]; // ... };enum hack的行为更像#define而非const你不能取enum的地址也不能创建引用这有时正是你想要的。它也是模板元编程的基础技术之一。对于形似函数的宏使用inline函数。// 糟糕的宏 #define CALL_WITH_MAX(a, b) f((a) (b) ? (a) : (b)) // 优秀的inline函数替代 templatetypename T inline void callWithMax(const T a, const T b) { f(a b ? a : b); }宏因为只是文本替换参数即使加上括号也可能因为求值次数或运算符优先级问题导致意外虽然上面这个例子加了括号但a和b会被求值两次如果a或b是带有副作用的表达式如i就会出问题。而inline函数遵循作用域和访问规则参数只会被求值一次类型安全是绝对更优的选择。常见问题有人会问constexpr和const有什么区别简单说所有constexpr对象都是const的但并非所有const对象都是constexpr的。constexpr用于指示编译器该值或函数可以在编译期计算这为性能优化和元编程打开了大门。在现代C中应优先考虑constexpr。3. 对象使用与管理的关键准则3.1 尽可能使用constconst是C中一个威力巨大但常被低估的关键字。它允许你指定一个语义约束——某个对象不应该被修改。编译器会强制执行这个约束。善用const可以帮助你侦测出错误用法因为编译器会在你试图修改const对象时报错。const与指针这是最容易混淆的地方。char greeting[] Hello; char* p greeting; // non-const pointer, non-const data const char* p greeting; // non-const pointer, const data (指针可变指向的数据不可变) char* const p greeting; // const pointer, non-const data (指针不可变指向的数据可变) const char* const p greeting; // const pointer, const data (都不可变)记忆口诀const出现在*左边表示被指物是常量出现在*右边表示指针自身是常量出现在两边表示两者都是常量。const与迭代器STL迭代器以指针为原型所以const行为类似。std::vectorint vec; const std::vectorint::iterator iter vec.begin(); // iter相当于 T* const *iter 10; // OK 修改iter所指物 iter; // 错误iter是const std::vectorint::const_iterator cIter vec.begin(); // cIter相当于 const T* *cIter 10; // 错误*cIter是const cIter; // OK 修改迭代器本身const成员函数这是const最具威力的用法之一。将const实施于成员函数是为了确认该成员函数可作用于const对象身上。这有两个好处使class接口更容易理解知道哪个函数可以改动对象内容哪个不行。使“操作const对象”成为可能这是编写高效代码的关键因为以const引用传递对象是避免不必要拷贝的常用手段如果该对象没有const成员函数就无法调用。两个成员函数如果只是常量性不同可以被重载class TextBlock { public: const char operator[](std::size_t position) const // for const objects { return text[position]; } char operator[](std::size_t position) // for non-const objects { return text[position]; } private: std::string text; }; TextBlock tb(Hello); std::cout tb[0]; // 调用 non-const TextBlock::operator[] tb[0] x; // OK 写一个non-const TextBlock const TextBlock ctb(World); std::cout ctb[0]; // 调用 const TextBlock::operator[] ctb[0] x; // 错误写一个const TextBlockmutable关键字有时一个const成员函数从逻辑上不应该修改对象状态但可能需要修改一些物理上与对象状态无关的“缓存”成员如mutex 缓存的计算结果。这时可以用mutable释放掉non-static成员变量的bitwise constness约束。class CTextBlock { public: std::size_t length() const; private: char* pText; mutable std::size_t textLength; // 这些成员变量可能总是会被更改 mutable bool lengthIsValid; // 即使在const成员函数内。 }; std::size_t CTextBlock::length() const { if (!lengthIsValid) { textLength std::strlen(pText); // 现在可以修改mutable成员了 lengthIsValid true; } return textLength; }避坑指南const最重要的作用在于声明接口。在设计类时应该立即问自己哪些成员函数不修改对象状态把它们声明为const。这不仅仅是一种风格更是一种契约能帮助编译器为你检查出许多潜在的错误。3.2 确定对象被使用前已先被初始化读取未初始化的值会导致不明确的行为。对于内置类型你必须手工初始化。int x 0; // 手工初始化 const char* text A C-style string; // 手工初始化 double d; std::cin d; // 以读取input stream的方式完成初始化对于用户自定义类型类初始化的责任落在了构造函数身上。规则很简单确保每一个构造函数都将对象的每一个成员初始化。但这里要区分“赋值”和“初始化”。class PhoneNumber { /* ... */ }; class ABEntry { public: ABEntry(const std::string name, const std::string address, const std::listPhoneNumber phones); private: std::string theName; std::string theAddress; std::listPhoneNumber thePhones; int numTimesConsulted; }; // 赋值而非初始化 ABEntry::ABEntry(const std::string name, const std::string address, const std::listPhoneNumber phones) { theName name; // 这些都是赋值 theAddress address; // 而非初始化 thePhones phones; numTimesConsulted 0; }C规定对象的成员变量的初始化动作发生在进入构造函数本体之前。对于theName,theAddress,thePhones这些非内置类型在进入构造函数体之前它们的默认构造函数已经被调用。然后在构造函数体内operator又被调用进行了一次赋值操作。这导致了一次默认构造加一次赋值的开销效率不高。正确的做法是使用成员初始化列表ABEntry::ABEntry(const std::string name, const std::string address, const std::listPhoneNumber phones) : theName(name), // 这些是初始化 theAddress(address), thePhones(phones), numTimesConsulted(0) // 内置类型也可用初始化列表 { } // 构造函数本体现在为空现在theName以name为初值进行拷贝构造theAddress以address为初值进行拷贝构造thePhones以phones为初值进行拷贝构造。这通常比“默认构造赋值”更高效。规则总是在初始化列表中列出所有成员变量。即使对于内置类型如int使用初始化列表也只需要一个步骤而赋值则需要两个先默认初始化再赋值。虽然对于内置类型成本差异不大但为了一致性最好也放在初始化列表里。初始化顺序成员变量的初始化顺序与它们在类中的声明顺序相同与在初始化列表中的排列顺序无关。为了避免晦涩的错误初始化列表的顺序最好与声明顺序一致。const和引用成员const成员和引用成员必须使用初始化列表进行初始化因为它们不能被赋值。关于“不同编译单元内定义的non-local static对象的初始化顺序”问题这是一个经典难题。static对象全局对象、命名空间作用域的对象、类内static成员、函数内static对象的寿命从被构造出来直到程序结束。编译单元是指产出单一目标文件的源码文件。 问题在于如果某个编译单元内的某个non-local static对象的初始化动作使用了另一个编译单元内的某个non-local static对象而它所用到的这个对象尚未被初始化就会出问题因为C对“定义于不同编译单元内的non-local static对象”的初始化顺序并无明确定义。解决方案将每个non-local static对象搬到自己的专属函数内该对象在此函数内被声明为static然后让函数返回该对象的引用。这是Singleton模式的一个常见实现手法。因为C保证函数内的local static对象会在“该函数被调用期间”“首次遇上该对象的定义式”时被初始化。所以如果你以“函数调用”返回一个引用指向local static对象替换“直接访问non-local static对象”就能保证获得的那个引用指向一个历经初始化的对象。// 原始版本可能有初始化顺序问题 class FileSystem { ... }; FileSystem tfs; // non-local static对象 class Directory { public: Directory() { std::size_t disks tfs.numDisks(); // 使用tfs } }; Directory tempDir; // 如果tfs在tempDir之前初始化没问题否则灾难。 // 改进版本 class FileSystem { ... }; FileSystem tfs() { // 用函数替换直接对象 static FileSystem fs; // 在函数内定义并初始化local static对象 return fs; // 返回引用 } class Directory { public: Directory() { std::size_t disks tfs().numDisks(); // 改为函数调用 } }; Directory tempDir() { static Directory td; return td; }这个手法的基础在于C不但保证函数内的local static对象会在第一次被使用时初始化还保证如果初始化过程中抛出异常下次进入函数时会再次尝试初始化。4. 资源管理与智能指针的现代实践4.1 以对象管理资源RAII这是C资源管理的核心思想Resource Acquisition Is Initialization。资源动态内存、文件句柄、互斥锁、数据库连接等在构造函数中获得在析构函数中释放。这样将资源管理的责任交给了对象的生命周期由编译器自动调用析构函数从而确保资源被释放即使遇到异常。传统手工管理的陷阱void f() { Investment* pInv createInvestment(); // 调用工厂函数 ... // 如果这里有一个return语句或者抛出了异常... delete pInv; // ...那么这句可能永远不会执行导致内存泄漏。 }使用智能指针进行管理void f() { std::unique_ptrInvestment pInv(createInvestment()); // 使用unique_ptr ... // 无论函数如何结束正常返回、异常退出pInv的析构函数都会自动删除资源 } // 自动调用deletestd::unique_ptr是“独占式”智能指针它确保一个对象及其资源在同一时间只被一个unique_ptr拥有。当unique_ptr被销毁离开作用域或被重置它所拥有的对象也会被销毁。两个关键想法获得资源后立刻放进管理对象内上面代码中createInvestment返回的资源被直接用来初始化unique_ptr。管理对象运用析构函数确保资源被释放unique_ptr的析构函数会自动对其所拥有的指针调用delete。注意事项auto_ptr是C98的产物具有“转移语义”在C11中已被废弃请使用unique_ptr。unique_ptr禁止拷贝拥有权唯一但可以通过std::move转移拥有权。对于需要共享所有权的场景使用std::shared_ptr。shared_ptr通过引用计数来管理资源当最后一个shared_ptr被销毁时资源才会被释放。std::weak_ptr是shared_ptr的“弱引用”它不增加引用计数用于打破shared_ptr的循环引用。4.2 在资源管理类中小心拷贝行为并非所有资源都是动态内存。有时你需要管理的是互斥锁、文件句柄等。这时你需要建立自己的资源管理类。但当你复制一个RAII对象时会发生什么你有几种选择禁止复制许多时候允许RAII对象被复制并不合理比如互斥锁。你可以通过继承一个像boost::noncopyable的类或者将拷贝构造函数和拷贝赋值运算符声明为privateC11后使用 delete来禁止复制。class Lock { public: explicit Lock(Mutex* pm) : mutexPtr(pm) { lock(mutexPtr); } ~Lock() { unlock(mutexPtr); } private: Lock(const Lock) delete; // 禁止拷贝 Lock operator(const Lock) delete; Mutex* mutexPtr; };对底层资源使用“引用计数法”有时我们希望保有资源直到它的最后一个使用者被销毁。std::shared_ptr允许指定“删除器”这是一个函数或函数对象当引用计数为0时被调用而不是简单地delete。class Lock { public: explicit Lock(Mutex* pm) : mutexPtr(pm, unlock) { // 以unlock为删除器 lock(mutexPtr.get()); // 获取资源 } // 无需声明析构函数因为mutexPtr的析构函数会自动调用删除器unlock private: std::shared_ptrMutex mutexPtr; // 使用shared_ptr管理Mutex };这里Lock类不再声明析构函数因为mutexPtr的析构函数会在其引用计数为0时自动调用我们指定的删除器unlock。shared_ptr的默认行为是delete但我们可以通过第二个模板参数指定自定义删除器。复制底部资源进行“深度拷贝”。例如标准字符串类复制一个字符串对象时会同时复制其底层的字符缓冲区。转移底部资源的所有权像unique_ptr那样将资源的所有权从被复制物转移到目标物。关键点复制RAII对象必须一并复制它所管理的资源所以资源的拷贝行为决定了RAII对象的拷贝行为。常见的RAII类拷贝行为是禁止拷贝、使用引用计数。这些可以通过unique_ptr和shared_ptr来实现从而避免自己处理复杂的拷贝逻辑。4.3 在资源管理类中提供对原始资源的访问RAII类将原始资源封装起来但有时你不得不需要直接访问原始资源例如某些遗留API需要原始指针。这时你需要提供一种方式让外界能够取得RAII对象所管理的原始资源。有两种方式显式转换和隐式转换。显式转换提供一个get()成员函数返回原始资源的指针或引用。shared_ptr和unique_ptr都提供了get()成员函数来返回原始指针。这种方式更安全因为用户必须显式调用知道自己正在接触原始资源。std::shared_ptrInvestment pInv(createInvestment()); int daysHeld(const Investment* pi); // 一个需要原始指针的API int days daysHeld(pInv.get()); // 显式转换隐式转换通过重载运算符如operator-和operator*或提供隐式类型转换函数来实现。shared_ptr和unique_ptr重载了operator-和operator*所以你可以像使用原始指针一样使用它们。这种方式更自然但可能增加误用的风险。class FontHandle { ... }; class Font { // RAII class public: explicit Font(FontHandle fh) : f(fh) {} ~Font() { releaseFont(f); } FontHandle get() const { return f; } // 显式转换函数 operator FontHandle() const { return f; } // 隐式转换函数可能危险 private: FontHandle f; }; void changeFontSize(FontHandle f, int newSize); // 需要原始资源的API Font f(getFont()); changeFontSize(f.get(), 20); // 显式转换清晰 changeFontSize(f, 20); // 隐式转换方便但可能意外发生类型转换建议通常显式转换如get()函数更受欢迎因为它减少了因非故意类型转换而导致的错误。但有时为了使用的自然性如智能指针隐式转换也是合理的。你需要根据具体场景权衡安全性和便利性。5. 设计与声明构建健壮的接口5.1 让接口容易被正确使用不易被误用好的接口就像一段好的代码是直观且自解释的。理想情况下如果用户误用了接口代码不应该通过编译如果编译通过了接口的行为也应该符合用户的直觉。促进正确使用保持接口的一致性与内置类型或标准库的行为一致。例如STL容器的接口就非常一致size()成员函数在各个容器中都有相同的语义。阻止误用建立新类型许多误用源于“错误的参数传递”。例如一个表示日期的类class Date { public: Date(int month, int day, int year); ... };用户可能很容易搞错顺序Date d(30, 3, 1995);。我们可以通过引入简单的“外覆类型”来让编译器检查类型。struct Day { explicit Day(int d) : val(d) {} int val; }; struct Month { explicit Month(int m) : val(m) {} int val; }; struct Year { explicit Year(int y) : val(y) {} int val; }; class Date { public: Date(const Month m, const Day d, const Year y); ... }; Date d(Month(3), Day(30), Year(1995)); // 类型正确 Date d(30, 3, 1995); // 错误类型不匹配 Date d(Day(30), Month(3), Year(1995)); // 错误类型顺序不匹配限定对象的值对于Month其有效值是1到12。我们可以预先定义所有有效的Month对象。class Month { public: static Month Jan() { return Month(1); } // 返回有效月份 static Month Feb() { return Month(2); } ... // 其他月份 static Month Dec() { return Month(12); } private: explicit Month(int m); // 阻止生成新的月份 ... // 其他数据 }; Date d(Month::Mar(), Day(30), Year(1995)); // 清晰且正确限制类型上的操作const就是一个典型的例子它限制了修改操作。提供行为一致的接口例如STL容器都提供size()成员函数而不是有的用size()有的用length()。智能指针与资源管理shared_ptr支持自定义删除器这可以防止一个常见的错误“跨DLL的new/delete”。如果对象在一个DLL中被new创建却在另一个DLL中被delete在许多平台上会导致运行时错误。shared_ptr的默认删除器是delete但如果你在创建shared_ptr时指定了删除器比如DLL提供的释放函数那么无论shared_ptr在哪里被销毁都会调用正确的释放函数。核心思想好的接口应该考虑到用户可能犯的所有错误并利用类型系统在编译期尽可能多地阻止这些错误。这通常意味着你需要投入更多精力在设计阶段但这会换来更少的运行时错误和更低的调试成本。5.2 设计class犹如设计type在C中定义一个class就是定义了一个新的type。设计一个高效的class是每个C程序员的核心任务。当你设计一个新的class时你实际上是在回答一系列问题新type的对象应该如何被创建和销毁这决定了你的构造函数和析构函数以及operator new,operator new[],operator delete,operator delete[]的重载。对象的初始化和对象的赋值该有什么样的差别这决定了你的构造函数和拷贝赋值运算符的行为。新type的对象如果被passed by value以值传递意味着什么拷贝构造函数定义了以值传递一个对象时的行为。什么是新type的“合法值”这决定了你的成员函数特别是构造函数、赋值运算符和“setter”函数必须进行的错误检查。它也影响函数抛出的异常。你的新type需要配合某个继承图系吗如果你继承自已有的类你的设计就会受到那些类的约束特别是虚函数。如果你打算让其他类继承你的类那就要决定是否将析构函数声明为虚函数。你的新type需要什么样的类型转换如果你希望允许类型T1隐式转换为你的新类型T2你需要在T2内写一个以T1为参数的构造函数非explicit。如果你希望允许你的新类型T2隐式转换为其他类型你需要在T2内写一个类型转换函数operator T1等。什么样的操作符和函数对此新type是合理的这决定了你将为你的类声明哪些函数。什么样的标准函数应该被驳回那些必须声明为private的C11后可用 delete。谁该取用新type的成员这决定了哪些成员是public,protected,private。也决定了哪些类和/或函数应该是友元。什么是新type的“未声明接口”它对效率、异常安全性以及资源运用提供何种保证这些保证将为你的class实现代码加上相应的约束。你的新type有多么一般化也许你其实不是在定义一个新的type而是在定义一整个types家族。如果是这样你应该定义一个class template。你真的需要一个新type吗如果只是定义新的派生类以便为已有的class添加功能说不定单纯定义一个或多个非成员函数或模板更能达到目标。一个具体的例子有理数类假设我们要设计一个表示有理数的类Rational。创建/销毁需要构造函数默认、带参数、拷贝构造函数、析构函数编译器生成的可能就够用除非有资源管理。初始化 vs 赋值构造函数初始化operator赋值。传值需要拷贝构造函数。合法值分母不能为0。构造函数和赋值操作需要检查。继承可能不涉及。类型转换可能希望从int隐式构造RationalRational r 5;所以需要Rational(int numerator 0, int denominator 1);并且不能是explicit。也可能希望支持到double的隐式转换这可能有精度损失风险所以可能提供asDouble()显式转换函数更好。操作符需要operator,operator-,operator*,operator/以及它们的复合赋值版本和比较操作符。这些操作符应该作为非成员函数还是成员函数如果它们需要访问私有成员可以设为友元或者通过公有接口实现。通常像operator这样的对称操作符定义为非成员函数更自然允许左侧操作数发生隐式类型转换。驳回的函数可能不需要。成员访问分子分母可能是私有提供getter/setter。未声明接口保证不抛出异常保证强异常安全需要明确。一般化也许可以做成模板类templatetypename T class Rational其中T是整数类型。真的需要吗是的我们需要一个精确表示分数的类型。通过系统地思考这些问题你设计出的class会更健壮、更易用、更不容易被误用。这不仅仅是语法问题更是软件设计问题。
返回列表