《Effective C++》学习笔记(持续更新)

此文由来

《Effective C++》同《C++ primier》一样,也是非常出名的一本书,正如此书的副标题所说——改善程序与设计的55个具体做法,此书的目的,就是教会读者优化C++的使用方式,所以里面的内容都是要求你对C++的知识有一定理解之后再开始研读,所以如果你想要精进C++,这本书是不二法门,当然,还有他的姊妹篇《More Effective C++》,这个就等以后再介绍吧。本书主要介绍55个设计小tip,而我也以这55个小知识点,对C++知识进行一些梳理。

第一部分:让自己习惯C++

条款1:视C++为一个语言联邦

这一条作者主要提出一个概念:次语言。而他把C++分成了四个次语言,分别为:

  • C
  • Object-Oriented C++
  • Template C++
  • STL
    也就是说,在作者的眼里,这四个部分就像是四种不同的语言,各自有各自的语法规则。

条款2:尽量以const,enum,inline替换 #define

例如:

#define T_s 4396

当你运用此常量并获得一个编译错误信息时,系统可能会提到4396这个数,而你可能并不知道这个数字代表的是哪个变量。
解决之道就是

const double TS=4396;

预处理器盲目地将宏名称T_S替换为4396,可能会导致目标码出现多份4396,若改用常量TS则绝不会出现相同情况。
顺带一提,我们无法利用#define创建一个class专属常量,因为#defines并不重视作用域。一旦宏被定义,他就在其后的编译过程中有效。这意味着#defines不仅不能够用来定义class专属常量,也不能够提供任何封装性

enum hack技术

class Game {
    
    
private:
    static const int a = 10;
    int scores[a];
};

这在现在的编译器中,是没问题的,但是如果在比较老的编译器,静态变量a需要在类外初始化。

class Game {
    
    
private:
    static const int a;
    int scores[a];
};
 
const int Game::a = 10;

但是因为scores是一个数组,a只有在下面才初始化,程序到int scores[a];这行代码是便会出错。
这时候就可以用enum技术了


class Game {
    
    
private:
    // static const int GameTurn;
    enum{
    
    a=10};
    int scores[a];
};

这时就可以轻松通过了。

enum hack是template metaprogramming(模板元编程)的基础技术

题外话:目前还不到预处理器全面隐退的时候,你应该明确地给它更长地假期。

条款3:尽可能使用const

我们先介绍一下C++ const成员函数
在C++中,若一个变量声明为const类型,则试图修改该变量的值的操作都被视编译错误。

const Screen blankScreen;
blankScreen.display();   // 对象的读操作
blankScreen.set(*);    // 错误:const类对象不允许修改

在C++中,只有被声明为const的成员函数才能被一个const类对象调用。
要声明一个const类型的类成员函数,只需要在成员函数参数列表后加上关键字const.

class Theshy{
    
    
public:
char get()const;
};

若将成员成员函数声明为const,则该函数不允许修改类的数据成员。例如

class Theshy{
    
    
public:
int a()const{
    
    return b;} //合法
int b(c)const{
    
    b=e;}//函数试图修改类数据成员,不合法
}

值得注意的是,把一个成员函数声明为const可以保证这个成员函数不修改数据成员,但是,如果据成员是指针,则const成员函数并不能保证不修改指针指向的对象,编译器不会把这种修改检测为错误。

class Name {
    
    
public:
    void setName(const string &s) const;
private:
    char *m_sName;
};
 
void setName(const string &s) const {
    
    
    m_sName = s.c_str();      // 错误!不能修改m_sName;
 
    for (int i = 0; i < s.size(); ++i) 
        m_sName[i] = s[i];    // 不好的风格,但不是错误的
}

虽然 m_Name 不能被修改,但 m_sName 是 char * 类型,const 成员函数可以修改其所指向的字符。

const成员函数承诺绝不改变其对象的逻辑状态。如果在const函数内调用non-const函数,就是冒了这样的风险:你曾经承诺不改动的那个对象被改动了。这就是为什么const成员函数调用non-const成员函数是一种错误行为。

条款4:确定对象在被使用前已先被初始化

最佳方法:永远在使用对象之前先将它初始化。
C++规定,对象的成员变量的初始化动作发生在进入构造函数本体之前。

A::A(......)
{
    
    
theName=name;
theAddress=address;//这些都是赋值,而非初始化。
}

而优化方法是:使用member initialization list(成员初始值列)替换赋值动作

A::A(.....)
:theName(name),             //现在,这些都是初始化
theAddress(address),
thePhones(phones)
{
    
    
//而构造函数本体则不用发生任何动作。
}

条款5:了解C++默默编写并调用哪些函数

如果你没有声明任何构造函数,编译器也会为你声明一个default构造函数,并且这些函数都是publicinline的。

copy构造函数

复制构造函数是构造函数的一种,也称拷贝构造函数,它只有一个参数,参数类型是本类的引用。
如果类的设计者不写复制构造函数,编译器就会自动生成复制构造函数,
默认构造函数(即无参构造函数)不一定存在,但是复制构造函数总是会存在。
下面是调用默认复制构造函数的例子

#include <iostream>

using namespace std;

class Theshy
{
    
    

    public:double rk,jk;
    Theshy(double r,double j)
    {
    
    
        rk=r,jk=j;
    }

};
int main()
{
    
    
    Theshy t1(1,2);
    Theshy t2(t1);用复制构造函数初始化t2
    cout<<t2.rk<<","<<t2.jk;
    return 0;
}

如果编写了复制构造函数,则默认复制构造函数就不存在了。下面是一个非默认复制构造函数的例子。

#include <iostream>

using namespace std;

class Theshy
{
    
    

    public:double rk,jk;
    Theshy(double r,double j)
    {
    
    
        rk=r,jk=j;
    }
    Theshy(const Theshy &t)
    {
    
    
    rk=t.rk;
    jk=t.jk;
    cout<<"Copy Constructor called"<<endl;
    }

};
int main()
{
    
    
    Theshy t1(1,2);
    Theshy t2(t1);
    cout<<t2.rk<<","<<t2.jk;
    return 0;
}

赋值运算符

在默认情况下(用户没有定义,但是也没有显示的删除),编译器会自动隐式生成一个拷贝构造函数和赋值运算符,但用户可以使用delete来指定不生成拷贝构造函数和赋值运算符,这样的对象就不能通过值传递,也不能进行赋值运算

 Theshy& operator= (const Theshy& p) = delete;

这时用上面的 Theshy t2(t1);就不好使了。

编译器可以暗自为class创建default构造函数,copy构造函数,copy assignment(复制赋值)操作符,以及析构函数。

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

有些时候,我们希望某些成员是独一无二的,是不可被复制的。这时候我们就用不到条款5的拷贝构造函数。但是如果你不声明,编译器又会自动为你声明这些函数,这个时候,你可以把这些函数声明为private,可以阻止人们调用它。

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

虚析构函数

为了避免内存泄漏,而且是当子类中会有指针成员变量时才会使用到。即虚析构函数使得在删除指向子类对象的基类指针时,可以调用子类的析构函数来实现释放子类中堆内存的目的,从而防止内存泄漏。

纯虚函数

纯虚函数是一种特殊的虚函数,在许多情况下,在基类中不能对虚函数给出有意义的实现,而把它声明为纯虚函数,它的实现留给该基类的派生类去做。这就是纯虚函数的作用。
纯虚函数也可以叫抽象函数,一般来说它只有函数名、参数和返回值类型,不需要函数体。这意味着它没有函数的实现,需要让派生类去实现。
C++中的纯虚函数,一般在函数签名后使用=0作为此类函数的标志。Java、C#等语言中,则直接使用abstract作为关键字修饰这个函数签名,表示这是抽象函数(纯虚函数)。
中文名纯虚函数

  • 当派生类对象经由一个基类指针被删除,而基类带着一个非虚析构函数,其结果未有定义———实际执行时通常发生的是对象的派生类成分没被销毁,于是造成一个诡异的“局部销毁”对象。这可是形成资源泄露,在调试器上浪费许多时间的最佳途径。

  • 无端地将所有classes的析构函数声明为virtual,就像从未声明它们为virtual一样,都是错误的。

  • 有时候令class带一个纯虚析构函数,可能颇为便利。

带多态性质的基类应该声明一个虚析构函数。如果类中带有任何虚函数,他就应该拥有一个虚析构函数。
类的设计目的如果不是作为基类使用,或不是为了具备多态性,就不该声明虚析构函数。

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

假设一个vector包含十个元素,在析构第一个元素期间,有异常被抛出,其他九个元素还是应该被销毁,因此v应该调用它们各个机构函数。但假设在那些调用期间,第二个异常又抛出了。对C++而言,两个异常同时存在的情况下,程序如果不结束执行就会导致不明确行为。C++并不喜欢析构函数吐出异常
如果程序遭遇一个于析构期间发生的错误后无法继续执行,可以强迫结束,也可以不管。
一种策略是,自己提供一个close函数,富裕客户一个机会得以处理因该操作而发生的异常。
由客户自己调用close并不会对他们带来负担,而是给他们一个处理错误的机会,否则他们没机会响应。

析构函数绝对不要吐出异常。如果一个被析构函数调用的函数可能抛出异常,析构函数应该捕捉任何异常,然后吞下他们(不传播)或结束程序。
如果客户需要对某个操作函数运行期间抛出的异常做出反应,那么class应该提供一个普通函数来执行该操作。

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

派生类对象内的基类成分会在派生类自身成分被构造之前先构造妥当。对象在派生类构造函数开始执行前不会成为一个派生类对象。
相同道理也适用于析构函数。一旦derived class 析构函数开始执行,对象内的派生类成员变量便呈现未定义值,所以C++视它们不存在。

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

为了实现连锁赋值,赋值操作符必须返回一个reference指向操作符的左侧实参。

class Theshy
{
    
    
public:
Theshy &operator=(const Theshy& rhs)
{
    
    
return* this;
}

};
如果你不遵循它,代码一样可以通过编译。

条款11:在operator=中处理自我复制

如果一段代码操作pointers或者references而它们被用来指向多个相同类型的对象,就需要考虑这些对象是否为同一个。实际上,只要来自同一继承体系,它们甚至不需要声明为相同类型就可以造成别名(如C++的多态性,base class的指针或者引用可以指向derived class)。

赋值运算符重载函数(operator=)

对这个类的对象进行赋值时,使用默认的赋值运算符是没有问题的。

#include <iostream>
 
using namespace std;
 
class ClassA
{
    
    
public:
    int a;
    int b;
    int c;
};
 
int main()
{
    
    
    ClassA obj1;
    obj1.a = 1;
    obj1.b = 2;
    obj1.c = 3;
 
    ClassA obj2;
    obj2 = obj1;
 
    cout << "obj2.a is: " << obj2.a << endl;
 
    return 0;
}


但是,在下面的示例中,使用编译系统默认提供的赋值运算符,就会出现问题了。

#include <iostream>
#include <string.h>
 
using namespace std;
 
class ClassA
{
    
    
public:
    ClassA()
    {
    
    
    
    }
 
    ClassA(const char* pszInputStr)
    {
    
    
        pszTestStr = new char[strlen(pszInputStr) + 1];
        strncpy(pszTestStr, pszInputStr, strlen(pszInputStr) + 1);
    }
    virtual ~ClassA()
    {
    
    
        delete pszTestStr;
    }
public:
    char* pszTestStr;
};
 
int main()
{
    
    
    ClassA obj1("liitdar");
 
    ClassA obj2;
    obj2 = obj1;
 
    cout << "obj2.pszTestStr is: " << obj2.pszTestStr << endl;
    cout << "addr(obj1.pszTestStr) is: " << &obj1.pszTestStr << endl;
    cout << "addr(obj2.pszTestStr) is: " << &obj2.pszTestStr << endl;
 
    return 0;

}
//上述错误信息说明:当obj1和obj2进行析构的时候,由于重复释放了一块内存,导致程序崩溃报错。在这种情况下,就需要我们重载赋值运算符“=”了。

我们修改一下前面出错的代码示例,现编写一个包含赋值运算符重载函数的类,代码如下:

#include <iostream>
#include <string.h>
 
using namespace std;
 
class ClassA
{
    
    
public:
    ClassA()
    {
    
    
    
    }
    ClassA(const char* pszInputStr)
    {
    
    
        pszTestStr = new char[strlen(pszInputStr) + 1];
        strncpy(pszTestStr, pszInputStr, strlen(pszInputStr) + 1);
    }
    virtual ~ClassA()
    {
    
    
        delete pszTestStr;
    }
    // 赋值运算符重载函数
    ClassA& operator=(const ClassA& cls)
    {
    
    
        // 避免自赋值
        if (this != &cls)
        {
    
    
            // 避免内存泄露
            if (pszTestStr != NULL)
            {
    
    
                delete pszTestStr;
                pszTestStr = NULL;
            }
 
            pszTestStr = new char[strlen(cls.pszTestStr) + 1];
            strncpy(pszTestStr, cls.pszTestStr, strlen(cls.pszTestStr) + 1);
        }
        
        return *this;
    }
    
public:
    char* pszTestStr;
};
 
int main()
{
    
    
    ClassA obj1("liitdar");
 
    ClassA obj2;
    obj2 = obj1;
 
    cout << "obj2.pszTestStr is: " << obj2.pszTestStr << endl;
    cout << "addr(obj1.pszTestStr) is: " << &obj1.pszTestStr << endl;
    cout << "addr(obj2.pszTestStr) is: " << &obj2.pszTestStr << endl;
 
    return 0;
}


确保当对象自我赋值时operator=有良好的行为。其中技术包括比较“来源对象”和“目标对象”的地址、精心周到的语句顺序、以及copy-and-swap。
确定任何函数如果操作一个以上的对象,而其中多个对象是同一个对象时,其行为仍然正陈确。

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

如果你为class添加一个成员变量,你必须同时修改复制函数,有时候复制了成员,还有可能没有复制新添加的变量。
确保复制所有local成员变量
调用所有base classes内的适当的复制函数。

copying函数应该确保复制“对象内的所有成员变量”及“所有base class成分”
不要尝试以某个copying函数实现另一个copying函数。应该将共同机能放进第三个函数,并由两个coping函数,共有两个copying函数共同调用。

资源管理

所谓资源就是,一旦用了将来必须还给系统,不然会导致内存泄露。

条款13:以对象管理资源

为确保某个对象返回的资源总是被释放,我们需要将资源放进对象内,当控制流离开f,该对象的析构函数会自动释放那些资源。实际上这正是隐身于本条款背后的半边想法:把资源放进对象内,我们便可以依赖C++的析构函数自动调用机制确保资源被释放。

智能指针

出了静态内存和栈内存以外,每个程序还有一个内存池,称为自由空间或堆内存。程序用堆来存储动态分配的对象即那些在程序运行时分配的对象,当动态对象不再使用时,我们的代码必须显式的销毁它们。
而使用newdelete会出现两种情况,一种情况是忘记释放内存,会导致资源泄露,第二种情况是此时还有指针引用内存,这时候释放,会导致产生引用非法内存的指针。
为了更加容易(更加安全)的使用动态内存,引入了智能指针的概念。智能指针的行为类似常规指针,重要的区别是它负责自动释放所指向的对象。标准库提供的两种智能指针的区别在于管理底层指针的方法不同,shared_ptr允许多个指针指向同一个对象,unique_ptr则“独占”所指向的对象。标准库还定义了一种名为weak_ptr的伴随类,它是一种弱引用,指向shared_ptr所管理的对象,这三种智能指针都定义在memory头文件中。
指针并没有一个内在机制来自动管理与释放,这时候我们就想到了前面的条款,使用类,类内部储存指针,然后在析构函数中销毁指针。
本条款提到auto_ptr(类指针对象),在析构函数自动堆其所指对象调用delete。
由于auto_ptr被销毁时会自动删除它所指之物,所以一定要注意别让多个auto_ptr同时指向同一对象,如果是那样,对象会被删除一次以上。auto_ptr有一个特殊的性质,如果它被复制构造函数或者赋值运算符复制它们,它们会变成Null,而复制品可以取得资源。

为防止资源泄露,请使用RAII(资源获取就是初始化)对象,它们在构造函数中获得资源并在析构函数中释放资源。
两个常被使用的RAII类分别是tr1::shared_ptr和auto_ptr。前者通常是较佳选择,因为其copy行为比较直观。若选择auto_ptr,复制动作会使它们指向null.

条款14:在资源管理类中小心copying行为

假设我们使用C API函数处理类型为Mutex的互斥器对象,共有lock和unlock两函数可用:

void lock(Mutex* pm) //锁定pm所指的互斥器
void unlock(Mutex* pm) //将互斥器解除锁定

为了确保不会忘记将一个被锁住的Mutex解锁,可能会希望建立一个class用来管理机锁

class Lock {
    
    
	public:
		explicit Lock(Mutex* pm) : mutextPtr(pm) {
    
    
			lock(mutextPtr);
		}
		~Lock() {
    
    
			unlock(mutextPtr);
		}
	private:
		Mutex *mutextPtr;				
};

客户对Lock的用法符合RAII方式:

Mutex m;
...
{
    
    
	lock ml(&m); // 锁定互斥器
	...          // 在区块末尾,自动解除互斥器锁定
}
Lock ml1(&m);  // 锁定m
Lock ml2(ml1); // 将ml1复制到ml2上,会发生什么?

当一个RAII对象被复制,会发生什么?一般会选择以下两种可能

禁止复制将copying操作声明为private(条款6):
对底层资源祭出"引用计数法":shared_ptr,但是shared_ptr的缺省行为是:“当引用计数变为0时删除其所指物”,我们想要做的工作是解除锁定而非删除;这时候我们可以使用shared_ptr允许制定的"删除器"来实现这一行为

class Lock {
    
    
	public:
		explicit Lock(Mutex* pm):mutexPtr(pm, unlock) {
    
     // 指定unlock作为删除器
			lock(mutexPtr.get()); 
		}
		private:
			std::shared_ptr<Mutex> mutexPtr;
}

条款15:在资源管理类中提供对原始资源的访问

思想来自于在资源管理类中提供对原始资源的访问
前面提到,我们使用栈对象来管理资源已达到对资源的正确回收,防止资源泄露的目的。有了资源管理类后,我们还需要再类里面提供对资源的访问方法,不然如果我们无法通过管理类来对资源进行访问,那我们还是会直接绕过管理类去直接访问资源,这样是不好的。
一般来说,资源管理类可以提供两种方式供外界去访问它所管理的资源:显式访问隐式转换

显式访问

所谓显示访问就是在管理类的内部提供某个函数,使得外界可以得到资源的指针。通过这个函数被命名为**get()*函数,当然为了方便,我们也可以重载,->运算符。
假设有一类资源FontHandle 字体处理类:

FontHandle getHandle();得到字体资源
void releaseFont(FontHandle fh);释放字体资源

有一个资源管理类:


class Font
{
    
    
public:
Font(FontHandle fh):f(fh)
{
    
    
}
~Font()
{
    
    
releaseFont(f);
}
private:
FontHandle f;
};

再加上get函数,就可以访问里面的原始资源了。

class Font
{
    
    
public:
Font(FontHandle fh):f(fh)
{
    
    
}
~Font()
{
    
    
releaseFont(f);
}
FontHandle get() const
{
    
    
    Return f;
}
private:
FontHandle f;
};

隐式转换

假设资源管理类已经提供了显示访问的API,那么用户每次访问底层资源都需要显示地调用get()函数,这样既有好处也有不足。好处在于这种转换都是用户知晓的,由用户控制的,不会发生一些用户不愿意转换却转换的事情。不足在于,如果这类显示访问太于频繁将很影响管理类的便利性
于是隐式转换就出现了,隐式转换提供一种自动将资源管理对象转换为原始资源指针的功能。这主要是通过重载类型转换运算符实现的。

class Font
{
    
    
public:
Font(FontHandle fh):f(fh)
{
    
    
}
~Font()
{
    
    
releaseFont(f);
}
FontHandle get() const
{
    
    
    Return f;
}
operator FontHandle() const
{
    
    
    Return f;
}
private:
FontHandle f;
};

这样在所有可以以FontHandle为参数的地方都可以填入Font对象了。

条款16:成对使用new和delete时要采取相同方式

当你使用new,有两件事发生,第一,内存被分配出来,第二,针对此内存会有一个(或更多)构造函数被调用。
当你使用delete,也有两件事发生:针对此内存会有一个(或更多)析构函数被调用,然后内存才被释放。
delete最大问题在于:即将被删除的内存之内究竟有多少对象?这个问题的答案决定了有多少个析构函数被调用。
如果想要定向地删除某个元素,可能是数组某个下标,可能是整个数组,这需要你自己去告诉指针。

  • delete a1; //删除一个对象
  • delete [] a2; //删除一个由对象组成的数组

如果你调用neq时使用[],你必须在对应调用delete时也使用[]。如果你调用new时没有使用[],那么也不该在对应调用delete时使用[]。

条款17:以独立语句将newed对象置于智能指针。

虽然我们使用对象管理式资源,上述调用却可能泄露资源。
编译器在对processWidget调用之前,必须先核算即将被传递的各个实参,上面第一个实参是std::shared_prt(new Widget),第二个实参是priority().
std::shared_prt(new Widget)由两部分组成:

  • 1.执行new widget表达式
    2.调用shared_prt构造函数

于是在调用processWidget之前,编译器必须创建代码,做以下三件事情:
调用priority
执行new widget
调用shared_prt构造函数
C++编译器以什么次序完成这些事情呢?这没有保证,我们唯一可以确定的是new widget一定执行于shared_prt构造函数被调用之前。
但是对priority的调用可以排在第一、第二或第三执行。
假如编译器以第二顺位执行它:

  • 1.执行new widget
    2.调用priority
    3.调用shared_prt构造函数

万一对priority的调用导致异常,会发生什么事?
这种情况下new widget返回的指针会遗失,因为它没有被放入shadred_prt内。
所以说,对processWidget的调用过程中可能引发资源泄露,因为在资源被创建和资源被转换为管理对象两个时间点之间有可能发生异常干扰。

以独立语句将newed对象存入智能指针内,如果不这样做,一旦异常被抛出,有可能导致难以察觉的资源泄露。

条款18:让接口容易被正确使用,不易被误用

当你设计接口时,客户可能会用错参数等等,这时候可以用到外覆类型
,来设计比较成熟的接口

预防客户错误的另一个方法是,限制类型内什么事可做,什么事不能做。常见的限制是加上const.

任何接口如果要求客户必须记得做某些事情,就是有着“不正确使用”的倾向,因为客户可能会忘记做那些事。
比如说声明了一个指针,而这个指针在内存管理时会出现两个问题,第一个问题是客户可能会忘记删除,第二个是客户可能会多删除几次,如条款13而言,将某个对象的返回值交给了一个智能指针,但是如果客户忘记了使用智能指针怎么办?
那就令函数返回一个智能指针

std::tr1::shared_ptr 尖括号Investment尖括号 createInvestment();

"阻止误用"的办法包括建立新类型、限制类型上的操作,舒服对象值,以及消楚客户的资源管理责任。

条款19:设计class犹如设计type

要设计一个良好的class,需要思考以下问题:

  • 新types对象应该如何被创建和销毁?

     涉及到构造函数,析构函数,内存分配和释放函数(operator new,operator new[],operator delete,operator delete[])的设计
    

    2). 对象的初始化和对象的赋值该有怎样的差别?

     涉及到构造函数和赋值操作符的行为以及它们的差异
    

    3). 新type的对象如果如果被pass-by-value(以值传递),意味着什么?

    4). 什么是type的合法值?

     对于class的成员变量而言,可能只有某些数据集是有效的,此时某些成员函数(特别是构造函数,赋值操作符和"setter"函数)必须进行的错误和检查工作,它也影响函数抛出的异       常,以及(极少使用的)函数异常明细列.
    

    5). 你的type需要配合某个继承图系(inheritance graph)吗?

    如果设计的type继承自某些类,就会收到哪些类的"函数是virtual或non-virtual"的影响;
    
    根据是否设计的type是否被继承,判断所声明的函数(尤其是析构函数)是否为虚.
    

    6). 你的新types需要什么样的转换?

    如果需要隐式转换,可以重载类型转换函数或允许non-explict-one-arguement(非explict单实参)构造函数.如果只允许显示转换,就专门写出负责执行转换的函数,且禁止类型     转换操作符和non-explict-one-arguement(非explict单实参)构造函数
    

    7). 什么样的操作符和函数对此新type而言是合理的?

    8). 什么样的标准函数应该驳回?

    声明为private或只声明不定义.(具体见条款6)
    

    9). 谁该取用新type的成员?

    决定哪些成员为public,哪些为protect,哪些为private,那些类和函数是friends,以及将它们嵌套于另一个之内是否合理.
    

    10). 什么是新type的未声明接口?

    明确它对效率,异常安全性(见条款29),以及资源运用(例如多任务锁定和动态内存)提供何种保证.
    

    11). 你的新type有多么一般化?

    判断是否直接定义一个新的class template.
    

    12). 你真的需要一个新type吗?

    如果只是为已有类添加新功能,说不定单纯定义一个或多个non-member函数或template即可.
    

条款20:宁以pass-by-reference-to-const替换pass-by-value

使用pass by reference to const进行回避:

bool validateStudent(const Student& s);
这种传递方式效率就高很多,没有任何构造函数或析构函数被调用,因为没有对象被创建。这里的const使用是重要的,不这样做的话调用者会忧虑validateStudent会不会改变他们传入的哪个Student。

以by reference方式传递参数还可以避免slicing(对象切割)问题。

如果函数参数是基类对象,当传入一个派生类对象时,会造成派生类对象相比基类对象“之所以是个派生类对象”的所有特性化信息都会被切除。解决切割(slicing)问题的办法,就是以by reference to const的方式传递参数。
由C++编译器的角度来看,references往往以指针实现出来,一次pass by reference 通常意味真正传递的是指针。如果对象属于内置类型(例如int),pass by value往往比pass by reference的效率高些

条款21:必须返回对象时,别妄想返回其reference

思想来自于:条款21

如果他返回一个reference,那么后者一定指向某个既有的Rational对象,内含两个Rational对象的乘积。
因此,我们不能期望这样一个内含乘积的Rational对象在调用operator* 之前就存在。也就是说:

Rational a(1, 2);       //a = 1/2
Rational b(3, 5);       //b = 3/5
Rational c = a * b;     //c应该是3/10

期望“原本就存在一个值为3/10的Rational对象”并不合理。如果operator* 要返回一个reference指向这个数值,它就必须自己创建这个Rational对象!

创建对象

一般来说,函数创建对象有两种方法:
在栈和堆空间创建
如果我们定义一个local变量,就是在stack空间创建对象。根据这个策略,尝试写一下operator*:

const Rational& operator* ( const Rational& lhs,
                            const Rational& rhs)
{
    
    
    Rational result(lhs.n * rhs.n, lhs.d * rhs.d);  //使用了构造函数实现,但是非常糟糕!
    return result;
}                       

对于上面这个方法,是一个比较糟糕的办法!因为我们的目标是避免使用构造函数,而result却必须用构造函数的方法来进行构造。
更严重的是,这个函数返回一个reference指向result,但是result是一个local对象,而local对象在函数退出之前就被销毁了。因此,此时operator* 所指向的Rational,是一个已经被销毁的Rational!于是,此时将会陷入“无定义行为”的困境。
简单总结一句话:

任何函数如果返回一个reference指向某个local对象,都会产生必然的错误!

因此,我们考虑在heap内构造一个对象,并返回reference指向它。
Heap-based对象是由new创建的,因此我们需要写一个heap-based operator* ,形式如下:

const Rational& operator* ( const Rational& lhs,
                            const Rational& rhs)
{
    
    
    Rational* result = new Rational(lhs.n * rhs.n, lhs.n * rhs.d);    //更为!糟糕的写法!
    return *result;
}                       

在上面的代码中,我们依然需要付出一个“构造函数调用”的代价,因为分配获得的内存将以一个适当的构造函数完成初始化动作。
然而,此时还有一个更为严重问题:

谁应该为被new出来的对象实施delete??
即使我们十分谨慎,还是会在合情合理的使用下,造成内存泄漏

Rational w, x,y,z;
w = x * y * z;          //与operator*(operator*(x, y), z)相同

在上面的代码中,同一个语句调用了两次operator*,因此使用了两次new,因此也就需要两次delete。
但是,并没有合理的办法让operator的使用者进行哪些delete调用,因为没有合理的办法让他们取得operator 返回的references背后隐藏的那个指针。

这势必会造成内存泄漏!

条款22:将成员变量声明为private

在将成员变量隐藏在函数接口的背后,可以为“所有可能”提供弹性、例如这可是的成员变量被读或被写时轻松通知其他对象。
public成员变量完全没有封装性,protected成员变量就像public成员变量一样缺乏封装性,因为在这两种情况下,如果成员变量被改变,都会有不可预知的大量代码遭到破坏。

切记将成员变量声明为private。这可富裕客户访问数据的一致性、可席位划分访问控制,允诺约束条件获得保证,并提供class作者以充分的实现弹性。
protectedBingbubi1public更具封装性。

条款23:宁以non-member、non-friend替换member函数

思想来自于条款23

class WebBrower
{
public:
void ClearCach();
void ClearHistory();
void RemoveCookies();
};
定义了一个WebBrower的类,里面执行对浏览器的清理工作,包括清空缓存,清除历史记录和清除Cookies,现在需要将这三个函数打包成一个函数,这个函数执行所有的清理工作.而这个清理函数可以放在类内,也可以放在类外。

根据面向对象守则的要求,数据以及操作数据的函数应该捆绑在一起,都放在类中,这意味着把它放在类内会比较好。但从封装性的角度而言,它却放在类外好,为什么?

为了区分开,我们把在类内的总清除函数称之为ClearEverything,而把类外的总清除函数称之为ClearWebBrower。ClearEverything对封装性的冲击更大,因为它位于类内,这意味着它除了访问这三个公有函数外,还可以访问到类内的私有成员,是的,你也许会说现在这个函数里面只有三句话,但随着功能的扩充,随着程序员的更替,这个函数的内容很可能会逐渐“丰富”起来。而越“丰富”说明封装性就会越差,一旦功能发生变更,改动的地方就会很大。

再回过头来看看类外的实现,在ClearWebBrowser()里面,是通过传入WebBrower的形参对象来实现对类内公有函数的访问的,在这个函数里面是绝对不会访问到私有成员变量(编译器会为你严格把关)。因此,ClearWebBrowser的封装性优于类内的ClearEverything。

但这里有个地方需要注意,ClearWebBrower要是类的非友元函数,上面的叙述才有意义,因为类的友元函数与类内成员函数对封装性的冲击程度是相当的。

看到这里,你也许会争辩,把这个总清除的功能函数放在类外,就会割离与类内的关联,逻辑上看,这个函数就是类内函数的组合,放在类外会降低类的内聚性。

最后总结一下,本条款强调封装性优先于类的内聚逻辑,这是因为“愈多东西被封装,愈少人可以看到它,而愈少人看到它,我们就有愈大的弹性去改变它,因为我们的改变仅仅影响看到改变的那些人或事物”。采用namespace可以对内聚性进行良好的折中。

条款24: 若所有参数皆需类型转换,请为此采用non-member函数

class Rational {
    
    
public:
    Rational(int numerator = 0,     //构造函数刻意不是explicit
             int denominator = 1);  //允许int-to-rational进行隐式转换
    int numerator() const;          //分子的访问函数
    int denominator() const;        //分母的访问函数
private:
    ...
};

有理数的私有成员是分子numerator和分母denominator,定义了构造函数,注意这个构造函数前面没有加explicit关键字,这意味着允许隐式转换(即标题所说的类型转换)。GetNumerator()和GetDenominator()是用来获取私有成员的值的,下面是我们要重点讲述的乘法运算符的重载。

我们的问题是,用什么方式实现乘法运算符的重载呢?我记得在我初学C++时,书上介绍了两种方法,一种是采用类内成员函数(也就是上面这段程序的写法),另一种是采用友元函数.
就这两种方法而言,其实友元函数的实现方式更优,为什么这样说?因为它们对封装性的冲击是相当的,但友元函数支持下面的代码:

int main()
{
    
    
    Rational r1(3, 5);
    Rational r2(2, 1);
    r2 = r1 * 2;
    r2 = 2 * r1;
    return 0;
}

成员函数实现却在r2 = 2 * r1上通不过编译。将这句话还原成运算符函数的形式,就能看到原因了:

r2 = 2.opeator* (r1);
哈哈,这样就知道为什么编译器不允许了,2必须首先转成Rational对象,才能使用它自身的operator*,但编译器不允许类型转换成this(隐含的自身指针)的,所以报错。

但对于友元函数实现,我们也将之还原成运算符函数的形式:

r2 = operator *(2, r1);
第一个参数会发生隐式转换(因为构造函数前面没有explicit),不会同时进行向this的转换,所以编译器是放行的。

这里插一句,隐式转换的发生过程就像这样:

1 const Rational temp(2);
2 r2 = operator*(temp, r1);
综上,成员函数实现不能支持含隐式转换的交换率,而友元函数实现却可以,故我们认为友元函数实现要优于成员函数实现(程序不支持乘法交换率是难以接受的)。

解决了这个问题之后,再来思考一下,能否对封装性进行改进,因为条款二十三说了,宁以non-member,non-friend函数去替换member函数,其实对友元函数形式稍加修改,去掉friend,改用public成员变量来访问函数内的私有成员,就可以实现好的封装,像这样:

const Rational operator* (const Rational& r1, const Rational& r2)
{
return Rational(r1.GetNumerator() * r2.GetNumerator(), r1.GetDenominator() * r2.GetDenominator());
}
1
2
3
4
在这个函数里面,是不能直接访问到类的私有成员的,因而保证了好的封装性。

如果你需要为某个函数的所有参数(包括被this指针所指的那个隐喻参数)进行类型转换,那么这个函数必须是个non-member,且为了封装,最好是个non-friend。

条款25:考虑写出一个不抛异常的swap函数

首先说下标准库的swap算法:

1 namespace std{
    
    
2     template<typename T>
3     void swap(T & a, T & b)
4     {
    
    
5         T tmp = a;
6         a = b;
7         b = tmp;
8     }
9 }

再来看以下代码

class WidgetImpl{
    
    
 2 public:
 3     ...
 4 private:
 5     int a, b, c;
 6     std::vector<double> v;
 7     ...
 8 };
 9 class Widget{
    
    
10 pubcic:
11     Widget(const Widget & rhs);
12     Widget & operator=(const Widget & rhs)
13     {
    
    
14         ...
15         *pImpl = *(rhs.pImpl);
16         ...
17     }
18 private:
19     WidgteImpl * pImpl;
20 };

这个operator=配上上面那个swap一起的效率便非常的地下,本来交换Widget中Impl两者的指针就可以很高效的完成swap操作,但是这里硬是活生生的拷贝了两个对象。
那么怎么完成高效的swap函数呢,首先想到的可能就是将标准库中的swap特例化,像下面这样:

1 namespace std{
    
    
2     template<> 
3     void swap<Widget>(Widget & a, Widget & b)
4     {
    
    
5         swap(a.Impl, b.Impl);
6     }
7 }

上面这个swap是标准库swap的total template specialization版本,但是他并不能通过编译,原因是因为其访问了对象的私有函数。正确的做法一般是下面这样:

1 class Widget{
    
    
 2 public:
 3     ...
 4     void swap(Widget & other){
    
    
 5         using std::swap;
 6         swap(pUmpl, other.pUmpl);
 7     }
 8     ...
 9 };
10 namespace std{
    
    
11     template<> 
12     void swap<Widget>(Widget & a, Widget & b)
13     {
    
    
14         a.swap(b);
15     }
16 }

上面的做法与STL标准库也是高度一致,标准库的容器也都是有std版本的swap而且都在自己内部空间实现了swap供给前者调用。
但是上述的做法在Widget是类模板的时候就不起作用了。因为下面的式子:

namespace std{
    
    
    template<typename T>
    void swap<Widget<T> >(Widget<T> & a, Widget<T> & b)
    {
    
    
        a.swap(b);
    }
}

被c++规定为非法的。
所以所,在无法进行模板特例化的情况下,就需要在特定的自己定义的名字空间创造一个swap函数(或者在std空间中重载一个版本,但是c++是不允许那样做的)。那么据类似下面这样:


 1 namespace WidgetStuff{
    
    
 2     ...
 3     template<typename T>
 4     class Widget{
    
    .......};
 5     ...
 6     template <typename T>
 7     void swap(Widget <T> & a, Widget<T> & b)
 8     {
    
    
 9         a.swap(b);
10     }
11 }

就大功告成了
这样,在任何的位置调用有关Widget的swap的时候,就会首先在WidgetStuff的名字空间里面寻找相应的swap。
注意Widget的成员函数版本的swap中有一行是加上了下划线的,这个using声明语句的作用是即使当这个名字空间中的swap对Impl不能起到作用的时候,至少可以让Impl可以借助于std::swap来完成交换的操作。

1. 提供一个class public swap对象,使其高效的置换两个特殊对象的值
    2. 在这个class或者template所在的命名空间提供一个non-memberswap,作用是来调用上述的
    swap.
    3.如果上述的class或者class template实际上是个class而非template,最好是特例化std::swap
    ,并且用这个来调用member的swap函数。
    4.在使用swap的时候,尤其是在自己定义的命名空间下,记得使用一个using 声明吧std::swap包含进来,这样即使在本命名空间中找不到合适的swap,至少也可以调用std的swap.且使用swap的时候不要再前面加上名字空间声明,这样前面声明的std::swap就白做了

猜你喜欢

转载自blog.csdn.net/m0_50816320/article/details/116605667
今日推荐