news 2026/8/11 3:19:48

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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++分解为四个主要的次语言(Sublanguage):

  1. C:C++的基础,包括区块、语句、预处理器、内置数据类型、数组、指针等。但记住,C++的C部分并非完全兼容C89/C99,且有更严格的类型检查。
  2. Object-Oriented C++:面向对象部分,包括类、封装、继承、多态、虚函数等。这是很多初学者认为的“C++核心”。
  3. Template C++:泛型编程部分,这是C++威力巨大的领域,带来了STL和编译期多态。
  4. STL:标准模板库,一个包含容器、迭代器、算法和函数对象的特殊模板库,它有自己的一套约定和用法。

为什么这个观念如此重要?因为每个次语言都有自己的规约。当你切换“语言”时,高效编程的准则也需要切换。例如:

  • C部分,内置类型传值通常比传引用更高效。
  • 在**Object-Oriented C++**部分,通过引用传递用户自定义类型以避免切片和拷贝开销是常态。
  • 在**Template C++**部分,你面对的是未知类型,typename和模板推导规则成为主角。
  • STL部分,你必须遵循迭代器和函数对象的约定,否则代码无法与算法协作。

实操心得:我见过很多代码的混乱,根源在于没有建立这种“联邦”心智模型。比如,在应该使用STL算法和Lambda表达式的现代C++场景,却用C风格的循环和函数指针硬套,导致代码冗长且易错。建立这种模型后,你在阅读或编写代码时,会本能地识别当前代码主要属于哪个“联邦”,并应用相应的最佳实践,代码会立刻显得更清晰、更专业。

2.2 用const,enum,inline替换#define

这条准则关乎的是“宁可用编译器,也不用预处理器”。#define是预处理器指令,它在编译前进行简单的文本替换,这带来了诸多问题:

  1. 调试困难#define定义的符号不会被编译器看到,如果出现编译错误,错误信息指向的是替换后的值,而不是符号名。例如,#define ASPECT_RATIO 1.653,如果这个值在某个地方引发错误,编译器报错信息是1.653,而不是ASPECT_RATIO,你不得不去头文件里查找这个魔数。
  2. 作用域和封装性差#define不尊重作用域,一旦定义,在其后的编译单元中都有效(除非#undef)。这容易导致命名污染。
  3. 无法定义类专属常量#define不能用来定义类作用域的常量。

解决方案

  • 对于常量:使用constconstexpr
    // 替代 #define PI 3.14159 const double Pi = 3.14159; // 更推荐使用constexpr constexpr double Pi = 3.14159; // C++11起,编译期常量
  • 对于类专属常量:使用static conststatic 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函数替代 template<typename T> inline void callWithMax(const T& a, const T& b) { f(a > b ? a : b); }
    宏因为只是文本替换,参数即使加上括号,也可能因为求值次数或运算符优先级问题导致意外(虽然上面这个例子加了括号,但ab会被求值两次,如果ab是带有副作用的表达式,如++i,就会出问题)。而inline函数遵循作用域和访问规则,参数只会被求值一次,类型安全,是绝对更优的选择。

常见问题:有人会问,constexprconst有什么区别?简单说,所有constexpr对象都是const的,但并非所有const对象都是constexpr的。constexpr用于指示编译器该值(或函数)可以在编译期计算,这为性能优化和元编程打开了大门。在现代C++中,应优先考虑constexpr

3. 对象使用与管理的关键准则

3.1 尽可能使用const

const是C++中一个威力巨大但常被低估的关键字。它允许你指定一个语义约束——某个对象不应该被修改。编译器会强制执行这个约束。善用const可以帮助你侦测出错误用法,因为编译器会在你试图修改const对象时报错。

  1. 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出现在*左边,表示被指物是常量;出现在*右边,表示指针自身是常量;出现在两边,表示两者都是常量。

  2. const与迭代器:STL迭代器以指针为原型,所以const行为类似。

    std::vector<int> vec; const std::vector<int>::iterator iter = vec.begin(); // iter相当于 T* const *iter = 10; // OK, 修改iter所指物 ++iter; // 错误!iter是const std::vector<int>::const_iterator cIter = vec.begin(); // cIter相当于 const T* *cIter = 10; // 错误!*cIter是const ++cIter; // OK, 修改迭代器本身
  3. 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 TextBlock
  4. mutable关键字:有时,一个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::list<PhoneNumber>& phones); private: std::string theName; std::string theAddress; std::list<PhoneNumber> thePhones; int numTimesConsulted; }; // 赋值,而非初始化 ABEntry::ABEntry(const std::string& name, const std::string& address, const std::list<PhoneNumber>& 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::list<PhoneNumber>& phones) : theName(name), // 这些是初始化 theAddress(address), thePhones(phones), numTimesConsulted(0) // 内置类型也可用初始化列表 { } // 构造函数本体现在为空

现在,theNamename为初值进行拷贝构造theAddressaddress为初值进行拷贝构造,thePhonesphones为初值进行拷贝构造。这通常比“默认构造+赋值”更高效。

规则

  1. 总是在初始化列表中列出所有成员变量。即使对于内置类型(如int),使用初始化列表也只需要一个步骤,而赋值则需要两个(先默认初始化,再赋值)。虽然对于内置类型成本差异不大,但为了一致性,最好也放在初始化列表里。
  2. 初始化顺序:成员变量的初始化顺序与它们在类中的声明顺序相同,与在初始化列表中的排列顺序无关。为了避免晦涩的错误,初始化列表的顺序最好与声明顺序一致。
  3. 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_ptr<Investment> pInv(createInvestment()); // 使用unique_ptr ... // 无论函数如何结束(正常返回、异常退出),pInv的析构函数都会自动删除资源 } // 自动调用delete

std::unique_ptr是“独占式”智能指针,它确保一个对象及其资源在同一时间只被一个unique_ptr拥有。当unique_ptr被销毁(离开作用域,或被重置),它所拥有的对象也会被销毁。

两个关键想法

  1. 获得资源后立刻放进管理对象内:上面代码中,createInvestment返回的资源被直接用来初始化unique_ptr
  2. 管理对象运用析构函数确保资源被释放unique_ptr的析构函数会自动对其所拥有的指针调用delete

注意事项

  • auto_ptr是C++98的产物,具有“转移语义”,在C++11中已被废弃,请使用unique_ptr
  • unique_ptr禁止拷贝(拥有权唯一),但可以通过std::move转移拥有权。
  • 对于需要共享所有权的场景,使用std::shared_ptrshared_ptr通过引用计数来管理资源,当最后一个shared_ptr被销毁时,资源才会被释放。
  • std::weak_ptrshared_ptr的“弱引用”,它不增加引用计数,用于打破shared_ptr的循环引用。

4.2 在资源管理类中小心拷贝行为

并非所有资源都是动态内存。有时你需要管理的是互斥锁、文件句柄等。这时你需要建立自己的资源管理类。但当你复制一个RAII对象时,会发生什么?你有几种选择:

  1. 禁止复制:许多时候,允许RAII对象被复制并不合理(比如互斥锁)。你可以通过继承一个像boost::noncopyable的类,或者将拷贝构造函数和拷贝赋值运算符声明为private(C++11后使用= 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; };
  2. 对底层资源使用“引用计数法”:有时我们希望保有资源,直到它的最后一个使用者被销毁。std::shared_ptr允许指定“删除器”,这是一个函数或函数对象,当引用计数为0时被调用,而不是简单地delete

    class Lock { public: explicit Lock(Mutex* pm) : mutexPtr(pm, unlock) { // 以unlock为删除器 lock(mutexPtr.get()); // 获取资源 } // 无需声明析构函数,因为mutexPtr的析构函数会自动调用删除器unlock private: std::shared_ptr<Mutex> mutexPtr; // 使用shared_ptr管理Mutex };

    这里,Lock类不再声明析构函数,因为mutexPtr的析构函数会在其引用计数为0时自动调用我们指定的删除器unlockshared_ptr的默认行为是delete,但我们可以通过第二个模板参数指定自定义删除器。

  3. 复制底部资源:进行“深度拷贝”。例如,标准字符串类,复制一个字符串对象时,会同时复制其底层的字符缓冲区。

  4. 转移底部资源的所有权:像unique_ptr那样,将资源的所有权从被复制物转移到目标物。

关键点:复制RAII对象必须一并复制它所管理的资源,所以资源的拷贝行为决定了RAII对象的拷贝行为。常见的RAII类拷贝行为是:禁止拷贝、使用引用计数。这些可以通过unique_ptrshared_ptr来实现,从而避免自己处理复杂的拷贝逻辑。

4.3 在资源管理类中提供对原始资源的访问

RAII类将原始资源封装起来,但有时你不得不需要直接访问原始资源(例如,某些遗留API需要原始指针)。这时,你需要提供一种方式,让外界能够取得RAII对象所管理的原始资源。

有两种方式:显式转换和隐式转换。

  • 显式转换:提供一个get()成员函数,返回原始资源的指针或引用。shared_ptrunique_ptr都提供了get()成员函数来返回原始指针。这种方式更安全,因为用户必须显式调用,知道自己正在接触原始资源。
    std::shared_ptr<Investment> pInv(createInvestment()); int daysHeld(const Investment* pi); // 一个需要原始指针的API int days = daysHeld(pInv.get()); // 显式转换
  • 隐式转换:通过重载运算符(如operator->operator*)或提供隐式类型转换函数来实现。shared_ptrunique_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 让接口容易被正确使用,不易被误用

好的接口就像一段好的代码,是直观且自解释的。理想情况下,如果用户误用了接口,代码不应该通过编译;如果编译通过了,接口的行为也应该符合用户的直觉。

  1. 促进正确使用:保持接口的一致性,与内置类型或标准库的行为一致。例如,STL容器的接口就非常一致,size()成员函数在各个容器中都有相同的语义。

  2. 阻止误用

    • 建立新类型:许多误用源于“错误的参数传递”。例如,一个表示日期的类:
      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()
  3. 智能指针与资源管理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时,你实际上是在回答一系列问题:

  1. 新type的对象应该如何被创建和销毁?这决定了你的构造函数和析构函数,以及operator new,operator new[],operator delete,operator delete[]的重载。
  2. 对象的初始化和对象的赋值该有什么样的差别?这决定了你的构造函数和拷贝赋值运算符的行为。
  3. 新type的对象如果被passed by value(以值传递),意味着什么?拷贝构造函数定义了以值传递一个对象时的行为。
  4. 什么是新type的“合法值”?这决定了你的成员函数(特别是构造函数、赋值运算符和“setter”函数)必须进行的错误检查。它也影响函数抛出的异常。
  5. 你的新type需要配合某个继承图系吗?如果你继承自已有的类,你的设计就会受到那些类的约束,特别是虚函数。如果你打算让其他类继承你的类,那就要决定是否将析构函数声明为虚函数。
  6. 你的新type需要什么样的类型转换?如果你希望允许类型T1隐式转换为你的新类型T2,你需要在T2内写一个以T1为参数的构造函数(非explicit)。如果你希望允许你的新类型T2隐式转换为其他类型,你需要在T2内写一个类型转换函数(operator T1等)。
  7. 什么样的操作符和函数对此新type是合理的?这决定了你将为你的类声明哪些函数。
  8. 什么样的标准函数应该被驳回?那些必须声明为private的(C++11后可用= delete)。
  9. 谁该取用新type的成员?这决定了哪些成员是public,protected,private。也决定了哪些类和/或函数应该是友元。
  10. 什么是新type的“未声明接口”?它对效率、异常安全性以及资源运用提供何种保证?这些保证将为你的class实现代码加上相应的约束。
  11. 你的新type有多么一般化?也许你其实不是在定义一个新的type,而是在定义一整个types家族。如果是这样,你应该定义一个class template。
  12. 你真的需要一个新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。
  • 未声明接口:保证不抛出异常?保证强异常安全?需要明确。
  • 一般化:也许可以做成模板类template<typename T> class Rational,其中T是整数类型。
  • 真的需要吗?是的,我们需要一个精确表示分数的类型。

通过系统地思考这些问题,你设计出的class会更健壮、更易用、更不容易被误用。这不仅仅是语法问题,更是软件设计问题。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/11 3:16:51

DDrawCompat终极指南:3步让经典游戏在现代Windows完美运行

DDrawCompat终极指南&#xff1a;3步让经典游戏在现代Windows完美运行 【免费下载链接】DDrawCompat DirectDraw and Direct3D 1-7 compatibility, performance and visual enhancements for Windows Vista, 7, 8, 10 and 11 项目地址: https://gitcode.com/gh_mirrors/dd/DD…

作者头像 李华
网站建设 2026/8/11 3:14:58

电脑音频文件管理软件从标签整理到跨设备访问的完整梳理

电脑上积累的音频文件一多&#xff0c;管理起来就让人头疼——文件名混乱、标签缺失、重复文件占空间。市面上的音频管理软件大致分为“全能型选手”和“专注标签与整理的专家”两类&#xff0c;各有侧重。 一、全能型选手&#xff1a;管理、播放、同步一体化 适合希望用一个软…

作者头像 李华
网站建设 2026/8/11 3:12:52

AI 玩具机芯供应商怎么选:10 个采购避坑维度

AI 玩具机芯供应商怎么选&#xff1a;10 个采购避坑维度 选 AI 玩具机芯供应商&#xff0c;工厂最常问『哪家好』。其实更该问『我家场景适合哪家』。本文列 10 个避坑维度&#xff0c;帮采购把问题问对。 技术路线&#xff1a;机芯是 AI 玩具的内核 从供给端看&#xff0c;AI …

作者头像 李华
网站建设 2026/8/11 3:12:18

胶球与光球:容度原理框架下从胶子到光子的自指统一路径

胶球与光球&#xff1a;容度原理框架下从胶子到光子的自指统一路径 专知智库 自指余行论研究中心 版本1.0 | 2026年8月 摘要 2026年8月&#xff0c;北京谱仪III&#xff08;BESIII&#xff09;实验国际合作组正式宣布首次证实了胶球的存在——一种完全由胶子构成、不包含任…

作者头像 李华
网站建设 2026/8/11 3:09:42

从代码到智能:开发者如何将AI能力工程化为可交付的软件产品

1. 从代码到智能&#xff1a;一个开发者的视角转变 作为一名写了十几年代码的老兵&#xff0c;我经历过从C/S架构到B/S&#xff0c;再到移动互联网和云原生的技术浪潮。每一次浪潮袭来&#xff0c;都伴随着开发范式的巨大转变。而这一次&#xff0c;当“人工智能”这个词从实验…

作者头像 李华
网站建设 2026/8/11 3:09:23

Kaplan-Meier生存曲线实战指南:从原理到R/Python实现

1. 项目概述&#xff1a;从数据到洞察&#xff0c;生存曲线的实战价值 在临床研究、药物开发、工业可靠性分析等领域&#xff0c;我们常常面临一个核心问题&#xff1a;某个事件&#xff08;比如患者死亡、疾病复发、设备故障&#xff09;在特定时间点发生的概率有多大&#xf…

作者头像 李华