【Effective C++】 阅读笔记1

目录

1. 尽量以const、inline、constexpr替代预处理器

 2. 尽量使用const

3. 确保对象被正常初始化

4. 编译器默认生成的函数

5. 如果不需要拷贝对象,禁用拷贝操作

 6. 为多态基类声明虚析构函数

7. 不让异常逃离析构函数

8. 不在构造和析构过程中调用虚函数

9. 令operator=返回一个引用指向当前对象

10 赋值运算符中处理“自我赋值”

1. 尽量以const、inline、constexpr替代预处理器

问题分析

C++中使用#define进行预处理定义常量和宏会导致很多潜在的问题,例如无法被调试器识别、容易出现命名冲突等。

  • 调试器无法识别分析
    • #define 在预处理阶段被替换成文本,编译器不会将这些宏或常量作为符号存储在符号表中。
    • 这会导致在调试时,无法通过调试器追踪或查看这些宏的值和来源,给调试过程带来困难。
  • 作用域命名冲突
    • #define 的宏是全局的,没有作用域限制,这可能导致不同文件中定义的宏产生命名冲突
    • 如果两个文件中使用相同名字的宏,它们的值会互相覆盖,导致代码行为出错且难以发现问题
  • 类型安全检查
    • 宏在替换的时候不会进行类型安全检查,这样就可能导致未知错误的出现

方案一:const替代

  •  因为define在编译的时候是没有类型检查等缺点,但是使用const可以克服这些缺点
  • const优点总结
    • 类型安全const 常量有明确的类型,编译器可以检查类型错误。
    • 易于调试PI 会作为符号表的一部分,可以在调试器中查看。
#define PI 3.14159

const double PI = 3.14159;

方案二:使用inline替代宏定义函数 

  • 因为宏函数是没有类型检查的,所以容易出现错误
  • 使用inline的优点
    • 类型安全:编译器会进行类型检查。
    • 避免副作用:不会重复计算参数表达式。
    • 优化性能inline 函数在调用时会被内联展开,性能接近于宏。
// 使用宏
#define SQUARE(x) ((x) * (x))
int a = 5;
int result = SQUARE(a++);  // 结果可能不是预期的 36


// 使用Inline函数
inline int square(int x) {
    return x * x;
}

方法三:使用constexpr替代常量表达式

  • constexpr主要用于定义变量和表达式在编译期间就会求值,从而减少运行时候的开销。
  • 优点总结
    • 编译期计算:提升程序性能。
    • 类型安全:如 const,会进行类型检查。
    • 更灵活constexpr 函数可以接受参数并在编译期计算。
constexpr double PI = 3.14159;
constexpr int square(int x) {
    return x * x;
}

补充contexpr函数的作用

  • C++14后,该函数的限制放宽,可以使用循环或者条件语句
constexpr int factorial(int n) {
    int result = 1;
    for (int i = 1; i <= n; ++i) {
        result *= i;
    }
    return result;
}
  • C++17还增加了支持复杂语句、Lambda表达式以及类内部支持静态成员变量初始化
  • C++20增加了动态内存分配、虚函数调用、以及部分STL容器

代码实践

#include <iostream>

// 不推荐的宏常量和宏函数
#define PI 3.14159
#define SQUARE(x) ((x) * (x))

// 推荐的替代方案
constexpr double ConstPI = 3.14159;
inline int square(int x) {
    return x * x;
}

int main() {
    std::cout << "宏定义的 PI: " << PI << std::endl;
    std::cout << "Constexpr 定义的 PI: " << ConstPI << std::endl;

    int a = 5;
    std::cout << "宏函数 SQUARE(a++): " << SQUARE(a++) << std::endl;  // 结果意外
    a = 5;
    std::cout << "内联函数 square(a++): " << square(a++) << std::endl;  // 正确结果

    return 0;
}

总结 

  • 少用预处理器:C++ 提供了更安全的替代方案。
  • 提高代码质量:通过类型检查和调试器支持,减少错误的发生
  • 性能优化:使用 inlineconstexpr 可以获得与宏类似的性能,但没有宏的缺点。

 2. 尽量使用const

使用const原因

  • 防止数据被意外修改const 修饰的数据不可更改,避免程序逻辑错误。
  • 提高代码的可读性const 表明某个变量或函数不会修改数据,读代码时更易理解。
  • 允许编译器进行更多优化const 数据是不可变的,编译器能更高效地优化代码。

const修饰局部变量

  • 局部变量使用const修饰,可以避免该变量在后续代码中被意外修改

#include <iostream>

void printMessage(const std::string& message) {
    std::cout << "消息内容:" << message << std::endl;
}

int main() {
    const int number = 42;
    std::cout << "常量 number 的值:" << number << std::endl;

    std::string greeting = "你好,大古!";
    printMessage(greeting);

    // 错误:尝试修改 `number` 会导致编译错误
    // number = 43;

    return 0;
}

const修饰指针

  • 主要注意两件事情,一个是指向常量的指针(此时指向的数据不可以修改),一个是常量指针(指针的指向不可以更改)
  • 也可以是指向常量的常量指针,此时指向和数值都不可以更改

 

#include <iostream>

int main() {
    int value = 10;
    int anotherValue = 20;

    // 1. 指向常量的指针,指向的数据不可修改
    const int* p1 = &value;
    std::cout << "p1 指向的值:" << *p1 << std::endl;
    // *p1 = 11;  // 错误:尝试修改常量数据

    // 2. 常量指针,本身不可修改,但指向的数据可以修改
    int* const p2 = &value;
    *p2 = 15;  // 正确:修改数据
    std::cout << "修改后的值:" << *p2 << std::endl;
    // p2 = &anotherValue;  // 错误:常量指针不可重新赋值

    // 3. 指向常量的常量指针,指针和数据都不可修改
    const int* const p3 = &value;
    std::cout << "p3 指向的值:" << *p3 << std::endl;

    return 0;
}

const修饰成员函数

  • 使用const修饰的成员,可以保证该成员函不会修改成员变量

#include <iostream>

class Person {
public:
    Person(const std::string& name) : name_(name) {}

    // const 成员函数,保证不会修改成员变量
    void printName() const {
        std::cout << "名字:" << name_ << std::endl;
    }

private:
    std::string name_;
};

int main() {
    Person person("张三");
    person.printName();

    return 0;
}

总结

  • 局部变量使用const,可以避免该局部变量被无意修改
  • const修饰的指针,有效控制指针的指向和指向数据
  • 将不修改对象状态的成员函数声明为 const,增强代码的可读性和可维护性

3. 确保对象被正常初始化

C++并不会自动初始化所有的变量和对象,这也就会导致了未初始化的变量会带来很多未定义的行为,也就会导致很多Bug。所以在对象创建的时候要正确初始化。

核心方法总结

  • 初始化变量,不要使用未初始化的变量
  • 使用构造函数的初始化列表:当类成员中有默认构造函数的时候,初始化列表更为高效
  • 成员变量要设定默认值:类内成员的初始化好处多
  • 避免两阶段的初始化:优先使用带参数的构造函数,确保对象在创建时就被正确初始化

使用构造函数的初始化列表

使用初始化列表,可以保证类成员在对象创建的时候就初始化,效率高(因为避免了先调用默认构造函数再赋值的开销)

#include <iostream>

class Person {
public:
    // 使用构造函数初始化列表初始化成员变量
    Person(const std::string& name, int age) 
        : name_(name), age_(age) {}

    void printInfo() const {
        std::cout << "名字:" << name_ << ",年龄:" << age_ << std::endl;
    }

private:
    std::string name_;
    int age_;
};

int main() {
    Person person("李四", 25);
    person.printInfo();

    return 0;
}

避免未初始化的变量 

  • 实验,不初始化变量打印其数值
  • 需要在C++11以前才可以测出
#include <iostream>

int main() {
    int value;  // 未初始化变量
    std::cout << "未初始化变量的值:" << value << std::endl;  // 结果不可预测
    return 0;
}

C++11以后,会直接在类内给成员变量提供默认值 

#include <iostream>

class Person {
public:
    Person() {}  // 默认构造函数

    void printInfo() const {
        std::cout << "名字:" << name_ << ",年龄:" << age_ << std::endl;
    }

private:
    std::string name_ = "未知";  // 提供默认值
    int age_ = 0;               // 提供默认值
};

int main() {
    Person person;
    person.printInfo();

    return 0;
}

不要两段初始化

  • 简单来说就是不要创建一个的函数对变量进行初始化,这样容易导致错误

class Person {
public:
    Person() {}  // 空的默认构造函数

    void initialize(const std::string& name, int age) {
        name_ = name;
        age_ = age;
    }

    void printInfo() const {
        std::cout << "名字:" << name_ << ",年龄:" << age_ << std::endl;
    }

private:
    std::string name_;
    int age_;
};

int main() {
    Person person;  // 创建对象
    // 如果忘记调用 initialize() 会导致错误
    // person.initialize("王五", 30);  
    person.printInfo();  // 使用未初始化对象

    return 0;
}

启发

  • 未初始化变量是 bug 的来源,所以确保每个变量都被初始化
  • 构造函数初始化列表既高效又简洁,是处理类成员变量初始化的最佳实践。
  • 使用类内成员默认值可以让代码更清晰,并减少构造函数的负担。

4. 编译器默认生成的函数

编译器自动生成的成员函数

  • 默认构造函数ClassName()):初始化对象时调用。
  • 拷贝构造函数ClassName(const ClassName&)):用已有对象创建新对象时调用。
    • 此处是浅拷贝,可能会导致内存泄漏和双重释放问题
  • 拷贝赋值运算符ClassName& operator=(const ClassName&)):将一个对象的值赋给另一个已有对象时调用。
  • 析构函数~ClassName()):销毁对象时调用

默认生成函数的证明

#include <iostream>

class Person {
public:
    std::string name;
    int age;
};

int main() {
    // 默认构造函数
    Person p1;

    // 拷贝构造函数
    Person p2 = p1;

    // 拷贝赋值运算符
    p2 = p1;

    std::cout << "默认行为:" << p2.name << "," << p2.age << std::endl;

    return 0;
}

控制编译器生成默认函数 

  • 使用深拷贝可以避免指针共享导致的错误
  • 自定义析构函数确保资源可以正确的释放

#include <iostream>
#include <cstring>

class Person {
public:
    // 自定义构造函数
    Person(const char* name, int age) {
        this->name = new char[strlen(name) + 1];
        strcpy(this->name, name);
        this->age = age;
    }

    // 自定义拷贝构造函数(深拷贝)
    Person(const Person& other) {
        name = new char[strlen(other.name) + 1];
        strcpy(name, other.name);
        age = other.age;
    }

    // 自定义析构函数,释放资源
    ~Person() {
        delete[] name;
    }

    void printInfo() const {
        std::cout << "名字:" << name << ",年龄:" << age << std::endl;
    }

private:
    char* name;
    int age;
};

int main() {
    Person p1("张三", 25);
    Person p2 = p1;  // 调用拷贝构造函数

    p2.printInfo();  // 输出 p2 的信息

    return 0;
}

 使用C++11中的=delete和=default

class Person {
public:
    Person() = delete;  // 禁止使用默认构造函数
};



class Person {
public:
    Person() = default;  // 使用默认构造函数
};

拓展:智能指针简化深拷贝

#include <iostream>
#include <memory>  // 包含智能指针头文件

class Person {
public:
    // 构造函数
    Person(const std::string& name, int age)
        : name_(std::make_unique<std::string>(name)), age_(age) {}

    // 拷贝构造函数(深拷贝)
    Person(const Person& other)
        : name_(std::make_unique<std::string>(*other.name_)), age_(other.age_) {
        std::cout << "拷贝构造函数被调用" << std::endl;
    }

    // 移动构造函数
    Person(Person&& other) noexcept = default;  // 使用默认移动构造函数

    // 打印信息
    void printInfo() const {
        std::cout << "名字:" << *name_ << ",年龄:" << age_ << std::endl;
    }

private:
    std::unique_ptr<std::string> name_;
    int age_;
};

int main() {
    Person p1("李四", 30);
    Person p2 = p1;  // 拷贝构造
    Person p3 = std::move(p1);  // 移动构造

    p2.printInfo();
    p3.printInfo();

    return 0;
}

5. 如果不需要拷贝对象,禁用拷贝操作

原因分析

当拷贝构造函数和拷贝赋值运算符不需要的时候,有可能会导致意外拷贝,所以当程序代码中不需要拷贝的情况下,禁止编译器自动生成拷贝构造和拷贝赋值。

  • 防止意外拷贝:如果类中有动态内容或者系统资源的时候,默认的浅拷贝可能会导致多个对象共享资源,从而带来内存泄漏或者双重释放问题的出现
  • 拷贝构造可能会导致相应的性能开销

总结

  • 优点使用C++11的=delete禁用不需要的拷贝操作
  • 防止资源共享,类中管理资源的时候,考虑禁用拷贝,避免拷贝引发的错误
  • 单例模式等场景下一定要禁用拷贝

C++11以后使用-delete禁用 

#include <iostream>
#include <memory>

class NonCopyable {
public:
    NonCopyable() {
        std::cout << "构造函数被调用" << std::endl;
    }

    // 禁用拷贝构造函数
    NonCopyable(const NonCopyable&) = delete;

    // 禁用拷贝赋值运算符
    NonCopyable& operator=(const NonCopyable&) = delete;

    void display() const {
        std::cout << "这是一个不可拷贝的对象" << std::endl;
    }
};

int main() {
    NonCopyable obj1;
    obj1.display();

    // NonCopyable obj2 = obj1;  // 编译错误:拷贝构造被禁用
    // NonCopyable obj3;
    // obj3 = obj1;              // 编译错误:拷贝赋值被禁用

    return 0;
}
  • 补充,C++98中可以通过将类放入私有域中实现防止拷贝操作,但是不建议使用

典型事例(单例模式)

#include <iostream>

class Singleton {
public:
    // 提供全局访问点
    static Singleton& getInstance() {
        static Singleton instance;
        return instance;
    }

    void display() const {
        std::cout << "这是单例对象" << std::endl;
    }

    // 禁用拷贝构造和赋值操作
    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;

private:
    // 私有构造函数,防止外部创建实例
    Singleton() {
        std::cout << "单例对象被创建" << std::endl;
    }
};

int main() {
    Singleton& s1 = Singleton::getInstance();
    s1.display();

    // Singleton s2 = s1;  // 编译错误:拷贝构造被禁用

    return 0;
}

 6. 为多态基类声明虚析构函数

总结

  •  如果一个类是被用作基类,并且有可能通过基类指针或者引用指向派生类对象,那么就必须将基类的析构函数声明为虚函数
  • 因为没有虚析构函数会导致派生类的资源泄漏

实验证明内存泄漏的发生

当使用基类指针指向派生类对象的时候,如果基类的析构函数不是虚函数,那么基类指针删除对象的时候只会调用基类的析构函数,而派生类的析构函数不会被调用,这样就会导致资源泄漏。、

#include <iostream>

class Base {
public:
    Base() { std::cout << "Base 构造函数" << std::endl; }
    ~Base() { std::cout << "Base 析构函数" << std::endl; }  // 非虚析构函数
};

class Derived : public Base {
public:
    Derived() { std::cout << "Derived 构造函数" << std::endl; }
    ~Derived() { std::cout << "Derived 析构函数" << std::endl; }
};

int main() {
    Base* p = new Derived();  // 基类指针指向派生类对象
    delete p;  // 只会调用 Base 的析构函数

    return 0;
}

更改后:将析构的函数声明为虚函数就可以解决该问题

#include <iostream>

class Base {
public:
    Base() { std::cout << "Base 构造函数" << std::endl; }
    virtual ~Base() { std::cout << "Base 虚析构函数" << std::endl; }  // 虚析构函数
};

class Derived : public Base {
public:
    Derived() { std::cout << "Derived 构造函数" << std::endl; }
    ~Derived() { std::cout << "Derived 析构函数" << std::endl; }
};

int main() {
    Base* p = new Derived();  // 基类指针指向派生类对象
    delete p;  // 调用虚析构函数,确保正确析构

    return 0;
}

使用虚函数可以保证内存安全原因分析 

C++中,如果使用基类指针指向派生类对象的时候,默认行为就是调用基类的析构函数。如果基类的析构函数不是虚函数,那么销毁对象的时候就只会调用基类的析构函数,而派生类的析构函数不会被调用。这样也就导致了派生类资源无法正常释放

从析构过程分析:如果通过一个指向基类的指针删除对象的时候,编译器会根据指针的类型决定调用哪个析构函数;如果析构函数不是虚函数,那么编译器就会静态绑定,只会调用基类的析构函数,而忽略派生类的析构函数。

如果将基类的析构函数声明为虚函数,就会动态绑定,通过基类指针删除派生类对象的时候,就会先调用派生类的析构函数,然后调用析构函数。

静态绑定就是在编译的时候决定调用的函数,也就是提前就知道这个函数的作用;动态绑定则是根据运行时候的实际类型决定调用的函数。

使用=default和override修饰虚析构函数

  • C++11后,就可以用=default让编译器生成默认的虚构函数
class Base {
public:
    virtual ~Base() = default;  // 使用默认虚析构函数
};
  • 派生类中可以使用override明确表明析构函数是重写基类的虚析构函数
class Derived : public Base {
public:
    ~Derived() override { std::cout << "Derived 析构函数" << std::endl; }
};

7. 不让异常逃离析构函数

析构函数中抛出异常的时候没有被捕捉到,此时有可能导致程序异常停止。如果在处理一个异常的时候,析构函数此时又抛出一个异常,此时就会导致程序崩溃。

析构函数抛出异常导致程序崩溃分析

如果一个异常出现,程序此时会依次调用栈中的析构函数来清理资源。如果析构函数在清理资源的时候,又出现了一个异常,这样就导致了两个异常,这明显了违反C++的规则,所以最后会导致程序的崩溃。

测试程序

使用runtime_error函数在main中抛出一个异常,同时创建一个临时类对象,模仿一个双异常的场景。也就是说main中抛出异常后,Test的析构函数会在栈中展开,在析构函数中设置了第二个异常,最终也就导致了两个异常的产生,C++此时是无法处理的,所以可能会导致程序崩溃。

#include <iostream>
#include <stdexcept>

class Test {
public:
    ~Test() {
        std::cout << "析构函数被调用" << std::endl;
        throw std::runtime_error("析构函数中的异常"); //第二个异常 
    }
};

int main() {
    try {
        Test t;  // 临时对象,离开作用域时调用析构函数
        throw std::runtime_error("主逻辑中的异常");//第一个异常
    }
    catch (const std::exception& e) {
        std::cout << "捕获到异常:" << e.what() << std::endl;
    }

    return 0;
}

解决方法:在析构函数捕获异常并对错误进行修正

 析构函数中及时捕捉异常,也可以使用日志替代处理异常

#include <iostream>
#include <stdexcept>

class Test {
public:
    ~Test() {
        try {
            std::cout << "析构函数被调用" << std::endl;
            throw std::runtime_error("析构函数中的异常");
        }
        catch (const std::exception& e) {
            std::cout << "捕获到析构函数中的异常:" << e.what() << std::endl;
            // 可以在此处记录日志,而非向外抛出
        }
    }
};

int main() {
    try {
        Test t;
        throw std::runtime_error("主逻辑中的异常");
    }
    catch (const std::exception& e) {
        std::cout << "捕获到异常:" << e.what() << std::endl;
    }

    return 0;
}

使用智能指针可以确保在异常情况下资源也可以被正确的释放

#include <iostream>
#include <memory>

class Resource {
public:
    Resource() { std::cout << "资源被分配" << std::endl; }
    ~Resource() { std::cout << "资源被释放" << std::endl; }
};

int main() {
    try {
        std::unique_ptr<Resource> res = std::make_unique<Resource>();
        throw std::runtime_error("主逻辑中的异常");
    }
    catch (const std::exception& e) {
        std::cout << "捕获到异常:" << e.what() << std::endl;
    }

    return 0;
}

反思

  • 设计析构函数的时候,需要考虑到双重异常问题
  • 智能指针可以简化资源管理,减少异常处理的复杂性

8. 不在构造和析构过程中调用虚函数

构造函数与析构函数中调用虚函数不安全的原因

首先每个对象都有一个虚表指针指向该类的虚表构造函数和析构函数执行的时候,虚表指针会指向当前正在构造或者析构的类的虚表。

其次,关键问题在于,构造或者析构的时候,虚表还没有更新完整。

  • 构造期间:基类构造函数执行的时候,虚表指针只会指向基类的虚表,因此,即使此时调用虚函数,也只会调用到基类版本的虚函数
  • 析构期间:基类在析构的时候,也同样只会指向基类的虚表,派生类版本的虚函数也不会被使用

构造函数中调用虚函数事例分析

  • Base构造函数执行的时候,虚函数指针只会指向Base的虚表,不会更新Derived虚表,从下面的调试可以看到 

 

class Base {
public:
    Base() {
        std::cout << "Base 构造函数" << std::endl;
        print();  // 调用虚函数
    }

    virtual void print() const {
        std::cout << "Base 的 print 函数" << std::endl;
    }
};

class Derived : public Base {
public:
    Derived() {
        std::cout << "Derived 构造函数" << std::endl;
    }

    void print() const override {
        std::cout << "Derived 的 print 函数" << std::endl;
    }
};

int main() {
    Derived d;
    return 0;
}

析构函数中调用虚函数

同上,执行Base析构函数的时候,虚表指针也是指向Base

#include <iostream>

class Base {
public:
    virtual ~Base() {
        std::cout << "Base 析构函数" << std::endl;
        print();  // 调用虚函数
    }

    virtual void print() const {
        std::cout << "Base 的 print 函数" << std::endl;
    }
};

class Derived : public Base {
public:
    ~Derived() override {
        std::cout << "Derived 析构函数" << std::endl;
    }

    void print() const override {
        std::cout << "Derived 的 print 函数" << std::endl;
    }
};

int main() {
    Base* p = new Derived();
    delete p;  // 调用析构函数

    return 0;
}

解决方法总结

首先不要在构造和析构函数中使用虚函数。可以将需要调用的函数放在专门的初始化函数或销毁函数中,然后外部代码进行调用。

也就是基类中设计一个纯虚函数,并且设置一个对该纯虚函数使用的函数

#include <iostream>

class Base {
public:
    Base() {
        std::cout << "Base 构造函数" << std::endl;
    }

    virtual ~Base() {
        std::cout << "Base 析构函数" << std::endl;
    }

    virtual void print() const = 0;  // 纯虚函数
    void init() { print(); }  // 初始化时调用虚函数
};

class Derived : public Base {
public:
    Derived() {
        std::cout << "Derived 构造函数" << std::endl;
    }

    ~Derived() override {
        std::cout << "Derived 析构函数" << std::endl;
    }

    void print() const override {
        std::cout << "Derived 的 print 函数" << std::endl;
    }
};

int main() {
    Derived d;
    d.init();  // 在构造完成后调用虚函数

    return 0;
}

9. 令operator=返回一个引用指向当前对象

operator=运算符用于将一个对象赋给另一个对象,为了符合其行为,赋值运算符应该返回一个指向当前对象的引用(*this)

分析返回*this的原因

首先C++支持a=b=c这样的链式赋值语句,如果想要实现链式赋值,那么operator=就必须要返回一个指向当前对象的引用

其次部分内置运算符返回的是左值引用,为了符合其类型,自定义的类型赋值运算符就要返回*this

#include <iostream>

class Test {
public:
    Test(int value = 0) : value_(value) {}  // 构造函数

    // 拷贝赋值运算符
    Test& operator=(const Test& other) {
        if (this == &other) return *this;  // 自赋值检测
        value_ = other.value_;  // 执行赋值操作
        std::cout << "赋值运算符被调用" << std::endl;
        return *this;  // 返回当前对象的引用
    }

    void print() const {
        std::cout << "值:" << value_ << std::endl;
    }

private:
    int value_;
};

int main() {
    Test a(5), b(10), c;
    c = b = a;  // 链式赋值
    c.print();  // 输出:值:5

    return 0;
}

处理自赋值

如果要实现operator=的时候,需要检查a=a,否则就可能导致资源被重复释放或者没有定义

Test& operator=(const Test& other) {
    if (this == &other) return *this;  // 自赋值检测
    value_ = other.value_;
    return *this;
}

动态资源拷贝赋值的时候需要考虑内存释放问题

赋值运算符中应该使用深拷贝,避免多个对象共享同一块内存;在赋值之前要先释放旧资源,防止内存泄漏。

#include <iostream>
#include <cstring>

class String {
public:
    String(const char* str = "") {
        data_ = new char[strlen(str) + 1];
        strcpy(data_, str);
    }

    // 拷贝赋值运算符
    String& operator=(const String& other) {
        if (this == &other) return *this;  // 自赋值检测

        // 释放旧资源
        delete[] data_;

        // 深拷贝新数据
        data_ = new char[strlen(other.data_) + 1];
        strcpy(data_, other.data_);

        std::cout << "赋值运算符被调用" << std::endl;
        return *this;
    }

    ~String() {
        delete[] data_;
    }

    void print() const {
        std::cout << "字符串:" << data_ << std::endl;
    }

private:
    char* data_;
};

int main() {
    String s1("11111");
    String s2("22222");
    String s3;

    s3 = s2 = s1;  // 链式赋值
    s3.print();  

    return 0;
}

总结

  • 赋值运算符要确保支持链式赋值和自赋值检测
  • 动态资源管理的时候,要小心处理赋值运算符,避免内存泄漏和资源重复释放

10 赋值运算符中处理“自我赋值”

问题分析

自我赋值就是一个对象给自己赋值,如果没有恰当处理好自我赋值,那么可能会导致资源泄漏、野指针、逻辑错误等情况。

例如自我赋值的时候导致了内存区域被释放,最终导致程序崩溃。下述代码就是自我赋值后,字符指针成为了野指针

class String {
public:
    String(const char* str = "") {
        data_ = new char[strlen(str) + 1];
        strcpy(data_, str);
    }

    // 错误:未处理自我赋值
    String& operator=(const String& other) {
        delete[] data_;  // 释放旧资源

        data_ = new char[strlen(other.data_) + 1];
        strcpy(data_, other.data_);

        std::cout << "赋值运算符被调用" << std::endl;
        return *this;
    }

    ~String() {
        delete[] data_;
    }

    void print() const {
        std::cout << "字符串:" << data_ << std::endl;
    }

private:
    char* data_;
};

int main() {
    String s("你好");
    s = s;  // 自我赋值
    s.print();  

    return 0;
}

 解决方法:自我赋值的时候,先进行一次自我赋值判断

String& operator=(const String& other) {
    if (this == &other) return *this;  // 自我赋值检测

    delete[] data_;  // 释放旧资源

    data_ = new char[strlen(other.data_) + 1];
    strcpy(data_, other.data_);

    std::cout << "赋值运算符被调用" << std::endl;
    return *this;
}

使用临时对象实现异常安全的赋值 

 也就是创建一个临时对象保存新数据,然后与当前对象进行交换,从而避免程序异常的出现

#include <iostream>
#include <cstring>
#include <utility>  // std::swap

class String {
public:
    String(const char* str = "") {
        data_ = new char[strlen(str) + 1];
        strcpy(data_, str);
    }

    // 拷贝构造函数
    String(const String& other) {
        data_ = new char[strlen(other.data_) + 1];
        strcpy(data_, other.data_);
    }

    // 赋值运算符:使用临时对象交换法
    String& operator=(String other) {
        std::swap(data_, other.data_);  // 交换数据
        std::cout << "赋值运算符被调用" << std::endl;
        return *this;
    }

    ~String() {
        delete[] data_;
    }

    void print() const {
        std::cout << "字符串:" << data_ << std::endl;
    }

private:
    char* data_;
};

int main() {
    String s1("小米15");
    String s2("小米SU7");

    s1 = s2;  // 赋值操作
    s1.print(); 

    s1 = s1;  // 自我赋值
    s1.print();  

    return 0;
}

 总结

  • 始终检测自我赋值:避免资源释放后重新分配,也防止野指针
  • 使用临时对象:避免内存泄漏
  • 实现拷贝构造、赋值运算符和析构函数的时候,要考虑到移动构造和移动赋值运算符

猜你喜欢

转载自blog.csdn.net/gma999/article/details/143273681