Item M21:通过重载避免隐式类型转换
通过函数重载来避免隐式类型转换带来的性能开销。当一个用户自定义类型(如 UPInt)与一个基本类型(如 int)一起使用算术运算符(如 +)时,C++ 编译器会尝试通过创建临时对象来进行隐式类型转换,以便能够调用相应的运算符重载函数。虽然这使得编程更加方便,但同时也带来了不必要的性能开销,因为每次都需要创建临时对象。
为了避免这种开销,建议显式地为不同类型的参数重载运算符。例如,对于 UPInt 类,可以重载 operator+ 以接受一个 UPInt 和一个 int 作为参数,或者两个 UPInt 作为参数。这样做可以让编译器直接选择正确的重载版本,而不是创建临时对象来匹配已有的重载版本。
cpp
class UPInt { // 无限精度整数类
public:
UPInt();
UPInt(int value);
// 其他成员函数...
};
// 重载运算符以支持不同类型的参数
const UPInt operator+(const UPInt& lhs, const UPInt& rhs); // 加两个 UPInt
const UPInt operator+(const UPInt& lhs, int rhs); // 加一个 UPInt 和一个 int
const UPInt operator+(int lhs, const UPInt& rhs); // 加一个 int 和一个 UPInt
// 示例使用
UPInt upi1, upi2;
UPInt upi3 = upi1 + upi2; // 直接调用两个 UPInt 参数的重载
upi3 = upi1 + 10; // 直接调用一个 UPInt 和一个 int 参数的重载
upi3 = 10 + upi2; // 直接调用一个 int 和一个 UPInt 参数的重载
作者强调了一个重要的点,即不应该重载只有基本类型参数的运算符,如 const UPInt operator+(int lhs, int rhs);,因为 C++ 规定每个重载的运算符至少需要一个用户定义类型的参数。这是因为如果允许对基本类型重载运算符,可能会导致预定义行为的意外改变,进而引发程序错误。
如果允许程序员重载这些基本类型的运算符,那么就有可能改变这些基本类型的默认行为。如果允许重载 int + int,那么 2 + 2 可能不再等于 4,而是其他值,这会导致程序行为不可预测。
Item M22:考虑用运算符的赋值形式(op=)取代其单独形式(op)
整理后的代码及解释
- 定义 Rational 类
首先,定义一个简单的 Rational 类,用于表示有理数,并实现 operator+= 和 operator-=:
cpp
class Rational {
public:
Rational(int numerator = 0, int denominator = 1) : num(numerator), den(denominator) {
if (den == 0) {
throw std::invalid_argument("Denominator cannot be zero");
}
normalize();
}
Rational& operator+=(const Rational& rhs) {
num = num * rhs.den + rhs.num * den;
den *= rhs.den;
normalize();
return *this;
}
Rational& operator-=(const Rational& rhs) {
num = num * rhs.den - rhs.num * den;
den *= rhs.den;
normalize();
return *this;
}
private:
void normalize() {
int gcd = std::gcd(num, den);
num /= gcd;
den /= gcd;
}
int num; // 分子
int den; // 分母
};
- 实现 operator+ 和 operator- 通过 operator+= 和 operator-=
接下来,通过 operator+= 和 operator-= 来实现 operator+ 和 operator-:
cpp
const Rational operator+(const Rational& lhs, const Rational& rhs) {
return Rational(lhs) += rhs;
}
const Rational operator-(const Rational& lhs, const Rational& rhs) {
return Rational(lhs) -= rhs;
}
- 使用模板实现通用的 operator+ 和 operator-
为了进一步简化代码,可以使用模板来实现通用的 operator+ 和 operator-:
cpp
template<class T>
const T operator+(const T& lhs, const T& rhs) {
return T(lhs) += rhs;
}
template<class T>
const T operator-(const T& lhs, const T& rhs) {
return T(lhs) -= rhs;
}
效率:operator+= 和 operator-= 是赋值运算符,它们直接在左操作数上进行操作,不需要创建临时对象。这使得它们比单独的 operator+ 和 operator- 更高效。
代码复用:通过 operator+= 和 operator-= 来实现 operator+ 和 operator-,可以减少代码重复,只需要维护 operator+= 和 operator-=。
避免临时对象:operator+ 和 operator- 通过创建临时对象来调用 operator+= 和 operator-=,但这仍然是一个高效的实现方式,因为临时对象的创建和销毁开销相对较小。
临时对象:operator+ 和 operator- 会创建临时对象,这可能会带来一些开销。但是,现代编译器通常会进行返回值优化(RVO),减少临时对象的创建和销毁开销。
使用未命名的临时对象(如 return T(lhs) += rhs;)通常比命名对象(如 T result(lhs); return result += rhs;)更高效,因为未命名对象更容易被编译器优化。
通过实现 operator+= 和 operator-=,并利用这些赋值运算符来实现 operator+ 和 operator-,可以确保代码的高效性和一致性
Item M23:考虑变更程序库
理想的程序库应该是短小、快速、强大、灵活、可扩展、直观、普遍适用、有良好支持、没有使用约束、没有错误的。但现实中不可能同时具备所有这些特性。不同的设计者会对这些特性赋予不同的优先级,因此即使是提供相同功能的程序库,其性能特征也可能完全不同。
iostream 和 stdio 的比较:
类型安全和可扩展性:iostream 是类型安全的,支持面向对象的扩展,而 stdio 则不具备这些特性。
性能:stdio 通常在执行速度和生成的执行文件大小上优于 iostream。作者通过一个基准测试(benchmark)程序验证了这一点,结果显示 stdio 在大多数情况下更快,有时甚至快很多。
代码实现:stdio 的高效性主要来自于其代码实现,特别是在运行时解析格式字符串的方式。而 iostream 在编译时确定操作数的类型,理论上可以更高效,但实际表现取决于具体的实现。
性能优化:
一旦找到软件的瓶颈(通过性能分析工具如 profiler),可以考虑更换程序库来消除瓶颈。例如,如果程序有 I/O 瓶颈,可以考虑用 stdio 替代 iostream;如果程序在动态内存分配和释放上花费大量时间,可以考虑使用其他 operator new 和 operator delete 的实现。
Item M24:理解虚拟函数、多继承、虚基类和 RTTI 所需的代价
1. 虚拟函数(Virtual Functions)
虚拟函数的实现通常依赖于虚拟表(vtbl)和虚拟表指针(vptr)。每个包含虚函数的类都有一个 vtbl,其中包含指向虚函数实现的指针。每个对象都有一个 vptr,指向其类的 vtbl。
性能和内存开销:对象大小:每个包含虚函数的对象都会有一个额外的 vptr,增加了对象的大小。
类数据:每个类需要一个 vtbl,其大小与类中声明的虚函数数量成正比。
函数调用开销:调用虚函数需要通过 vptr 查找 vtbl,再通过 vtbl 查找函数指针,这比调用非虚函数稍微复杂一些,但通常不是性能瓶颈。
内联限制:虚函数不能内联,因为其调用只能在运行时确定。
2. 多继承(Multiple Inheritance)
在多继承中,对象可能有多个 vptr,每个基类对应一个 vptr。每个基类可能有自己的 vtbl,派生类还需要生成特殊的 vtbl。
对象大小:多继承增加了对象的复杂性,对象中可能有多个 vptr,增加了对象的大小。
类数据:多继承增加了 vtbl 的数量和复杂性,增加了类数据的大小。
函数调用开销:多继承使得查找 vptr 和 vtbl 更复杂,增加了函数调用的开销。
3. 虚基类(Virtual Base Classes)
虚基类用于避免多继承时基类数据成员的重复。
实现虚基类通常需要在对象中添加额外的指针,指向虚基类。
对象大小:虚基类增加了对象的大小,因为需要额外的指针。
类数据:虚基类可能需要额外的 vtbl 和 vptr,增加了类数据的大小。
函数调用开销:虚基类使得对象布局更复杂,增加了函数调用的开销。
4. 运行时类型识别(RTTI)
RTTI 通过 typeid 操作符获取对象的类型信息。
类的 vtbl 中包含一个指向 type_info 对象的指针,用于存储类型信息。
对象大小:RTTI 本身不会增加对象的大小,因为它依赖于 vtbl。
类数据:每个类需要一个 type_info 对象,增加了类数据的大小。
函数调用开销:RTTI 的开销主要在于查找 type_info 对象,通常不是性能瓶颈。