DJの小站

记录生活,发现自己

0%

Effective C++ 构造/析构/赋值运算

一、了解C++默默编写并调用了哪些函数:

  1. C++编译器会自动为一个空类生成8个成员函数:

    • default构造函数
    • 析构函数
    • copy构造函数
    • copy(赋值)运算符重载
    • 取址运算符重载
    • 常量取址运算符重载
    • 移动构造函数(C++11)
    • 移动赋值运算符重载(C++11)

    其中,当你定义了某个函数,C++便不会生成相应的成员函数,值得注意的是,从C++11起,如果一个类有析构函数,为其生成拷贝构造函数和拷贝赋值运算符的特性被弃用了。

  2. 对于一些特殊情况,C++会拒绝生成这些成员函数,例如:reference(引用)的拷贝,base类的某个函数为private,derived类不会生成相应的函数

二、若不想使用编译器生成的函数,就该拒绝

若某个对象应具有唯一性,那对它进行copy显然是不合理的,这种情况下,自行定义或不定义copy,都没有禁止copy行为,正确的做法有两种:

  1. 将不想使用但必须拥有的成员函数声明为private而不去实现它:

    1
    2
    3
    4
    5
    6
    7
    class houseforsale{
    private:
    houseforsale(const houseforsale& rhs); //声明而不实现
    houseforsale& operator = (const houseforsale& rhs);
    public:
    ......
    }
  2. 利用访问限制,定义一个base类,该类中必须具有的成员函数声明为private:

    1
    2
    3
    4
    5
    6
    7
    8
    class uncopyable{
    private:
    uncopyable(const uncopyable&);
    uncopyable& operator = (const uncopyable&);
    }
    class houseforsale : public uncopyforsale{
    ......
    }

三、为多态基类声明virtual析构函数

  1. 当一个base类指针指向derived类对象时,若直接delete掉这个基类指针,会导致derived类资源未被释放,从而使得内存泄漏,故而在写希望产生多态效果的基类时,要将基类析构函数写为虚函数:

    1
    2
    3
    4
    5
    6
    7
    8
    class base{
    public:
    base(){}
    virtual ~base(){}
    }
    class derived : public base{
    ......
    }
  2. 虚函数为C++运行时多态(同名的函数,不同的效果)提供了支持,其基本原理是,具有虚函数的类中,除了成员数据部分,还会放一个虚函数表指针,指向一个放置虚函数指针的虚函数表,在运行阶段,程序通过虚函数表指针,找到虚函数表,再找到相应的函数,完成多态。

  3. 虚函数表指针是一个指针,是具有大小的,在32位系统中指针大小为32位bit(4byte),64位系统中为64位bit(8byte),因此,虽然虚函数很强大,但不必使每个类都有虚函数,通常做法是:当一个类中必不可少有一个虚函数时,才声明虚析构函数。

四、不要使析构函数抛出异常

C++不喜欢析构函数吐出异常,比如

1
2
3
4
class Weight{
~Weight(){...}
}
std::vector<Weight> v;

当v销毁时,它会销毁vector中的每一个Weight,但若有两个Weight在销毁时都抛出异常,会使程序终止或导致不明确行为,所以析构函数的异常应当自我消化,在捕捉到异常时,应当终止程序或记录异常采用鸵鸟策略,绝不应当在析构函数中抛出:

1
2
3
4
5
6
7
8
9
10
11
12
class example{
private:
Dbconnection db;
public:
~example(){
try{db.close();}
catch(...){
std::abort(); //使不正常程序终止
//或记录析构失败,继续执行
}
}
}

另外,作为软件开发人员,我们可将析构中可能包含异常的行为写成一个函数,供用户调用,使得用户去处理这个异常(甩锅给客户),若析构时发现用户未调用该函数,则在析构时处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class example{
private:
Dbconnection db;
bool isclosed = false;
public:
void closedb(){ //写给客户
db.close();
isclosed = true;
}
~example(){
if(!isclosed){
try{db.close();}
catch(...){
std::abort(); //使不正常程序终止
//或 记录析构失败,继续执行
}
}
}
}

五、不在构造或析构过程中调用virtual函数

  1. 举例游戏场景:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    class Game {
    public:
    Game();
    ~Game();
    virtual void Log() const = 0;
    };
    Game::Game(){
    Log();
    std::cout << "Game() Log()" << std::endl;
    };
    Game::~Game(){
    Log();
    std::cout << "~Game() Log()" << std::endl;
    };
    class Kill : public Game {
    public:
    virtual void Log() const override { //const override表示重写父类虚函数
    std::cout << "Kill Log()" << std::endl;
    }
    };
    int main(void){
    Kill a;
    return 0;
    }

    这段代码在编译时会报waning,运行时也会出错,因为是Log函数为纯虚函数,无法对它产生调用,因为在派生类构造阶段,会先调用父类构造,再调用成员对象构造,最后才是自己的成员变量构造,故而在第一阶段时,父类的构造函数调用的是它本身的纯虚函数,而不是派生类的函数

    同样的道理也适用于析构函数,众所周知,析构函数和构造函数的顺序是反着来的

  2. 更奇怪的情况下,可能会让人摸不着头脑:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    class Game{
    public:
    Game(){init();}
    virtual void Log() const = 0;
    private:
    void init(){
    Log();
    }
    }
    class Kill : public Game {
    public:
    virtual void Log() const override { //const override表示重写父类虚函数
    std::cout << "Kill Log()" << std::endl;
    }
    };

    对于这段代码,未经优化的编译器不会有任何反应(据说Visual Studio集成了整本Effective C++给出的建议),然而最终运行时,会报错:Kill调用了错误版本的Log()函数,使人在风中凌乱……所以,一定要确定自己的代码中,构造和析构函数中不调用虚函数

  3. 那么,我们想实现类似的功能该怎么办呢,一个好的方法将原函数写为非虚的,在构造时传参到Base类中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Game{
public:
Game(const string& loginfo){
Log(loginfo);
}
private:
Log(const string& loginfo){....}
}
class Kill : public Game{
public:
Kill(parameters):Game(createinfo(parameters)){...}
private:
static const string& createinfo(parameters){...}
}

六、operator “ = “ 的重载

  1. 为了使自定义重载运算符和基本类型运算符保持一致,我们通常使” = “返回一个当前对象的reference to *this

  2. 对于一个含指针的类Weight,我们对它的赋值函数进行三次改版。

    先是Weight类:

    1
    2
    3
    4
    5
    class Weight{
    Bitmap *pb;
    public:
    Weight& operator = (const Weight& rhs);
    }

    第一版赋值函数:

    1
    2
    3
    4
    5
    6
    //version_1
    Weight& Weight::operator = (const Weight& rhs){
    delete pb;
    pb = new Bitmap(*rhs.pb);
    return *this;
    }

    这种情况下,当lhs == rhs时,逻辑上,这个函数已经出错了!Bitmap将指向一个已被删除的对象!由此引出第二版赋值构造函数:

    1
    2
    3
    4
    5
    6
    7
    //version_2
    Weight& Weight::operator = (const Weight& rhs){
    if(rhs == *this) return *this;
    delete pb;
    pb = new Bitmap(*rhs.pb);
    return *this;
    }

    这个版本中,我们对于传入参数和自身进行了比较,有效地避免了rhs == lhs的问题。

    但是,这个新版本依旧有问题:若是new Bitmap时出现异常(可能由于内存不够,可能由于copy抛出异常),由于先删除了pb后赋值,对象内的指针会指向一块被删除的Bitmap,为此,出现了第三版:

    1
    2
    3
    4
    5
    6
    7
    8
    //version_3
    Weight& Weight::operator = (const Weight& rhs){
    if(rhs == *this) return *this;
    Bitmap *tpb = pb;
    pb = new Bitmap(*rhs.pb);
    delete tpb;
    return *this;
    }

    这一版就没有上述两版的问题了,它既有证同测试,又有防异常行为,prefect version达成了!

    对于性能提升上,Ecpp还给出了一个新的版本:

    1
    2
    3
    4
    5
    6
    7
    void swap(Weight &rhs){
    ...
    }
    Weight& Weight::operator = (Weight rhs){
    swap(rhs);
    return *this;
    }

    它利用了传值时会调用拷贝构造这一行为,其思想和移动很像,这应该是“移动”的前身了!

七、复制对象时切勿忘记复制它的每个部分

  1. copy系函数应当确保复制对象内所有的成员变量所有Base类的成分
  2. 不要使copy构造函数和copy operator函数相互调用,通常将它们共通的部分写在一个函数中去调用。