性格决定命运 思路决定出路 建站目的:网络资源丰富,为了大家互相交流,资源共享。声明:本站部分资料来自网络和论坛,不做商业用途,仅与网友分享!!如有侵权请告之 cyberfan@163.com,本站将立即删除!特此声明!!
深度探索C++对象模型(二)
(作者置顶)
深度探索C++对象模型(6)
这是这个系列笔记的第7篇了,我们还在和构造函数打交道,以前写程序时怎么根本没有考虑过构造函数的事情呢?原来编译器为我们做了这么多的事情,我们都不知道.,要想完全搞明白,看来还需要一段时间.我们继续向下走,进入一个新的章节.在第三章一开始,我就吃了一惊..书上给出了一个例子:
class X{};
class Y:public virtual class X{};
class Z:public virtual class X{};
class A:public Y,public Z{};
下面的结果会因为机器,以及编译有关,不同的情况会产生不同的结果.(怎么会是这样?)
sizeof X; //结果为1
sizeof Y; //结果为8
sizeof Z; //结果为8
sizeof A; //结果为12
一个没有任何成员的类,大小居然不是0.
为什么?
首先一个没有明显的含有成员的类,它的大小不是0,因为实际上它不是空的,它被编译器安插了一个char,为的是使这个类的两个对象能够在内存中被分配独一无二的地址.至于两个派生的类Y和Z,因为语言本身造成的负担,还有编译器对于特殊情况进行的优化处理,再有Alignment的限制,因此结果变成了8.这个8是怎么组成的?
4个bytes用来存放指针,什么指针?指向virtual base class subobject的指针呀.
一个同class X一样的char.它占了1 个bytes.然后受到Alignment的限制,所以填补了3个bytes.
4+1+3=8
不过需要注意的是不同的编译器Y和Z大小的结果也会不同.因为新的编译器会将一个空的virtual base class看做是派生类对象的开头部分,因此派生类有了member,因此也就不必分配char的那一个bytes.也就用不到填补的3个bytes,因此有可能在某些编译器中,class Y和class Z的大小为4.
最后看看A.根据我们对class Y的分析可以得出以下算式:
4+4+1+3=12;
不是我们想象的16,而是12.如果换成我们上面说的新的编译器来编译,结果很有可能是8.
我们来看Data Member 的Binding,现在我们对数据成员的绑定只需要记住一个防御性风格:始终把嵌套类型的声明放在class的开始部分,这样做可以确保非直觉绑定的正确性。看下面的一个例子:
typedef int length; //zai
class point3d
{
public:
//length被决议成global typedef 也就是int
//_val被决议成Point3d::_val
void mumble(length val){_val=val;}
length mumble(){return _val;}
//……
private:
//length必须在这个class对它的第一个参考操作之前被看见
//这样声明将使先前的参考操作不合法
typedef float length;
length _val;
//……
};
怎么成了抄书了,雷神也不知不觉,可能是在这章的理解上比较容易些吧,不用去想个看的见摸的着的东西比划。好象小朋友学算术,一位数的计算不用掰手指头,可是两位数或者三位数的计算,手指头加上脚指头还是不够。学习就是这么回事。理解力和抽象能力很重要。回来继续学习。
通过这一章我还知道了。数据成员的布局。数据成员的存取。并且对Static data members有了进一步的了解,在class的生命周期中,静态成员被看作是全局变量,每一个member的存取不会导致任何空间或效率上的额外负担。不论是从一个复杂的继承关系中继承还是直接声明的,Static data member都只会有一个实体。并且有着非常直接的存取路径。另外如果两个类都声明了一个相同名字的静态成员变量,那么编译器会通过一种算法,为我们解决名字冲突的问题。而非静态的成员变量的存去实际上是通过implicit class object(this指针)来完成的。例如
Point3d
Point3d::translate(const Point3d &pt)
{
x+=pt.x;
y+=pt.y;
z+=pt.z;
}
被编译器经过内部转换成为了下面这个样子:
Point3d
Point3d::translate(Point3d *const this,const Point3d &pt)
{
this->x+=pt.x;
this->y+=pt.y;
this->z+=pt.z;
}
如果要对一个非静态的成员变量进行存取,编译器会把类对象的起始地址加上数据成员的偏移量。例如:
Point3d origin;
origin._y=0.0;
//地址&origin._y将等于
&origin+(&Point3d::_y-1);
目的是使编译系统能够区分出以下两种情况:
一个指向数据成员的指针,用来指出类的第一个成员。
一个指向数据成员的指针,没有指出任何成员。
这是什么意思?什么是指向数据成员的指针。书上的例子:
class Point3d
{
public:
virtual ~Point3d();
//……
protected:
static Point3d origin;//静态的数据成员,位置在class object之外
float x,y,z;//每个float是4bytes
}
&Point3d::z; //这个值是什么?
我们在这篇文章开始的时候已经知道了还有一个vptr,不过vptr的位置也许在对象的开始,也许在对象的结尾部。所以上面的操作的值应该是8或者12(如果vptr在前面的话)。但实际上取会的值被加上了1。原因是必须要区别一个不指向任何成员的指针,和一个指向第一个成员的指针。又有点不好理解了,举个例子:
想象你和你的另外两个朋友合住一个三室一厅的房子,你住在第一间。如果你给一个你们三个人共同的朋友的地址你可以给房号就行了。不用给出你们的任意一个人的那间房子号(不指向任何成员)。但如果你给你的一个私人朋友地址,你会给出房间号和你的那个房间号。为了使这个地址有区别,你必须有一个厅来作为偏移量(offset)。不知道大家明白这个例子吗,也许这个例子会影响你的正确思维。那就太糟糕了。不过我还是喜欢这样想问题,也许不太准确,但可以帮助我,因为想象一个内存空间比想象一个三居室要难好几点儿。
深度探索C++对象模型(7)
在单一继承的体系中,虚函数机制是一种很有效率的机制。我们判断一个类是否支持多态,只需要看它有没有虚函数便可以了。 第四章,函数的语意学。先做个复习C++支持三种成员函数:静态、虚、和非静态。每一种函数的调用方式都不同,当然他们的作用也会有区别,一般来说我们只要掌握根据我们的需要正确的使用这三种类型的成员函数便可以了,至于内部是如何运做的我们可以不知。
我们的在设计和使用类时最常用的便是非静态成员函数,使用成员函数是为了封装和隐藏我们的数据,我想这是成员函数和外部函数的最明显的区别。但是他们的效率是否有不同呢?我们不会想为了保护我们的数据而使用成员函数,最后确导致效率降低的结。让我们看看非静态成员函数在实际的执行时被编译器搞成了什么样子。
float magnitude3d(const Point3d *_this){…}
//这是一个外部函数,它有参数。表示它间接的取得坐标(Point3d)成员。
float Point3d::mangnitude3d() const {…}
//这是一个成员函数,它直接取得坐标(Point3d)的成员。
表面上看,似乎成员函数的效率高很多,但实际上他们的效率真的想我们想象的那样吗?非也。实际上一个成员函数被内部转化成了外部函数。
1、 一个this指针被加入到成员函数的参数中,为的是能够使类的对象调用这个函数。
2、 将对所有非静态数据成员的存取操作改为由this来存取。
3、 对函数的名称进行重新的处理,使它成为程序中独一无二的。
这时后,经过以上的转换,成员函数已经成为了非成员函数。
float Point3d::mangnitude3d() const {…}//成员函数将被变成下面的样子
//伪码
mangnitude3d__7Point3dFv(register Point3d * const this)
{
return sqrt(this->_x * this->x+
this->_y * this->y+
this->_z * this->z);
}
调用此函数的操作也被转换
obj. mangnitude3d()
被转换成:
mangnitude3d__7Point3dFv(*obj);
怎么样看出来了吧,和我们开始声明的非成员函数没有区别了。因此得出结论:两个铁球同时落地。
一般来说,一个成员的名称前面会被加上类的名称,形成唯一的命名。实际上在对成员名称做处理时,除了加上了类名,还会将参数的链表一并加上,这样才能保证结果是独一无二的。
我们在来看看静态成员函数。我们有这样的概念,成员函数的调用必须是用类的对象,象这样obj.fun();或者这样ptr->fun().但实际上,只有一个或多个静态数据成员被成员函数存取时才需要类的对象。类的对象提供一个指针this,用来将用到的非静态数据成员绑定到类对象对应的成员上。如果没有用到任何一个成员数据,就不需要用到this指针,也就没有必要通过类的对象来调用一个成员函数。而且我们还知道静态数据成员是在类之外的,可以被视做全局变量的,只不过它只在一个类的生命范围内可见。(参考前面的笔记)。而且一般来说我们会将静态的数据成员声明为一个非Public。这样我们便必须提供一个或多个成员函数用来存取这个成员。虽然我们可以不依靠类的对象存取静态数据成员,但是这个可以用来存取静态成员的函数确实必须绑定在类的对象上的。为了更加好的解决这个问题,cfront2.0引入了静态成员函数的概念。
静态成员函数是没有this指针的。因为它不需要通过类的对象来调用。而且它不能直接存取类中的非静态成员。并且不能够被声明为virtual,const,volatile.如果取得一个静态成员函数的地址,那么我们获得的是这个函数在内存中的位置。(非静态成员函数的地址我们获得的是一个指向这个类成员函数的指针,函数指针)。可以看到由于静态成员函数没有this指针,和非成员函数非常的相似。
有了前面几章的基础,好象这些描述理解起来也不很费劲,而且我们的思路可以跟着书上所说的一路倾泻下来,这便是读书的乐趣所在了,如果一本书读起来都想读第一章时那样费劲,我想我读不下去的可能性会很高。
继续我们的学习,下面书上开始将虚函数了。我们知道虚函数是C++的一个很重要的特性,面向对象的多态便是由虚函数实现的。多态的概念是一个用一个public base class的指针(或者引用),寻址出一个派生类对象。虚函数实现的模型是这样。每一个类都有一个虚函数表,它包含类中有作用的虚函数的地址,当类产生对象时会有一个指针,指向虚函数表。为了支持虚函数的机制,便有了“执行期多态”的形式。
下面这样。
我们可以定义一个基类的指针。
Point *ptr;
然后在执行期使他寻址出我们需要的对象。可以是
ptr =new Point2d;
还可以是
ptr=new Pont3d;
ptr这个指针负责使程序在任何地方都可以采用一组由基类派生的类型。这种多态形式是消极的,因为它必须在编译时期完成。与之对应的是一种多态的积极形式,即在执行期完成用指针或引用查找我们的一个派生类的对象。
象下面这样:
ptr->z();
要想达到我们目的,这个函数z()应该是虚函数,并且还应该知道ptr所指的对象的真实类型,以便我们选择z()的实体。以及z()实体的位置,以便我们能够调用它。这些工作编译器都会为我们做好,编译器是如何做的呢?
我们已知每一个类会有一个虚函数表,这个表中含有对应类的对象的所有虚函数实体的地址,并且可能会改写一个基类的虚函数实体。如果没有改写基类存在的虚函数实体,则会继承基类的函数实体,这还没完,还会有一个pure_virtual_called()的函数实体。每一个虚函数不论是继承的还是改写的,都会被指派一个固定的索引值,这个索引在整个继承体系中保持与特定的虚函数关联。
说明:当没有改写基类的虚函数时,该函数的实体地址是被拷贝到派生类的虚函数表中的。
这样我们便实现了执行期的积极多态。这种形式的特点是,我们从头到尾都不知道ptr指针指向了那一个对象类型,基类?派生类1?派生类2?我们不知道,也不需要知道。我们只需要知道ptr指向的虚函数表。而且我们也不知道z()函数的实体会被调用,我们只知道z()函数的函数地址被放在虚函数表中的位置。
总结:在单一继承的体系中,虚函数机制是一种很有效率的机制。我们判断一个类是否支持多态,只需要看它有没有虚函数便可以了。
但是构造函数和析构函数和new和delete不同,他们并非必须成对的出现。决定是否为一个类写构造函数或者析构函数,是取决于这个类对象的生命在哪里结束(或开始)。需要什么操作才能保证对象的完整。
深度探索C++对象模型(8)
书的第四章后半部分详细的讲解内联函数,由于比较容易理解,我做一个简单总结便过去吧。
内联函数和其他的函数相比是一种效率很高的函数,未优化的情况下效率可以提高25%,优化以后简直是数量级的变化,书上的给出的数据是0.08比4.43。简直没法比了。内联函数对于封装提供了一种必要的支持,可以有效的存去类中的非共有数据成员,同时可以替代#define(前置处理宏)。但是它也有缺点,程序会随着调用内联函数次数的增多,而产生大量的扩展码。
在内联函数的扩展时每一个形式参数被对应的实参取代,因此会有副作用。通常需要引入临时对象解决多次对实际参数求值的操作产生的副作用。
第五章的开始给出了一个不恰当的抽象类的声明:
class Abstract_base
{
public:
virtual ~Abstract_base()=0;//纯虚析构函数
virtual void interface() const=0; //纯虚函数
virtual const char* mumble() const{return _mumble;}
protected:
char *_mumble;
};
这是一个不能产生实体的抽象类,因为它有纯虚函数。为什么说它存在不合适的地方呢?以下逐一进行说明。
1、 它没有一个明确的构造函数,因为没有构造函数来初始化数据成员则它的派生类无法决定数据成员的初值。类的成员数据应该在构造函数或成员函数中被指定初值,否则将破坏封装性质。
2、 每一个派生类的析构函数会被编译器进行扩展以静态调用方式调用其上层基类的析构,哪怕是纯虚函数。但是编译器并不能在链接时找到纯虚的析构函数,然后合成一个必要的函数实体,因此最好不要把虚的析构函数声明成纯虚的。
3、 除非必要,不要把所有的成员函数都声明为虚函数。这不是一个好设计观念。
4、 除非必要,不要使用const声明函数,因为很多派生的实体需要修改数据成员。
有了以上的观点上面的抽象类应该改为下面这种样子:
class Abstract_base
{
public:
virtual ~Absteact_base(); //不在是纯虚
virtual void interface()=0; //不在是const
const char * mumble() const{return _mumble;} //不在是虚函数
protected:
Abstract_base(char *pc=0); //增加了唯一参数的构造
Char *_mumble;
};
下一个问题,对象的构造。构造一个对象出来很简单,这是我们在编程时经常要做的事情。我理解书上的意思是为我们分析了各种不同的类,例如一个没有Copy constructor,Copy operator的类,或者有私有变量但是没有定义虚函数的类等等,当他们构造对象时也有多种情况,global,local,还有在new时,编译器都做了什么,内存的分配情况如何。搞清楚它们也很有意思。另外这好象是前面几章学到的东西的一个进一步的研究。我们找出最复杂的虚拟继承来进行一下研究。当一个类对象被构造时,实际上这个类的构造函数被调用,不论是我们自己写的,还是由编译器为我们合成的。并且编译器会背着我们做很多的扩充工作,将记录在成员初始化列表中的数据成员的初始化工作放进构造函数,如果一个数据成员没有在成员初始化列表中出现,则会调用默认的构造函数,这个类的所有基类的构造都会被调用,以基类的声明顺序。所有的虚拟基类的构造也会被调用。还要为virtual table pointers设定初始值,指向适当的virtual tables。好家伙,编译器还真累。好象说的不是很清楚,抄一段书上的代码。
已知一个类的层次结构和派生关系如下图:
见书上P211。
这是程序员给出的PVertex的构造函数:
PVertex::PVertex(float x,float y,float z):_next(0),Vertex3d(x,y,z),Point(x,y)
{
if(spyOn)
cerr<<”within PVertex::PVertex()”<<”size:”<<size()<<endl;
}
它可能被扩展成为:
//C++伪码
// PVertex构造函数的扩展结果
PVertex *
PVertex::PVertex(PVertex * this,bool most_derived,float x,float y,float z)
{
//条件式的调用虚基类的构造函数
if(_most_derived!=false)
this->Point::Point(x,y);
//无条件的调用上层基类的构造函数
this->Vertex3d::Vertex3d(x,y,z);
//将相关的vptr初始化
this->_vptr_PVerex=_vtbl_PVertex;
this->_vptr_Point_PVertex=_vtbl_Point_PVertex;
//原来构造函数中的代码
if(spyOn)
cerr<<”within PVertex::PVertex()”<<”size:”
//经虚拟机制调用
<<(*this->_vptr_PVertex[3].faddr )(this)<<endl;
//返回被构造的对象
return this;
}
通过上面的代码我们可以比较清晰的了解在有多重继承+虚拟继承的时候构造一个对象时,编译会将构造函数扩充成一个什么样子。以及扩充的顺序。知道了这个相对于无继承,或者不是虚拟继承时对象的构造应该也可以理解了。与构造对象相对应的是析构。但是构造函数和析构函数和new和delete不同,他们并非必须成对的出现。决定是否为一个类写构造函数或者析构函数,是取决于这个类对象的生命在哪里结束(或开始)。需要什么操作才能保证对象的完整。象构造函数一样析构函数的最佳实现策略是维护两份destructor实体。一个complete object实体,总是设定好vptrs,并调用虚拟基类的析构函数。一个base class subobject实体。除非在析构函数中调用一个虚函数,否则绝不会调用虚拟基类的析构函数,并设定vptrs。
一个对象生命结束于析构函数开始执行的时候。它的扩展形式和构造函数的扩展顺序相反。
当编译一个C++程序时,计算机的内存被分成了4个区域,一个包括程序的代码,一个包括所有的全局变量,一个是堆栈,还有一个是堆(heap),我们称堆是自由的内存区域,我们可以通过new和delete把对象放在这个区域。你可以在任何地方分配和释放自由存储区。但是要注意因为分配在堆中的对象没有作用域的限制,因此一旦new了它,必须delete它,否则程序将崩溃,这便是内存泄漏。(C#已经通过内存托管解决了这一令人头疼的问题)。C++通过new来分配内存,new的参数是一个表达式,该表达式返回需要分配的内存字节数,这是我以前掌握的关于new的知识,下面看看通过这本书,使我们能够更进一步的了解到些什么。
深度探索C++对象模型(9)
这一章主要是说Runtime Semantics执行期语义学。
这是我们平时写的程序片段:
Matrix identity; //一个全局对象
Main()
{
Matrix m1=identity;
……
return 0;
}
很常见的一个代码片段,雷神从来没有考虑过identity如何被构造,或者如何被销毁。因为它肯定在Matrix m1=identity之前就被构造出来了,并且在main函数结束前被销毁了。我们不用考虑这些问题,好象C++就应该这样。但这本书是研究C++底层机制的。既然我们在看这本书,说明我们希望了解C++的编译器又做了那些大量的工作,使得我们可以这样使用对象。
在C++程序中所有的全局对象都被放在data segment中,如果明确赋值,则对象以该值为初值,否则所配置到内存内容为0。也就是说,如果我们有以下定义
Int v1=1024;
Int v2;
则v1和v2都被配置于data segment,v1值为1024,v2值为0。(雷神在VC6环境用MFC编程时中发现如果int v2;v2的值不为0,而是-8,不知为什么?编译器造成的?)。
如果有一个全局对象,并且这个对象有构造函数和析构函数的话,它需要静态的初始化操作和内存释放工作,C++是一种跨平台的编程语言,因此它的编译器需要一种可以移植的静态初始化和内存释放的方法。下面便是它的策略。
1、 为每一个需要静态初始化的档案产生一个_sit()函数,内带构造函数或内联的扩展。
2、 为每一个需要静态的内存释放操作的文件中,产生一个_std()函数,内带析构函数或内联的扩展。
3、 提供一个_main()函数,用来调用所有的_sti()函数,还有一个exit()函数调用所有的_std()函数。
侯先生说:
Sit可以理解成static initialization的缩写。
Std可以理解成static deallocation的缩写。
那么main函数会被编译器变成这样:
Matrix identity; //一个全局对象
Main()
{
_main();//对所有的全局对象做static initialization动作。
Matrix m1=identity;
……
exit();//对所有的全局对象做static deallocation动作。
}
其中_main()会有一个对identity对象的静态初始化的_sti函数,象下面伪码这样:
// matrix_c是文件名编码_identity表示静态对象,这样能够保证向执行文件提供唯一的识别符号
_sti__matrix_c_identity()
{
identity.Matrix:: Matrix(); //这就是静态初始化
}
相应的在exit()函数也会有一个_std_matrix_c_identity(),来进行static deallocation动作。
但是被静态初始化的对象有一些缺点,在使用异常时,对象不能被放置在try区段内。还有对象的相依顺序引出的复杂度,因此不建议使用需要静态初始化的全局对象。
局部静态对象在C++底层机制是如何构造和在内存中销毁的呢?
1、 导入一个临时对象用来保护局部静态对象的初始化操作。
2、 第一次处理时,临时对象为false,于是构造函数被调用,然后临时对象被改为true.
3、 临时对象的true或者false便成为了判断对象是否被构造的标准。
4、 根据判断的结果决定对象的析构函数是否执行。
如果一个类定义了构造函数或者析构函数,则当你定义了一个对象数组时,编译器会通过运行库将你的定义进行加工,例如:
point knots[10]; //我们的定义
vec_new(&knots,sizeof(point),10,&point::point,0); //编译器调用vec_new()操作。
下面给出vec_new()原型,不同的编译器会有差别。
void * vec_new(
void *array, //数组的起始地址
size_t elem_size, //每个对象的大小
int elem_count, //数组元素个数
void(*constructor)(void*),
void(*destructor)(void* ,char)
)
对于明显获得初值的元素,vec_new()不再有必要,例如:
point knots[10]={
Point(), //knots[0]
Point(1.0,1.0,0.5), //knots[1]
-1.0 //knots[2]
};
会被编译器转换成:
//C++伪码
Point::Point(&knots[0]);
Point::Point(&knots[1],1.0,1.0,0.5);
Point::Point(&knots[2],-1.0,0.0,0.0);
vec_new(&knots,sizeof(point),10,&point::point,0); //剩下的元素,编译器调用vec_new()操作。
怎么样,很神奇吧。
当编译一个C++程序时,计算机的内存被分成了4个区域,一个包括程序的代码,一个包括所有的全局变量,一个是堆栈,还有一个是堆(heap),我们称堆是自由的内存区域,我们可以通过new和delete把对象放在这个区域。你可以在任何地方分配和释放自由存储区。但是要注意因为分配在堆中的对象没有作用域的限制,因此一旦new了它,必须delete它,否则程序将崩溃,这便是内存泄漏。(C#已经通过内存托管解决了这一令人头疼的问题)。C++通过new来分配内存,new的参数是一个表达式,该表达式返回需要分配的内存字节数,这是我以前掌握的关于new的知识,下面看看通过这本书,使我们能够更进一步的了解到些什么。
Point3d *origin=new Point3d; //我们new 了一个Point3d对象
编译器开始工作,上面的一行代码被转换成为下面的伪码:
Point3d * origin;
If(origin=_new(sizeof(Point3d)))
{
try{
origin=Point3d::Point3d(origin);
}
catch(…){
_delete(origin);
throw;
}
}
而delete origin;
会被转换成(我将书上的代码改为exception handling情况):
if(origin!=0){
try{
Point3d::~Point3d(origin);
_delete(origin);
catch(…){
_delete(origin); //不知对否?
throw;
}
}
一般来说对于new的操作都直截了当,但语言要求每一次对new的调用都必须传回一个唯一的指针,解决这个问题的办法是,传回一个指针指向一个默认为size=1的内存区块,实际上是以标准的C的malloc()来完成。同样delete也是由标准C的free()来完成。原来如此。
最后这篇笔记再说说临时对象的问题。
T operator+(const T&,const T&); //如果我们有一个函数
T a,b,c; //以及三个对象:
c=a+b;
//可能会导致临时对象产生。用来放置a+b的返回值。然后再由 T的copy constructor把临时对象当作c的初值。也有可能直接由拷贝构造将a+b的值放到c中,这时便不需要临时对象。另外还有一种可能通过操作符的重载定义,经named return value优化也可以获得c对象。这三种方法结果一样,区别在于初始化的成本。对临时对象书上有很好的总结:
在某些环境下,有processor产生的临时对象是有必要的,也是比较方便的,这样的临时对象由编译器决定。
临时对象的销毁应该是对完整表达式求值过程的最后一个步骤。
因为临时对象是根据执行期语义有条件的产生,因此它的生命规则就显得很复杂。C++标准要求凡含有表达式执行结果的临时对象,应该保留到对象的初始化操作完成为止。当然这样也会有例外,当一个临时对象被一个引用绑定时,对象将残留,直到被初始化的引用的生命结束,或者超出临时对象的作用域。
深度探索C++对象模型(一)
(作者置顶)
《深入C++对象模型》一书没有什么可以被成为重点的东西,感觉每一个字都不应该放过,全是重点。
深度探索C++对象模型(1)
第一章:关于对象(Object Lessons)读完这一章使我想到了一个很久以前看到的一个笑话,编写一个HELLO WORLD的程序,随着水平和职务的不一样,程序代码也随着变化。当初看时完全当作笑话来看,现在看来写此笑话的人水平不一般。如果要使你的代码能够最大限度的适应不同的运行环境,和最大限度的复用,则在设计和编写的过程中需要考虑的问题很多,因此代码已变的不在具有C语言的简洁,高效。而牺牲了这些优势换来的是更好的封装。当然如果你只是要打印Hello World则不必这样做了。
以C++的思维方式解决问题,对于对C语言已经很熟悉的人来说会很不能适应。需要一段时间来适应,不然会将代码写的似是而非。而且不能邯郸学步,必须从思想上彻底的C++(OO),如果只是依葫芦画瓢,那结果很可能是用C++的语法编写C式的程序。本人曾经犯的典型的低级的错误之一,就是无意识的一个类无限制的扩充,完全没有考虑到类的多层结构(基类-派生类),需要属性或方法便在类中增加,虽然也用到了多态、重载等一些OO的设计方式,但最后这个类庞大无比,除了在当前系统中任劳任怨的工作外,一点复用的可能都没有,如果另一个系统还需要一个类似的东西,那只能重新设计实现一个新的类。并且最致命的是在维护更新时带来得麻烦,需要不断全部编译不说,而且代码在用了大量注释后,在过一段时间读起来也是一件重脑力劳动。及失去了C的简洁清晰和高效,也不完全具备C++的面向对象的特性。这根本不能叫C++程序。(我想有时间重写一下以前代码也会有很多收获,温故而知新吗)C和C++在编程思想上是相互矛盾的。这也就是说如果你想学C++,完全可以不学C,只需要一本好书和一个不太笨的大脑再加上努力就可以了,如果你已有C的经验在一定的情况下反而会捣乱。
本章是对对象模型的一个大略浏览。既然我们选择了C++而不是C作为开发工具,那我们的编程思想也应该转为C++的,而不能再延续C的Procedural方式。我们必须学会C++的思考方式。采用抽象数据类型或用一个多层的class体系对数据以及数据处理函数进行封装,只有摆脱C程序的使用全局数据的惯性,才能充分发挥出C++对象模型的强大威力。
在C++中有两种数据成员static和nonstatic,以及三种成员函数static、nonstatic和virtual。C++对象模型对内存空间和存取时间做了优化,nonstatic的数据成员被置于类对象之内,而static数据成员被置于类对象之外。static和nonstatic成员函数被放在类对象之外。而virtual函数是由类对象的一个指向vtbl(虚函数表)的指针vptr来进行支持。而vptr的设定和重置由类的构造函数、析构函数以及copy assignment运算符自动完成。
我们设计的每一个类几乎都要有一个或多个构造函数、析构函数和一个Assignment运算符。他们的作用是构造函数产生一个新的对象并确定它被初始化。析构函数销毁一个对象并确定它已经被适当的清理(避免出现内存泄露的问题),Assignment运算符给对象一个新值。
这是第一章的第一部分,由于雷神最近几天在做模式小组的主页,时间周转不开了。本想写完整个一章再发,考虑一下还是先发一部分吧。原因有2。1、第一章的后半部可能又要拖上10天半个月的。2、笔记实在难写,我不愿意将笔记做成将书上的重点再抄一边,而是喜欢尽量将自己的理解描述出来,谁知第一章便如此的难以消化,已经反复读了3遍,还是有些夹生。所以本着对大家和自己负责的态度,雷神准备再看它3遍在说。突然发现自己的C++还差的很远,好可怕呀。
深度探索C++对象模型(2)
笔记贴出后,有朋友便给我提出了一个很好的建议,原文如下:
史列因:我刚看了你写的“深度探索C++对象模型(1)”,感觉很不错。不过我有一个建议:你说“谁知第一章便如此的难以消化,已经反复读了3遍,还是有些夹生”是很自然的。第一章是一个总览,如果你能全看懂,后面的就没什么看的必要了。第一章的内容后面都有详细介绍,开始只要有个大概印象就可以了。这本书中很多内容都是前后重复的。我建议你先不管看懂看不懂,只管向后看,之后再从头看几遍,那样效果好得多。
我想史列因说的应该是一种非常好的阅读方式,类似《深度探索C++对象模型》这样的技术书籍,需要的是理解,和学习英文不同,不能靠死记硬背,如果出现理解不了的情况,那你不妨将书放下,打一盘红警(俺骄傲的说,我是高手)。或者跳过去也是一个不错的方法。好了,我们还是继续研究C++的对象模型吧。
简单的对象模型
看书上的例子(注释是表示solt的索引)
Class Point
{
public:
Point(float xval); //1
virtual ~Point(); //2
float x() const; //3
static int PointCount(); //4
protected:
virtual ostream& print(ostream &os) const; //5
float _x; //6
static int _point_count; //7
}
每一个Object是一系列的Slots,每一个Slots指向一个members。
表格驱动对象模型
当构造对象时便会有一个类似指针数组的东西存放着类数据成员在内存中位置的指针,还有指向成员函数的指针。为了对一个类产生的所有对象实体有一个标准的表达,所以对象模型采用了表格,把所有的数据成员放在数据成员表中,把所有的成员函数的地址放在了成员函数表中,而类对象本身有指向这两个表的指针。
为了便于理解,雷神来举个不恰当的例子说明一下,注意是不很恰当的例子
我们把写字楼看成一个类,写字楼中的人看成是类的数据成员,而每一个租用写字楼的公司看成类的成员函数。我们来看一个实体,我们叫它雷神大厦。雷神大厦的物业管理部门需要登记每个出入写字楼的人,以便发通行证,并且需要登记每个公司的房间号,并制作了一个牌子在大厅的墙上。实际上这便是类的对象构造过程。你可以通过大厅墙上的公司列表找到任何一家在雷神大厦租房的公司,也可以通过物业提供的花名册找到任何一个出入雷神大厦的人。
C++的对象模型
C++对象模型是从简单对象模型派生得来,并对内存空间和存取时间做了优化。它引入了虚函数表(virtual table)的方案。每个类产生一堆指向虚函数的指针,放在表格中。每个类的对象被添加了一个指针(vptr),指向相关的虚函数表(virtual table)。而这个指针是由每一个类的constructor、destructor和copy assignment运算符自动完成。
我们还用上面的雷神大厦举例,物业管理为了提高效率,对长期稳定的公司和人员不再登记,指对不稳定或不能确定的公司进行登记,以便于管理。
得出结论,C++对象模型和双表格对象模型相比,提高了空间和存储时间的效率,却失去了弹性。
试想一下,没有整个雷神大厦人员和公司的名录,如果他们发生变化,则需要物业管理部门做很多工作。重新确定长期稳定的公司和人员是那些。对应应用程序则需要重新编译。(这次更离谱,但为了保持连贯,大家请进行理解性的思考,不要局限字面的意思)
这篇笔记是分成多次一点点写的,甚至每天抽出一个小时都不能保证(没办法最近实在忙),因此可能会有不连贯,如果你读起来很不爽认为雷神的思维短路了,那属于正常。不过雷神还是再上传之前努力的将思路进行了一下整理。希望能把这些支言片语串起来。
深度探索C++对象模型(3)
多态是一种威力强大的设计机制,允许你继承一个抽象的public接口之后,封装相关的类型,需要付出的代价就是额外的间接性--不论是在内存的获得,或是在类的决断上,C++通过class的pointer和references来支持多态,这种程序风格就称为"面向对象". 这篇笔记主要解决了几个常常被人问到的问题。
1、C++支持多重继承吗?
2、结构和类的区别是什么?
3、如何设计一个面向对象的模型?
C++支持多重继承(JAVA和C#不支持多重继承),虽然我想我可能一辈子用不到它这一特性(C++是雷神的业余爱好),但至少我要知道它可以。典型的多重继承是下面这个:
//iostream 从istream 和 ostream 两个类继承。
class iostream:public istream,public ostream
{......};
结构struct和类class到底有没有区别?VCHELP上前几天还看到一个帖子在讨论这个问题。其实结构和类真的没什么区别,不过我们需要掌握的是什么时候用结构好,什么时候用类好,当然这没有严格的规定。通常我们混合使用它们,从书上的例子,我们可以看出为什么还需要保留结构,并且书上给出了一个方法:
struct C_point{.......}; //这是一个结构
class Point
{
public:
operator C_point(){return _c_point;}
//....
private:
C_point _c_point;
//....
}
这种方法被成为组合(composition).它将一个对象模型的全部或部分用结构封装起来,这样做的好处是你既可以在C++中应用这个对象模型,也可以在C中应用它。因为struct封装了lass的数据,使C++和C都能有合适的空间布局。
面向对象模型是有一些彼此相关的类型,通过一个抽象的base class(用来提供接口),被封装起来。真正的子类都是通过它派生的。当然一个设计优秀的对象模型还必须考虑很多的细节问题,雷神根据自己的理解写出一个面向对象模型的代码,大家可以看看,高手请给指出有没有问题。
思路:我想要实现一个人员管理管理的对象模型,雷神一直在思考一个人员管理的组件(当然最终它会用C#实现的一个业务逻辑对象,并通过数据库控制对象和数据库进行交互,通过WEB FORM来显示界面)。这里借用一下自己的已经有的的想法,用C++先进行一下实验,由于只是为了体会面向对象的概念,我们采用面向对象的方法实现一个链表程序,而且没有收集信息的接口。信息从mina()函数显式给出。
这个对象模型应该可以实现对人员的一般性管理,要求具备以下功能:
创建一个人员信息链表
添加、删除人员信息
显示人员信息
//*************************************************
//PersonnelManage.cpp
//创建人:雷神
//日期:
//版本:
//描述:
//*************************************************
#include <iostream.h>
#include <string.h>
//基类,是此对象模型的最上层父类
class Personnel
{
friend class point_list; //用来实现输出链表,以及插入或删除人员的功能.
protected:
char serial_number[15];//编号
char name[10];//名称
char password[15]//口令
Personnel *pointer;
Personnel *next_link;
public:
Personnel(char *sn,char *nm,char *pwd)
{
strcpy(serial_number,sn);
strcpy(name,sm);
strcpy(password,pwd);
next_link=0;
}
Personnel()
{
serial_number[0]=NULL;
name[0]=NULL;
password[0]=NULL;
next_link=0;
}
void fill_serial_number(char *p_n)
{
strcpy(serial_number,p_n);
}
void fill_name(char *p_nm)
{
strcpy(name,p_nm);
}
void fill_password(char *p_pwd)
{
strcpy(password,p_pwd);
}
virtual void addnew(){}
virtual void display()
{
cout<<"\n编号:"<<serial_number<<"\n";
cout<<"名字:"<<name<<"\n";
cout<<"口令:"<<password<<"\n"
}
};
//下面是派生的子类,为了简单些我在把子类进行了成员简化。
//思路:由父类派生出成员子类,正式成员要求更详细的个人资料,这里省略了大部份.
//并且正式成员可以有一些系统的操作权限,这里省略了大部份。
//正式成员子类
class Member:public Personnel
{
friend class point_list;
private:
char member_email[50];
char member_gender[10];
double member_age;
public:
Member(char *sn,char *nm,char *pwd,char *em,char *gd,double ag):Personnel(sn,nm,pwd)
{
strcpy(member_email,em);
strcpy(member_gender,gd);
member_age=age;
}
Member():Personnel()
{
member_email[0]=NULL;
member_gender=NULL;
member_age=0.0;
}
void fill_email(char *p_em)
{
strcpy(member_email,p_em);
}
void fill_gender(char *p_gd)
{
strcpy(member_gender,p_gd);
}
void fill_age(double ages)
{
member_age=ages;
}
void addnew()
{
pointer=this;
}
void display()
{
Personnel::display()
cout<<"电子邮件:"<<member_email<<"\n";
cout<<"性别:"<<member_gender<<"\n";
cout<<"年龄"<<member_age<<"\n";
}
};
//好了,我们还需要实现一个超级成员子类和一个项目经理的子类.
//这是超级成员类
class Supermember:public Member
{
friend class point_list;
private:
int sm_documentcount;//提交的文档数
int sm_codecount;//提交的代码段数
public:
Supermember(char *sn,char *nm,char *pwd,char *em,char *gd,double ag,int dc,int cc):Member(sn,nm,pwd,gd,ag)
{
sm_documnetcount=0;
sm_codecount=0;
}
Spupermember():Member()
{
sm_documentcount=0;
sm_codecount=0;
}
void fill_documentcount(int smdc)
{
sm_documentcount=smdc;
}
void fill_codecount(int smcc)
{
sm_codecount=smcc;
}
void addnew()
{
pointer=this;
}
void display()
{
Member::display()
cout<<"提交文章数:"<<sm_documentcount<<"\n";
cout<<"提交代码段数"<<sm_codecount<<"\n";
}
};
//实现友元类
class point_list
{
private:
Personnel *location;
public:
point_list()
{
location=0;
}
void print();
void insert(Personnel *node);
void delete(char *serial_number);
}
//显示链表
void point_list::print()
{
Personnel *ps=location;
while(ps!=0)
{
ps->display();
ps=ps->next_link;
}
}
//插入链表
void point_list::insert(Personnel *node)
{
Personnel *current_node=location;
Personnel *previous_node=0;
while(current_node!=0 && (strcmp(current_node->name,node->name<0)
{
previous_node=current_node;
current_node=current_node->next_link;
}
node->addnew()
node->pointer->next_link=current_node;
if(previous_node==0)
location=node->pointer;
else
previous_node->next_link=node->pointer;
}
//从链表中删除
void point_list::delete(char *serial_number)
{
Personnel *current_node=location;
Personnel *previous_node=0;
while(current_node!=0 && strcmp(current_node->serial_number,serial_number)!=0)
{
previous_node=current_node;
current_node=current_node->next_link;
}
if(current_node !=0 && previous_node==0)
{
location=current_node->next_link;
}
else if(current_node !=0 && previous_node!=0)
{
previous_node->next_link=current_node->next_link;
}
}
//这是主函数,我们显式的增加3个Supermember信息,然后在通过编号删除一个
//我们没有从成员再派生出管理成员,所以没有办法演示它,但我们可以看出要实现它并不难
//注意:此程序没有经过验证,也许会有BUG.
main()
{
point_list pl;
Supermember sm1("000000000000001","雷神","123456","lsmodel@ai361.com","男",29.9,10,10);
Supermember sm1("000000000000002","木一","234567","MY@ai361.com","男",26.5,20,5);
Supermember sm1("000000000000003","落叶夏日","345678","LYXR@ai361.com","男",24.8,5,15);
//如果我们还派生了管理人员,可能的方式如下:
//Managemember mm1("000000000000004","ADMIN","888888","webmaster@ai361.com","男",30,5,15,......);
//下面是将上面的3个人员信息加到链表中
pl.insert(&sm1);
pl.insert(&sm2);
pl.insert(&sm3);
//对应管理人员的 pl.insert(&mm1);
//下面是显示他们
//下面是显示人员列表
pl.print();
//下面是删除一个人员信息
pl.delete("000000000000001");
//我们再显示一次看看.
cout<<"\n删除后的列表:\n";
pl.print();
}
程序没有上机验证,在我的脑子里运行了一下,我想输出结果应该是这样的:
编号:000000000001
名称:雷神
口令:123456
电子邮件:lsmodel@ai361.com
性别:男
年龄:29.9
提交文章数:10
提交代码数:10
编号:000000000002
名称:木一
口令:234567
电子邮件:MY@21CN.com
性别:男
年龄:26.5
提交文章数:20
提交代码数:5
编号:000000000003
名称:落叶夏日
口令:345678
电子邮件:LYXR@163.com
性别:男
年龄:24.8
提交文章数:5
提交代码数:15
删除后的列表:
编号:000000000002
名称:木一
口令:234567
电子邮件:MY@21CN.com
性别:男
年龄:26.5
提交文章数:20
提交代码数:5
编号:000000000003
名称:落叶夏日
口令:345678
电子邮件:LYXR@163.com
性别:男
年龄:24.8
提交文章数:5
提交代码数:15
****************************************************************************************
通过上面的例子,我想我们能够理解对象模型的给我们带来的好处,我们用了大量的指针和引用,来完成多态的特性.和书上的资料库的例子不同,我们多了一层,那是因为我考虑人员可能是匿名,也可能是注册的,所以为了区别他们,用了两层来完成接口,然后所有注册的正式成员才都由Member类派生出不同的权限的人员,例如超级成员和管理人员.
最后用书上的一段话总结一下吧.P34
总而言之,多态是一种威力强大的设计机制,允许你继承一个抽象的public接口之后,封装相关的类型,需要付出的代价就是额外的间接性--不论是在内存的获得,或是在类的决断上,C++通过class的pointer和references来支持多态,这种程序风格就称为"面向对象".
深度探索C++对象模型(4)
读完了《深度探索C++对象模型》的第一章,虽然还是有些疑惑,但是已经感到收获很大。按照朋友的说法,第一章是一个概括的介绍,具体的细节会在以后的章节阐述,如果没有通读本书,第一章还是比较不容易理解的。
第二章主要讲的的构造函数语意(Semantics),这是一个什么意思?我的英文和中文学的都不好,但我想是书上弄错了(也许只是一个笔误),也许应该翻译成语义比较恰当。The study or science of meaning in anguage forms. 语义学以语言形式表示意思的研究或科学。我们要研究构造函数的,并且以语言的形式将它描述清楚。
看完题目我的第一个感觉,构造函数我知道。构造函数是一个类的成员函数,构造函数和析构函数是进行对象数据的创建,初始化,清除工作的成员函数,可以重载构造函数,使一个类不止具备一个构造函数,因有时需要以这些方法中的某一种分别创建不同的对象。不能重载析构函数。构造函数作为成员函数和类有相同的名字。例:一个类名为:aClass,构造函数就是aClass()。构造函数没有返回值,而且不能定义其返回类型,void也不行。析构函数同样使用这一点。当编写重载函数时,只有参数表不同,通过比较其参数个数或参数类型可以区分两个重载函数。但是我读完第一小段后就知道这一章要告诉我们什么了。
这一章并不是要告诉我们什么是构造函数,它的作用是什么。而是要告诉我们的是构造函数是如何工作的。我的。在得知这点后我很兴奋,因为我确实不知道构造函数是如何构造一个类的对象的,并且一直想知道。我一直对面向对象神奇的功能很感兴趣。为什么一个类在被实例化时,可以自动的完成很多工作,使我们的主函数清晰,简单,稳健,高效。以前只看到了表面,没有深入,这会我们有机会去皮剔肉深入骨髓了。
书上主要讨论了几种情况:
带有缺省构造函数的成员对象。如果一个类没有任何的构造函数,但他有一个成员对象,这个对象的类有一个缺省的构造函数,那么编译器会在需要的时候为这个类合成一个构造函数。
举个例子:
我们有以下几个类。它们都有一个构造函数。
猫{public:猫(),......};
狗{public:狗(),......};
鸟{public:鸟(),......};
鱼{public:鱼(),......};
我们又有一个类。宠物,我们将猫作为它的成员之一。并且没有给它声明构造函数。
宠物{
public:
猫 一只猫;
狗 一只狗;
鸟 一只鸟;
鱼 一只鱼;
private:
int ival;
......
}
则当需要的时候编译器会为它合成一个构造函数,并且采用内联方式。大概象下面的样子。
inline
宠物::宠物()
{
猫.猫::猫();
狗.狗::狗();
鸟.鸟::鸟();
鱼.鱼::鱼();
ival=0;
}
为什么会这样,我们来看看编译器的行动。编译器开始执行用户的代码,准备生成宠物对象之前,会首先调用必要的构造函数,来初始化类的成员,以便为对象分配合适的内存空间。结果编译器会合成上面的构造函数,如果程序员为宠物类写了一个构造函数。 宠物::宠物(){ival=0;}那编译器也会将这个构造函数扩张成上面的那样。编译器是怎样实现的呢?原来当一个类没有任何用户定义的构造函数,而是由编译器自动生成的话,则这个被暗中生成的构造函数将会是一个没有什么用处的构造函数。但是通过编译器的工作能够为我们合成一个nontrivial default constructor.
好象香港电影中演的,如果你惹上官司(你要设计一个类),你又没有钱去请高级的律师(没有给出构造函数),那会给你分配一个律师(缺省的构造函数),当然这个律师的能力也许和那些大律师比起来有差距(trivial)。不过我们要知道他们也不是一点用都没有。但是由于有律师行的督导,可以使这些律师能够努力做到最好(nontrivial)。
同样的道理,我们可以理解另外的几种nontrivial default constructor的情况。
如果你的类没有任何的构造函数,并且它派生于一个有着缺省构造函数的基类,那这个派生类的缺省构造函数会被视为nontrivial,因此需要被合成出来,他的合成步骤是调用上一层基类的缺省构造函数,并根据它们的声明次序为派生类合成一个构造函数。
如果类声明或继承了一个虚函数,或者类派生于一个继承串链,其中有一个或更多的虚拟基类。由于缺少使用者声明的构造函数,则编译器会合成一个缺省的构造函数,以便正确的初始化每一个类对象的vptr。
最后说一点,在合成的缺省构造函数中,只有基类的子对象和类的成员对象会被初始化,所有其他的非静态数据成员都不会被初始化,因为这些操作是需要程序员来做的。编译器没有必要连这些工作都做了。
好了,这篇就写到这里吧。这本书真的是雷神所看过的书中,看的最慢的一本了。但这些深层的知识有必要了解的很清楚吗,我们不知道编译器如何合成缺省的构造函数不也能写程序吗?雷神用侯大师的话来回答这个问题:练从难处练,用从易处用。知其然而不知其所以然,不是一个严谨的学习态度。
深度探索C++对象模型(5)
上一篇我们对合成确省的构造函数做了一个了解,这一篇我们继续看看构造函数这个有趣的东西.Copy Constructor是什么?我们经常看到代码中有一些这样的函数调用方式X(X&) (“X of X ref”). 这个函数用用户自定义类型作为参数,那它的参数的构造便是由Copy Constructor负责的. 可见这个玩意非常重要,实际上Copy Constructor是由编译器自动合成的,不需要你去作任何事情,但编译器都做了些什么呢?我们的问题出来了.
我们有三种情况需要用一个对象的内容作为另一个类对象的初值.也就是需要编译器来为我们自动合成Copy Constructor.一种是我们在编程中肯定回用到的由类生成对象例如以下形式:
class ClassA{......}
ClassA a;
ClassA b=a; //一个Class对象以另一个对象做初值
另外的一种情况是以对象为参数在函数中传递看下面的伪码:
//例如我们有一个CUser类
CUser{
CUser();
......
};
//我们还有一个CDatabase类,它有一个AddNew的方法
CDatabase{
......
public:
AddNew(CUser userone);
......}
//我们用CUser类产生了一个对象实例.userone,并将他作为AddNew函数的参数,以便
//AddNew函数能够完成在数据库中增加一条记录,用来记录一个用户的信息
CDatabase db=new CDatabase();
db.AddNew(CUser userone) //在这里,你不用将你的用户类的成员全部展开.
还有一种当然是用做函数的return,例如你可以在CDatabase类中添加一个函数用来读取一个用户的信息例如这样CUser GetUserOne(int userID),通过一个用户的唯一的编号可以获得一个用户的信息,并返回一个CUser类的对象.
我们来看看Copy Constructor是如何工作的.首先Copy Constructor和Default Constructor一样都是在需要的时候由编译器产生出来,一个类如果没有声明一个Copy Constructor就会存在一个隐含的声明(或定义).它也被分为trivial和nontrivial两种.
我们来看书上的例子:
Class Word
{
public:
Word(const char*);
~Word(){delete [] str;}
private:
int cnt;
Char *str;
}
这个类的声明不需要合成出Default Copy Constructor.但当进行如下应用时:
#include "Word.h"
Word noun("lsmodel");
void foo()
{
Word verb=noun;
}
结果将会出现灾难性的后果.为什么?因为我们的逻辑对象verb和全局对象noun都指向了相同的字符串,在退出函数foo()之前verb会执行析构,则字符串被删除,从此全局对象nonu指向了一堆无意义的东西.你可以声明一个explicit copy constructor来解决这个问题,当然还可以让编译器来自动的给你合成一个Copy construct.
我们将上面的Word类改写成下面的样子:
Class Word
{
public:
Word(const String&);//注意这里和我们开始的X(X&)形式一样
~Word();
//......
private:
int cnt;
String str; // 这个成员是String类的对象,String是我们自定义的类型
};
Class String
{
public:
String(const char*);
String(const String&);//这里声明了一个Copy constructir
~String();
//......
}
这时在执行我们的代码
#include "Word.h"
Word noun("lsmodel");
void foo()
{
Word verb=noun;
}
编译器会为我们的Word类合成一个Copy Constructor,用来调用它的str(member class String object)的Copy Constructor.象下面伪码表示的这样:
inline Word::Word(const Word &wd)
{
str.String::String(wd.str);
cnt=wd.cnt;
}
当这个类中有一个或多个虚函数时,或者这个类是派生于一个继承串链,并且这个串中有一个或多个虚拟的基类时.这个类在进行拷贝时便不会展现逐次拷贝(bitwise copy).并且会通过合成的Copy Constructor来重新明确的设定vptr来指向虚函数表,而不是将右边对象的vprt直接拷贝过来.书上的ZooAnimal例子的可以很清晰的描述出这点.
如果一个对象以另一个对象做初值,而后者有一个Virtual Base Class Subobject,那会怎样呢?任何一个编译器都会做到在派生类对象中的virtual base class Subobject的位置在执行期就准备妥当,但bitwise copy可能会破坏这一位置,因此也需要由编译器合成出一个copy constructor,来安插一些代码来设定virtual base class pointer/offset,对每一个成员执行必要的memberwise初始化操作,以及执行内存相关的工作.
我们这篇学习的内容是:当一个对象以另一个对象作为初始值时,会发生什么事情.分成了两种情况,一种是我们声明了explicit copy constructor,这个不是这篇文章需要搞明白的(我想大家也都很明白了).我们想知道的是我们没有为class声明explicit copy constructor函数时编译器都干了些什么.编译器会为我们合成一个copy constructor.以便适应任何时候的对象被正确的初始化.并且我们了解了有以下四种情况class不在按位逐一进行拷贝.
1.当你设计的类声明了一个explicit copy constructor函数时.
2.当你设计的类是由一个具有explicit copy constructor的基类派生的时.
3.当你设计的类声明了一个或多个虚函数时.
4.当你设计的类派生自一个继承串链,这个继承串链中有一个或多个virtual base classes时.
C++风格与技巧
(作者置顶)
目录:
我如何写这个非常简单的程序?
为什么编译要花这么长的时间?
为什么一个空类的大小不为0?
我必须在类声明处赋予数据吗?
为什么成员函数默认不是virtual的?
为什么析构函数默认不是virtual的?
为什么不能有虚拟构造函数?
为什么重载在继承类中不工作?
我能够在构造函数中调用一个虚拟函数吗?
有没有“指定位置删除”(placement delete)?
我能防止别人继承我自己的类吗?
为什么不能为模板参数定义约束(constraints)?
既然已经有了优秀的qsort()函数,为什么还需要一个sort()?
什么是函数对象(function object)?
我应该如何对付内存泄漏?
我为什么在捕获一个异常之后就不能继续?
为什么C++中没有相当于realloc()的函数?
如何使用异常?
怎样从输入中读取一个字符串?
为什么C++不提供“finally”的构造?
什么是自动指针(auto_ptr),为什么没有自动数组(auto_array)?
可以混合使用C风格与C++风格的内存分派与重新分配吗?
我为什么必须使用一个造型来转换*void?
我如何定义一个类内部(in-class)的常量?
为什么delete不会将操作数置0?
我能够写“void main()”吗?
为什么我不能重载点符号,::,sizeof,等等?
怎样将一个整型值转换为一个字符串?
“int* p”正确还是“int *p”正确?
对于我的代码,哪一种布局风格(layout style)是最好的?
我应该将“const”放在类型之前还是之后?
使用宏有什么问题?
我如何写这个非常简单的程序?
特别是在一个学期的开始,我常常收到许多关于编写一个非常简单的程序的询问。这个问题有一个很具代表性的解决方法,那就是(在你的程序中)读入几个数字,对它们做一些处理,再把结果输出。下面是一个这样做的例子:
#include<iostream>
#include<vector>
#include<algorithm>
using namespace std;
int main()
{
vector<double> v;
double d;
while(cin>>d) v.push_back(d); // 读入元素
if (!cin.eof()) { // 检查输入是否出错
cerr << "format error\n";
return 1; // 返回一个错误
}
cout << "read " << v.size() << " elements\n";
reverse(v.begin(),v.end());
cout << "elements in reverse order:\n";
for (int i = 0; i<v.size(); ++i) cout << v[i] << '\n';
return 0; // 成功返回
}
对这段程序的观察:
这是一段标准的ISO C++程序,使用了标准库(standard library)。标准库工具在命名空间std中声明,封装在没有.h后缀的头文件中。
如果你要在Windows下编译它,你需要将它编译成一个“控制台程序”(console application)。记得将源文件加上.cpp后缀,否则编译器可能会以为它是一段C代码而不是C++。
是的,main()函数返回一个int值。
读到一个标准的向量(vector)中,可以避免在随意确定大小的缓冲中溢出的错误。读到一个数组(array)中,而不产生“简单错误”(silly error),这已经超出了一个新手的能力——如果你做到了,那你已经不是一个新手了。如果你对此表示怀疑,我建议你阅读我的文章“将标准C++作为一种新的语言来学习”("Learning Standard C++ as a New Language"),你可以在本人著作列表(my publications list)中下载到它。
!cin.eof()是对流的格式的检查。事实上,它检查循环是否终结于发现一个end-of-file(如果不是这样,那么意味着输入没有按照给定的格式)。更多的说明,请参见你的C++教科书中的“流状态”(stream state)部分。
vector知道它自己的大小,因此我不需要计算元素的数量。
这段程序没有包含显式的内存管理。Vector维护一个内存中的栈,以存放它的元素。当一个vector需要更多的内存时,它会分配一些;当它不再生存时,它会释放内存。于是,使用者不需要再关心vector中元素的内存分配和释放问题。
程序在遇到输入一个“end-of-file”时结束。如果你在UNIX平台下运行它,“end-of-file”等于键盘上的Ctrl+D。如果你在Windows平台下,那么由于一个BUG它无法辨别“end-of-file”字符,你可能倾向于使用下面这个稍稍复杂些的版本,它使用一个词“end”来表示输入已经结束。
#include<iostream>
#include<vector>
#include<algorithm>
#include<string>
using namespace std;
int main()
{
vector<double> v;
double d;
while(cin>>d) v.push_back(d); // 读入一个元素
if (!cin.eof()) { // 检查输入是否失败
cin.clear(); // 清除错误状态
string s;
cin >> s; // 查找结束字符
if (s != "end") {
cerr << "format error\n";
return 1; // 返回错误
}
}
cout << "read " << v.size() << " elements\n";
reverse(v.begin(),v.end());
cout << "elements in reverse order:\n";
for (int i = 0; i<v.size(); ++i) cout << v[i] << '\n';
return 0; // 成功返回
}
更多的关于使用标准库将事情简化的例子,请参见《C++程序设计语言》中的“漫游标准库”("Tour of the Standard Library")一章。
为什么编译要花这么长的时间?
你的编译器可能有问题。也许它太老了,也许你安装它的时候出了错,也许你用的计算机已经是个古董。在诸如此类的问题上,我无法帮助你。
但是,这也是很可能的:你要编译的程序设计得非常糟糕,以至于编译器不得不检查数以百计的头文件和数万行代码。理论上来说,这是可以避免的。如果这是你购买的库的设计问题,你对它无计可施(除了换一个更好的库),但你可以将你自己的代码组织得更好一些,以求得将修改代码后的重新编译工作降到最少。这样的设计会更好,更有可维护性,因为它们展示了更好的概念上的分离。
看看这个典型的面向对象的程序例子:
class Shape {
public: // 使用Shapes的用户的接口
virtual void draw() const;
virtual void rotate(int degrees);
// ...
protected: // common data (for implementers of Shapes)
Point center;
Color col;
// ...
};
clas Circle : public Shape {
public:
void draw() const;
void rotate(int) { }
// ...
protected:
int radius;
// ...
};
class Triangle : public Shape {
public:
void draw() const;
void rotate(int);
// ...
protected:
Point a, b, c;
// ...
};
设计思想是,用户通过Shape的public接口来操纵它们,而派生类(例如Circle和Triangle)的实现部分则共享由protected成员表现的那部分实现(implementation)。
这不是一件容易的事情:确定哪些实现部分是对所有的派生类都有用的,并将之共享出来。因此,与public接口相比,protected成员往往要做多得多的改动。举例来说,虽然理论上“中心”(center)对所有的图形都是一个有效的概念,但当你要维护一个三角形的“中心”的时候,是一件非常麻烦的事情——对于三角形,当且仅当它确实被需要的时候,计算这个中心才是有意义的。
protected成员很可能要依赖于实现部分的细节,而Shape的用户(译注:user此处译为用户,指使用Shape类的代码,下同)却不见得必须依赖它们。举例来说,很多(大多数?)使用Shape的代码在逻辑上是与“颜色”无关的,但是由于Shape中“颜色”这个定义的存在,却可能需要一堆复杂的头文件,来结合操作系统的颜色概念。
当protected部分发生了改变时,使用Shape的代码必须重新编译——即使只有派生类的实现部分才能够访问protected成员。
于是,基类中的“实现相关的信息”(information helpful to implementers)对用户来说变成了象接口一样敏感的东西,它的存在导致了实现部分的不稳定,用户代码的无谓的重编译(当实现部分发生改变时),以及将头文件无节制地包含进用户代码中(因为“实现相关的信息”需要它们)。有时这被称为“脆弱的基类问题”(brittle base class problem)。
一个很明显的解决方案就是,忽略基类中那些象接口一样被使用的“实现相关的信息”。换句话说,使用接口,纯粹的接口。也就是说,用抽象基类的方式来表示接口:
class Shape {
public: //使用Shapes的用户的接口
virtual void draw() const = 0;
virtual void rotate(int degrees) = 0;
virtual Point center() const = 0;
// ...
// 没有数据
};
class Circle : public Shape {
public:
void draw() const;
void rotate(int) { }
Point center() const { return center; }
// ...
protected:
Point cent;
Color col;
int radius;
// ...
};
class Triangle : public Shape {
public:
void draw() const;
void rotate(int);
Point center() const;
// ...
protected:
Color col;
Point a, b, c;
// ...
};
现在,用户代码与派生类的实现部分的变化之间的关系被隔离了。我曾经见过这种技术使得编译的时间减少了几个数量级。
但是,如果确实存在着对所有派生类(或仅仅对某些派生类)都有用的公共信息时怎么办呢?可以简单把这些信息封装成类,然后从它派生出实现部分的类:
class Shape {
public: //使用Shapes的用户的接口
virtual void draw() const = 0;
virtual void rotate(int degrees) = 0;
virtual Point center() const = 0;
// ...
// no data
};
struct Common {
Color col;
// ...
};
class Circle : public Shape, protected Common {
public:
void draw() const;
void rotate(int) { }
Point center() const { return center; }
// ...
protected:
Point cent;
int radius;
};
class Triangle : public Shape, protected Common {
public:
void draw() const;
void rotate(int);
Point center() const;
// ...
protected:
Point a, b, c;
};
为什么一个空类的大小不为0?
要清楚,两个不同的对象的地址也是不同的。基于同样的理由,new总是返回指向不同对象的指针。
看看:
class Empty { };
void f()
{
Empty a, b;
if (&a == &b) cout << "impossible: report error to compiler supplier";
Empty* p1 = new Empty;
Empty* p2 = new Empty;
if (p1 == p2) cout << "impossible: report error to compiler supplier";
}
有一条有趣的规则:一个空的基类并不一定有分隔字节。
struct X : Empty {
int a;
// ...
};
void f(X* p)
{
void* p1 = p;
void* p2 = &p->a;
if (p1 == p2) cout << "nice: good optimizer";
}
这种优化是允许的,可以被广泛使用。它允许程序员使用空类以表现一些简单的概念。现在有些编译器提供这种“空基类优化”(empty base class optimization)。
我必须在类声明处赋予数据吗?
不必须。如果一个接口不需要数据时,无须在作为接口定义的类中赋予数据。代之以在派生类中给出它们。参见“为什么编译要花这么长的时间?”。
有时候,你必须在一个类中赋予数据。考虑一下复数类的情况:
template<class Scalar> class complex {
public:
complex() : re(0), im(0) { }
complex(Scalar r) : re(r), im(0) { }
complex(Scalar r, Scalar i) : re(r), im(i) { }
// ...
complex& operator+=(const complex& a)
{ re+=a.re; im+=a.im; return *this; }
// ...
private:
Scalar re, im;
};
设计这种类型的目的是将它当做一个内建(built-in)类型一样被使用。在声明处赋值是必须的,以保证如下可能:建立真正的本地对象(genuinely local objects)(比如那些在栈中而不是在堆中分配的对象),或者使某些简单操作被适当地inline化。对于那些支持内建的复合类型的语言来说,要获得它们提供的效率,真正的本地对象和inline化都是必要的。
为什么成员函数默认不是virtual的?
因为很多类并不是被设计作为基类的。例如复数类。
而且,一个包含虚拟函数的类的对象,要占用更多的空间以实现虚拟函数调用机制——往往是每个对象占用一个字(word)。这个额外的字是非常可观的,而且在涉及和其它语言的数据的兼容性时,可能导致麻烦(例如C或Fortran语言)。
要了解更多的设计原理,请参见《C++语言的设计和演变》(The Design and Evolution of C++)。
为什么析构函数默认不是virtual的?
因为很多类并不是被设计作为基类的。只有类在行为上是它的派生类的接口时(这些派生类往往在堆中分配,通过指针或引用来访问),虚拟函数才有意义。
那么什么时候才应该将析构函数定义为虚拟呢?当类至少拥有一个虚拟函数时。拥有虚拟函数意味着一个类是派生类的接口,在这种情况下,一个派生类的对象可能通过一个基类指针来销毁。例如:
class Base {
// ...
virtual ~Base();
};
class Derived : public Base {
// ...
~Derived();
};
void f()
{
Base* p = new Derived;
delete p; // 虚拟析构函数保证~Derived函数被调用
}
如果基类的析构函数不是虚拟的,那么派生类的析构函数将不会被调用——这可能产生糟糕的结果,例如派生类的资源不会被释放。
为什么不能有虚拟构造函数?
虚拟调用是一种能够在给定信息不完全(given partial information)的情况下工作的机制。特别地,虚拟允许我们调用某个函数,对于这个函数,仅仅知道它的接口,而不知道具体的对象类型。但是要建立一个对象,你必须拥有完全的信息。特别地,你需要知道要建立的对象的具体类型。因此,对构造函数的调用不可能是虚拟的。
当要求建立一个对象时,一种间接的技术常常被当作“虚拟构造函数”来使用。有关例子,请参见《C++程序设计语言》第三版15.6.2.节。
下面这个例子展示一种机制:如何使用一个抽象类来建立一个适当类型的对象。
struct F { // 对象建立函数的接口
virtual A* make_an_A() const = 0;
virtual B* make_a_B() const = 0;
};
void user(const F& fac)
{
A* p = fac.make_an_A(); // 将A作为合适的类型
B* q = fac.make_a_B(); // 将B作为合适的类型
// ...
}
struct FX : F {
A* make_an_A() const { return new AX(); } // AX是A的派生
B* make_a_B() const { return new BX(); } // AX是B的派生
};
struct FY : F {
A* make_an_A() const { return new AY(); } // AY是A的派生
B* make_a_B() const { return new BY(); } // BY是B的派生
};
int main()
{
user(FX()); // 此用户建立AX与BX
user(FY()); // 此用户建立AY与BY
// ...
}
这是所谓的“工厂模式”(the factory pattern)的一个变形。关键在于,user函数与AX或AY这样的类的信息被完全分离开来了。
为什么重载在继承类中不工作?
这个问题(非常常见)往往出现于这样的例子中:
#include<iostream>
using namespace std;
class B {
public:
int f(int i) { cout << "f(int): "; return i+1; }
// ...
};
class D : public B {
public:
double f(double d) { cout << "f(double): "; return d+1.3; }
// ...
};
int main()
{D* pd = new D;
cout << pd->f(2) << '\n';
cout << pd->f(2.3) << '\n';
}
它输出的结果是:
f(double): 3.3
f(double): 3.6
而不是象有些人猜想的那样:
f(int): 3
f(double): 3.6
换句话说,在B和D之间并没有发生重载的解析。编译器在D的区域内寻找,找到了一个函数double f(double),并执行了它。它永远不会涉及(被封装的)B的区域。在C++中,没有跨越区域的重载——对于这条规则,继承类也不例外。更多的细节,参见《C++语言的设计和演变》和《C++程序设计语言》。
但是,如果我需要在基类和继承类之间建立一组重载的f()函数呢?很简单,使用using声明:
class D : public B {
public:
using B::f; // make every f from B available
double f(double d) { cout << "f(double): "; return d+1.3; }
// ...
};
进行这个修改之后,输出结果将是:
f(int): 3
f(double): 3.6
这样,在B的f()和D的f()之间,重载确实实现了,并且选择了一个最合适的f()进行调用。
我能够在构造函数中调用一个虚拟函数吗?
可以,但是要小心。它可能不象你期望的那样工作。在构造函数中,虚拟调用机制不起作用,因为继承类的重载还没有发生。对象先从基类被创建,“基类先于继承类(base before derived)”。
看看这个:
#include<string>
#include<iostream>
using namespace std;
class B {
public:
B(const string& ss) { cout << "B constructor\n"; f(ss); }
virtual void f(const string&) { cout << "B::f\n";}
};
class D : public B {
public:
D(const string & ss) :B(ss) { cout << "D constructor\n";}
void f(const string& ss) { cout << "D::f\n"; s = ss; }
private:
string s;
};
int main()
{
D d("Hello");
}
程序编译以后会输出:
B constructor
B::f
D constructor
注意不是D::f。设想一下,如果出于不同的规则,B::B()可以调用D::f()的话,会产生什么样的后果:因为构造函数D::D()还没有运行,D::f()将会试图将一个还没有初始化的字符串s赋予它的参数。结果很可能是导致立即崩溃。
析构函数在“继承类先于基类”的机制下运行,因此虚拟机制的行为和构造函数一样:只有本地定义(local definitions)被使用——不会调用虚拟函数,以免触及对象中的(现在已经被销毁的)继承类的部分。
更多的细节,参见《C++语言的设计和演变》13.2.4.2和《C++程序设计语言》15.4.3。
有人暗示,这只是一条实现时的人为制造的规则。不是这样的。事实上,要实现这种不安全的方法倒是非常容易的:在构造函数中直接调用虚拟函数,就象调用其它函数一样。但是,这样就意味着,任何虚拟函数都无法编写了,因为它们需要依靠基类的固定的创建(invariants established by base classes)。这将会导致一片混乱。
有没有“指定位置删除”(placement delete)?
没有,不过如果你需要的话,可以自己写一个。
看看这个指定位置创建(placement new),它将对象放进了一系列Arena中;
class Arena {
public:
void* allocate(size_t);
void deallocate(void*);
// ...
};
void* operator new(size_t sz, Arena& a)
{
return a.allocate(sz);
}
Arena a1(some arguments);
Arena a2(some arguments);
这样实现了之后,我们就可以这么写:
X* p1 = new(a1) X;
Y* p2 = new(a1) Y;
Z* p3 = new(a2) Z;
// ...
但是,以后怎样正确地销毁这些对象呢?没有对应于这种“placement new”的内建的“placement delete”,原因是,没有一种通用的方法可以保证它被正确地使用。在C++的类型系统中,没有什么东西可以让我们确认,p1一定指向一个由Arena类型的a1分派的对象。p1可能指向任何东西分派的任何一块地方。
然而,有时候程序员是知道的,所以这是一种方法:
template<class T> void destroy(T* p, Arena& a)
{
if (p) {
p->~T(); // explicit destructor call
a.deallocate(p);
}
}
现在我们可以这么写:
destroy(p1,a1);
destroy(p2,a2);
destroy(p3,a3);
如果Arena维护了它保存着的对象的线索,你甚至可以自己写一个析构函数,以避免它发生错误。
这也是可能的:定义一对相互匹配的操作符new()和delete(),以维护《C++程序设计语言》15.6中的类继承体系。参见《C++语言的设计和演变》10.4和《C++程序设计语言》19.4.5。
我能防止别人继承我自己的类吗?
可以,但你为什么要那么做呢?这是两个常见的回答:
效率:避免我的函数被虚拟调用
安全:保证我的类不被用作一个基类(例如,保证我能够复制对象而不用担心出事)
根据我的经验,效率原因往往是不必要的担心。在C++中,虚拟函数调用是如此之快,以致于它们在一个包含虚拟函数的类中被实际使用时,相比普通的函数调用,根本不会产生值得考虑的运行期开支。注意,仅仅通过指针或引用时,才会使用虚拟调用机制。当直接通过对象名字调用一个函数时,虚拟函数调用的开支可以被很容易地优化掉。
如果确实有真正的需要,要将一个类封闭起来以防止虚拟调用,那么可能首先应该问问为什么它们是虚拟的。我看见过一些例子,那些性能表现不佳的函数被设置为虚拟,没有其他原因,仅仅是因为“我们习惯这么干”。
这个问题的另一个部分,由于逻辑上的原因如何防止类被继承,有一个解决方案。不幸的是,这个方案并不完美。它建立在这样一个事实的基础之上,那就是:大多数的继承类必须建立一个虚拟的基类。这是一个例子:
class Usable;
class Usable_lock {
friend class Usable;
private:
Usable_lock() {}
Usable_lock(const Usable_lock&) {}
};
class Usable : public virtual Usable_lock {
// ...
public:
Usable();
Usable(char*);
// ...
};
Usable a;
class DD : public Usable { };
DD dd; // 错误: DD::DD() 不能访问
// Usable_lock::Usable_lock()是一个私有成员
(来自《C++语言的设计和演变》11.4.3)
为什么不能为模板参数定义约束(constraints)?
可以的,而且方法非常简单和通用。
看看这个:
template<class Container>
void draw_all(Container& c)
{
for_each(c.begin(),c.end(),mem_fun(&Shape::draw));
}
如果出现类型错误,可能是发生在相当复杂的for_each()调用时。例如,如果容器的元素类型是int,我们将得到一个和for_each()相关的含义模糊的错误(因为不能够对对一个int值调用Shape::draw的方法)。
为了提前捕捉这个错误,我这样写:
template<class Container>
void draw_all(Container& c)
{
Shape* p = c.front(); // accept only containers of Shape*s
for_each(c.begin(),c.end(),mem_fun(&Shape::draw));
}
对于现在的大多数编译器,中间变量p的初始化将会触发一个易于了解的错误。这个窍门在很多语言中都是通用的,而且在所有的标准创建中都必须这样做。在成品的代码中,我也许可以这样写:
template<class Container>
void draw_all(Container& c)
{
typedef typename Container::value_type T;
Can_copy<T,Shape*>(); // accept containers of only Shape*s
for_each(c.begin(),c.end(),mem_fun(&Shape::draw));
}
这样就很清楚了,我在建立一个断言(assertion)。Can_copy模板可以这样定义:
template<class T1, class T2> struct Can_copy {
static void constraints(T1 a, T2 b) { T2 c = a; b = a; }
Can_copy() { void(*p)(T1,T2) = constraints; }
};
Can_copy(在运行时)检查T1是否可以被赋值给T2。Can_copy<T,Shape*>检查T是否是Shape*类型,或者是一个指向由Shape类公共继承而来的类的对象的指针,或者是被用户转换到Shape*类型的某个类型。注意这个定义被精简到了最小:
一行命名要检查的约束,和要检查的类型
一行列出指定的要检查的约束(constraints()函数)
一行提供触发检查的方法(通过构造函数)
注意这个定义有相当合理的性质:
你可以表达一个约束,而不用声明或复制变量,因此约束的编写者可以用不着去设想变量如何被初始化,对象是否能够被复制,被销毁,以及诸如此类的事情。(当然,约束要检查这些属性的情况时例外。)
使用现在的编译器,不需要为约束产生代码
定义和使用约束,不需要使用宏
当约束失败时,编译器会给出可接受的错误信息,包括“constraints”这个词(给用户一个线索),约束的名字,以及导致约束失败的详细错误(例如“无法用double*初始化Shape*”)。
那么,在C++语言中,有没有类似于Can_copy——或者更好——的东西呢?在《C++语言的设计和演变》中,对于在C++中实现这种通用约束的困难进行了分析。从那以来,出现了很多方法,来让约束类变得更加容易编写,同时仍然能触发良好的错误信息。例如,我信任我在Can_copy中使用的函数指针的方式,它源自Alex Stepanov和Jeremy Siek。我并不认为Can_copy()已经可以标准化了——它需要更多的使用。同样,在C++社区中,各种不同的约束方式被使用;到底是哪种约束模板在广泛的使用中被证明是最有效的,还没有达成一致的意见。
但是,这种方式非常普遍,比语言提供的专门用于约束检查的机制更加普遍。无论如何,当我们编写一个模板时,我们拥有了C++提供的最丰富的表达力量。看看这个:
template<class T, class B> struct Derived_from {
static void constraints(T* p) { B* pb = p; }
Derived_from() { void(*p)(T*) = constraints; }
};
template<class T1, class T2> struct Can_copy {
static void constraints(T1 a, T2 b) { T2 c = a; b = a; }
Can_copy() { void(*p)(T1,T2) = constraints; }
};
template<class T1, class T2 = T1> struct Can_compare {
static void constraints(T1 a, T2 b) { a==b; a!=b; a<b; }
Can_compare() { void(*p)(T1,T2) = constraints; }
};
template<class T1, class T2, class T3 = T1> struct Can_multiply {
static void constraints(T1 a, T2 b, T3 c) { c = a*b; }
Can_multiply() { void(*p)(T1,T2,T3) = constraints; }
};
struct B { };
struct D : B { };
struct DD : D { };
struct X { };
int main()
{
Derived_from<D,B>();
Derived_from<DD,B>();
Derived_from<X,B>();
Derived_from<int,B>();
Derived_from<X,int>();
Can_compare<int,float>();
Can_compare<X,B>();
Can_multiply<int,float>();
Can_multiply<int,float,double>();
Can_multiply<B,X>();
Can_copy<D*,B*>();
Can_copy<D,B*>();
Can_copy<int,B*>();
}
// 典型的“元素必须继承自Mybase*”约束:
template<class T> class Container : Derived_from<T,Mybase> {
// ...
};
事实上,Derived_from并不检查来源(derivation),而仅仅检查转换(conversion),不过这往往是一个更好的约束。为约束想一个好名字是很难的。
既然已经有了优秀的qsort()函数,为什么还需要一个sort()?
对于初学者来说,
qsort(array,asize,sizeof(elem),elem_compare);
看上去太古怪了,而且比这个更难理解:
sort(vec.begin(),vec.end());
对于专家来说,在元素与比较方式(comparison criteria)都相同的情况下,sort()比qsort()更快,这是很重要的。而且,qsort()是通用的,所以它可以用于不同容器类型、元素类型、比较方式的任意有意义的组合。举例来说:
struct Record {
string name;
// ...
};
struct name_compare { // 使用"name"作为键比较Record
bool operator()(const Record& a, const Record& b) const
{ return a.name<b.name; }
};
void f(vector<Record>& vs)
{
sort(vs.begin(), vs.end(), name_compare());
// ...
}
而且,很多人欣赏sort()是因为它是类型安全的,使用它不需要进行造型(cast),没有人必须去为基本类型写一个compare()函数。
更多的细节,参见我的文章《将标准C++作为一种新的语言来学习》(Learning C++ as a New language),可以从我的文章列表中找到。
sort()胜过qsort()的主要原因是,比较操作在内联(inlines)上做得更好。
什么是函数对象(function object)?
顾名思义,就是在某种方式上表现得象一个函数的对象。典型地,它是指一个类的实例,这个类定义了应用操作符operator()。
函数对象是比函数更加通用的概念,因为函数对象可以定义跨越多次调用的可持久的部分(类似静态局部变量),同时又能够从对象的外面进行初始化和检查(和静态局部变量不同)。例如:
class Sum {
int val;
public:
Sum(int i) :val(i) { }
operator int() const { return val; } // 取得值
int operator()(int i) { return val+=i; } // 应用
};
void f(vector v)
{
Sum s = 0; // initial value 0
s = for_each(v.begin(), v.end(), s); // 求所有元素的和
cout << "the sum is " << s << "\n";
//或者甚至:
cout << "the sum is " << for_each(v.begin(), v.end(), Sum(0)) << "\n";
}
注意一个拥有应用操作符的函数对象可以被完美地内联化(inline),因为它没有涉及到任何指针,后者可能导致拒绝优化。与之形成对比的是,现有的优化器几乎不能(或者完全不能?)将一个通过函数指针的调用内联化。
在标准库中,函数对象被广泛地使用以获得弹性。
我应该如何对付内存泄漏?
写出那些不会导致任何内存泄漏的代码。很明显,当你的代码中到处充满了new 操作、delete操作和指针运算的话,你将会在某个地方搞晕了头,导致内存泄漏,指针引用错误,以及诸如此类的问题。这和你如何小心地对待内存分配工作其实完全没有关系:代码的复杂性最终总是会超过你能够付出的时间和努力。于是随后产生了一些成功的技巧,它们依赖于将内存分配(allocations)与重新分配(deallocation)工作隐藏在易于管理的类型之后。标准容器(standard containers)是一个优秀的例子。它们不是通过你而是自己为元素管理内存,从而避免了产生糟糕的结果。想象一下,没有string和vector的帮助,写出这个:
#include<vector>
#include<string>
#include<iostream>
#include<algorithm>
using namespace std;
int main() // small program messing around with strings
{
cout << "enter some whitespace-separated words:\n";
vector<string> v;
string s;
while (cin>>s) v.push_back(s);
sort(v.begin(),v.end());
string cat;
typedef vector<string>::const_iterator Iter;
for (Iter p = v.begin(); p!=v.end(); ++p) cat += *p+"+";
cout << cat << '\n';
}
你有多少机会在第一次就得到正确的结果?你又怎么知道你没有导致内存泄漏呢?
注意,没有出现显式的内存管理,宏,造型,溢出检查,显式的长度限制,以及指针。通过使用函数对象和标准算法(standard algorithm),我可以避免使用指针——例如使用迭代子(iterator),不过对于一个这么小的程序来说有点小题大作了。
这些技巧并不完美,要系统化地使用它们也并不总是那么容易。但是,应用它们产生了惊人的差异,而且通过减少显式的内存分配与重新分配的次数,你甚至可以使余下的例子更加容易被跟踪。早在1981年,我就指出,通过将我必须显式地跟踪的对象的数量从几万个减少到几打,为了使程序正确运行而付出的努力从可怕的苦工,变成了应付一些可管理的对象,甚至更加简单了。
如果你的程序还没有包含将显式内存管理减少到最小限度的库,那么要让你程序完成和正确运行的话,最快的途径也许就是先建立一个这样的库。
模板和标准库实现了容器、资源句柄以及诸如此类的东西,更早的使用甚至在多年以前。异常的使用使之更加完善。
如果你实在不能将内存分配/重新分配的操作隐藏到你需要的对象中时,你可以使用资源句柄(resource handle),以将内存泄漏的可能性降至最低。这里有个例子:我需要通过一个函数,在空闲内存中建立一个对象并返回它。这时候可能忘记释放这个对象。毕竟,我们不能说,仅仅关注当这个指针要被释放的时候,谁将负责去做。使用资源句柄,这里用了标准库中的auto_ptr,使需要为之负责的地方变得明确了。
#include<memory>
#include<iostream>
using namespace std;
struct S {
S() { cout << "make an S\n"; }
~S() { cout << "destroy an S\n"; }
S(const S&) { cout << "copy initialize an S\n"; }
S& operator=(const S&) { cout << "copy assign an S\n"; }
};
S* f()
{
return new S; // 谁该负责释放这个S?
};
auto_ptr<S> g()
{
return auto_ptr<S>(new S); // 显式传递负责释放这个S
}
int main()
{
cout << "start main\n";
S* p = f();
cout << "after f() before g()\n";
// S* q = g(); // 将被编译器捕捉
auto_ptr<S> q = g();
cout << "exit main\n";
// *p产生了内存泄漏
// *q被自动释放
}
在更一般的意义上考虑资源,而不仅仅是内存。
如果在你的环境中不能系统地应用这些技巧(例如,你必须使用别的地方的代码,或者你的程序的另一部分简直是原始人类(译注:原文是Neanderthals,尼安德特人,旧石器时代广泛分布在欧洲的猿人)写的,如此等等),那么注意使用一个内存泄漏检测器作为开发过程的一部分,或者插入一个垃圾收集器(garbage collector)。
我为什么在捕获一个异常之后就不能继续?
换句话说,C++为什么不提供一种简单的方式,让程序能够回到异常抛出点之后,并继续执行?
主要的原因是,如果从异常处理之后继续,那么无法预知掷出点之后的代码如何对待异常处理,是否仅仅继续执行,就象什么也没有发生一样。异常处理者无法知道,在继续之前,有关的上下文环境(context)是否是“正确”的。要让这样的代码正确执行,抛出异常的编写者与捕获异的编写者必须对彼此的代码与上下文环境都非常熟悉才行。这样会产生非常复杂的依赖性,因此无论在什么情况下,都会导致一系列严重的维护问题。
当我设计C++的异常处理机制时,我曾经认真地考虑过允许这种继续的可能性,而且在标准化的过程中,这个问题被非常详细地讨论过。请参见《C++语言的设计和演变》中的异常处理章节。
在一次新闻组的讨论中,我曾经以一种稍微不同的方式回答过这个问题。
为什么C++中没有相当于realloc()的函数?
如果你需要,你当然可以使用realloc()。但是,realloc()仅仅保证能工作于这样的数组之上:它们被malloc()(或者类似的函数)分配,包含一些没有用户定义的复制构造函数(copy constructors)的对象。而且,要记住,与通常的期望相反,realloc()有时也必须复制它的参数数组。
在C++中,处理内存重新分配的更好的方法是,使用标准库中的容器,例如vector,并让它自我增长。
如何使用异常?
参见《C++程序设计语言》第4章,第8.3节,以及附录E。这个附录针对的是如何在要求苛刻的程序中写出异常安全的代码的技巧,而不是针对初学者的。一个关键的技术是“资源获得即初始化”(resource acquisiton is initialization),它使用一些有析构函数的类,来实现强制的资源管理。
怎样从输入中读取一个字符串?
你可以用这种方式读取一个单独的以空格结束的词:
#include<iostream>
#include<string>
using namespace std;
int main()
{
cout << "Please enter a word:\n";
string s;
cin>>s;
cout << "You entered " << s << '\n';
}
注意,这里没有显式的内存管理,也没有可能导致溢出的固定大小的缓冲区。
如果你确实想得到一行而不是一个单独的词,可以这样做:
#include<iostream>
#include<string>
using namespace std;
int main()
{
cout << "Please enter a line:\n";
string s;
getline(cin,s);
cout << "You entered " << s << '\n';
}
在《C++程序设计语言》(可在线获得)的第3章,可以找到一个对诸如字符串与流这样的标准库工具的简介。对于使用C与C++进行简单输入输出的详细比较,参见我的文章《将标准C++作为一种新的语言来学习》(Learning Standard C++ as a New Language),你可以在本人著作列表(my publications list)中下载到它。
为什么C++不提供“finally”的构造?
因为C++提供了另外一种方法,它几乎总是更好的:“资源获得即初始化”(resource acquisiton is initialization)技术。基本的思路是,通过一个局部对象来表现资源,于是局部对象的析构函数将会释放资源。这样,程序员就不会忘记释放资源了。举例来说:
class File_handle {
FILE* p;
public:
File_handle(const char* n, const char* a)
{ p = fopen(n,a); if (p==0) throw Open_error(errno); }
File_handle(FILE* pp)
{ p = pp; if (p==0) throw Open_error(errno); }
~File_handle() { fclose(p); }
operator FILE*() { return p; }
// ...
};
void f(const char* fn)
{
File_handle f(fn,"rw"); //打开fn进行读写
// 通过f使用文件
}
在一个系统中,需要为每一个资源都使用一个“资源句柄”类。无论如何,我们不需要为每一个资源获得都写出“finally”语句。在实时系统中,资源获得要远远多于资源的种类,因此和使用“finally”构造相比,“资源获得即初始化”技术会产生少得多的代码。
什么是自动指针(auto_ptr),为什么没有自动数组(auto_array)?
auto_ptr是一个非常简单的句柄类的例子,在<memory>中定义,通过“资源获得即初始化”技术支持异常安全。auto_ptr保存着一个指针,能够象指针一样被使用,并在生存期结束时释放指向的对象。举例:
#include<memory>
using namespace std;
struct X {
int m;
// ..
};
void f()
{
auto_ptr<X> p(new X);
X* q = new X;
p->m++; // 象一个指针一样使用p
q->m++;
// ...
delete q;
}
如果在...部分抛出了一个异常,p持有的对象将被auto_ptr的析构函数正确地释放,而q指向的X对象则产生了内存泄漏。更多的细节,参见《C++程序设计语言》14.4.2节。
auto_ptr是一个非常简单的类。特别地,它不是一个引用计数(reference counted)的指针。如果你将一个auto_ptr赋值给另一个,那么被赋值的auto_ptr将持有指针,而原来的auto_ptr将持有0。举例:
#include<memory>
#include<iostream>
using namespace std;
struct X {
int m;
// ..
};
int main()
{
auto_ptr<X> p(new X);
auto_ptr<X> q(p);
cout << "p " << p.get() << " q " << q.get() << "\n";
}
将会打印出一个指向0的指针和一个指向非0的指针。例如:
p 0x0 q 0x378d0
auto_ptr::get()返回那个辅助的指针。
这种“转移”语义不同于通常的“复制”语义,这是令人惊讶的。特别地,永远不要使用auto_ptr作为一个标准容器的成员。标准容器需要通常的“复制”语义。例如:
std::vector<auto_ptr<X> >v; // 错误
auto_ptr只持有指向一个单独元素的指针,而不是指向一个数组的指针:
void f(int n)
{
auto_ptr<X> p(new X[n]); //错误
// ...
}
这是错误的,因为析构函数会调用delete而不是delete[]来释放指针,这样就不会调用余下的n-1个X的析构函数。
那么我们需要一个auto_array来持有数组吗?不。没有auto_array。原因是根本没有这种需要。更好的解决方案是使用vector:
void f(int n)
{
vector<X> v(n);
// ...
}
当...部分发生异常时,v的析构函数会被正确地调用。
可以混合使用C风格与C++风格的内存分派与重新分配吗?
在这种意义上是可以的:你可以在同一个程序中使用malloc()和new。
在这种意义上是不行的:你不能使用malloc()来建立一个对象,又通过delete来释放它。你也不能用new建立一个新的对象,然后通过free()来释放它,或者通过realloc()在数组中再建立一个新的。
C++中的new和delete操作可以保证正确的构造和析构:构造函数和析构函数在需要它们的时候被调用。C风格的函数alloc(), calloc(), free(), 和realloc()却不能保证这一点。此外,用new和delete来获得和释放的原始内存,并不一定能保证与malloc()和free()兼容。如果这种混合的风格在你的系统中能够运用,只能说是你走运——暂时的。
如果你觉得需要使用realloc()——或者要做更多——考虑使用标准库中的vector。例如:
// 从输入中将词读取到一个字符串vector中
vector<string> words;
string s;
while (cin>>s && s!=".") words.push_back(s);
vector会视需要自动增长。
更多的例子与讨论,参见我的文章《将标准C++作为一种新的语言来学习》(Learning Standard C++ as a New Language),你可以在本人著作列表(my publications list)中下载到它。
我为什么必须使用一个造型来转换*void?
在C语言中,你可以隐式地将*void转换为*T。这是不安全的。考虑一下:
#include<stdio.h>
int main()
{
char i = 0;
char j = 0;
char* p = &i;
void* q = p;
int* pp = q; /* 不安全的,在C中可以,C++不行 */
printf("%d %d\n",i,j);
*pp = -1; /* 覆盖了从i开始的内存 */
printf("%d %d\n",i,j);
}
使用一个并不指向T类型的T*将是一场灾难。因此,在C++中,如果从一个void*得到一个T*,你必须进行显式转换。举例来说,要得到上列程序的这个令人别扭的效果,你可以这样写:
int* pp = (int*)q;
或者使用一个新的类型造型,以使这种没有检查的类型转换操作变得更加清晰:
int* pp = static_cast<int*>(q);
造型被最好地避免了。
在C语言中,这种不安全的转换最常见的应用之一,是将malloc()的结果赋予一个合适的指针。例如:
int* p = malloc(sizeof(int));
在C++中,使用类型安全的new操作符:
int* p = new int;
附带地,new操作符还提供了胜过malloc()的新特性:
new不会偶然分配错误的内存数量;
new会隐式地检查内存耗尽情况,而且
new提供了初始化。
举例:
typedef std::complex<double> cmplx;
/* C风格: */
cmplx* p = (cmplx*)malloc(sizeof(int)); /* 错误:类型不正确 */
/* 忘记测试p==0 */
if (*p == 7) { /* ... */ } /* 糟糕,忘记了初始化*p */
// C++风格:
cmplx* q = new cmplx(1,2); // 如果内存耗尽,将抛出一个bad_alloc异常
if (*q == 7) { /* ... */ }
我如何定义一个类内部(in-class)的常量?
如果你需要一个通过常量表达式来定义的常量,例如数组的范围,你有两种选择:
class X {
static const int c1 = 7;
enum { c2 = 19 };
char v1[c1];
char v2[c2];
// ...
};
乍看起来,c1的声明要更加清晰,但是要注意的是,使用这种类内部的初始化语法的时候,常量必须是被一个常量表达式初始化的整型或枚举类型,而且必是static和const形式。这是很严重的限制:
class Y {
const int c3 = 7; // 错误:不是static
static int c4 = 7; // 错误:不是const
static const float c5 = 7; // 错误:不是整型
};
我倾向使用枚举的方式,因为它更加方便,而且不会诱使我去使用不规范的类内初始化语法。
那么,为什么会存在这种不方便的限制呢?一般来说,类在一个头文件中被声明,而头文件被包含到许多互相调用的单元去。但是,为了避免复杂的编译器规则,C++要求每一个对象只有一个单独的定义。如果C++允许在类内部定义一个和对象一样占据内存的实体的话,这种规则就被破坏了。对于C++在这个设计上的权衡,请参见《C++语言的设计和演变》。
如果你不需要用常量表达式来初始化它,那么可以获得更大的弹性:
class Z {
static char* p; // 在定义中初始化
const int i; // 在构造函数中初始化
public:
Z(int ii) :i(ii) { }
};
char* Z::p = "hello, there";
你可以获取一个static成员的地址,当且仅当它有一个类外部的定义的时候:
class AE {
// ...
public:
static const int c6 = 7;
static const int c7 = 31;
};
const int AE::c7; // 定义
int f()
{
const int* p1 = &AE::c6; // 错误:c6没有左值
const int* p2 = &AE::c7; // ok
// ...
}
为什么delete不会将操作数置0?
考虑一下:
delete p;
// ...
delete p;
如果在...部分没有涉及到p的话,那么第二个“delete p;”将是一个严重的错误,因为C++的实现(译注:原文为a C++ implementation,当指VC++这样的实现了C++标准的具体工具)不能有效地防止这一点(除非通过非正式的预防手段)。既然delete 0从定义上来说是无害的,那么一个简单的解决方案就是,不管在什么地方执行了“delete p;”,随后都执行“p=0;”。但是,C++并不能保证这一点。
一个原因是,delete的操作数并不需要一个左值(lvalue)。考虑一下:
delete p+1;
delete f(x);
在这里,被执行的delete并没有拥有一个可以被赋予0的指针。这些例子可能很少见,但它们的确指出了,为什么保证“任何指向被删除对象的指针都为0”是不可能的。绕过这条“规则”的一个简单的方法是,有两个指针指向同一个对象:
T* p = new T;
T* q = p;
delete p;
delete q; // 糟糕!
C++显式地允许delete操作将操作数左值置0,而且我曾经希望C++的实现能够做到这一点,但这种思想看来并没有在C++的实现中变得流行。
如果你认为指针置0很重要,考虑使用一个销毁的函数:
template<class T> inline void destroy(T*& p) { delete p; p = 0; }
考虑一下,这也是为什么需要依靠标准库的容器、句柄等等,来将对new和delete的显式调用降到最低限度的另一个原因。
注意,通过引用来传递指针(以允许指针被置0)有一个额外的好处,能防止destroy()在右值上(rvalue)被调用:
int* f();
int* p;
// ...
destroy(f()); // 错误:应该使用一个非常量(non-const)的引用传递右值
destroy(p+1); // 错误:应该使用一个非常量(non-const)的引用传递右值
我能够写“void main()”吗?
这种定义:
void main() { /* ... */ }
在C++中从未被允许,在C语言中也是一样。参见ISO C++标准3.6.1[2]或者ISO C标准5.1.2.2.1。规范的实现接受这种方式:
int main() { /* ... */ }
和
int main(int argc, char* argv[]) { /* ... */ }
一个规范的实现可能提供许多版本的main(),但它们都必须返回int类型。main()返回的int值,是程序返回一个值给调用它的系统的方式。在那些不具备这种方式的系统中,返回值被忽略了,但这并不使“void main()”在C++或C中成为合法的。即使你的编译器接受了“void main()”,也要避免使用它,否则你将冒着被C和C++程序员视为无知的风险。
在C++中,main()并不需要包含显式的return语句。在这种情况下,返回值是0,表示执行成功。例如:
#include<iostream>
int main()
{
std::cout << "This program returns the integer value 0\n";
}
注意,无论是ISO C++还是C99,都不允许在声明中漏掉类型。那就是说,与C89和ARM C++形成对照,当声明中缺少类型时,并不会保证是“int”。于是:
#include<iostream>
main() { /* ... */ }
是错误的,因为缺少main()的返回类型。
为什么我不能重载点符号,::,sizeof,等等?
大多数的运算符能够被程序员重载。例外的是:
. (点符号) :: ?: sizeof
并没有什么根本的原因要禁止重载?:。仅仅是因为,我没有发现有哪种特殊的情况需要重载一个三元运算符。注意一个重载了 表达式1?表达式2:表达式3 的函数,不能够保证表达式2:表达式3中只有一个会被执行。
Sizeof不能够被重载是因为内建的操作(built-in operations),诸如对一个指向数组的指针进行增量操作,必须依靠它。考虑一下:
X a[10];
X* p = &a[3];
X* q = &a[3];
p++; // p指向a[4]
// 那么p的整型值必须比q的整型值大出一个sizeof(X)
所以,sizeof(X)不能由程序员来赋予一个不同的新意义,以免违反基本的语法。
在N::m中,无论N还是m都不是值的表达式;N和m是编译器知道的名字,::执行一个(编译期的)范围解析,而不是表达式求值。你可以想象一下,允许重载x::y的话,x可能是一个对象而不是一个名字空间(namespace)或者一个类,这样就会导致——与原来的表现相反——产生新的语法(允许 表达式1::表达式2)。很明显,这种复杂性不会带来任何好处。
理论上来说,.(点运算符)可以通过使用和->一样的技术来进行重载。但是,这样做会导致一个问题,那就是无法确定操作的是重载了.的对象呢,还是通过.引用的一个对象。例如:
class Y {
public:
void f();
// ...
};
class X { // 假设你能重载.
Y* p;
Y& operator.() { return *p; }
void f();
// ...
};
void g(X& x)
{
x.f(); // X::f还是Y::f还是错误?
}
这个问题能够用几种不同的方法解决。在标准化的时候,哪种方法最好还没有定论。更多的细节,请参见《C++语言的设计和演变》。
怎样将一个整型值转换为一个字符串?
最简单的方法是使用一个字符串流(stringstream):
#include<iostream>
#include<string>
#include<sstream>
using namespace std;
string itos(int i) // 将int转换成string
{
stringstream s;
s << i;
return s.str();
}
int main()
{
int i = 127;
string ss = itos(i);
const char* p = ss.c_str();
cout << ss << " " << p << "\n";
}
自然地,这种技术能够将任何使用<<输出的类型转换为字符串。对于字符串流的更多说明,参见《C++程序设计语言》21.5.3节。
“int* p”正确还是“int *p”正确?
二者都是正确的,因为二者在C和C++中都是有效的,而且意义完全一样。就语言的定义与相关的编译器来说,我们还可以说“int*p”或者“int * p”。
在“int* p”和“int *p”之间的选择与正确或错误无关,而只关乎风格与侧重点。C侧重表达式;对声明往往比可能带来的问题考虑得更多。另一方面,C++则非常重视类型。
一个“典型的C程序员”写成“int *p”,并且解释说“*p表示一个什么样的int”以强调语法,而且可能指出C(与C++)的语法来证明这种风格的正确性。是的,在语法上*被绑定到名字p上。
一个“典型的C++程序员”写成“int* p”,并且解释说“p是一个指向int的指针类型”以强调类型。是的,p是一个指向int的指针类型。我明确地倾向于这种侧重方向,而且认为对于学好更多的高级C++这是很重要的。
严重的混乱(仅仅)发生在当人们试图在一条声明中声明几个指针的时候:
int* p, p1; // 也许是错的:p1不是一个int*
把*放到名字这一边,看来也不能有效地减少这种错误:
int *p, p1; // 也许是错的?
为每一个名字写一条声明最大程度地解决了问题——特别是当我们初始化变量的时候。人们几乎不会这样写:
int* p = &i;
int p1 = p; // 错误:int用一个int*初始化了
如果他们真的这么干了,编译器也会指出。
每当事情可以有两种方法完成,有人就会迷惑。每当事情仅仅是一个风格的问题,争论就会没完没了。为每一个指针写一条声明,而且永远都要初始化变量,这样,混乱之源就消失了。更多的关于C的声明语法的讨论,参见《C++语言的设计和演变》。
对于我的代码,哪一种布局风格(layout style)是最好的?
这种风格问题属于个人的爱好。人们往往对布局风格的问题持有强烈的意见,不过,也许一贯性比某种特定的风格更加重要。象大多数人一样,我花了很长的时间,来为我的偏好作出一个固定的结论。
我个人使用通常称为“K&R”的风格。当使用C语言没有的构造函数时,需要增加新的习惯,这样就变成了一种有时被称为“Stroustrup”的风格。例如:
class C : public B {
public:
// ...
};
void f(int* p, int max)
{
if (p) {
// ...
}
for (int i = 0; i<max; ++i) {
// ...
}
}
比大多数布局风格更好,这种风格保留了垂直的空格,我喜欢尽可能地在合理的情况下对齐屏幕。对函数开头的大括弧的放置,有助于第一眼就分别出类的定义和函数的定义。
缩进是非常重要的。
设计问题,诸如作为主要接口的抽象基类的使用,使用模板以表现有弹性的类型安全的抽象,以及正确地使用异常以表现错误,比布局风格的选择要重要得多。
我应该将“const”放在类型之前还是之后?
我把它放在前面,但那仅仅是个人爱好问题。“const T”和“T const”总是都被允许的,而且是等效的。例如:
const int a = 1; // ok
int const b = 2; // also ok
我猜想第一种版本可能会让少数(更加固守语法规范)的程序员感到迷惑。
为什么?当我发明“const”(最初的名称叫做“readonly”,并且有一个对应的“writeonly”)的时候,我就允许它出现在类型之前或之后,因为这样做不会带来任何不明确。标准之前的C和C++规定了很少的(如果有的话)特定的顺序规范。
我不记得当时有过任何有关顺序问题的深入思考或讨论。那时,早期的一些使用者——特别是我——仅仅喜欢这种样子:
const int c = 10;
看起来比这种更好:
int const c = 10;
也许我也受了这种影响:在我最早的一些使用“readonly”的例子中
readonly int c = 10;
比这个更具有可读性:
int readonly c = 10;
我创造的那些最早的使用“const”的(C或C++)代码,看来已经在全球范围内取代了“readonly”。
我记得这个语法的选择在几个人——例如Dennis Ritchie——当中讨论过,但我不记得当时我倾向于哪种语言了。
注意在固定指针(const pointer)中,“const”永远出现在“*”之后。例如:
int *const p1 = q; // 指向int变量的固定指针
int const* p2 = q; //指向int常量的指针
const int* p3 = q; //指向int常量的指针
使用宏有什么问题?
宏不遵循C++中关于范围和类型的规则。这经常导致一些微妙的或不那么微妙的问题。因此,C++提供更适合其他的C++(译注:原文为the rest of C++,当指C++除了兼容C以外的部分)的替代品,例如内联函数、模板与名字空间。
考虑一下:
#include "someheader.h"
struct S {
int alpha;
int beta;
};
如果某人(不明智地)地写了一个叫“alpha”或“beta”的宏,那么它将不会被编译,或者被错误地编译,产生不可预知的结果。例如,“someheader.h”可能包含:
#define alpha 'a'
#define beta b[2]
将宏(而且仅仅是宏)全部大写的习惯,会有所帮助,但是对于宏并没有语言层次上的保护机制。例如,虽然成员的名字包含在结构体的内部,但这无济于事:在编译器能够正确地辨别这一点之前,宏已经将程序作为一个字符流进行了处理。顺便说一句,这是C和C++程序开发环境和工具能够被简化的一个主要原因:人与编译器看到的是不同的东西。
不幸的是,你不能假设别的程序员总是能够避免这种你认为“相当白痴”的事情。例如,最近有人报告我,他们遇到了一个包含goto的宏。我也见过这种情况,而且听到过一些——在很脆弱的时候——看起来确实有理的意见。例如:
#define prefix get_ready(); int ret__
#define Return(i) ret__=i; do_something(); goto exit
#define suffix exit: cleanup(); return ret__
void f()
{
prefix;
// ...
Return(10);
// ...
Return(x++);
//...
suffix;
}
作为一个维护的程序员,就会产生这种印象;将宏“隐藏”到一个头文件中——这并不罕见——使得这种“魔法”更难以被辨别。
一个常见的微妙问题是,一个函数风格的宏并不遵守函数参数传递的规则。例如:
#define square(x) (x*x)
void f(double d, int i)
{
square(d); // 好
square(i++); // 糟糕:这表示 (i++*i++)
square(d+1); //糟糕:这表示(d+1*d+1); 也就是 (d+d+1)
// ...
}
“d+1”的问题,可以通过在“调用”时或宏定义时添加一对圆括号来解决:
#define square(x) ((x)*(x)) /*这样更好 */
但是, i++被执行了两次(可能并不是有意要这么做)的问题仍然存在。
是的,我确实知道有些特殊的宏并不会导致C/C++预处理宏这样的问题。但是,我无心去发展C++中的宏。作为替代,我推荐使用C++语言中合适的工具,例如内联函数,模板,构造函数(用来初始化),析构函数(用来清除),异常(用来退出上下文环境),等等。
危险的关系:美元本位制与金融风险——从日本的经验看中国
(作者置顶)
创业者如何组装一部赚钱机器
(作者置顶)
仁爱与博爱:中西文化析论三二
(作者置顶)
企业管理实践中的诡道
(作者置顶)
从“毛泽东热”看“中国式管理”
(作者置顶)
由西门庆勾引潘金莲看营销谈判的过程技巧
(作者置顶)
华夏道,天下为公
(作者置顶)