More effective C++:效率(2)

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)

整理后的代码及解释

  1. 定义 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; // 分母
};
  1. 实现 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;
}
  1. 使用模板实现通用的 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 对象,通常不是性能瓶颈。

相关推荐
Tang Paofan35 分钟前
C++ constexpr
开发语言·c++
AICodeThunder1 小时前
C++知识点总结(57):STL综合
java·c++·算法
薔薇十字1 小时前
【代码随想录day32】【C++复健】509. 斐波那契数;70. 爬楼梯;746. 使用最小花费爬楼梯
开发语言·c++·算法
qq_1873526341 小时前
c++基础36时间复杂度
c++·c++基础36时间复杂度
理论最高的吻2 小时前
222. 完全二叉树的节点个数【 力扣(LeetCode) 】
c++·算法·leetcode·职场和发展·二叉树
ducking__2 小时前
设计模式练习(二) 简单工厂模式
c++·设计模式·简单工厂模式
捕鲸叉2 小时前
C++创建型设计模式综合示例
开发语言·c++·设计模式
捕鲸叉2 小时前
C++创建型设计模式体现出的面向对象设计原则
开发语言·c++·设计模式
yangmc042 小时前
区间和 离散化 模板题
c语言·数据结构·c++·算法·矩阵·objective-c
No0d1es2 小时前
2024年9月青少年软件编程(C语言/C++)等级考试试卷(七级)
c语言·开发语言·c++·算法·青少年编程·电子学会·七级