《Imperfect C++》读书笔记

Imperfect C++

 

译序一

在本书中,Matthew Wilson不但为我们指出C++中诸多不完美之处,还提供了经过实践检验的应对技术和技巧,便于我们利用“不完美的C++”编写出近乎完美的代码-强健、高效、灵活、可移植、优雅的代码,而这些代码在声称为“完美的语言”中往往更难实现。

本书具有一定的阅读门槛,目标读者为中、高级职业C++程序员。

 

译序二

书中几乎巨细靡遗地涵盖了C++中大大小小的不完美之处,并以一系列成功案例证明C++的确同时也提供了迂回之道、解决之道,再加上其用本主义的立场,正如一把实用的瑞士军刀,功能繁杂而面面俱到,实用之至。同其他C++著作不一样,本书虽然尊重标准,但同时又超越标准,当标准不能满足需求或称为拦路石的时候,需求才是第一位的,于是有了作者所谓的“不完美主义的实践者”以及“不完美工具箱”之说。此外,作者的所谓“苦行僧式编程”哲学在我看来也是及其实用的一种编码方式!

本书的一个小缺憾就是它不适合初学者,某些地方甚至对于中级读者来说都有一定的难度。

 

前言

 

你将从中学到什么

 

我希望你在读完本书后能够对下面这些问题有一个更好的把握:

1.如何克服C++类型系统中的不足

2.模板编程在提高代码灵活性和健壮性上的强大能力

3.如何在(当前标准尚未定义)未定义行为的险恶丛林中生存下来。后者包括动态库、静态对象及线程等。

4.隐式转换的代价、它所带来的问题,及其替代方案,即基于显式转换的、高效且易于控制的泛用性编程。

5.如何编写能够与(或更容易令它们与)其他编译器、库、线程模型等兼容的软件。

6.编译器“在幕后”都做了些什么?你对编译器可以施加什么样的影响。

7.数组和指针之间微妙而棘手的互操作性,以及用于防止它们的行为变得彼此相似的技术。

8.C++对RAII(Resource Acquisition Is Initialization,资源获取即初始化)机制的支持,以及该机制可以被运用到的各种问题领域。

9.如何通过最大限度地榨取编译器的侦错能力来节省你自己所需花费的工夫。

有了上面这些“装备”,你编写的代码将更有效率、更具可维护性、更健壮、也更具灵活性。

 

我假设你们已经知道

 

我假设你们的知识和经验能够使你们轻松地理解Scott Meyer的Effective C++系列和HerbSutter的Exceptional C++系列中讲述的绝大部分概念。我还假设你们拥有一本C++“圣经”,即BjarneStroustrup的The C++ Programming Language。

我还是建议你们最好熟悉如何使用模板。

了解一下内容将会有所帮助(尽管并非必不可少):COM和CORBA、动态库(UNIX和Win32的)、STL、线程(POSIX和Win32的)、UNIX以及Win32。

 

不完美主义实践的哲学

本书要传达的意思有4个方面:

原则1-C++是卓越的,但并不完美

原则2-穿上“苦行衣”

原则3-让编译器成为你的奴仆

原则4-永不言弃,总会有解决方案的

这四项原则构成了我所谓的“不完美主义实践者”的哲学。

C++并不完美

本书致力于解决的并不是软件开发者应经验不足或知识不够而遇到的问题,而是这个职业中的所有成员,从初学者甚至到最有才干和经验的那些人,所共同遭遇的问题。

苦行僧式编程

我会尽我所能让我的软件来严厉地对待我,以便在我企图去误用它时将我打住。我喜欢const,许多const,我会在任何可能的地方使用它。我尽可能使用private,我优先使用引用(references)。我尽可能实施不变式。我将资源返还给它的出处,即便我知道有其他安全的“捷径”,我使用所谓的“概念性typedef”来增强C++类型检查。......

我并不是获得获得“年度程序员”奖项提名才这么做的。这些只不过是我移植以来懒惰所导致的结果。所有优秀的工程师都有懒惰的习惯。懒惰意味着你不想在运行期查错;懒惰意味着你不想第二次犯同样的错误;懒惰意味着尽可能榨取你的编译器的能力,这样你才得以清闲。

让编译器称为你的仆从

但我们得承认,软件开发者才是开发过程中的主宰;而语言、编译器和库只不过是被我们所驱使的工具而已。

永不言败

工程师的努力(而不是理论上的归纳),再加上顽强地和C++中的不完美之处抗争,使我最终获得以下一系列的发现:

......

共11项,不过在后面的具体章节中都有介绍,这儿就暂时省略了:)

 

"Imperfect C++"的精神

除了不完美主义实践者的哲学信条之外,本书大体上还反映了我在编写C++代码时遵循的指导原则。它们总的来说(尽管并非完全)跟“C精神”[Como-SOC]和“C++精神”[Como-SOP]这一对孪生兄弟则是相吻合的。

C精神:

相信程序员:“Imperfect C++”不羞于作出丑陋的决策。

不要阻止程序员去做必须完成的事情:“Imperfect C++”实际上会帮助你完成你想要做的事情

保持解决方案的简洁:书中的大部分代码正是如此,而且高度平台无关和可移植的。

使它快!即使影响到可移植性的保证也在所不惜:效率被给予高度的重视,尽管我们偶尔会为此牺牲可移植C++精神

C++是C的一种方言,不过C++针对现代软件开发者做了一些增强:我们在不少重要的场合下仍然依赖于和C的互操作性

尽管C++是一门比C庞大得多的语言,但你并不需要为你不使用的东西付出代价。

尽可能地在编译期捕获错误:“Imperfect C++”在任何适当的地方使用静态断言(static assertions)和约束(constraints)。

尽量避开预处理(大多数情况下inline、const、template等才是正道):我们会看到各种各样的技术,它们使用C++语言而不是与处理器来实现我们的目标

除了这些原则外,本书还会以身作则地示范以下的做法,即在任何可能的情况下:

编写不依赖特定编译器(扩展和特性)、操作系统、错误处理模型、线程模型以及字符编码策略的代码

在不可能使用编译期错误侦测的情况下采用契约式(Design-by-Contract)

 

术语

编译单元:来自一个源文件以及该源文件所包含的全部头文件的全部源代码所构成的整体。

聚合体

标准这样来描述聚合体:“一个数组,或一个无用户自定义构造函数、无private或protected的非静态数据成员、无基类并且无虚函数的类。”

POD类型

POD意思是“Plain-old-data”,它是C++中的一个非常重要的概念,但通常会被人误解,而且虽说是个“小东西”,却相当重要。

标准对POD类型的总体性定义如下:“标准类型、POD结构类型、POD联合类型,以上这些类型的数组,以及这些类型以const/volatile修饰的版本。”

POD结构是“一个聚合体类,其任何非静态成员的类型都不能是如下任意一种:指向成员的指针、'非POD'结构、‘非POD’联合,以及以上这些类型的数组或引用,同时该聚合体类不允许包含用户自定义的拷贝赋值操作符和用户自定义的析构函数。”

但POD类型到底有什么重要之处呢?哦,POD类型允许我们与C函数进行交互,它们是C与C++之间互通的手段。POD最好的记忆办法就是将它看作是一种与C兼容的类型,当然同时你也不能忘记有关POD

的方方面面。

POD其他重要的方面包括:

宏offsetof()所作用于的类型应该是“一个POD结构或一个POD联合”,用在其他任何其他类型身上都是未定义的。

POD类型可以被放在联合中。

如果一个POD类型的静态变量是以常量表达式来初始化的话,那么其初始化发生于执行流进入它所在的语句块之前,而且也是在任何需要动态初始化的对象的初始化之前。

指向成员的指针不是POD类型,这一点跟其他任何类型指针类型恰恰相反。

POD结构或POD联合类型可以具有静态成员、成员typedef、嵌套类型和方法。

第1章 强制设计:约束、契约和断言

在我们设计软件时,我们希望软件根据设计而进行使用。这并非一句空话。在大多数情况下,很容易发生以意料之外的方式来使用软件,而这么做的结果往往是令人失望的。

但是呢,大多数软件的文档几乎都是不完整的,甚至是过时的。“如果还有比没有文档更糟糕的情形,那就是文档是错误的”。

怎么办,一方面,可以增强参数验证(但这种方式通常不那么具有吸引力,因为它会损及性能,还倾向于滋生坏习惯),另一方面,制作良好的文档并让它们保持更新当然是解决方案的一个重要组成部分(然而这种做法是远远不够的,因为它是“非强制性”的。此外,要想写出好文档也是一件极其困难的事情)

 

在工作中有相当的bug都是对于库函数不完全了解也就是说没有根据库设计而使用引起的,如果每个函数你都能正确使用,估计你想产生多少bug都产生不了:)

 

如果编译器能够为我们找出错误那就更可取了。事实上,本书中的相当一部分内容都是关于如何驱使和利用编译器,令它在碰到糟糕的代码时卡壳,从而便于我们在编译器及早抓住错误。

 

1.1 绿蛋和火腿

我并不怀疑我很可能是在教小孩吃奶,然而有些事情很重要,在此不得不说。因此,请各位允许我唠叨片刻:

在设计期捕获bug比在编码/编译器捕获好。

在编码/编译器捕获bug比在单元测试中捕获好。

在单元测试中捕获bug比在调试中捕获好。

在调试中捕获bug比在预发行/beta版中捕获好。

在预发行/beta版中捕获bug比让你的客户捕获好。

让你的客户捕获bug(以具亲和力的方式)比没有客户好。

这些都是相当明显的东西,尽管客户可能并不赞同最后一条。最好把那条留给我们自己。实施强制有两种方式:在编译器和在运行期。这些正是本章要讲述的内容。

 

我应该如何做好在各阶段的bug捕获工作?好多时候我们自己都不知道应该做成什么样子?这本书都是从实现上来说的,解决不了这个问题。

 

 

1.2 编译器契约:约束

1.2.1 must_have_base()

template<typename D,typename B>

class must_have_base

{

public:

    ~must_have_base()

    {

        void (*p)(D*, B*) = constraints;

    }

private:

    static void constraints(D* pd, B* pb)

    {

        pb = pd;   // 核心实现原理

    }

};

它的工作原理如下:在成员函数constraints()中尝试把D类型的指针赋值给B类型的指针,constraints()是一个单独的静态成员函数,这是为了确保它永远都不会被调用,因此这种方案没有任何运行期负担。析构函数中则声明了一个指向constraints()的指针,从而强迫编译器至少去评估该函数以及其中的赋值语句是否有效。

 

事实上,如果D和B是同样类型(即仍然能编译通过)或者D和B的继承关系并非公有继承(即虽然是继承但不能编译通过),那么该约束会失败。

 

使用方法例:

class A{};

class B : public A{};

int main()

{

    must_have_base<B,A> temp;      // 如果B是继承自A的那么就能编译通过,否则不行

}

 

这个约束用在什么场景?

 

1.2.2 must_be_subscriptable()

另一个有用的约束是要求一个类型可以按下标方式访问,实现起来很简单:

template<typename T>

class must_be_subscriptable

{

public:

    ~must_be_subscriptable()

    {

        void (*p)(const T&) = constraints;

    }

private:

    static void constraints(const T& T_is_not_subscriptable)

    {

        sizeof(T_is_not_subscriptable[0]);   // 核心实现原理

    }

};

使用

struct subs

{

public:

    int operator[](size_t index) const;

};

struct not_subs{};

 

int main()

{

    //must_be_subscriptable<int[]> a;    书上说可以编译通过,实际不行,在VS2005上提示errorC2265: “T”: 对零大小数组的引用非法

    //must_be_subscriptable<int[0]> a;   同上

    must_be_subscriptable<int[4]>b;

    must_be_subscriptable<int*>c;

    must_be_subscriptable<subs>d;

    //must_be_subscriptable<not_subs> e;

 

    return 0;

}

另外,有个奇怪的现象,如下

struct A

{

    int a[0]; // warning C4200.但可以编译通过,因为我们在写通信程序时经常都想这样用

};

int main()

{

    A a;

    //int a[0]; 编译不能通过,因为这样定义实在没有什么用

    return 0;

}

以上代码在VS2005和VC6中验证过。

 

 

 

第2章 对象生命周期

 

2.1 对象生命周期

 

每个C++对象的生命周期分为哪4段?对象的所占的空间是在构造函数之前分配好吗?对象可以以哪4种标准方式诞生?

 

除了上述的4种标准方式外,你还可以通过placement new和显示析构明确地控制对象所占的内存和生命周期:

例:

#include "stdafx.h"

#include <iostream>

#include <cstdlib>

using namespace std;

 

class CTest

{

public:

    CTest()

    {

        m_i = 0;

        m_j = 0;

    }

    CTest(int i, int j)

    {

        m_i = i;

        m_j = j;

    }

    ~CTest()

    {

        m_i = 0;

        m_j = 0;

    }

 

    int m_i;

    int m_j;

};

 

int _tmain(int argc, _TCHAR* argv[])

{

    //unsigned char* pBuf = new unsigned char[sizeof(CTest)];

    CTest temp;

 

    //CTest* pTest = new(pBuf) CTest(1, 2);

    CTest* pTest = new(&temp) CTest(1, 2);

    wcout << _T("m_i =") << pTest->m_i << _T(" m_j = ")<<pTest->m_j<< endl;// 12

 

    pTest->~CTest();

    wcout << _T("m_i =") << pTest->m_i << _T(" m_j = ")<<pTest->m_j<< endl;// 00

 

    //delete []pBuf;

 

    _tsystem(_T("pause"));

 

    return 0;

}

也就是说,你可以显式地把对象创建在一片已经分配好的内存上,不管这片内存是分配在哪儿的,并且让你自己控制对象的析构.

 

毫无疑问,这不是好的编程习惯.姑且不管容器的实现(容器是按值而不是按引用存储的),很少有合理的理由来使用该技术.

是否有使用该技术的场景呢?

答: 目前我还一直没找到什么情况下可能会使用该技术.查MSDN或许能找到.

 

对于输入输出流,是不是需要我自己根据是否定义了_UNICODE去定义究竟是使用cout还是wcout?并且奇怪的是,如果你用的是cout,你传宽字符串给它,也能编译通过,当然,打印出来的肯定是错误的!

答:对于cout等,它们已经是直接使用的对象了,看看它究竟是个什么东东

extern _CRTIMP istream cin;

extern _CRTIMP ostream cout;

extern _CRTIMP ostream cerr, clog;

extern _CRTIMP wistream wcin;

extern _CRTIMP wostream wcout, wcerr, wclog;

 

typedef basic_istream<char,char_traits<char> > istream;

typedef basic_ostream<char,char_traits<char> > ostream;

 

typedef basic_istream<wchar_t,char_traits<wchar_t> > wistream;

typedef basic_ostream<wchar_t,char_traits<wchar_t> > wostream;

如果要通用的话,那么当然你就不能使用cout这些了,记得以前是使用basic_XXXX,将TCHAR作为模板实参自己去特化。在这儿,好像根据是否定义了_UNICODE去定义究竟是使用cout还是wcout也是可以的。

 

 

2.2 控制你的客户端

也就是说,通过使用public,protected,private及friend关键字,我们可以控制客户代码使用我们的类的方式.

2.2.1 成员类型

控制外界对你的类实例的操纵的强大方式之一,是将类的成员声明为const和引用类型,这样你就不能将两个对象做赋值操作(=)了,例:

#include "stdafx.h"

 

class CTest

{

public:

    CTest(int i)    :

    m_i(10)

    {

 

    }

    const int m_i;

};

 

int _tmain(int argc, _TCHAR* argv[])

{

    CTest t1(10);

    CTest t2 = t1; // 注意,这不是赋值,这是拷贝构造函数,故编译能通过

    t2 = t1;    // 由于m_i是常量不能被赋值,导致对象的赋值不行,出现编译错误error C2582:operator =函数在CTest中不可用

   

    _tsystem(_T("pause"));

 

    return 0;

}

通过const成员产生的编译告警信息,来告诉用户不应该将该类用在拷贝赋值操作中,是不恰当的.如果你想你的类不能被赋值操作的话,你应该采用更明确的方式,及将赋值操作符函数声明为受保护的或私有的.

 

我们应该养成这种直觉,只要类中有const成员变量或引用,就说明这个类不具有被赋值的属性,而不是去记住不能定义赋值操作符.

 

什么时候在类中用const成员变量?什么时候在类中用引用?

答: 感觉我好久都没这样用过了:)

 

2.2.2 默认构造函数

只要我们自己定义了构造函数,编译器则不会再为我们生成默认构造函数.(这部分内容和我以前学到的一样,略)

 

一般在啥时候我们将默认构造函数搞成保护或私有的?

答:

 

2.2.3 拷贝构造函数

无论你是否定义了一个默认或者其他构造函数,只要你没有显式地提供拷贝构造函数,编译器就会为你"合成"一个(按位拷贝).

 

一般在啥时候我们需要把拷贝构造函数搞成保护或私有的?

答:

 

仅当你的类型是简单的值类型并且不拥有任何资源时,你才可以让编译器为你生成一个拷贝构造函数.

但为什么大部分情况下我们都让编译器帮我们生成了,是不是这样是有问题的?

答:

 

2.2.4 赋值操作符

和拷贝构造函数一样,如果你没有为类定义一个赋值操作符的话,编译器就会为你生成一个.再一次提醒,只有对于简单的值类型,你可可以任由编译器为你生成赋值操作符.

 

我们经常看见拷贝构造函数和赋值操作符一起被作为保护或私有的,他们之间有什么联系吗?

答:

 

2.2.5 new和delete

new和delete被用于在堆上创建和析构对象.通过限制对他们的访问,你可以阻止对象被创建在堆上,换句话说,使对象只能被创建于栈上(全局对象,栈对象).具体做法就是将new和delete声明为类的私有或保护成员函数,例:

class CTest

{

private:

    void* operator new(size_t); // 因为私有的,别人不可能调用它,故可以不提供实现.我估计,只要是没使用到的函数,都可以不定义而编译能通过.

    void operator delete(void*);

};

 

int _tmain(int argc, _TCHAR* argv[])

{

    CTest t1; // 在栈上可以创建

    CTest* pTest = new CTest; // 因为new操作符是私有的,故编译不能通过,错误提示error C2248:CTest::operator new: 无法访问private 成员(在CTest类中声明)

   

    _tsystem(_T("pause"));

    return 0;

}

什么时候我们需要显式定义new操作符?(包括全局或成员函数形式的)

答:

 

什么时候我们需要阻止对象被创建在堆上?

答:

 

然而,对这些操作符使用访问控制也还是有局限性的,因为在任何派生类中都可以重写它们.即使你在基类中让它们成为私有的,也没有办法可以阻止派生类去定义它自己的公有版本(造成基类又可以在堆中创建).因此,限制对new和delete的访问实际上不过是个"文档策略".

 

2.2.6 虚析构

译者注: non-placement delete是指形如voidoperator(void*);的成员函数.C++98中这句话的意思是,如果你提供了虚析构函数,编译器就会同时在该类的内部查找non-placementdelete操作符,如果查找到的话,那么必须为可访问且无二义性的.之所以有这么一个规定,是为了确保有operator delete可用于多态的析构行为.事实上,你当然也可以在提供虚析构函数的同时将non-placementdelete声明为private成员以阻止外界的显式调用,如果是这样的话,你必须为它提供一个空的定义体,不然即使外界不调用,连接器也会报错说"无法解析的外部符号operatordelete(void*)",这是由于C++的多态实现机制使然.

 

不太理解有什么意义?

答:

 

2.2.7 explicit

explicit关键字的作用是禁止编译器将类的构造函数用于隐式转换.例:略

 

何时我们不需要隐式转换?书上说有时隐式转换会带来高昂的代价,举例说明?为何现实的代码中用得不多呢?

答:

 

我建议总是对这些所谓的转换构造函数使用explicit关键字,除非你明确地希望允许隐式转换.

 

2.2.8 析构函数

通过把析构函数隐藏起来,我们可以强制性地禁止对象在全局作用域上创建,还可以禁止对指向该类的实例的指针使用delete.当我们需要可以访问和使用某一个对象却不能销毁它时,这种能力时有用的.在防止对引用计数的指针的误用方面则特别有用.

另一个值得指出的重要方面是: 在类模板中,析构函数是放置"方法内"约束的首选地点(见1.2节).考虑程序清单2.1中的类模板:

程序清单2.1

template<typename T>

class SomeT

{

    public:

       SomeT(constchar*)

       {

           //只有当这个构造函数被实例化时,其内部的约束才会起作用

           constraint_must_be_pointer_type(T);

       }  

       SomeT(double)

       {

           //只有当这个构造函数被实例化时,其内部的约束才会起作用

           constraint_must_be_pointer_type(T);

       }

...

 

如果将约束置于某个构造函数中的话,那么只有当那个构造函数被实例化时,约束才得到检验.然而,对于模板,C++只实例化那些被需要的成员.这个特性很好,它带来了一些及其有用的技术(见3.3.2节).

如果像上面所说的,那么为了确保约束得到检验,你要将其置于所有构造函数中.然而,析构函数永远只有一个,因此你可以将约束放在那里,这么做既免除了手指疲劳又带来了更好的可维护性.例:

...

    ~SomeT()

    {

       // 只有当这个构造函数被实例化时,其内部的约束才会起作用

       constraint_must_be_pointer_type(T);

    }

}

很自然,即便如此,只要你不创建该类的任何实例(例如,你只调用该类的静态成员函数),约束仍得不到检验的.不过,几乎在所有的情况下,将约束放在析构函数中即可满足要求.

 

2.2.9 友元

友元如果使用不当的话可能会破坏我们在访问控制上付出的所有努力.作者觉得友元的使用应该被限制在位于同一个头文件里的类之间.对于一个良好的设计来说,类和函数集应该呆在它们自己的头文件中,只有那些彼此紧密依赖的才可以被放在同一个头文件中,例如一个序列类及其迭代器类,或者一个值类型及其自由操作符函数.因此,最好离友元远一点,从长远来看它们只会给你带来麻烦.

如果你遵循"尽量采用类的成员函数来取代定义自由函数"的实践准则,那就可以戏剧性地减少对友元的需求.对此一个很好的例子是通过X::operator+=(Xconst&)来取代自由函数形式的operator+(X const& X const).我们将在25章探讨这种技术.

 

不过,从我在MFC中以及STL见到的使用friend的方式,还真的给作者说的完全一致.

 

2.3 MIL及其优点

作者说只有数组型成员变量不能在MIL(member initializer list,成员初始化列表)中进行初始化,其他的都可以.

其实我们可以区分一下概念,从本质上来说,在进入构造函数体之前进行的才是初始化,而在构造函数中进行的都是赋值操作.

[Stro1997]: 如果在构造函数中进行赋值操作的话,你可能是在对一个已经因为非平凡的构造而付出代价的对象进行代价高昂的赋值操作,从而既损失了运行期的速度,同时什么好处都没得到.(针对有构造函数的对象会出现这个问题,因为进行构造函数之前,会先调用成员对象的构造函数,对于内建类型因为没有构造函数,故不存在此问题)

一句话,作者建议我们总是使用成员初始化列表进行初始化.

2.3.1 取得一块更大的场地

反对成员初始化列表的借口之一是: 成员初始化列表所提供的"工作场地"太小了.通常人们抱怨在成员初始化列表的限制下很难进行有效和高效的参数合法性验证和操作(其实聂富华不喜欢用初始化列表就是因为这个原因).然而,我认为这种说话很大程度上是错误的,只要稍微发挥一点想象力,我们就可以提供既健壮又具有合理的约束性同时仍然高效的初始化方案.例:

String::String(char const* s)

    : m_s(strcpy(new char[strlen(s ? s : "") + 1], s ? s : "") )

{}

这样使用初始化话列表的话,确实因为场地小不好看.

解决方案就是将那部分复杂的操作封装成一个私有(静态)成员函数,变成

String::String(char const* s)

    : m_s(create_string(s) )

{}

这样改造就两个优点,第一,代码干净了,第二,得到了可以重用create_string函数的可能.

 

注意,为了实现所需的效果,辅助方法(例如create_string)不一定要是静态的.然而,如果它是非静态的话,相当于我们使用了尚处于

部分构造状态的对象中的东西.因此,最好还是使用静态辅助函数.

 

从实现来看,我们不是取得了一块更大的场地,而是尽量不太多的场地.

 

2.3.2 成员顺序依赖

使用初始化列表的一个告诫是: 成员变量是按照它们被声明的顺序来初始化的,与它们在初始化列表中的出现顺序无关.(如果在构造函数中用赋值来初始化或许不存在此问题,最好的办法是不要让成员变量之间有依赖)

 

当我们必须依赖成员变量的初始化顺序时,该如何防止别人改变我们成员变量的声明顺序呢(对不熟悉代码的人这是很可能发生的,有时调整一下可能看起来更好看,别人就改了,哪知道还有机关)?答案是使用编译期断言(见1.4节).你可以在auto_buffer类模板最初的实现中找到关于如何使用编译器断言来保护成员顺序的例子,也就是在构造函数中包含一个保护性的断言,如下,略.

本质上就是通过offsetof判断成员变量在对象中的偏移量来判断究竟是哪个成员在前哪个在后.

还有一点注意的,C++标准说它应用的类型"应该是一个POD类型结构或者PODunion".这就意味着它运用在类类型身上是非法的,并且有导致未定义行为的潜在可能.C++标准之所以说它只可以被用于POD类型上,是因为当存在多重继承时它不可能产生一个正确的编译期值(即计算出正确的偏移量).因为我是一名实用主义者,所以我会在任何我觉得必要且何时的地方使用它.

作者说offsetof在几个流行的库中都得到相当广泛的使用,我需要看看别人都是用来干嘛?

 

2.3.4 MIL: 尾声

除了某些极端的情况外,我建议你尽量始终使用初始化列表,除了对数组不能使用外,几乎任何地方都可以.

 

 

第3章 资源封装

 

资源封装则是封装的一个精华。对于资源封装来说,被封装的数据实际上是指分配的资源的指针。这里的资源是一个广义的概念。资源可以是其他类实例、分配的内存、系统对象、API/库的状态以及对象状态,等等。

数据封装是一种保护封装类型内部状态的机制,其目的是确保该类型的一致性,以及对其公共接口进行抽象。资源封装则是一种外覆(wrap)对资源的引用的机制,其目的是对封装的资源提供更为健壮的接口,并辅助对资源进行管理。换句话说,它是一种用于保护资源本身的途径。这两者之间的区别微妙,在显式中自然也常常存在许多重叠的地方,不过记住它们之间的区别仍然还是值得的。

在资源封装方面,对于C语言家族来说,C++的能力无人能望其项背。这是由于C++通过一对孪生机制来支持资源封装,即构造和自动进行确定性的析构。

 

3.1 资源封装分类

 

RAII(Resource Acquisition Is Initialization,资源获取即初始化)

 

资源封装所提供的能力:资源的自动获取、操作资源的方便接口、资源的自动释放

给出以上的列表后,我们就可以为资源封装做出如下分类:

1.没有任何封装

2.POD类型

3.外覆代理类(Wrapper proxies)

4.RRID类型

5.RAII类型

 

 

第5章 对象访问模型

 

5.1 确定性生命期(VouchedLifetimes)

 

class EnvirontmentVariable

{

public:

    const string* GetValuePart(size_tindex)const

    {

        return index < m_valueParts.size()? &m_valueParts[index]: NULL;

    }

 

private:

    vector<string>m_valueParts;

};

对于GetValuePart来说,这是最高效的访问模型,因为调用者得到了它欲访问的实例的直接引用(或指针)。Inthe above example, the member variable m_name and a string from them_valueParts collection are both accessed in the same sense, viavouched lifetime.然而,如果客户代码试图在容器的生命期结束之后使用在这之前获得的指针(或引用)的话,代码就会遭遇未定义行为。不过,只要客户代码不违反这个限制,正确地进行编码,这仍然是个非常不错的访问模型,并且也是最为常用的一种。

 

检出模型

估计是指在多线程的时候,在访问之前先判断是否资源空闲,空闲则允许访问,否则让客户代码等待或返回一个标识表明资源不可用.(在工作中我们好像经常都需要这样?一般都是等待)

 

为什么称之为确定性生命期?

 

 

5.2 返回拷贝(Copied for Caller)

 

class EnvirontmentVariable

{

public:

    string GetValuePart(size_t index) const

    {

        return index < m_valueParts.size()? m_valueParts[index]: string();

    }

 

private:

    vector<string>m_valueParts;

};

这种访问模型为标准库容器所采用,即把元素的拷贝返回给调用者.

这个模型有一些独具魅力的地方: 易于理解,无需考虑容器和客户代码的相关生命期问题.然而,如果返回的不是基本类型或简单值类型的对象,可能会引入不菲的性能代价.

 

在sip呼叫器中,我建议黄建实现任务管理的时候就是用的这种?这样是最好的吗?

 

5.3 直接交给调用者(Given to Caller)

即直接将m_valueParts(通过指针或引用)暴露给客户代码.

该模型的用处并不算广泛,但是在某些场合下特别有用.例如,实现一个很少会遍历超过一次,且其元素占用大量内存(或其它资源)的容器时.在这些场合下,维持拷贝无意义,即它们直接交给调用者即可.因此,该模型比确定性生命期更高效.

 

比确定性生命期模型更高效如何理解?

 

5.4 共享对象(Shared Objects)

 

在其适合的场合,这是我最喜爱的访问模型,它是基于引用计数的.借助于某个何时的引用计数智能指针类(我们假定ref_ptr就是这么一个类),这种模型会变得异常简单.ref_ptr的语义确保了其实例被拷贝时引用计数递增,销毁时递减.当引用计数减为0时,ref_ptr会delete它拥有的对象.

 

用引用计数智能指针类包装一下的确会dai来性能开销,不过通常开销并不大.

 

这种解决方案甚至对于原先没有考虑引用计数的类型也是可行的,只要利用一个带有引用计数逻辑的智能指针类,例如Boost的shared_ptr即可.此外,引用计数的方式还可被用于去除被包含对象的生命期对包含它的(原始)容器的生命期的依赖.

 

我的总结:

 

在工作中我经常碰到访问模型问题,特别是在多线程中很烦人?该如何选择呢?

 

 

第10章 线程

 

我将多线程编程的挑战全部归于对资源的同步访问.(如何确定线程都占用了哪些资源?)

 

C和C++面试时多线程还不像现在这么流行。因此:C和C++对线程只字未提.

 

在编写多任务系统时,人们必须知道的两个经典概念是竞争条件和死锁.(确实是这样的,我和我们团队在工作中,只要涉及多线程,毫无疑问都出现过这两个情况)

 

竞争条件和死锁是难于预见或测试的,这是多线程编程的实际挑战之一.(如何预见?)

 

10.1 对整数值的同步访问

这部分的内容和我以前知道的大概差不多,关键函数无非就是InterlockedIncrement,InterlockedDecrement,互斥体等的使用.

 

在实践中,人们通常可以使用构建(build)设置来决定是否使用原子操作,也就是根据是否定义了_MT宏来决定是否使用锁函数.(我在工作中基本上没遇到过还要考虑是不是多线程程序的情况,可以说全是多线程的.但是作者说Boost和ATL中有这方面的例子,我就不了解了)

 

10.4 多线程扩展

主要讲了一下D和Java中的synchronized关键字,可以不关注但了解一下还是可以的.

 

10.5 线程相关的存储

参考Win32中的TLS相关函数

 

总结: 本章基本上没有什么新东西,要想更深入一点建议还是去看看一些多线程专著.

 

 

第11章 静态对象

 

静态对象变量被置于连接单元的.data(或.bss)段,该节被简单地拷贝到可读写的内存段.

如果静态对象的类型是POD类型并且是通过常量表达式来初始化的话,编译器就可以直接将它们的初值写入可执行文件中,从而避免任何运行期的初始化.

 

11.1 非局部静态对象: 全局对象

 

使用非局部静态对象通常是不被推荐的方式.其主要的问题跟"顺序"有关.顺序问题由两个紧密相关的议题组成.第一个问题是,在两个或多个静态变量之间可能存在循环依赖.这是基础的工程问题,并且没有特效解决方案,但是存在一些途径可以让它更易于被觉察.

第二个问题是,在某个非局部静态变量被初始化或反初始化之后引用它是可能的.这是令开发者手忙脚乱的事件之一,并且常常出现.

Imperfection: C++未提供全局(静态)对象的顺序的机制.

11.1.1 编译单元内的顺序性

已知道,略

11.1.2 编译单元间的顺序性

由编译器实现决定.

建议: 不要依赖于全局对象初始化顺序.至少应该辅以全局对象初始化顺序跟踪机制.

也就是说,在调试时,在每个编译单元中添加一个不同名(通过宏__FILE__实现,cppunit中就是这么干的)的全局对象,通过在其构造函数中的打印语句,我们就能确定各个单元的连接顺序,当然全局对象初始化的顺序也就知道了.

我觉得最关键的还不是确定顺序,而是我们压根就不清楚系统中究竟包含多哪些全局对象,之间关系如何?

 

11.1.3 利用main()避免全局对象

int main()

{

    Global1global1;

    g_pGlobal1= &global1;

 

    Global2global2;

    g_pGlobal2= &global2;

 

    ...

}

这种做法的一个显著缺点是你得通过指针来使用所有的全局变量,这可能会导致轻微的效率损失以及句法上的不便.一个更为"阴险"的缺点是,若有任何使用该对象的客户代码本身是属于真正全局对象,并且在main()之外使用这个"基于main()"的"伪全局变量"的话,你的程序将会面临崩溃的危险.但是从积极的方面来说,如果你能够掌控局势的话,你将得到对你的程序静态对象的生命期和顺序的完全控制,所以说,有时这种做法所付出的努力还是值得的.(有体会,比如V9中的那些模块)

 

11.2 单件

在C++中,单件的实现通常依赖于静态对象.在C++中实现单件有几种方式,其中任何一种都在某种形式上依赖于static关键字.

 

11.2.1 Meyers单件

也就是平常我们用得最多的那种,但是我们基本上都忘了把拷贝构造函数和赋值操作符搞成私有的,弄得很不专业.

然而,该径途存在几个问题.首先,它使用了局部静态对象,从而就其当前形式而言存在多线程情况下会招致竞争条件(上次跟大师没搞出来).其次,你无法控制该实例的生命期,它在第一次被使用时得到创建,并且跟其他静态对象(局部和非局部的)混在一起,由进程关闭机制来销毁(析构).如果某些东西,可能时另一个静态对象的析构函数,在s_instance析构之后调用Thing::GetInstance(),它就等于是在跟一个死去的人打交道,当然,这意味着一些很不里面的事就要发生了.这就是所谓的"死引用"问题.(我们的UCC就出现过这种问题,所以我很讨厌什么都是静态的)

 

11.2.2 Alexandrescu单件

Alexandrescu单件利用一种复杂的技术解决了死引用问题,大致上说,Alexandrescu单件通过将单件对象挂钩到语言清理机制上,并确保它们按照一个相对寿命级别被销毁.

 

代码太复杂,还没看懂?

 

在这个实现中存在着一些非常有趣有用的概念,我强烈建议你熟悉它们,但是我遇到的问题是,该解决方案是建立在程序员为系统中的所有全局对象提供寿命级别的基础上的.这看起来很容易,但一不小心就搞错,并且可能难于诊断,因为没有任何理由可以确保一个错误的寿命级别应该在某个测试序列中暴露出来.此外,如果有新的类型/单件需要加进来,将需要你付出相当大的功夫(进行必要的相对寿命调整).待会我会介绍我自己的解决方案,虽然远没那么精巧,但却不需要程序员具有那种程度的远见.

(看了一下Alexandrescu单件的代码,太复杂了,有点杀鸡用牛刀的感觉,估计我是不会用它)

 

11.2.3 即时Schwarz计数器: 一个极妙的主意

 

这是我们要确保在主执行流程开始之前以及被任何其他静态对象使用之前处于可用状态的单件.

class Thing

{

    public:

       staticThing& GetInstance();

    ...

};

 

下面东西放到一个头文件中,插入到需要使用Thing的编译单元中

struct ThingInit

{

    ThingInit()

    {

       Thing::GetInstance();

    }

};

static ThingInit s_thingInit;

 

有点想不通的是,难道我直接调用Thing::GetInstance不可以吗?

 

建议: 不要在全局对象初始化期间创建线程.

 

不过作者说了半天,这个东西还是有漏洞.

 

11.2.4 对API计数

有状态库必须在使用之前得到初始化,并且在不再被使用时被反初始化.其初始化可能具有好几种形式.其中的一种形式是,初始化函数应该只被调用一次,是在进程的main()之中调用.而另一种形式则是,对初始化函数的调用只有第一次是有效的,其他调用全被忽略.不管是哪种形式都可能包含一个反初始化函数,不过并非总是这样.

然而,当被使用到一个由多个连接单元的应用程序中时,这些形式都存在着同样的问题.一个更好的形式是允许对初始化函数进行多次调用,并且同时要求对反初始化函数进行相应次数的调用,这就是所谓的引用计数API.(比较有道理,库要通用的话,最好用它,我以后就用它得了)

该解决方案的一个极好的副作用是任何循环依赖都能够被立刻侦测出来,因为循环依赖会导致初始化进入无限循环.

 

最后还有一点小小的要点.有些库要求对其初始化函数的调用被串行化,也就是说,这种库的使用者必须确保它的第一次调用是位于主线程当中,并位于其他任何线程有机会调用它之前.这通常是由于"被用于确保线程安全的访问"的同步原语本身就是在第一次对该库的调用时被创建的.在大多数情况下这并不是个问题,不言而喻,可以处理多线程初始化的库是更为健壮并且更易使用的.

 

11.4 静态成员

 

11.4.1 解决连接问题

作者想实现一个东西只被初始化一次(目的是为了提高性能,比如获取QueryPerformanceFrequency),但是又想只使用头文件(由于STLSoft的指导原则之一是100%使用头文件.因为不能使用cpp文件,故全局变量派不上用场了,当然在头文件中放静态变量又会导致产生多个实例),故想到了以下这种利用函数中的静态变量只会被初始化一次的特性找到了解决办法:

#ifndef HIGH_PERFORMANCE_COUNTER_H

#define HIGH_PERFORMANCE_COUNTER_H

 

class high_performance_counter

{

    static LARGE_INTEGERconst query_frequency()

    {

        LARGE_INTEGER freq;

        if(!QueryPerformanceFrequency(&freq))

        {

            freq.QuadPart = _I64_MAX;

        }

        return freq;

    }

public:

    static LARGE_INTEGERconst &frequency()

    {

        // 只会执行一次,并且还持久存在,再加上函数实现可以写在头文件中,终于满足了作者的要求,不容易啊,呵呵

        static LARGE_INTEGERs_frequency = query_frequency();

        return s_frequency;

    }

};

 

#endif

 

我是否能在实现中找到这种使用场景呢?

 

100%使用头文件是一种好的方式吗?

 

 

11.5 静态成员: 尾声

总结一下,你会发现静态对象的问题可以归结为顺序性,同一性以及计时这3方面.不难想象语言在这3方面是如何脆弱.顺序性是仅当你有几个互相依赖的全局对象时才会出现的问题.而同一性问题仅当你在同一个可执行文件中有多个连接单元时才会被提上台面.

最后,计时问题只在多线程环境下才会出现.

作者还说,就算C++在设计时预见了多线程这个东西,它并不一定设计出来的东西就能解决问题,C++之后出现的新语言也没有很好的解决这些问题.试问有哪一门语言是高效的而且没有线程方面的问题.

因此,我拿出老掉牙的忠告来炫耀一番.真正的解决方案永远只有一个,就是了解你的问题,并使用那些可以避免问题,至少对问题有所改善的合适技术.这些技术从概念上来说都非常简单,虽然其中有些实现比较乏味(例如基于API的单件).

 

 

第16章 关键字

 

 

第30章 短路(没多少实践意义,不用仔细看)

 

主要讲的是&&和||具有所谓的"短路"求值语义,这个我以前就是知道的,可以不用太关心,写代码的时候注意一下就行了.

然而,如果我们针对用户自定义类型对这两个操作符进行重载的话,它们的短路特性就不复存在了.例:

#include "stdafx.h"

#include <stdio.h>

 

class Y

{

public:

    bool operator&&(bool) const

    {

        return false;

    }

};

 

int _tmain(int argc, _TCHAR* argv[])

{

    Y y;

    int i = 0;

    if(y && (++i)) // 如果是false &&(++i)的话,++i就不会被执行。重载后,++i相当于作为&&函数的参数(这个变化大喽),所以它总是会被求值。

    {

        ;

    }

    _tprintf(_T("i = %d\n"), i);// i = 1

   

    _tsystem(_T("pause"));

    return 0;

}

 

从语义上来讲,这意味着&&和||操作符的语义发生了本质上的改变,而且这种改变仅通过查看代码是几乎无法看出来的.

这个缺陷显然是要避免的.作者说他在十几年的C++编程生涯中都从来没用到过,我难道还能用到吗?可能性几乎为0.话说回来,如果你确实需要重载它们的话,你可一定要小心语义的变化所带来的潜在副效应.

 

 

 

第17章 语法

 

我其实相当惧怕写这方面的内容,因为在这个主题上,风格的论战通常无聊且没有赢家。我敢打赌,对这类话题的讨论不可能得到所有人一致赞同的,甚至无法得到大多数读者的赞同。

然而,C++中确实有一部分容易引发错误,其中有些错误使我不得不将它们提出来讨论一番,即便我聊到你们中肯定有人坚决不同意我的看法。

 

17.1 类的代码布局

 

作者一开始提出了一种按public,protected,private划分(即相同权限的全部堆积到一个块中)的办法,然后提出了两个不好的地方把它否定了。

 

作者最后提出了一种他认可的方法,就是按功能块分。在实际代码中,功能性区段可包括构造/析构函数、操作、属性、迭代、状态、实现、成员以及我最喜欢的“声明但不予实现”区段。

 

原以为类的代码布局没什么用,但是,上个周我把SIP界面的类布局整理了一下,看起来明显的清新悦目多了,代码看起来很舒服。因为看代码的时候,我们一般首先的都是看头文件,所以类的代码布局是很重要的。因为我比较笨和懒,太复杂的功能块和规则我掌握不了,我是按如下进行划分的:

公共的构造函数和析构函数

公共的接口函数

公共的其他函数

保护的各种函数

私有的各种函数

公共成员

保护成员

私有成员

划分的规则其实很简单,先public后private;先成员函数,后成员变量;适当按功能相关性分段。

这个办法虽然可能没有作者的好,但是相较于以前的,已经不错了!

注意,消息处理函数都搞成保护的,因为其派生类可能会调用它们。

 

类代码布局得好不好的评判标准可能有哪些?

1.容易让代码阅读者很快抓住重点(抽象)

2.当成员的访问权限发生变化时,从一个版本控制系统中很容易看到究竟是发生了什么改动(具体)

 

我的办法规则比较严格,不容易发生混乱,但是如果成员的访问权限发生变化,改动较大。作者的规则灵活,不好掌握,可能会把各种访问权限的东西混在一起。以后具体采用那种方式,暂时不作决定,试试再说。

 

17.2 条件表达式

 

对于

if(x) {...}

这种情况,作者建议还是用

if(x != 0){...}

这种方式。

 

反驳的原因有:1.用INVALID_HANDLE_VALUE说明不一定非零就表示成功 2.用basic_ios::operatorvoid*()证明返回空指针并一定就是失败。

 

虽然我有点不同意这两个反驳原因(毕竟我们没有那么死板,我们要因地制宜才对嘛,如果会犯上面反驳例子,咱也太死板了吧),但是关系,因为我其实也是比较赞同作者的解决方案的:总是令你的表达式有显式地布尔类型。

也就是麻烦一点,多敲几个字符而已嘛!作者针对那些不愿多敲字符的人,他们却没有认识到软件工程的一个事实:大部分的编码工作是在维护其完成的,因此,尽管现在需要敲更多的字,但从长远来看却是磨刀不误砍柴工。坦白地说,我很少从库作者或者具有很长生命期的大项目上工作的工程师口中听到这样的抱怨。

 

其实工作中我也一直在考虑该如何判断,比如断言时,例ASSERT(pButton);呢还是ASSERT(pButton!= NULL);现在我可以更加坚定了,至少作者是和我同样的方式。

 

17.2.2 一个危险的赋值

 

也就是

if(i = 1)

问题。

 

这是一个老掉牙的问题,作者首先提出了一个解决方案,就是把编译器警告级别置高,但他觉得还不好,然后提出的就是我们已经知晓的,将右值放左边。

作者的观点,它简单,无趣,甚至有点丑陋。它甚至可以说是有违人们思维习惯的,但是只需很短的时间人们就可以习惯它。

 

可惜我不愿意去习惯它,我还是喜欢按人的正常思维来写代码,我们使用的编译器都能帮我们告警这种情况,再说,最好的办法是让我们习惯用==而不是乱用=

 

17.3 for

17.3.1 初始化作用域

因为可移植性是软件的一个非常有用的特性,所以我们需要一个同时能在新、老规则下工作的形式。换一个途径是使用一个额外的外围作用域,将for语句整个包在一个局部块中:

    { for (int i = 0; i < 100;++i)

    {

        //...

    }}

 

    { for (int i = 0; i < 100;++i)

    {

        //...

    }}

这从效果上迫使任何老规则的代码都去使用新规则。因为新规则比起老规则从正确性上来讲更为合理,该技术的一个良好的副作用是能够促使代码和代码的作者以新的方式去思考。

 

可惜我不太赞同这种方式,看起来很丑陋,还不如把i放到for外面声明得了。

 

17.3.2 异质初始化类型

    for (int i = 0, j = 10; i < j; ++i)

    {

        //...

    }

想到

    for (int i = 0; vector<int>::const_iteratorb = v.begin(); i < 10&& b != v.end(); ++i)

    {

        // ...

    }

但实际上后者编译是不能通过的。这是因为在初始化子句中只允许出现一个种类型标识符。作者提出的解决办法类似

    {   int i;

    vector<int>::const_iterator b;

    for (int i = 0; b = v.begin(); i < 10 && b!= v.end();++i)

    {

        // ...

    }}

 

我很不喜欢这种方式,丑陋。

 

17.4 变量命名

 

...。因而我使用cb和cch前缀,它们分别表示“按字节计”和“按字符计”。

 

“sm_”前缀表示静态成员。

 

大概和我们之前的差不多,没有好坏之分,只要一致就行,故此处略。

 

 

 

第26章 你的地址是什么

 

本章我们将考察重载operator&()可能带来的一些问题。我们的解决方案是平淡无奇的,只是建议你遵循Peter的话中隐含的意思,坚决不要只顾眼前的利益对它进行重载,这在以后的日子里会为你省却许多麻烦事。

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值