《Effective C++》读书笔记(2):构造/析构/赋值运算

2023-08-10 08:25:09 浏览数 (2)

款5、了解C 默认编写并调用哪些函数

通常情况下,如果代码中没有声明构造函数、拷贝构造函数、拷贝运算符、析构函数,编译器会在需要时创建他们,但这往往只能满足编译器的需求,很可能无法满足程序的需求。

实际的生成规则复杂一些,可以查阅cppreference。

代码语言:javascript复制
class Empty{};
//上面的空类好比下面的类
class Empty{
public:
  Empty() { ... }
  Empty(const Empty& rhs) { ... }
  ~Empty() { ... }
  
  Empty& operator=(const Empty& rhs) { ... }
};

编译器生成的构造函数/析构函数是只是调用基类和非静态成员变量的构造函数/析构函数;生成的析构函数是非虚的,除非基类有虚析构函数。

至于生成的拷贝构造函数和拷贝操作符只是单纯将每个非静态成员变量拷贝;有const成员或者引用成员时,以及基类拒绝拷贝操作符时,默认生成的拷贝操作符没有意义,必须自己定义。


条款6、若不想使用编译器自动生成的函数,就该明确拒绝

很多时候,你并不希望某些类被拷贝,而仅仅不实现拷贝构造/拷贝运算符是不够的,因为编译器可能会自行生成。

为此,可以把拷贝构造/拷贝运算符声明为"=delete",或者声明为private(后者较为过时)。

代码语言:javascript复制
class Uncopyable{
public:
  Uncopyable() = delete;
  Uncopyable& operator=(const Uncopyable&) = delete;
};

事实上,对于析构函数中需要释放资源的类,为了防止内存问题,除非真的需要拷贝功能,否则最好都禁止拷贝。

可以让它们继承上面的类,即可禁止编译器生成拷贝操作:编译器试图为它们生成拷贝构造/拷贝运算符时会尝试调用基类Uncopyable的对应操作,而这会被拒绝。

代码语言:javascript复制
class SomeClass: private Uncopyable{ ... };

条款7、为多态基类声明virtual析构函数

C 中多态性质体现于虚函数:基类指针或引用调用虚函数时会检查指向的对象是基类还是派生类,再调用对应的函数。其具体实现这里不再赘述。

当这样的一个指向派生类的基类指针析构时,如果析构函数不是虚函数,则直接调用基类的析构函数,那么派生类获取的资源未释放,则会造成内存泄漏。

而当析构函数是虚函数时则先调用对应的派生类析构函数,再调用基类析构函数,资源全部释放。

不过这种操作只有在基类是多态用途时才需要注意,也有很多类不是为了多态的用途,例如STL容器和上文的Uncopyable。


条款8、别让异常逃离析构函数

C 中抛出异常时会逐步展开其函数调用栈,清空局部资源,直到异常被catch。

如果析构函数可以抛出异常,那么清空局部资源时局部对象的析构函数再次异常时同时存在两个异常,C 无法处理,可能会过早结束或出现不明确行为。因此,析构函数绝对不要抛出异常,应通过try-catch捕获任何异常。

有时,客户需要处理某些异常,那么类应该提供一个普通成员函数执行相关操作,供用户调用并处理异常。

例如数据库连接这样的类中,假设用户需要处理关闭连接时的异常,同时析构函数不能抛出异常,可以这样:

代码语言:javascript复制
class DBConn{
public:
  void close(){
    db.close();
    closed = true;
  }
  ~DBConn(){
    if(!closed){
      try{
        db.close();
      }catch(...){
        //记录相关信息
      }
    }
  }
private:
  DBConnection db;
  bool closed;
};

条款9、绝不在构造和析构过程中调用virtual函数

C 的构造过程是先构造基类再构造子类、先初始化再进入构造函数体;析构过程相反。

对于派生类的构造函数而言,进入其中时基类部分已构造完而派生类部分未构造完,对象类型是基类,故而此时调用虚函数,实际上使用的是基类的虚函数。

析构函数同理。进入析构函数后派生类部分呈未定义值,对象类型是基类,调用的是基类的虚函数。

总而言之,在构造函数与析构函数中虚函数的行为有特殊变化;为了避免出错,不要在其过程中使用虚函数。如果真的有需求,可以改造成非虚函数再使用。

条款10、令operator=返回一个reference to *this

C 中通常支持连锁赋值,采用右结合律:

代码语言:javascript复制
int a,b,c;
a = b = c = 15;
// a = (b = (c = 15));

为了支持这个常规,拷贝运算符需要返回一个引用。这一条款并不强制,但约定俗成。

代码语言:javascript复制
class Widget{
public:
  Widget& operator=(const Widget& rhs){
    ...
    return *this;
  }
};

条款11、在operator=中处理“自我赋值”

考虑这样一个类,其中管理了一个堆对象

代码语言:javascript复制
class Widget{
  ...
private:
  Bitmap* pb; //一个堆对象
};

总有些时候,会出现实质上“a=a”这种自我赋值的情况。那么这样简单的拷贝运算符就会出错,先释放了自身的pb,又使用了pb:

代码语言:javascript复制
Widget& Widget::operator=(const Widget& rhs){
  delete pb;
  pb = new Bitmap(*rhs.pb);
  return *this;
}

传统做法是函数开头添加一个测试:

代码语言:javascript复制
if(this == &rhs) return *this;

这种做法具备“自我赋值安全性”,但不具备“异常安全性”:例如当new Bitmap异常时,pb指向被删除的内存。

常用的方法有两种,兼顾了“自我赋值安全性”与“异常安全性”:

代码语言:javascript复制
//第一种
Widget& Widget::operator=(const Widget& rhs){
  Bitmap* pb_original = pb;
  pb = new Bitmap(*rhs.pb);
  delete pb_original;
  return *this;
}
//第二种
void Widget::swap(Widget& rhs){
  ...
};
Widget& Widget::operator=(const Widget& rhs){
  Widget temp(rhs);
  swap(temp);
  return *this;
}

条款12、复制对象时勿忘其每一个成分

当自己实现拷贝构造/拷贝运算符时,编译器不会警告你遗漏了某些成员变量。因此,必须仔细地复制所有派生类成员,并调用基类的拷贝操作来复制基类成员。

代码语言:javascript复制
Derived::Derived(const Derived& rhs)
  :Base(rhs),some_member(rhs.some_member){}

Derived& Derived::operator=(const Derived& rhs){
  Base::operator=(rhs);
  some_member = rhs.some_member;
  return *this;
}

拷贝构造和拷贝运算符中很可能有相当多重复的操作,但因为两个函数性质完全不同,因此不能用其中一个调用另一个来减少冗余。

可以把共同功能放在第三个函数中,并由两个拷贝操作共同调用。

0 人点赞