返回随笔C++
旧稿归档ENGINEERING NOTE

Effective C++

围绕对象初始化、const、构造与析构、继承和模板等主题,对《Effective C++》条款进行系统整理。

C++工程实践

一、让自己习惯C++

条款01:视c++为一个语言联邦

如今的c++是一种多重泛型编程语言,同时支持面向过程,面向对象,函数形式,泛型形式,元编程形式的语言。可以这样理解C++:C++是一个由相关语言组成的语言联邦而并非单一语言。c++的主要次语言有四种:

  • C部分:区块,语句,预处理器,内置数据类型,数组,指针等。
  • Object-Oriented C++(面向对象):类型,封装,继承,多态等。
  • Template C++:泛型编程部分,提供了模板元编程。
  • STL标准模板库:包含容器,迭代器,算法以及函数对象等

C++并不是一个带有一组守则的一体语言,而是四个次语言组成的联邦。

条款02:尽量以const,emum,inline替换 #define

“宁可以编译器替换预处理器。“

将#define NUMBER 1.63替换为

const double Number = 1.63;

当使用const替换时,有两种特殊情况:

  • 定义常量指针时

常量表达式通常放在头文件内方便不同源码包含,有必要将指针(而不是指针所指之物声明为const):

const char* const authorName = "Js_KringKoter";
//或使用更为合宜的string对象:
const string autherName = "JS_KringKoter";
  • class专属常量时

为了将常量的作用域限制于class内,且防止其多次复制,有必要将其限定为static:

class Name {
private:
    static const int NameLenth = 10;
    ...
}

上述语句为NameLenth的声明而非定义,如果需要取该常量的地址,则需要在实现文件中对其定义:

const int Name::NameLenth;

亦可使用enum hack补偿做法:

class Name {
private:
    enum { NameLenth = 5 };
    int scores[NameLenth];
}

enum hack的行为与define较为相似,如取一个enum的地址为非法。

使用inline函数代替#define

#define可能会导致问题,如:

#define CALL_WITH_MAX(a, b) f((a) > (b) ? (a) : (b))

int a = 5, b = 0;
CALL_WITH_MAX(++a, b)        //a被累加两次

使用template inline来代替:

template <typename T>
inline void CallWithMax(const T& a, const T& b)  //由于不知道T是什么,采用传递引用方式
{
    f(a > b ? a : b);
}

总结:

  • 对于单纯常量,最好以const对象或enums替换#defines。
  • 对于形似函数的宏,最好改用inline函数代替#defines。

条款03:尽可能使用const

只要某值保持不变是一个事实,则应使用const,确保这条约束不被违反。可以通过const修饰指针和指针所指向的对象,使其不被修改:

int number = 10;
int* pn = number;               //非const指针,非const变量
const int* pn = number;         //非const指针,const变量
int* const pn = number;         //const指针,非const变量
const int* const pn = number;   //const指针,const变量

当const出现在*左边,则变量为const;出现在右边,则指针为const。另一种写法是:

int const * pn = number;        //有时会遇到,需要注意

const interator和const_interator

声明一个const interator和一个T* const一样,保证指向数据的指针为const;而const_iterator保证数据为const。

const vector<int>::iterator citer = vec.begin();   //相当于T* const
citer++;                                           //非法
*citer = 10;                                       //合法

vector<int>::const_iterator iterc = vec.begin();   //相当于const T*
iterc++;                                           //合法
*iterc = 10;                                       //非法

令函数返回const

令函数返回一个const,可以降低因客户错误而造成的意外的同时,不至于放弃安全和高效性。考虑有理数(详见条款24)的operator*声明:

class Rational {...};
const Rational operator* (const Rational& lhs, const Rational& rhs);
//客户有时会因粗心而这样做:
Rational a, b, c;
(a * b) = c;

将返回值设置为const可避免客户如此操作。

使用const成员函数

使用const成员函数有两个目的,一是指明了此class接口不会修改对象内容,二是使得函数可以操作const对象。一个事实是:两个成员函数只是常量性不同,可以被重载。也就是说,可以构造两个重载函数,一个用于操作const类型,一个操作non-const类型。

class Name {
private:
    int lenth[10];
public:
    //此函数中无法修改类的成员,无法对返回值赋值。
    const int& operator[](size_t locate) const
    { return lenth[locate]; }
    
    //此函数中可以修改成员,对返回值赋值会导致成员被修改。
    int& operator[](size_t locate)
    { return lenth[locate]; }

    //存在这样的函数,但会导致通过返回值修改const对象
    int& operator[](size_t locate) const
    { return lenth[locate]; }
}

函数前const限定了无法修改返回值,函数后const决定函数是对const还是non-const对象操作。对于const对象,我们并不希望其被修改,必须重载双const成员函数。

需要注意的是,一个non-const char operator[]返回一个char,诸如name[] = 'x'是不被允许的,即使允许,修改的也是一个副本,而原本的数据不会改变。

bitwise constness和logical constness

对于const该如何限定,有如上两派观点。BC派认为只有在不更改任何成员变量(static除外)时才能是const。这种观点可以很有效侦测违反点,但当出现指针指向了一个不属于类型的变量,如char* 字符串,const将失效:

class Name {
public:
	char& operator[](size_t locate) const
	{
		return name[locate];
	}
private:
	char* name;
};

const Name name;
name[0] = 'x';        //这条语句将通过编译,而且能够运行,最后结果是const对象的值被改变。

LC派认为const对象内可以包含不影响const性的非const对象,一个const成员函数可以修改它所处理的某些bits,但只有在客户端侦测不出时才能如此。使用mutable关键字修饰的变量可在const成员函数中修改。如:

class CTextBlock {
public:
	size_t length() const;

private:
	char* pText;
	mutable size_t textLength;
	mutable bool lengthIsValid;       //使用mutable关键字
};

size_t CTextBlock::length() const
{
	if (lengthIsValid == false) {
		textLength = strlen(pText);
		lengthIsValid = true;
	}
	return textLength;
}

让non-const调用const成员函数来减少代码重复

很多情况下const成员函数和non-const成员函数代码基本相同,调用const对象,转化为non-const对象可以减少代码重复。

class Name {
private:
    char* name;
public:
    const char& operator[](size_t locate) const
    { return name[locate]; }
    
    char& operator[](size_t locate)
    {
        const_cast<char&> ( static_cast<const Name&> (*this)[locate] );
    }
}

总结:

  • 将某些东西声明为const有助于编译器侦测出错误用法。
  • 编辑器强制实施bitwise constness,但编写程序时应使用“概念上的常量性”。
  • 当const和non-const成员函数有实质等价的实现时,令non-const版本调用const可避免代码重复。

二、构造,析构,赋值运算

条款04:确定对象使用前已被初始化

Name::Name(int a, string b, list<int> c)
{
    length = a;       //是初始化
    str = b;          //不是初始化,而是赋值
    listInt = b;      //不是初始化,而是赋值
}

对于类构造函数,应尽可能使用初始化列表。初始化列表和赋值操作结果相同,但效率更高,原因是在创建成员类的时候无需调用赋值构造函数。应将上述代码改为:

Name::Name(int a, string b, list<int> c)
    :length(a),
     str(b),
     listInt(c)
{ }                 //构造函数无需任何操作

如果需要一个无参的default构造函数,则仅需指定无物作为初始化列表即可。

将简单初始化移向函数

对于有多个构造函数的类型,如果一部分内置类型重复,可以考虑将其移到单独的函数中,可以使用赋值初始化(伪初始化)。但通过列表初始化更为可取。

一致声明顺序和初始化顺序

别问,问就是防止晦涩错误,比如A需要B的值,但B在A后初始化。

不同编译单元内的non-local static对象的初始化

non-local static对象:在本函数内的static对象被称作local static对象,而其他不是函数内的static对象称作non-local static对象。

编译单元:可以理解为不同的两个文件,如两个头文件,各包含了一个static对象,这两个static对象和两个文件之间就是non-local static关系。

由于non-local static对象的初始化顺序并没有一定的规定,所以当一个编译单元的元素需要一个non-local static对象时,有可能该对象还没有被编译。看以下实例:

class FileSystem {
    ...
}
extern FileSystem tfs;           //预留给客户的对象

//客户在别处使用
void printFile(const string& str)
{
    cout << str << tfs;          //此时tfs不一定完成了初始化
}

当使用tfs的时候该对象不一定完成了初始化。这将导致灾难。解决办法是写一个函数,在函数内构建一个local static对象替换non-local static对象,返回一个指向该对象的引用。

inline FileSystem& tfs()
{
    static FileSystem tfs;
    return tfs;
}

//客户使用
void printFile(const string& str)
{
    cout << str << tfs();        //调用tfs(),获得一个指向non-local tfs的引用
}

该函数简单的reference-returning使得它很适合成为inline函数。对于多线程编程,有可能带有不确定性,需要在单线程启动阶段手动调用所有reference-returning函数。

总结:

  • 对内置对象进行手工初始化,因为C++不保证初始化它们。
  • 构造函数最好使用初始化列表,不要在构造函数本体中使用赋值操作。初始化列表的成员顺序需要与声明列表一致。
  • 为了防止跨编译单元的初始化顺序问题,需要使用local static替换non-local static。

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

对于一个没有提供函数的类,编译器自动为其编写 构造函数,析构函数,复制构造函数以及operator=()运算符函数。如果用户创建了一个含参数的构造函数,编译器将不再自动编写default构造函数。

对于复制构造函数,c++将一一拷贝类型内每个bits来完成初始化(注意复制构造函数仅在创建类时调用,赋值操作使用=运算符),一般不会出现问题,而重载的=运算符在遇到引用和指针时会出现问题,此时编译器拒绝生成对应的operator=()运算符函数。

class Name {
private:
	int nameLength;
	std::string& name;                       //成员为引用
public:
	Name(const int& namel, string& na) :
		nameLength(namel),
		name(na)
	{}
};

int main()
{
    string na1("kringkoter");
    string na2("rainimator");
    Name name1(2, na1);
    Name name2(3, na2);
    
    name1 = name2;             //不被允许,c++拒绝生成operator=()函数。
}
//错误信息:无法引用 函数 "Name::operator=(const Name &)" (已隐式声明) -- 它是已删除的函数

如果需要使用一个内含引用或者const对象的类型,必须自己编写operator=函数,编译器不会做这件事。

总结:

  • 编译器可以暗自为class创建default构造函数,copy构造函数,copy assignment操作符函数,以及析构函数。

条款06:如果不想使用编译器提供的函数,就应当明确拒绝。

编译器可以自动创建复制和=运算符函数,有时并不希望使用这两个函数,但编译器会自动提供,此时需要明确拒绝。

将函数原型置于private

class Name {
private:
    int a;
    Name(const Name&);
    Name& operator=(const Name&);
}

将函数原型置于private使得其无法被除成员函数访问,且编译器不再自动提供。

继承Base类,将函数原型置于Base

class Base {
protected:
    Base() = default;
    ~Base() = default;
private:
    Base(const Base&);
    Base& operator=(const Base&);
}

class Name : public Base {
private:
    int length;
public:
    Name() = default;
    ~Name() = default;
}

int main()
{
    Name name1(6);
    Name name2(4);
    name1 = name2;      //非法,错误信息:
                        //无法引用 函数 "Name::operator=(const Name &)" (已隐式声明) -- 它是已删除的函数

}

总结:

  • 为驳回编译器自动提供的机能,可将相应的成员函数声明为private并不予实现。也可以使用base class办法。

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

如果基类析构函数不是virtual,在析构指向派生类对象的基类指针时,仅仅调用基类析构函数,导致派生类成员无法正确析构,可能会导致内存泄漏。

如果基类含有virtual函数,几乎确定应该有一个virtual析构函数。如果基类不含有virtual函数,表示它并不意图做一个base class,一般不用声明virtual析构函数。

当需要一个抽象基类而又没有适合的纯虚函数时,可以将析构函数声明为纯虚,同时也解决了析构函数为虚的问题。

class Base {
    public:
    virtual ~Base() = 0;
}

总结:

  • 带有多态性质的base class应该声明一个virtual析构函数。如果class带有任何virtual函数,应当拥有一个virtual析构函数。
  • 如果类型不是为了多态而设计,或者不是base class,就不该声明virtual析构函数。

条款08:*不要让异常逃离析构函数

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

有下列程序段:

class Name {
public:
	virtual void con() = 0;
	Name()
	{ con(); }
};

class Len : public Name {
public:
	virtual void con();
	Len()
	{}
};

在生成Len对象时,首先调用base class构造函数,此时调用的virtual还“不是”virtual函数,它并没有使用派生类中的实现。

由于无法在构造函数中使用virtual函数,可以改用向base class传递必要的构造参量来代替。

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

基本类型可以使用诸如x = y = z;的连续赋值,自定义类型也最好如此。想要实现这一功能,需要返回一个指向左值的引用。

Name& operator=(const Name& rhs)
{
    ...
    return* this;
}

对于其他赋值运算符,如+=,-=,*=等,也同样适用。

Name& operator+=(const Name& rhs)
{
    ...
    return* this;
}

条款11:在operator= 中处理自我赋值

证同测试法

在实现最前面加上证同测试是传统做法,它能够处理自我赋值,但无法处理异常安全性。同时增加的判断语句减慢了代码运行速度。

Name& operator=(const Name& rhs)
{
    if (this = &rhs) return *this;        //证同测试
    delete[] pd;
    pd = new char(*rhs.pd);
    return *this;
}

临时变量法

Name& operator(const Name& rhs)
{
    char* newChar = pd;         //获得一个pd的临时变量
    pd = new char(*rhs.pd);     //将pd指向新的内存空间,且值等于rhs.pd 
    delete[] newChar;           //删除临时变量
    return *this;
}

swap and copy法

使用一个swap函数交换*this和rhs的数据。

void swap(const Name& rhs);
Name& operator(const Name& rhs)
{
    Name newName(rhs);
    swap(newName);
    return *this;
}

总结:

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

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

每一个类中都会有copying构造函数和operator=函数,如果不显式声明,编译器会提供默认的函数。如果选择使用自己的函数,当有对象没有复制时,编辑器不会发出警告。

class Name {
private:
	int len1;
	int len2;
public:
	Name(int l1, int l2) 
		: len1(l1),
		  len2(l2)
	{}
	~Name() = default;

	Name(const Name& rhs)
		: len1(rhs.len1),
		  len2(rhs.len2)
	{}

	Name& operator=(const Name& rhs)
	{
		len1 = rhs.len1;
		len2 = rhs.len2;
		return *this;
	}
};

需要注意将每一个对象都包括在内。

在遇到继承时,要注意复制基类对象。由于继承类无法直接访问基类私有成员,需要调用基类复制构造函数和operator=函数。

class N : public Name {
private:
	int len3;
public:
	N(int l1, int l2, int l3)
		: Name(l1, l2),
		  len3(l3)
	{}
	~N() = default;

	N(const N& rhs)
		: Name(rhs),            //调用基类copying函数
		  len3(rhs.len3)
	{}

	N& operator=(const N& rhs)
	{
		Name::operator=(rhs);   //调用基类copy assignment函数
		len3 = rhs.len3;
		return *this;
	}
};

总结

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

三、资源管理

条款13:以对象管理资源

很多类拥有这样一个函数,它请求内存资源,返回一块内存空间,这样的函数成为factory函数(工厂函数)。

CoffeeBar* creatCOB();   //返回指针,指向COB继承体系内的动态分配对象。

由于在函数内使用了new,使用者需要在使用函数后使用delete:

void function()
{
    CoffeeBar* newCOB = creatCOB();
    ...
    delete CoffeeBar;
}

但在有的时候,delete有可能会被忽略,比如if,跳出了while,引发了异常等。这时内存得不到释放,即会发生内存泄漏。为确保资源归还给系统,可以把资源的控制权交给资源控制类来实现。可以使用unique_ptr和shared_ptr来管理内存。

void function()
{
    shared_ptr<CoffeeBar> newCOB = creatCOB();
    ...    
}    //在函数结束前,newCOB的析构函数将删除内存。

对于资源管理,有以下两个注意点:

  • 获得资源后立刻将资源放入对象,使用资源管理对象来管理资源通常要比手动更加安全。资源取得时机便是初始化时机。
  • 管理对象运用析构函数确保资源被释放。

share_ptr和unique_ptr包含在memory头文件内。

share_ptr被称作引用计数型智慧指针(RCSP),它持续追踪有多少对象指向资源,并在没有指向时删除它。

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

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

由于不是所有API都需要资源管理对象,直接访问原始资源显得很重要。比如下面的例子,它需要一个原始指针,但传递了一个智能指针,这是不被允许的。

void function(int* ptr);

shared_ptr<int> pi(new int);
function(pi);                   //错误!

需要将智能指针显示转换为普通指针对象,才能通过编译。智能指针对象提供一个get()成员函数,返回一个普通指针指向管理的内存。

function(pi.get());

有时需要在自己的资源管理类中提供显式和隐式转换函数,用来适配原始资源的API:

class ptr {                                         //这个类处理的是Cob类
public:
    Cob get() const { return cob; }                 //显式转换函数
}

class ptr {
public:
    operator Cob() const { return cob; }            //隐式转换函数
}

这两者都存在问题,前者使得每次转换都需要条用get()函数(其实也没有那么烦),而隐式转换容易出现意外转换的问题:

ptr newPtr1(new Cob);
Cob newPtr2 = newPtr1;          //本意是创建一个ptr对象。
                                //如果不声明隐式转换函数,则不会通过编译,减少了错误可能性。

总结:

  • API通常要求访问原始资源,每一个RAII class应该提供一个取得管理的原始资源的方法。
  • 对原始资源的访问可能经由显式和隐式转换,一般而言显示转换更加安全,但隐式转换对于客户来说更加方便。

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

如果在new表达式中使用[],必须在相应的delete表达式中也使用[]。尽量避免对数组使用typedef:

typedef string AddressLines[4];
string* pal = new AddressLines;        //这是个4元素的数组

delete pal;
delete[] pal;

条款17:以独立语句将new对象置入智能指针内

假设有个函数需要传递一个shared_ptr,该函数原型和调用如下:

void function(std::shared_ptr<int>, int fun);

function(std::shared_ptr<int>(new int), fun());                         //fun是一个返回int的函数

这样的调用可能会产生资源泄漏问题。在调用这样的函数时,需要进行三部操作:1.调用new int,2.调用shared_ptr构造函数,3.调用fun()函数。

c++并没有规定这三者的调用顺序(除了第一步肯定在第二步之前,但第一步和第三步的顺序未定),可能出现调用fun后才调用shared_ptr构造函数,如果在fun中抛出了异常,new int请求的资源就无法进入智能指针中,无法正确析构。

避免这样的情况只需要使用一条单独的语句构造智能指针对象:

std::shared_ptr<int> pi(new int);
function(pi, fun());

这样即使抛出异常,请求的资源也能正确回到系统中。

总结:

  • 以独立语句将newed对象储存于智能指针中,如果不这样做,一旦抛出异常,有可能导致资源泄漏。

四、设计与声明

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

对于一个记录日期的类,它需要三个参数:年,月,日。对于不同的地区,这三个量的排列顺序有可能不同。对于下面的类:

class Date {
public:
    Date(int valYear, int valMonth, int valDay)
        : year(valYear),
          month(valMonth),
          day(valDay)
    {}
private:
    int year;
    int month;
    int day;
}

构造函数的正确调用应该是:

Date date(2004, 5, 28);

但由于某种地区原因,有人可能会这样写:

Date date(28, 5, 2004);          //错误调用

避免这样的情况发生,可以指定每一个元素一个数据类型(比如一个结构),取消隐式类型转换,这样使得接口更加清晰明了:

struct Day {
	explicit Day(int day) 
        : val(day) 
    {}
	int val;
};
struct Month {
    Month(int month) 
        : val(month)
    {}
    int val;
}
struct Year {
	explicit Year(int year)
        : val(year)
    {}
	int val;
};

class Date {
private:
	Month month;
	Day day;
	Year year;
public:
	Date(const Year& valYear, const Month& valMonth, cosnt Day& valDay)
        : year(valYear),
          month(valMonth),
          day(valDay)
    {}

};

但是仍然有问题存在,比如客户传递了错误的月份。可以使用枚举量来代表某个月份,但那样很容易使得枚举量被用作int。所以使用静态成员函数,构造12个月份函数:

class Month {
public:
	static Month Jan() { return Month(1); };
	static Month Feb() { return Month(2); };
private:
	explicit Month(int month) : val(month) {}
	int val;
};

这样调用就会变成:

Date date(Year(2004), Month::Jan(), Day(28));

总结:

  • 好的接口很容易被正常使用,不容易被误用,应该在所有接口中努力达成这些性质
  • “促进正确使用”的办法包括接口的一致性,以及与内置类型的行为兼容。
  • “阻止误用”的办法包括建立新类型,限制类型上的操作,束缚对象值,以及消除客户的资源管理责任。

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

-看书看书看书看书-

条款20:宁以传递const引用替换传值

pass-by-value很多时候是费事且消耗资源的,在传递用户自定类型时需要传递引用以提高速度:

void function(CoffeeBar cob);          //传递用户自定义类型有时很消耗资源
void function(const CoffeeBar& cob);   //修改之后

使用const使得程序不会在运行中修改数据。

对于内置类型和STL迭代器来说,传递引用有时并不优于传值,对于上述两者考虑使用pass-by-value。

使用pass-by-reference-to-const防止对象切割

对象切割:在继承体系中,将子类按值转换为基类时,子类成员丢失,且不具备子类特性(如使用子类virtual函数)。

class CoffeeBar {
public:
    virtual string name() const;
}
class COB : public CoffeeBar {
public:
    virtual string name() const;
}

void function(CoffeeBar c)
{
    c.name();
}

在这个pass-by-value函数调用中,c.name()将解析为CoffeeBar::name(),而不是COB::name()。原因是按值传递时,c被初始化为基类对象,发生了对象切割。

void function(const CoffeeBar& c)
{
    c.name();                       //这样就不会了
}

修改如上,现在它能够正确反映多态。c.name()将解析为COB::name()。

总结:

  • 尽量以pass-by-reference-to-const替换pass-by-value。前者通常比较高效,并且可避免对象切割问题。
  • 以上规则并不适用于内置类型,以及STL的迭代器和函数对象。对它们而言,pass-by-value往往比较适当。

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

考虑一个有理数类,其中有一个operator*函数:

class Rational {
public:
	Rational(int num) : val(num) {}
	~Rational() = default;

	const Rational operator*(const Rational& rhs)
	{
		return Rational(rhs.val * val);
	}
private:
	int val;
};

其operator*函数返回一个对象(而非引用)。这样需要付出额外的构造及析构成本,如果返回reference,则不需付出代价,但是会出现其他很多问题。

Rational ra1(5);
Rational ra2(6);
Rational ra3 = ra1 * ra2;   //ra1和ra2返回的引用存在吗?

ra1 * ra2的值为30,但有没有值为30的对象呢,又有没有指向这个对象的引用呢?很明显没有。如果operator*要返回一个reference指向此值,必须自己创建对象。

但自己创建对象需要额外的构造及析构成本,这是pass-by-reference的初衷,但这样写已经违反了初衷,还会引发新的问题:

const Rational& operator*(const Rational& rhs)
{
    Rational result(rhs.val * val);
    return result;
}

在函数运行结束后,result对象被析构,此引用指向了一个并不存在的对象,这将导致无定义行为。

如果我们将本在栈区的对象放在堆区呢?这样反而会导致更多问题:

const Rational& operator*(const Rational& rhs)
{
    Rational* result = new Rational(rhs.val * val);
    return *result;
}

确实返回了一个可用的引用,但问题是,应该在何处delete请求的资源呢?将这个工作抛给客户是不合理的。

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

如果将成员变量声明为public或protected,意味着当某个变量发生改变(改名,或者是删除)时,客户码将会大幅度变化。另外,需要考虑封装性,当某个方法的实现改变时,客户不应当知道,也不应当需要修改客户码。

某物的封装性和“当改变其内容时造成的代码破坏量”成反比。protected和public一样,没有封装性。

记住:

  • 切记将成员变量声明为private。这可赋予客户访问数据的一致性、可细微划分访问控制、允诺约束调节获得保证,并提供class作者充分的弹性。
  • protected并不比public更具封装性。

条款23:宁以non-member, non-friend函数代替member函数

对于一个CoffeeBar类,其拥有三个成员变量,三个重置成员变量的函数。此时需要一个重置所有变量的函数,该函数有成员函数版本和非成员函数版本。

class CoffeeBar {
private:
	int val1, val2, val3;
public: 
	CoffeeBar(int v1, int v2, int v3)
		: val1(v1),
		  val2(v2),
		  val3(v3)
	{}

	CoffeeBar() = default;

	void resetV1() { val1 = 0; }
	void resetV2() { val2 = 0; }
	void resetV3() { val3 = 0; }

	void resetAll()                      //成员函数版本
	{
		resetV1();
		resetV2();
		resetV3();
	}
};

void resetCob(CoffeeBar& cob)            //非成员函数版本
{
	cob.resetV1();
	cob.resetV2();
	cob.resetV3();
}

面向对象守则要求,数据尽可能被封装。而封装性的判断标准从简单来说是:越多函数(member函数和friend函数)可以访问成员变量,封装性越差。

c++倾向于使用非成员函数,且非成员函数可以放在一个ntility工具类中,成为一个static函数。只要它不是CoffeeBar的一部分,他就不会影响封装性。比较自然的做法是让resetAll成为一个non-member函数并位于和Coffeebar相同的namespace内。

namespace可以跨越文件,而class不能。namespace内的对象可以不放在同一个文件中,只需要指名它属于同一个namesapce就可以了。

c++STL就是如此组织的,比如string,vector类,只需要包含特定的头文件,但它们都在namespaec std中。

namespace cob {
    
class CoffeeBar {
private:
	int val1, val2, val3;
public: 
	CoffeeBar(int v1, int v2, int v3)
		: val1(v1),
		  val2(v2),
		  val3(v3)
	{}

	CoffeeBar() = default;

	void resetV1() { val1 = 0; }
	void resetV2() { val2 = 0; }
	void resetV3() { val3 = 0; }
};
    
}

namespace cob {

	void resetCob(CoffeeBar& cob)            //非成员函数版本
	{
		cob.resetV1();
		cob.resetV2();
		cob.resetV3();
	}
}

总结:

  • 宁可拿non-member non-friend函数替换member函数。这样做可以增加封装性,包裹弹性和机能扩充性。

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

对于一个有理数类型:

class Rational {
public:
	Rational(int num)
		: val(num)
	{}
	~Rational() = default;

	const int getVal() const
	{
		return val;
	}
private:
	int val;
};

由于它是有理数类型,我们希望它能够和基本类型进行更加“明显的”乘法运算,比如ran * 4,首先4通过隐式类型转换,自动调用了构造函数转换为了Rational对象,再通过一个operator*函数返回一个Rational对象。如果乘法运算符函数是个成员函数:

const Rational operator*(const Rational& rhs)
{
    return Rational(val, rhs.val);
}

Rational ran(4);
cout << ran * 4;       //正确
cout << 4 * ran        //错误!

第二条语句不能通过编译,原因如同下面的代码:

ran.operator*(4);      //正确
4.operator*(ran);      //错误!

4没有对应的class,所以编译器试图寻找成员函数operator*失败,于是它在global寻找非成员函数版的operator函数,但没有找到。

4不在operator*()的参数列中(即函数调用不是operator(4)),无法进行隐式类型转换。此时需要一个非成员函数版本的运算符函数:

class Rational {
public:
	Rational(int num)
		: val(num)
	{}
	~Rational() = default;
    
	int getVal() const          //由于运算符函数传递const参数,后面的const是必须的
	{
		return val;
	}
private:
	int val;
};

const Rational operator*(const Rational& lhs, const Rational& rhs)
{
	return Rational(lhs.getVal() * rhs.getVal());
}

如果自定义的类型不必将所有类型转换为自定义类型,诸如此类的函数可以是member函数。

总结:

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

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

五、实现

条款26:尽可能延后变量定义式的出现时间

当定义了一个带有构造函数和析构函数的对象时,如果这个对象实际并未使用,仍需承受构造和析构成本。第二是当出现异常时,未使用的资源可能会浪费。

处理方法则是,只有当下一步就要用到这个对象,且可以对其进行初始化时,才能创建该对象。

std::string name("KringKoter");
int lenth = 10;

对于循环来说,在循环外声明的变量可以延续至循环后,且不必重复构造;但其可能重复调用复制函数。用哪个方法视具体情况而定。

总结:

  • 尽可能延后变量定义式的出现。这样做可以增加程序的清晰度并改善程序效率。

条款27:尽量少做转型动作

c++提供了四种“新式转型”,应在程序中代替旧式转型以增强程序可读性:

  • const_cast<>通常用来将对象的常量性移除,它是唯一有此权力的c++ style转型操作符。
  • dynamic_cast<>用来执行“安全向下转型,它的运行成本耗费重大。
  • reinterpret_cast<>执行低级转型,如将一个int指针转化为int。
  • static_cast<>执行强制类型转换,可以将non-const对象转化为const,但无法反向将const转化为non-const。

转型获得的是副本

就static_cast而言,其转换操作只是获得了其一个副本,其本身数据并没有改变。在继承体系中,尝试调用基类方法不能这样写:

static_cast<base>(*this).function();

它其实调用了一个base副本的函数,并没有修改原始对象。应该这样写:

base::function();

少用dynamic_cast,而改为直接使用派生类指针。

dynamic_cast转型需要耗费大量资源和时间,尝试替换下列代码:

shared_ptr<Base> newDerive;
dynamic_cast<Derived*> derive(newDerive->get());

//替换为
shared_ptr<Derived> newDerive;
Derived* derive(newDerive->get());

总结:

  • 如果可以,尽量避免转型,特别是注重效率的代码中避免dynamic_cast。如果有个设计需要转型动作,设计无需转型的代替设计。
  • 如果转型是必要的,将其放入函数。客户可以调用该函数。
  • 宁可使用c++ style转型,不要使用旧式转型。前者很容易辨识。

条款28:避免返回handles指向对象的内部成分

考虑下面的对象,其使用一个结构来保存val,并在对象内使用指针指向结构来节省内存。考虑其成员函数:

struct v{
	int val;
};

class Name {
private:
	shared_ptr<v> pv;
public:
	int& getVal() const { return pv->val; }
}; 

Name name(8);
int& n = name.getVal();
n = 100;                  //修改了成员数据

由于返回了一个引用(套指针,如果此例没使用指针的话会编译出错,此条例有限制条件),在外部可以修改内部数据。而且当对象析构时,内部对象不复存在,而外部引用仍然指向内部对象,这会导致引用或指针挂起。

总结:

  • 避免返回引用,指针,迭代器指向对象内部。这样可以增加封装性,帮助const对象的行为像个const,并减少发生虚吊引用或指针的情况。

条款29:*“为异常安全”而努力是值得的

条款30:透彻了解inline的里里外外

inline的组织方式

编译器处理inline函数的方式是,对于inline函数的每个调用,都使用相应的代码来代替它。当函数inline的代码很少时,编译器产出的代码可能比直接调用函数更少。如果如此,将增加程序的处理速度。但是,使用代码来替换调用使得inline函数没有地址,对其的函数指针将创建一个outline函数对象。

请求inline函数

inline关键字只是一个对编译器的申请,编译器有权利不理会。有时候inline函数并不需要明确指出,只需将函数定义于class内。这样的函数通常是成员函数,定义于class内的friend函数也是inline:

class Name {
    int val;
public:
    int getVal() { return val; }       //隐式的inline函数
}

明确声明为inline需要使用inline关键字,inline函数通常声明于头文件之内,因为编译器需要知道其如何实现。function template通常也声明于头文件内,很多程序员认为template函数一定要声明为inline。但这是个错误的想法,只有当确定其所有版本的函数都需要inline,才能将其声明为inline。

编译器可能拒绝inline

有的时候编译器确实有意向生成一个inline,但还是为其提供一个outline函数对象。编译器通常不对virtual函数实行inline,因为需要等待运行期才能知道调用哪个函数。

空的构造函数看上去很适合成为inline函数:

class Name : public Coffeebar{
private:
    std::string str;
public:
    Name() {}
}

但空的构造函数实际上并不是空的。编译器在其中添加了Base class的构造函数,初始化str和其他变量的方法,异常处理……如果上述函数也是inline,构造函数的代码量将极其庞大。

同时,将函数声明为inling会使得程序的更新变得复杂。在改变inline函数后,客户端需要重新编译。

掌握一个合乎逻辑的规则:一开始先不声明任何函数为inline,在合理的情况下声明为inline,并竭力减少其代码量。

条款31:将文件之间的编译关系降到最低

一个类的定义式不止详细叙述了其接口,还包括了十足的实现细目。当我们改变实现(甚至仅仅是一个变量的名字)时,所有有关文件都需要重新编译连接。但如果提供声明式,客户看到的只是接口,而无关实现。当实现改变时,和接口没有关系,客户便无需修改。

//定义式,包含了所有实现细目。
#include "cob.h"
class Name {
private:
    int val;
public:
    Name() {...}
}

//声明式,仅包含接口。
class cob;
class Name {
public:
    Name();
}

当一个类型的实现十分庞大时,当其实现代码改变时,我们需要重新编译连接。我们并不希望这样的情况发生。

handle class法

handle class将类型拆分为二,一个仅包含接口,通过一个指针指向另一个;另一个包含完整实现。这样,当实现改变时,客户无需重新编译连接,因为接口没有发生改变。

Implementation.h

class CoffeeBarImpl {
private:
	std::string name;
	int age;
public:
	CoffeeBarImpl(const std::string& name_, int age_)
		: name(name_), age(age_)
	{}

	~CoffeeBarImpl() = default;

	int getAge() const { return age; }
	void setAge(int val) { age = val; }
	std::string getName() const { return name; }
	void setName(const std::string& val) { name = val; }
};

CoffeeBar.h

#include "implementation.h"
class CoffeeBar {
private:
	std::shared_ptr<CoffeeBarImpl> pImpl;       //指向imlp的指针
public:
	CoffeeBar(const std::string& name_, int age_)
		: pImpl(new CoffeeBarImpl(name_, age_))
	{}

	~CoffeeBar() = default;

	int getAge() const;
	void setAge(int val);
	std::string getName() const;
	void setName(const std::string& val);
};

Coffeebar.cpp

#include "Coffeebar.h"
#include "implementation.h"
int CoffeeBar::getAge() const
{
	return pImpl->getAge();
}

void CoffeeBar::setAge(int val)
{
	pImpl->setAge(val);
}

std::string CoffeeBar::getName() const
{
	return pImpl->getName();
}

void CoffeeBar::setName(const std::string& val)
{
	pImpl->setName(val);
}

Implementation提供了实现细目,而Coffeebar只是调用其中的方法。将定义式和声明式置于两个不同的头文件中,客户在使用时只需要包含声明式头文件即可。

Interface class法

interface class法声明一个抽象基类,使用该抽象基类派生一个类。基类和派生类拥有一样的接口,但具体实现在派生类中。基类中包含一个factory函数,生产一个指向派生类的基类指针供使用。在使用时,调用factory函数获得一个指向派生类的基类指针,使用基类virtual接口,调用实际是派生类方法。

Base.h

class COBBase {
public:

	static std::shared_ptr<COBBase> creatCOB(const std::string name_, int age_)
    {
	    return std::shared_ptr<COBBase>(new Coffeebar(name_, age_));
    }
	//返回基类指针,指向派生类对象,可使用派生类virtual函数
	virtual ~COBBase() = 0;

	virtual std::string getName() const = 0;
	virtual void setName(const std::string& val) = 0;
	virtual int getAge() const = 0;
	virtual void setAge(int val) = 0;
};

Coffeebar.h

class Coffeebar : public COBBase {
private:
	std::string name;
	int age;
public:
	Coffeebar(const std::string name_, int age_)
		: name(name_),
		  age(age_)
	{}

	virtual ~Coffeebar()
	{}

	virtual std::string getName() const;
	virtual void setName(const std::string& val);
	virtual int getAge() const;
	virtual void setAge(int val);
};

成员函数实现文件和上一个方法基本一致。

总结:

  • 支持“编译依存性最小化”的一般构想是:相依于声明式,不要相依于定义式。基于此构想的两个手段是handle class和interface class(将接口和实现分离)。

六、继承与面向对象

条款32:确定在你的public继承塑膜出is-a关系

class之间的关系有三种:is-a,has-a,is-implemented-in-terms-of(根据某物实现出)。public继承要求严格is-a关系,也就是适用于base的每一件事情都适用于derived class。每一个derived class对象都是一个base class对象。

条款33:避免遮掩继承而来的名称

在继承体系中,如果派生类尝试重载基类接口,基类接口将直接被掩盖,而并非被重载。

class Base {
private:
	int val;
public:
	virtual void set1() { val = 5; }
	void set2() { val = 2; }
};

class Derived : public Base {
public:
    //即使接口只改变了参数列表,仍然不是重载,而是覆盖
	virtual void set1(int s) { getVal() = 1; }
	void set2(int n) { getVal() = 4; }
};

解决方法:使用using指令,使得基类函数作用域扩大:

class Derived : public Base {
public:
    //使用using
    //现在set1有两个重载版本:set1(), set1(int)
	using Base::set1;
	virtual void set1(int s) { getVal() = 1; }
	void set2(int n) { getVal() = 4; }
};

补充:函数与作用域

假设一个derived class的方法像这样:

class Derived : public Base {
    ...
    void baseFun() {
        fun();
    }
    ...
}

当编译器看到这里的fun时,它会现在local作用域(也就是baseFun中查找),但一无所获。之后它将查找其外围作用域,即Dervied class中,仍然一无所获。在之后它查找Derived class外,也就是Base class中,如果找到,则停止查找。如果再没有找到,它将检索#include的头文件中是否存在。如果还是没有,则发生编译时错误。

总结:

  • derived class内的名称会遮掩base class内的名称。在public继承下从来没有人希望如此。
  • 为了让被遮掩的函数重见天日,成为一个重载版本,可使用using声明式。

条款34:区分接口继承和实现继承

在对基类方法的继承中,我们可能需要

  • 仅继承接口,并要求派生类提供实现
  • 继承接口和实现,但希望派生类覆写实现
  • 继承接口和实现,且不允许覆写实现

对应的方法类型为pure virtual函数,simple pure virtual函数和non virtual函数。

仅继承接口,要求派生类提供实现

一个抽象基类:

class VirtualBase {
public:
    virtual void draw(int locate) = 0;
}

它的派生类只继承了它的接口,即函数定义,但没有实现。派生类需要自己提供实现。

  • 声明一个pure virtual函数的目的是让derived class只继承接口。

然而,可以给纯虚函数提供一份实现代码,由于抽象类无法创建具体对象,只有通过VirtualBase::draw()来访问:

void Virtual::draw(int locate)
{
    paint(locate);
    ...
}

VirtualBase::draw(8);

继承接口和实现,但希望派生类覆写实现

simple virtual函数通常会提供一份实现代码,在派生类缺省时调用。虽然派生类可以对其覆写,但仍然可以使用其缺省版本。它告诉客户:你必须支持一个virtual函数,但如果你不想自己写, 你可以使用基类的缺省版本。

  • simple virtual函数是让derived继承该函数的接口和缺省实现。
class VirtualBase {
public:
    virtual void draw(int locate);
}
//缺省代码
void Virtual::draw(int locate)
{
    paint(locate);
    ...
}

这样存在问题:如果客户新建类型继承自此基类,但忘记了覆写代码,其将自动调用缺省实现,且不会发生编译器错误(错误在编译器出现要好于在运行期出现)。

解决方法是:除非用户提出使用缺省实现,否则不提供。

  • 提供一份缺省代码,置于protected中:
class VirtualBase {
public:
    virtual void draw(int locate);
protected:
    void doDraw(int locate)
    {
        paint(locate);
    }
}
//使用缺省实现
class DerivedA : public VirtualBase {
    virtual void draw(int locate)
    {
        doDraw(paint);
    }
}
//使用覆写实现
class DerivedB : public VirtualBase {
    virtual void draw(int locate)
    {
        doOther(locate);
    }
}
  • 将原simple virtual函数改为pure virtual函数,提供一份该函数的实现。
class VirtualBase {
public:
    virtual void draw(int locate);
}
void VirtualBase::draw(int locate)
{
    paint(locate);
}

//使用缺省实现
class DerivedA : public VirtualBase {
    virtual void draw(int locate)
    {
        VirtualBase::draw(locate);
    }
}
//使用覆写实现
class DerivedB : public VirtualBase {
    virtual void draw(int locate)
    {
        doOther(locate);
    }
}

继承接口和实现,且不允许覆写实现

  • 声明non-virtual函数的目的是为了零derived class继承函数的接口及一份强制性实现。

总结:

  • 接口继承和实现继承不同。在public继承之下,derived class总是继承base class的接口。
  • pure virtual函数只指定接口继承。
  • simple virtual函数具体指定接口继承且提供缺省实现。
  • non-virtual函数具体指定接口继承以及强制性实现继承。

条款37:绝不重新定义继承而来的缺省参数值

当继承的函数是virtual或pure virtual,且提供了一份缺省参数时,重新定义缺省参数值将引发错误:

class A {
    virtual void draw(int val = 0);
}
class B : public A {
    virtual void draw(string val = "NONE");
}

A* pb = new B;     //创建一个A指向B的指针
B->draw();         //调用B的draw函数,没有提供参数。

在没有提供参数的情况下,B的draw函数提供了来自A的默认参数,即B->draw(int val = 0)。

解决办法:将virtual函数置于private,并提供一个non-virtual函数提供缺省参数。