将模型导出到Direct3D

(零)序言
你在3ds
max(以下称作max)或Maya(以下称作maya)中为一款游戏制作各种3D模型,你希望自己所做的模型能在一款3D游戏引擎中被活灵活现地显示出来。在那一刻你会感到非常喜悦,因为玩家那充满热情或惊叹的眼神使你感到自己的作品被承认、被赞赏。嗯,这的确值得我们去想象!

 
不过,先别急着去幻想那些情景啦,因为你在进入梦乡前会有足够的时间来想象这些情景。在此之前,你还是要务实一点,先要为能得到那样的眼神而付出相应程度的努力。

 
不知你是否清楚这样一个事实(或,我现在需要告诉你这个事实),那就是,你在max或maya中保存的.max或.ma、mb格式的文件是不能直接“放到”引擎中去显示的。一般来说,游戏引擎都不支持这些格式,通常它们会各自定义一些专门的文件格式来存储模型,而只有具有这种格式的文件才能被引擎直接使用。因此,你就需要将max或maya制作的模型导出为那些格式。

 
      

要知道一般3D游戏引擎都是基于Direct3D(以下简称D3D)或OpenGL编写的。而由于不同引擎支持的格式各不相同,所以在此很难逐一去讨论。正如文章的标题所示,本文主要讨论如何将模型导出到基于Direct3D编写的应用程序或引擎,再或者更精确地说(如果你了解Direct3D的话)导出到Direct3DX的.X文件格式。本文的首要目的就是让你了解导出一个3D模型的概念和具体做法;其次介绍了一种方法来验证你的模型是否能在D3D游戏中被正确的显示出来;最后指出在max中导出.X格式时会遇到的一个小麻烦及解决方法。

 
最后,我假设你用的3D建模软件是max或maya,整篇文章都是围绕在这两种软件中进行导出来说明的。并且我进一步假设你使用的是当前主流版本的max或maya,即3ds

max 5.0或Maya 4.0~5.0,如果你使用的是其它软件,或者版本号太旧或太新,则以下内容可能不是太适合于你。
 
      
OK,开始吧~!
(一)如何导出?
.X文件格式
.X文件是Direct3D开发包所直接支持的唯一一种3D模型格式,也就是说,如果你是一个程序员,你可以很轻松地写出一个Direct3D程序用于显示.X文件所存储的3D模型。.X文件包含了对于一款3D游戏而言有用的信息,诸如模型中顶点的位置、顶点颜色、材质、UV纹理坐标、动画关键帧等等。另外,.X文件有两种存储方式:文本和二进制,两者在功能和用途上没有任何差别,只是形式不同而已。

安装导出插件
      
另外,以下内容中的一些插图和说明以max为例。
 
l      
3ds max 5.0
1)       
如果max已经启动,则先关闭它。
2)       
复制:
XSkinExp.dle
到:
(3ds max所在目录)\plugins\
3)       
启动max后,选择菜单:
Customize-> Plug-in Manager...
检查弹出对话框的列表框中是否存在“XSKINEXP.DLE”,并且其Status是否为“loaded”,如下图:
 
       

l      
Maya 4.0~5.0
1)       
如果maya已经启动,则先关闭它。
2)       
复制:
xExport.mll
到:
(maya所在目录)\bin\plug-ins\
3)       
复制:
bicubicbezierpatches.mel和xfiletranslatoropts.mel
到:
(maya所在目录)\scripts\others\
4)       
启动maya后,选择菜单:
Window->Settings/Preferences->Plug-in
Manager...
检查弹出对话框的列表框中是否存在“xExport.mll”,并且是否构选了“loaded”和“auto
load”,如下图:
 
导出模型
l      
3ds max 5.0
1)       
选择菜单:
File->Export
在弹出对话框中选择保存类型为“X-File (*.X)”,并指定保存路径和文件名。
2)       
然后,可在弹出的导出选项对话框中进行设置,配置好以后按“Go!”按钮,导出选项和解释如下:
 
 
各个选项含义如下:
     
导出选项描述
     
Text文本
     
Binary
     
Binary Compressed二进制
     
压缩的二进制
     
Export Patch Data如果存在高次(high-order)表面则将导出patche以替代polygon网格
     
Include Animation Data是否同时导出动画数据,需要指定动画采样速率(单位:帧/秒)
     
Looping Animation Data是否循环播放动画

l      
Maya 4.0~5.0
1)       
选择菜单:
File->Export All…
在弹出对话框中选择保存类型为“xExport (*.*)”,并指定保存路径和文件名。
2)       

在文件保存的对话框中,按下左下角的“Options…”按钮后,可在弹出的导出选项对话框中进行设置,配置好以后按“Export”按钮,导出选项和解释如下:

 
 
各个选项含义如下:
     
导出选项描述
     
File Options选择文本、二进制、压缩的二进制
     
Tesselation
Options选择高次(high-order)和细分(subdivided)表面,或none为忽略这些表面
     
Material Options选择是否同时导出材质、是否翻转UV纹理坐标轴、纹理文件路径是采用限定路径,还是不限定路径
     
Animation Options“Export
     
Animation”用于选择是否保存动画属性。如果选择保存动画属性和“per-frame”后可以通过“Frame
     
Step”或拖动条来设置采样率;默认值是1,表示1:1的关系。该值与Maya的当前回放(playback)速度有关。
     
Skinning Options“Export
     
Skinning”允许你选择是否保存网格中skinning信息。如果没有选择该选项,则网格不能自我变形(deform),但如果网格继承于带有动画的层级的话,则它只可以在空间中移动。

观察导出结果
1)       
确认已安装了DirectX 9.0运行库(如果这个链接已失效,请点击这里来寻找最新的DirectX)。
2)       
运行Mesh Viewer,选择菜单:
File->Open Mesh File
选择相应的.X文件
1)       

你可尝试通过鼠标左、中、右键来旋转、缩放、移动地观察模型;你还可以选择显示法线、是否剔除(Culling)背面、以何种方式(点、线框、面)来显示模型等,以此来查看模型是否正确。

 
如果在Mesh
Viewer中一切显示正常,那么在基于Direct3D的程序中,一切也会表现良好。如果有些面没有正常显示或导出时就失败了,则说明你的模型存在一些问题,需要进一步检查一下模型,看看法线是否正确、多边形的边是否合拢、是否存在缝隙等。在max中进行导出时你会遇到一个小问题,你在导出前多做一件事情来解决这个问题。继续前进,来让我们看一下在max中导出时发生了什么“怪”事情。

(二)max的导出问题
起因
如果你以为将max制作的模型导出到.X文件后会有一样的外观的话,那你可把世事想得过于完美了。你至少会发现一个非常明显的错误,那就是:模型被关于XY平面作了镜像,譬如拿XY平面比作一面镜子,你在Direct3D中看到的将是那个在镜子中的模型(注意,我在这里没有提及maya,那是因为maya的导出插件已经处理了这个问题,真要感谢编写那个插件的那位作者!)。

 
以一个max模型(该模型系杨·为一同志所做,感谢他提供这个模型,正因为在导出这个模型时显露出来的错误使我更深入地研究了本文所涉及的主题J)为例,它显示出来导出前后模型的变换,导出前我们通过XY平面看到了狮子的背面,而导出后通过XY平面看到的却是狮子的腹部。如果想象原来的狮子站在一面镜子上,那么导出的就好比是那个镜子中的狮子。

 
         
(左图:在max中的模型;右图:导出后在Mesh Viewer中的模型)
 
好,现在你应该活动一下筋骨了,来尝试导出一个模型到.X文件吧。我建议你的试验模型最好不要是对称物体,并且具有一点能标识方向的几何体,我在这里提供了一个模型,如果你想偷点懒的话可以直接用它来做这个试验。然后亲身感受一下这个问题。

经过
      

那是什么原因造成了以上的问题呢?如果你是一个喜欢打破砂锅问到底的人,那这一段正是为你准备的。如果你是一个讲求实效或以“可用即可”作为行事准则的家伙,那么你可以直接跳到“结果”那段,“经过”对于这类人不是最重要的。

 
      

max和maya都使用了“右手坐标系”,以你计算机屏幕的左下角为坐标系原点的话,那么右手坐标系中X、Y、Z轴的正方向依次为向右、向上和向外(即指向你),如下左图。而Direct3D默认使用却是“左手坐标系”,与右手坐标系的唯一区别在于Z轴正方向相反,即向内(指向显示器内部)。另外,OpenGL使用的是右手坐标系,所以这些问题在OpenGL编程中不存在,无论你用max还是maya,直接导出原始的顶点数据即可通过OpenGL绘制出来。

 
(D3D使用的左手坐标系和max、maya、OpenGL使用的右手坐标系)
 
      
好了,当你明白这些知识后,让我们来看一下在从max或maya的右手坐标系转换成D3D的左手坐标系的过程中发生了什么。
 
      
考虑在使用右手坐标系的max中有一个顶点为(0, 0,
1),你应该可以想象它是位于Z轴的正方向那段上。当这个顶点被不加修改地以左手坐标系来解释的话,那么它还是位于Z轴的正向段的Z轴,但是绝对位置却发生了变换。那么当构成一个模型的所有顶点都被这样进行转变的话,那么得到的结果就好比是一个物体关于XY轴所构成的平面作了镜像转换。如下图所示:

        
(左图:建模完成的物体;右图:原来处于右手坐标系中的物体,直接转换到左手坐标系后的情况)
 
      

由上右图你可以看到,“镜子中”物体(即转换到左手坐标系后的物体)不是“现实”物体(原右手坐标系中的物体,也就是你建模完得到的物体)的副本,我希望你能明白我在这里所谓“副本”的含义,我指的是可以与原物体完全吻合的另一个物体、另一个复制品。而现在“现实”中物体无论如何旋转或移动是永远都不可能得到“镜子中”物体的,如果你不信可以想象或比划一下,是不是呢?不是吗?是的。J

结果
想知道解决方法吗?或许聪明的你已经知道该怎么做了。我这里有三种解决方法,可能你会更倾向于其中的某一种吧。
l      
美工解决(推荐)
众所周知,有一句话曰“负负得正”。既然导出后的结果是原模型的镜像结果,那何不在导出前先对模型作一次镜像呢?两次镜像的结果就是原来的样子。

 
在用max制作完一个模型准备导出前,做如下步骤:
1)       
选中整个模型
2)       
完成镜像操作,选择菜单:
Tools->Mirror
在弹出对话框中对于“Mirror Axis”选择“Z”,然后按“OK”按钮
3)       
导出
 
这样做的有两个前提:一、应选中场景中所有的物体来作镜像;二、物体应处于世界坐标系的原点。除非你知道当违反这两个前提时会得到什么结果,并对这样结果有所预期。不然你就应该保证这两点。

 
另外,你可以想象一下如果没有这两个前提的保证下会发生什么情况。对于不满足前提一来说,某些在max作了镜像的物体能如max中所显示的那样,而某些未作镜像的却在导出后被镜像了;而对于前提二来说,如果物体不处于世界原点,则导出结果的模型本身正确了,但位置却作了关于XY平面的镜像。

 
这个方法简单有效,不是吗?而且这种解决方法不是发生在游戏运行时刻,可以节省大量的运行时开销。顺便还能让程序员们可以轻松点!想想他们在成堆的代码中已经够可怜了(当然,有的程序员视为享受,天堂和地狱只在一念之间J),饶了他们吧。

l      
程序解决
你们注定是不平凡的,但同时也注定你们是最辛苦的。因为理论上任何开发相关的事情都可以由你们来完成,只是开发/运行效率存在高低之别而已。

 
      

在当前要解决的问题中,可以通过在程序中作一次镜像来完成,概念上与上述的“负负得正”一样。大致思想是:在进行单个导出物体的世界变换之前,先作关于XY平面的镜像(XY平面可以是世界坐标系的,也可以模型局部坐标系的,两者效果是不一样的),如果选择作关于世界坐标系的XY平面的镜像,则还需要将镜像后的结果移回到原来的位置,即按Z轴平移。以下代码演示了关于世界坐标系的一个镜像,然后将对象移回到原来位置。

 
D3DXMATRIX matWorld;
……
D3DXPLANE plane( 0.0f, 0.0f, 1.0f, 0.0f ); // 关于XY平面的镜像
D3DXMATRIX matReflect;
D3DXMatrixReflect( &matReflect,
&plane ); // 构建一个关于XY平面的反射(镜像)矩阵
float fZ = matWorld._43; // 记录下物体的原Z轴位置
D3DXMATRIX matMoveBack;
D3DXMatrixTranslation( &matMoveBack, 0, 0, -2 * fZ
); // 构建移回矩阵
matWorld = matReflect * matMoveBack * matWorld;
pDevice->SetTransform( D3DTS_WORLD,
&matWorld );
 
这段代码有几处值得进一步优化,但我现在这么写纯粹是出于让你看懂的目的,在你理解之后可作进一步的优化。
 
这种方法具有更多的灵活性,但你需要为此付出运行时开销。如果你用不到该方法带来的灵活性的话,还是从美工的角度来解决这个问题吧。

l      
工具解决
就我知道存在一个转换工具――Deep
Exploration(http://www.righthemisphere.com/),可以将诸如max、maya等多种格式的模型文件转换称.X文件,并且它已经处理了这个镜像的问题。该软件使用非常简单,选择相应的源文件,然后将其另存为.X类型的文件即可,在此就不展开讨论了。

 
可能还有更多工具有待你的发掘,如果找到别忘了告诉我一声。
(三)附录
l      
建模建议
1)       
如果建模软件支持更改坐标系的话,应尽量选择使用左手坐标系来制作将会在D3D用到的模型,由此避免了转换;
2)       
建模过程应尽量使物体的头方向朝着Y轴正方向,右方向朝着X轴正方向,前方向朝着Z轴正方向,这样也可以避免程序在运行时对物体模型进行旋转调整;

3)       
建模时应尽量使用相同比例的坐标单位,这也可以避免运行时的缩放;
4)       
导出模型前检查所有法线朝向是否正确,在max据说所知可以通过加一个XForm来检查。
文章转载自:

Homepage:http://www.zhouweidi.name
email & MSN Messenger:zhouweidi@hotmail.com

高质量C++教程 — 第11章 其它编程经验

高质量C++教程 -- 第11章 其它编程经验
来源:www.vcworld.net 

11.1
使用const提高函数的健壮性

看到const关键字,C++程序员首先想到的可能是const常量。这可不是良好的条件反射。如果只知道用const定义常量,那么相当于把火药仅用于制作鞭炮。const更大的魅力是它可以修饰函数的参数、返回值,甚至函数的定义体。

const是constant的缩写,“恒定不变”的意思。被const修饰的东西都受到强制保护,可以预防意外的变动,能提高程序的健壮性。所以很多C++程序设计书籍建议:“Use
const whenever you need”。

11.1.1 用const修饰函数的参数

如果参数作输出用,不论它是什么数据类型,也不论它采用“指针传递”还是“引用传递”,都不能加const修饰,否则该参数将失去输出功能。

const只能修饰输入参数:

u如果输入参数采用“指针传递”,那么加const修饰可以防止意外地改动该指针,起到保护作用。

例如StringCopy函数:

       
void StringCopy(char *strDestination, const char *strSource);

其中strSource是输入参数,strDestination是输出参数。给strSource加上const修饰后,如果函数体内的语句试图改动strSource的内容,编译器将指出错误。

u如果输入参数采用“值传递”,由于函数将自动产生临时变量用于复制该参数,该输入参数本来就无需保护,所以不要加const修饰。

例如不要将函数void Func1(int x) 写成void Func1(const int x)。同理不要将函数void
Func2(A a) 写成void Func2(const A a)。其中A为用户自定义的数据类型。

u对于非内部数据类型的参数而言,象void Func(A a)
这样声明的函数注定效率比较底。因为函数体内将产生A类型的临时对象用于复制参数a,而临时对象的构造、复制、析构过程都将消耗时间。

为了提高效率,可以将函数声明改为void Func(A
&a),因为“引用传递”仅借用一下参数的别名而已,不需要产生临时对象。但是函数void Func(A
&a)
存在一个缺点:“引用传递”有可能改变参数a,这是我们不期望的。解决这个问题很容易,加const修饰即可,因此函数最终成为void
Func(const A &a)。

以此类推,是否应将void Func(int x) 改写为void Func(const int
&x),以便提高效率?完全没有必要,因为内部数据类型的参数不存在构造、析构的过程,而复制也非常快,“值传递”和“引用传递”的效率几乎相当。

   
问题是如此的缠绵,我只好将“const &”修饰输入参数的用法总结一下,如表11-1-1所示。

 

对于非内部数据类型的输入参数,应该将“值传递”的方式改为“const引用传递”,目的是提高效率。例如将void Func(A
a) 改为void Func(const A &a)。
对于内部数据类型的输入参数,不要将“值传递”的方式改为“const引用传递”。否则既达不到提高效率的目的,又降低了函数的可理解性。例如void
Func(int x) 不应该改为void Func(const int &x)。

 

表11-1-1 “const &”修饰输入参数的规则

11.1.2 用const修饰函数的返回值

u如果给以“指针传递”方式的函数返回值加const修饰,那么函数返回值(即指针)的内容不能被修改,该返回值只能被赋给加const修饰的同类型指针。

例如函数

       
const char * GetString(void);

如下语句将出现编译错误:

       
char *str = GetString();

正确的用法是

       
const char *str = GetString();

 

u如果函数返回值采用“值传递方式”,由于函数会把返回值复制到外部临时的存储单元中,加const修饰没有任何价值。

   
例如不要把函数int GetInt(void) 写成const int GetInt(void)。

    同理不要把函数A
GetA(void) 写成const A GetA(void),其中A为用户自定义的数据类型。

   
如果返回值不是内部数据类型,将函数A GetA(void) 改写为const A &
GetA(void)的确能提高效率。但此时千万千万要小心,一定要搞清楚函数究竟是想返回一个对象的“拷贝”还是仅返回“别名”就可以了,否则程序会出错。见6.2节“返回值的规则”。

 

u函数返回值采用“引用传递”的场合并不多,这种方式一般只出现在类的赋值函数中,目的是为了实现链式表达。

例如

    class
A

    {…

       
A & operate = (const A
&other);   
// 赋值函数

    };

    A a, b,
c;        
// a, b, c 为A的对象

    …

    a = b =
c;           
// 正常的链式赋值

    (a = b) =
c;     
// 不正常的链式赋值,但合法

如果将赋值函数的返回值加const修饰,那么该返回值的内容不允许被改动。上例中,语句 a = b = c仍然正确,但是语句 (a
= b) = c 则是非法的。

 

11.1.3 const成员函数

   
任何不会修改数据成员的函数都应该声明为const类型。如果在编写const成员函数时,不慎修改了数据成员,或者调用了其它非const成员函数,编译器将指出错误,这无疑会提高程序的健壮性。

以下程序中,类stack的成员函数GetCount仅用于计数,从逻辑上讲GetCount应当为const函数。编译器将指出GetCount函数中的错误。

    class
Stack

{

     
public:

       
void    
Push(int elem);

       
int    
Pop(void);

       
int    
GetCount(void)  const;  //
const成员函数

     
private:

       
int    
m_num;

       
int    
m_data[100];

};

 

    int
Stack::GetCount(void)  const

{
       
++ m_num;  // 编译错误,企图修改数据成员m_num

   
Pop();     
// 编译错误,企图调用非const函数

    return
m_num;

    }

   
const成员函数的声明看起来怪怪的:const关键字只能放在函数声明的尾部,大概是因为其它地方都已经被占用了。

 

11.2 提高程序的效率

程序的时间效率是指运行速度,空间效率是指程序占用内存或者外存的状况。

全局效率是指站在整个系统的角度上考虑的效率,局部效率是指站在模块或函数角度上考虑的效率。

【规则11-2-1】不要一味地追求程序的效率,应当在满足正确性、可靠性、健壮性、可读性等质量因素的前提下,设法提高程序的效率。

【规则11-2-2】以提高程序的全局效率为主,提高局部效率为辅。

【规则11-2-3】在优化程序的效率时,应当先找出限制效率的“瓶颈”,不要在无关紧要之处优化。

【规则11-2-4】先优化数据结构和算法,再优化执行代码。

【规则11-2-5】有时候时间效率和空间效率可能对立,此时应当分析那个更重要,作出适当的折衷。例如多花费一些内存来提高性能。

【规则11-2-6】不要追求紧凑的代码,因为紧凑的代码并不能产生高效的机器码。

 

11.3 一些有益的建议

【建议11-3-1】当心那些视觉上不易分辨的操作符发生书写错误。

我们经常会把“==”误写成“=”,象“||”、“&&”、“<=”、“>=”这类符号也很容易发生“丢1”失误。然而编译器却不一定能自动指出这类错误。

【建议11-3-2】变量(指针、数组)被创建之后应当及时把它们初始化,以防止把未被初始化的变量当成右值使用。

【建议11-3-3】当心变量的初值、缺省值错误,或者精度不够。

【建议11-3-4】当心数据类型转换发生错误。尽量使用显式的数据类型转换(让人们知道发生了什么事),避免让编译器轻悄悄地进行隐式的数据类型转换。

【建议11-3-5】当心变量发生上溢或下溢,数组的下标越界。

【建议11-3-6】当心忘记编写错误处理程序,当心错误处理程序本身有误。

【建议11-3-7】当心文件I/O有错误。

【建议11-3-8】避免编写技巧性很高代码。

【建议11-3-9】不要设计面面俱到、非常灵活的数据结构。

【建议11-3-10】如果原有的代码质量比较好,尽量复用它。但是不要修补很差劲的代码,应当重新编写。

【建议11-3-11】尽量使用标准库函数,不要“发明”已经存在的库函数。

【建议11-3-12】尽量不要使用与具体硬件或软件环境关系密切的变量。

【建议11-3-13】把编译器的选择项设置为最严格状态。

【建议11-3-14】如果可能的话,使用PC-Lint、LogiScope等工具进行代码审查。

 

参考文献

[Cline] Marshall P. Cline and Greg A. Lomow,
C++ FAQs, Addison-Wesley, 1995

[Eckel] Bruce Eckel, Thinking in C++(C++
编程思想,刘宗田 等译),机械工业出版社,2000

[Maguire] Steve Maguire, Writing Clean
Code(编程精粹,姜静波 等译),电子工业出版社,1993

[Meyers] Scott Meyers, Effective C++,
Addison-Wesley, 1992

[Murry] Robert B. Murry, C++ Strategies and
Tactics, Addison-Wesley, 1993

[Summit] Steve Summit, C Programming FAQs,
Addison-Wesley, 1996

高质量C++教程 — 第10章 类的继承与组合

高质量C++教程 -- 第10章 类的继承与组合
来源:www.vcworld.net 

对象(Object)是类(Class)的一个实例(Instance)。如果将对象比作房子,那么类就是房子的设计图纸。所以面向对象设计的重点是类的设计,而不是对象的设计。

对于C++程序而言,设计孤立的类是比较容易的,难的是正确设计基类及其派生类。本章仅仅论述“继承”(Inheritance)和“组合”(Composition)的概念。

注意,当前面向对象技术的应用热点是COM和CORBA,这些内容超出了C++教材的范畴,请阅读COM和CORBA相关论著。

10.1 继承

如果A是基类,B是A的派生类,那么B将继承A的数据和函数。例如:

class A

{

public:

void Func1(void);

void Func2(void);

};

 

class B : public A

{

public:

void Func3(void);

void Func4(void);

};

 

main()

{

B b;

b.Func1(); // B从A继承了函数Func1

b.Func2(); // B从A继承了函数Func2

b.Func3();

b.Func4();

}

 

这个简单的示例程序说明了一个事实:C++的“继承”特性可以提高程序的可复用性。正因为“继承”太有用、太容易用,才要防止乱用“继承”。我们应当给“继承”立一些使用规则。

 

【规则10-1-1】如果类A和类B毫不相关,不可以为了使B的功能更多些而让B继承A的功能和属性。不要觉得“白吃白不吃”,让一个好端端的健壮青年无缘无故地吃人参补身体。

【规则10-1-2】若在逻辑上B是A的“一种”(a kind of
),则允许B继承A的功能和属性。例如男人(Man)是人(Human)的一种,男孩(Boy)是男人的一种。那么类Man可以从类Human派生,类Boy可以从类Man派生。

class Human

{

};

class Man : public Human

{

};

class Boy : public Man

{

};

 

注意事项

【规则10-1-2】看起来很简单,但是实际应用时可能会有意外,继承的概念在程序世界与现实世界并不完全相同。

例如从生物学角度讲,鸵鸟(Ostrich)是鸟(Bird)的一种,按理说类Ostrich应该可以从类Bird派生。但是鸵鸟不能飞,那么Ostrich::Fly是什么东西?

class Bird

{

public:

virtual void Fly(void);

};

 

class Ostrich : public Bird

{

};

 

例如从数学角度讲,圆(Circle)是一种特殊的椭圆(Ellipse),按理说类Circle应该可以从类Ellipse派生。但是椭圆有长轴和短轴,如果圆继承了椭圆的长轴和短轴,岂非画蛇添足?

所以更加严格的继承规则应当是:若在逻辑上B是A的“一种”,并且A的所有功能和属性对B而言都有意义,则允许B继承A的功能和属性。

 

10.2 组合

【规则10-2-1】若在逻辑上A是B的“一部分”(a part
of),则不允许B从A派生,而是要用A和其它东西组合出B。

例如眼(Eye)、鼻(Nose)、口(Mouth)、耳(Ear)是头(Head)的一部分,所以类Head应该由类Eye、Nose、Mouth、Ear组合而成,不是派生而成。如示例10-2-1所示。

class Eye

{
public:

void Look(void);

};

class Nose

{
public:

void Smell(void);

};

class Mouth

{
public:

void Eat(void);

};

class Ear

{
public:

void Listen(void);

};

// 正确的设计,虽然代码冗长。

class Head

{

public:

void Look(void) { m_eye.Look(); }

void Smell(void) { m_nose.Smell(); }

void Eat(void) { m_mouth.Eat(); }

void Listen(void) { m_ear.Listen(); }

private:

Eye m_eye;

Nose m_nose;

Mouth m_mouth;

Ear m_ear;

};

示例10-2-1 Head由Eye、Nose、Mouth、Ear组合而成

 

如果允许Head从Eye、Nose、Mouth、Ear派生而成,那么Head将自动具有Look、
Smell、Eat、Listen这些功能。示例10-2-2十分简短并且运行正确,但是这种设计方法却是不对的。

// 功能正确并且代码简洁,但是设计方法不对。

class Head : public Eye, public Nose, public Mouth, public
Ear

{

};

示例10-2-2 Head从Eye、Nose、Mouth、Ear派生而成

 

一只公鸡使劲地追打一只刚下了蛋的母鸡,你知道为什么吗?

因为母鸡下了鸭蛋。

很多程序员经不起“继承”的诱惑而犯下设计错误。“运行正确”的程序不见得是高质量的程序,此处就是一个例证。

高质量C++教程 — 第9章 类的构造函数、析构函数与赋

高质量C++教程 -- 第9章 类的构造函数、析构函数与赋值函数
来源:www.vcworld.net 

构造函数、析构函数与赋值函数是每个类最基本的函数。它们太普通以致让人容易麻痹大意,其实这些貌似简单的函数就象没有顶盖的下水道那样危险。

每个类只有一个析构函数和一个赋值函数,但可以有多个构造函数(包含一个拷贝构造函数,其它的称为普通构造函数)。对于任意一个类A,如果不想编写上述函数,C++编译器将自动为A产生四个缺省的函数,如

   
A(void);                   
// 缺省的无参数构造函数

    A(const A
&a);               
// 缺省的拷贝构造函数

   
~A(void);                   
// 缺省的析构函数

    A
& operate =(const A
&a);   
// 缺省的赋值函数

 

这不禁让人疑惑,既然能自动生成函数,为什么还要程序员编写?

原因如下:

(1)如果使用“缺省的无参数构造函数”和“缺省的析构函数”,等于放弃了自主“初始化”和“清除”的机会,C++发明人Stroustrup的好心好意白费了。

(2)“缺省的拷贝构造函数”和“缺省的赋值函数”均采用“位拷贝”而非“值拷贝”的方式来实现,倘若类中含有指针变量,这两个函数注定将出错。

      

对于那些没有吃够苦头的C++程序员,如果他说编写构造函数、析构函数与赋值函数很容易,可以不用动脑筋,表明他的认识还比较肤浅,水平有待于提高。

本章以类String的设计与实现为例,深入阐述被很多教科书忽视了的道理。String的结构如下:

    class
String

    {

     
public:

       
String(const char *str =
NULL);    //
普通构造函数

       
String(const String
&other);   
// 拷贝构造函数

       
~
String(void);                   
// 析构函数

       
String & operate =(const String
&other);   
// 赋值函数

     
private:

       
char     
*m_data;               
// 用于保存字符串

    };

9.1 构造函数与析构函数的起源

作为比C更先进的语言,C++提供了更好的机制来增强程序的安全性。C++编译器具有严格的类型安全检查功能,它几乎能找出程序中所有的语法问题,这的确帮了程序员的大忙。但是程序通过了编译检查并不表示错误已经不存在了,在“错误”的大家庭里,“语法错误”的地位只能算是小弟弟。级别高的错误通常隐藏得很深,就象狡猾的罪犯,想逮住他可不容易。

根据经验,不少难以察觉的程序错误是由于变量没有被正确初始化或清除造成的,而初始化和清除工作很容易被人遗忘。Stroustrup在设计C++语言时充分考虑了这个问题并很好地予以解决:把对象的初始化工作放在构造函数中,把清除工作放在析构函数中。当对象被创建时,构造函数被自动执行。当对象消亡时,析构函数被自动执行。这下就不用担心忘了对象的初始化和清除工作。

构造函数与析构函数的名字不能随便起,必须让编译器认得出才可以被自动执行。Stroustrup的命名方法既简单又合理:让构造函数、析构函数与类同名,由于析构函数的目的与构造函数的相反,就加前缀‘~’以示区别。

除了名字外,构造函数与析构函数的另一个特别之处是没有返回值类型,这与返回值类型为void的函数不同。构造函数与析构函数的使命非常明确,就象出生与死亡,光溜溜地来光溜溜地去。如果它们有返回值类型,那么编译器将不知所措。为了防止节外生枝,干脆规定没有返回值类型。(以上典故参考了文献[Eekel,
p55-p56])

9.2 构造函数的初始化表

构造函数有个特殊的初始化方式叫“初始化表达式表”(简称初始化表)。初始化表位于函数参数表之后,却在函数体 {}
之前。这说明该表里的初始化工作发生在函数体内的任何代码被执行之前。

构造函数初始化表的使用规则:

如果类存在继承关系,派生类必须在其初始化表里调用基类的构造函数。

例如

    class
A

    {…

       
A(int
x);       
// A的构造函数

}; 

    class B :
public A

    {…

       
B(int x, int y);// B的构造函数

    };

    B::B(int
x, int y)

    
:
A(x)            
// 在初始化表里调用A的构造函数

    {

     

}  

类的const常量只能在初始化表里被初始化,因为它不能在函数体内用赋值的方式来初始化(参见5.4节)。

类的数据成员的初始化可以采用初始化表或函数体内赋值两种方式,这两种方式的效率不完全相同。

非内部数据类型的成员对象应当采用第一种方式初始化,以获取更高的效率。例如

    class
A

{…

   
A(void);               
// 无参数构造函数

    A(const A
&other);       
// 拷贝构造函数

    A
& operate =( const A
&other);   
// 赋值函数

};

 

    class
B

    {

     
public:

       
B(const A
&a);   
// B的构造函数

     
private:   

       

m_a;           
// 成员对象

};

 

示例9-2(a)中,类B的构造函数在其初始化表里调用了类A的拷贝构造函数,从而将成员对象m_a初始化。

示例9-2
(b)中,类B的构造函数在函数体内用赋值的方式将成员对象m_a初始化。我们看到的只是一条赋值语句,但实际上B的构造函数干了两件事:先暗地里创建m_a对象(调用了A的无参数构造函数),再调用类A的赋值函数,将参数a赋给m_a。

B::B(const A &a)

: m_a(a)

{

}

B::B(const A &a)

{

m_a = a;

}

示例9-2(a) 成员对象在初始化表中被初始化 示例9-2(b)
成员对象在函数体内被初始化

 

对于内部数据类型的数据成员而言,两种初始化方式的效率几乎没有区别,但后者的程序版式似乎更清晰些。若类F的声明如下:

class F

{

public:

F(int x, int y); // 构造函数

private:

int m_x, m_y;

int m_i, m_j;

}

示例9-2(c)中F的构造函数采用了第一种初始化方式,示例9-2(d)中F的构造函数采用了第二种初始化方式。

F::F(int x, int y)

: m_x(x), m_y(y)

{

m_i = 0;

m_j = 0;

}

F::F(int x, int y)

{

m_x = x;

m_y = y;

m_i = 0;

m_j = 0;

}

示例9-2(c) 数据成员在初始化表中被初始化 示例9-2(d)
数据成员在函数体内被初始化

9.3 构造和析构的次序

构造从类层次的最根处开始,在每一层中,首先调用基类的构造函数,然后调用成员对象的构造函数。析构则严格按照与构造相反的次序执行,该次序是唯一的,否则编译器将无法自动执行析构过程。

一个有趣的现象是,成员对象初始化的次序完全不受它们在初始化表中次序的影响,只由成员对象在类中声明的次序决定。这是因为类的声明是唯一的,而类的构造函数可以有多个,因此会有多个不同次序的初始化表。如果成员对象按照初始化表的次序进行构造,这将导致析构函数无法得到唯一的逆序。[Eckel,
p260-261]

9.4
示例:类String的构造函数与析构函数

// String的普通构造函数

String::String(const char *str)

{

if(str==NULL)

{

m_data = new char[1];

*m_data = ‘’;

}

else

{

int length = strlen(str);

m_data = new char[length+1];

strcpy(m_data, str);

}

}

 

// String的析构函数

String::~String(void)

{

delete [] m_data;

// 由于m_data是内部数据类型,也可以写成 delete m_data;

}

9.5 不要轻视拷贝构造函数与赋值函数

由于并非所有的对象都会使用拷贝构造函数和赋值函数,程序员可能对这两个函数有些轻视。请先记住以下的警告,在阅读正文时就会多心:

本章开头讲过,如果不主动编写拷贝构造函数和赋值函数,编译器将以“位拷贝”的方式自动生成缺省的函数。倘若类中含有指针变量,那么这两个缺省的函数就隐含了错误。以类String的两个对象a,b为例,假设a.m_data的内容为“hello”,b.m_data的内容为“world”。

现将a赋给b,缺省赋值函数的“位拷贝”意味着执行b.m_data =
a.m_data。这将造成三个错误:一是b.m_data原有的内存没被释放,造成内存泄露;二是b.m_data和a.m_data指向同一块内存,a或b任何一方变动都会影响另一方;三是在对象被析构时,m_data被释放了两次。

 

拷贝构造函数和赋值函数非常容易混淆,常导致错写、错用。拷贝构造函数是在对象被创建时调用的,而赋值函数只能被已经存在了的对象调用。以下程序中,第三个语句和第四个语句很相似,你分得清楚哪个调用了拷贝构造函数,哪个调用了赋值函数吗?

String a(“hello”);

String b(“world”);

String c = a; // 调用了拷贝构造函数,最好写成 c(a);

c = b; // 调用了赋值函数

本例中第三个语句的风格较差,宜改写成String c(a) 以区别于第四个语句。

9.6
示例:类String的拷贝构造函数与赋值函数

// 拷贝构造函数

String::String(const String &other)

{

// 允许操作other的私有成员m_data

int length = strlen(other.m_data);

m_data = new char[length+1];

strcpy(m_data, other.m_data);

}

 

// 赋值函数

String & String::operate =(const String
&other)

{

// (1) 检查自赋值

if(this == &other)

return *this;

 

// (2) 释放原有的内存资源

delete [] m_data;

 

// (3)分配新的内存资源,并复制内容

int length = strlen(other.m_data);

m_data = new char[length+1];

strcpy(m_data, other.m_data);

 

// (4)返回本对象的引用

return *this;

}

 

类String拷贝构造函数与普通构造函数(参见9.4节)的区别是:在函数入口处无需与NULL进行比较,这是因为“引用”不可能是NULL,而“指针”可以为NULL。

类String的赋值函数比构造函数复杂得多,分四步实现:

(1)第一步,检查自赋值。你可能会认为多此一举,难道有人会愚蠢到写出 a = a
这样的自赋值语句!的确不会。但是间接的自赋值仍有可能出现,例如

// 内容自赋值

b = a;

c = b;

a = c;

// 地址自赋值

b = &a;

a = *b;

也许有人会说:“即使出现自赋值,我也可以不理睬,大不了化点时间让对象复制自己而已,反正不会出错!”

他真的说错了。看看第二步的delete,自杀后还能复制自己吗?所以,如果发现自赋值,应该马上终止函数。注意不要将检查自赋值的if语句

if(this == &other)

错写成为

if( *this == other)

(2)第二步,用delete释放原有的内存资源。如果现在不释放,以后就没机会了,将造成内存泄露。

(3)第三步,分配新的内存资源,并复制字符串。注意函数strlen返回的是有效字符串长度,不包含结束符‘’。函数strcpy则连‘’一起复制。

(4)第四步,返回本对象的引用,目的是为了实现象 a = b = c 这样的链式表达。注意不要将 return *this
错写成 return this 。那么能否写成return other 呢?效果不是一样吗?

不可以!因为我们不知道参数other的生命期。有可能other是个临时对象,在赋值结束后它马上消失,那么return
other返回的将是垃圾。

9.7
偷懒的办法处理拷贝构造函数与赋值函数

如果我们实在不想编写拷贝构造函数和赋值函数,又不允许别人使用编译器生成的缺省函数,怎么办?

偷懒的办法是:只需将拷贝构造函数和赋值函数声明为私有函数,不用编写代码。

例如:

class A

{ …

private:

A(const A &a); // 私有的拷贝构造函数

A & operate =(const A &a); //
私有的赋值函数

};

 

如果有人试图编写如下程序:

A b(a); // 调用了私有的拷贝构造函数

b = a; // 调用了私有的赋值函数

编译器将指出错误,因为外界不可以操作A的私有函数。

9.8 如何在派生类中实现类的基本函数

基类的构造函数、析构函数、赋值函数都不能被派生类继承。如果类之间存在继承关系,在编写上述基本函数时应注意以下事项:

u 派生类的构造函数应在其初始化表里调用基类的构造函数。

u 基类与派生类的析构函数应该为虚(即加virtual关键字)。例如

#include

class Base

{

public:

virtual ~Base() { cout<< "~Base"
<< endl ; }

};

 

class Derived : public Base

{

public:

virtual ~Derived() { cout<<
"~Derived" << endl ; }

};

 

void main(void)

{

Base * pB = new Derived; // upcast

delete pB;

}

 

输出结果为:

~Derived

~Base

如果析构函数不为虚,那么输出结果为

~Base

 

u 在编写派生类的赋值函数时,注意不要忘记对基类的数据成员重新赋值。例如:

class Base

{

public:

Base & operate =(const Base
&other); // 类Base的赋值函数

private:

int m_i, m_j, m_k;

};

 

class Derived : public Base

{

public:

Derived & operate =(const Derived
&other); // 类Derived的赋值函数

private:

int m_x, m_y, m_z;

};

 

Derived & Derived::operate =(const Derived
&other)

{

//(1)检查自赋值

if(this == &other)

return *this;

 

//(2)对基类的数据成员重新赋值

Base::operate =(other); // 因为不能直接操作私有数据成员

 

//(3)对派生类的数据成员赋值

m_x = other.m_x;

m_y = other.m_y;

m_z = other.m_z;

 

//(4)返回本对象的引用

return *this;

}

 

9.9 一些心得体会

有些C++程序设计书籍称构造函数、析构函数和赋值函数是类的“Big-Three”,它们的确是任何类最重要的函数,不容轻视。

也许你认为本章的内容已经够多了,学会了就能平安无事,我不能作这个保证。如果你希望吃透“Big-Three”,请好好阅读参考文献[Cline]
[Meyers] [Murry]。

高质量C++教程 — 第8章 C++函数的高级特性

高质量C++教程 -- 第8章 C++函数的高级特性
来源:www.vcworld.net 

对比于C语言的函数,C++增加了重载(overloaded)、内联(inline)、const和virtual四种新机制。其中重载和内联机制既可用于全局函数也可用于类的成员函数,const与virtual机制仅用于类的成员函数。

重载和内联肯定有其好处才会被C++语言采纳,但是不可以当成免费的午餐而滥用。本章将探究重载和内联的优点与局限性,说明什么情况下应该采用、不该采用以及要警惕错用。

8.1 函数重载的概念

8.1.1 重载的起源

自然语言中,一个词可以有许多不同的含义,即该词被重载了。人们可以通过上下文来判断该词到底是哪种含义。“词的重载”可以使语言更加简练。例如“吃饭”的含义十分广泛,人们没有必要每次非得说清楚具体吃什么不可。别迂腐得象孔已己,说茴香豆的茴字有四种写法。

在C++程序中,可以将语义、功能相似的几个函数用同一个名字表示,即函数重载。这样便于记忆,提高了函数的易用性,这是C++语言采用重载机制的一个理由。例如示例8-1-1中的函数EatBeef,EatFish,EatChicken可以用同一个函数名Eat表示,用不同类型的参数加以区别。

void EatBeef(…); // 可以改为 void Eat(Beef …);

void EatFish(…); // 可以改为 void Eat(Fish …);

void EatChicken(…); // 可以改为 void Eat(Chicken …);

示例8-1-1 重载函数Eat

C++语言采用重载机制的另一个理由是:类的构造函数需要重载机制。因为C++规定构造函数与类同名(请参见第9章),构造函数只能有一个名字。如果想用几种不同的方法创建对象该怎么办?别无选择,只能用重载机制来实现。所以类可以有多个同名的构造函数。

8.1.2 重载是如何实现的?

几个同名的重载函数仍然是不同的函数,它们是如何区分的呢?我们自然想到函数接口的两个要素:参数与返回值。

如果同名函数的参数不同(包括类型、顺序不同),那么容易区别出它们是不同的函数。

如果同名函数仅仅是返回值类型不同,有时可以区分,有时却不能。例如:

void Function(void);

int Function (void);

上述两个函数,第一个没有返回值,第二个的返回值是int类型。如果这样调用函数:

int x = Function ();

则可以判断出Function是第二个函数。问题是在C++/C程序中,我们可以忽略函数的返回值。在这种情况下,编译器和程序员都不知道哪个Function函数被调用。

所以只能靠参数而不能靠返回值类型的不同来区分重载函数。编译器根据参数为每个重载函数产生不同的内部标识符。例如编译器为示例8-1-1中的三个Eat函数产生象_eat_beef、_eat_fish、_eat_chicken之类的内部标识符(不同的编译器可能产生不同风格的内部标识符)。

如果C++程序要调用已经被编译后的C函数,该怎么办?

假设某个C函数的声明如下:

void foo(int x, int y);

该函数被C编译器编译后在库中的名字为_foo,而C++编译器则会产生像_foo_int_int之类的名字用来支持函数重载和类型安全连接。由于编译后的名字不同,C++程序不能直接调用C函数。C++提供了一个C连接交换指定符号extern“C”来解决这个问题。例如:

extern “C”

{

void foo(int x, int y);

… // 其它函数

}

或者写成

extern “C”

{

#include “myheader.h”

… // 其它C头文件

}

这就告诉C++编译译器,函数foo是个C连接,应该到库中找名字_foo而不是找_foo_int_int。C++编译器开发商已经对C标准库的头文件作了extern“C”处理,所以我们可以用#include
直接引用这些头文件。

注意并不是两个函数的名字相同就能构成重载。全局函数和类的成员函数同名不算重载,因为函数的作用域不同。例如:

void Print(…); // 全局函数

class A

{…

void Print(…); // 成员函数

}

不论两个Print函数的参数是否不同,如果类的某个成员函数要调用全局函数Print,为了与成员函数Print区别,全局函数被调用时应加‘::’标志。如

::Print(…); // 表示Print是全局函数而非成员函数

 

8.1.3 当心隐式类型转换导致重载函数产生二义性

示例8-1-3中,第一个output函数的参数是int类型,第二个output函数的参数是float类型。由于数字本身没有类型,将数字当作参数时将自动进行类型转换(称为隐式类型转换)。语句output(0.5)将产生编译错误,因为编译器不知道该将0.5转换成int还是float类型的参数。隐式类型转换在很多地方可以简化程序的书写,但是也可能留下隐患。

# include

void output( int x); // 函数声明

void output( float x); // 函数声明

 

void output( int x)

{

cout << " output int "
<< x <<
endl ;

}

 

void output( float x)

{

cout << " output float "
<< x <<
endl ;

}

 

void main(void)

{

int x = 1;

float y = 1.0;

output(x); // output int 1

output(y); // output float 1

output(1); // output int 1

// output(0.5); // error! ambiguous call, 因为自动类型转换

output(int(0.5)); // output int 0

output(float(0.5)); // output float 0.5

}

示例8-1-3 隐式类型转换导致重载函数产生二义性

8.2 成员函数的重载、覆盖与隐藏

成员函数的重载、覆盖(override)与隐藏很容易混淆,C++程序员必须要搞清楚概念,否则错误将防不胜防。

8.2.1 重载与覆盖

成员函数被重载的特征:

(1)相同的范围(在同一个类中);

(2)函数名字相同;

(3)参数不同;

(4)virtual关键字可有可无。

覆盖是指派生类函数覆盖基类函数,特征是:

(1)不同的范围(分别位于派生类与基类);

(2)函数名字相同;

(3)参数相同;

(4)基类函数必须有virtual关键字。

示例8-2-1中,函数Base::f(int)与Base::f(float)相互重载,而Base::g(void)被Derived::g(void)覆盖。

#include

class Base

{

public:

void f(int x){ cout <<
"Base::f(int) " << x
<< endl; }

void f(float x){ cout <<
"Base::f(float) " << x
<< endl; }

virtual void g(void){ cout <<
"Base::g(void)" << endl;}

};

class Derived : public Base

{

public:

virtual void g(void){ cout <<
"Derived::g(void)" << endl;}

};

void main(void)

{

Derived d;

Base *pb = &d;

pb->f(42); // Base::f(int) 42

pb->f(3.14f); // Base::f(float) 3.14

pb->g(); // Derived::g(void)

}

示例8-2-1成员函数的重载和覆盖

8.2.2 令人迷惑的隐藏规则

本来仅仅区别重载与覆盖并不算困难,但是C++的隐藏规则使问题复杂性陡然增加。这里“隐藏”是指派生类的函数屏蔽了与其同名的基类函数,规则如下:

(1)如果派生类的函数与基类的函数同名,但是参数不同。此时,不论有无virtual关键字,基类的函数将被隐藏(注意别与重载混淆)。

(2)如果派生类的函数与基类的函数同名,并且参数也相同,但是基类函数没有virtual关键字。此时,基类的函数被隐藏(注意别与覆盖混淆)。

示例程序8-2-2(a)中:

(1)函数Derived::f(float)覆盖了Base::f(float)。

(2)函数Derived::g(int)隐藏了Base::g(float),而不是重载。

(3)函数Derived::h(float)隐藏了Base::h(float),而不是覆盖。

#include

class Base

{

public:

virtual void f(float x){ cout <<
"Base::f(float) " << x
<< endl; }

void g(float x){ cout <<
"Base::g(float) " << x
<< endl; }

void h(float x){ cout <<
"Base::h(float) " << x
<< endl; }

};

class Derived : public Base

{

public:

virtual void f(float x){ cout <<
"Derived::f(float) " << x
<< endl; }

void g(int x){ cout <<
"Derived::g(int) " << x
<< endl; }

void h(float x){ cout <<
"Derived::h(float) " << x
<< endl; }

};

示例8-2-2(a)成员函数的重载、覆盖和隐藏

据作者考察,很多C++程序员没有意识到有“隐藏”这回事。由于认识不够深刻,“隐藏”的发生可谓神出鬼没,常常产生令人迷惑的结果。

示例8-2-2(b)中,bp和dp指向同一地址,按理说运行结果应该是相同的,可事实并非这样。

void main(void)

{

Derived d;

Base *pb = &d;

Derived *pd = &d;

// Good : behavior depends solely on type of the object

pb->f(3.14f); // Derived::f(float) 3.14

pd->f(3.14f); // Derived::f(float) 3.14

 

// Bad : behavior depends on type of the pointer

pb->g(3.14f); // Base::g(float) 3.14

pd->g(3.14f); // Derived::g(int) 3
(surprise!)

 

// Bad : behavior depends on type of the pointer

pb->h(3.14f); // Base::h(float) 3.14
(surprise!)

pd->h(3.14f); // Derived::h(float) 3.14

}

示例8-2-2(b) 重载、覆盖和隐藏的比较

8.2.3 摆脱隐藏

隐藏规则引起了不少麻烦。示例8-2-3程序中,语句pd->f(10)的本意是想调用函数Base::f(int),但是Base::f(int)不幸被Derived::f(char
*)隐藏了。由于数字10不能被隐式地转化为字符串,所以在编译时出错。

class Base

{

public:

void f(int x);

};

class Derived : public Base

{

public:

void f(char *str);

};

void Test(void)

{

Derived *pd = new Derived;

pd->f(10); // error

}

示例8-2-3 由于隐藏而导致错误

 

从示例8-2-3看来,隐藏规则似乎很愚蠢。但是隐藏规则至少有两个存在的理由:

写语句pd->f(10)的人可能真的想调用Derived::f(char
*)函数,只是他误将参数写错了。有了隐藏规则,编译器就可以明确指出错误,这未必不是好事。否则,编译器会静悄悄地将错就错,程序员将很难发现这个错误,流下祸根。

假如类Derived有多个基类(多重继承),有时搞不清楚哪些基类定义了函数f。如果没有隐藏规则,那么pd->f(10)可能会调用一个出乎意料的基类函数f。尽管隐藏规则看起来不怎么有道理,但它的确能消灭这些意外。

 

示例8-2-3中,如果语句pd->f(10)一定要调用函数Base::f(int),那么将类Derived修改为如下即可。

class Derived : public Base

{

public:

void f(char *str);

void f(int x) { Base::f(x); }

};

8.3 参数的缺省值

有一些参数的值在每次函数调用时都相同,书写这样的语句会使人厌烦。C++语言采用参数的缺省值使书写变得简洁(在编译时,缺省值由编译器自动插入)。

参数缺省值的使用规则:

【规则8-3-1】参数缺省值只能出现在函数的声明中,而不能出现在定义体中。

例如:

void Foo(int x=0, int y=0); // 正确,缺省值出现在函数的声明中

 

void Foo(int x=0, int y=0) // 错误,缺省值出现在函数的定义体中

{

}

为什么会这样?我想是有两个原因:一是函数的实现(定义)本来就与参数是否有缺省值无关,所以没有必要让缺省值出现在函数的定义体中。二是参数的缺省值可能会改动,显然修改函数的声明比修改函数的定义要方便。

 

【规则8-3-2】如果函数有多个参数,参数只能从后向前挨个儿缺省,否则将导致函数调用语句怪模怪样。

正确的示例如下:

void Foo(int x, int y=0, int z=0);

错误的示例如下:

void Foo(int x=0, int y, int z=0);

要注意,使用参数的缺省值并没有赋予函数新的功能,仅仅是使书写变得简洁一些。它可能会提高函数的易用性,但是也可能会降低函数的可理解性。所以我们只能适当地使用参数的缺省值,要防止使用不当产生负面效果。示例8-3-2中,不合理地使用参数的缺省值将导致重载函数output产生二义性。

#include

void output( int x);

void output( int x, float y=0.0);

void output( int x)

{

cout << " output int "
<< x <<
endl ;

}

void output( int x, float y)

{

cout << " output int "
<< x <<
" and float " << y
<< endl ;

}

void main(void)

{

int x=1;

float y=0.5;

// output(x); // error! ambiguous call

output(x,y); // output int 1 and float 0.5

}

示例8-3-2 参数的缺省值将导致重载函数产生二义性

8.4 运算符重载

8.4.1 概念

在C++语言中,可以用关键字operator加上运算符来表示函数,叫做运算符重载。例如两个复数相加函数:

Complex Add(const Complex &a, const Complex
&b);

可以用运算符重载来表示:

Complex operator +(const Complex &a, const
Complex &b);

运算符与普通函数在调用时的不同之处是:对于普通函数,参数出现在圆括号内;而对于运算符,参数出现在其左、右侧。例如

Complex a, b, c;

c = Add(a, b); // 用普通函数

c = a + b; // 用运算符 +

如果运算符被重载为全局函数,那么只有一个参数的运算符叫做一元运算符,有两个参数的运算符叫做二元运算符。

如果运算符被重载为类的成员函数,那么一元运算符没有参数,二元运算符只有一个右侧参数,因为对象自己成了左侧参数。

从语法上讲,运算符既可以定义为全局函数,也可以定义为成员函数。文献[Murray ,
p44-p47]对此问题作了较多的阐述,并总结了表8-4-1的规则。

运算符

规则
所有的一元运算符 建议重载为成员函数
= () [] -> 只能重载为成员函数
+= -= /= *= &= |= ~= %=
>>=
<<=
建议重载为成员函数
所有其它运算符 建议重载为全局函数

表8-4-1 运算符的重载规则

由于C++语言支持函数重载,才能将运算符当成函数来用,C语言就不行。我们要以平常心来对待运算符重载:

(1)不要过分担心自己不会用,它的本质仍然是程序员们熟悉的函数。

(2)不要过分热心地使用,如果它不能使代码变得更加易读易写,那就别用,否则会自找麻烦。

8.4.2 不能被重载的运算符

在C++运算符集合中,有一些运算符是不允许被重载的。这种限制是出于安全方面的考虑,可防止错误和混乱。

(1)不能改变C++内部数据类型(如int,float等)的运算符。

(2)不能重载‘.’,因为‘.’在类中对任何成员都有意义,已经成为标准用法。

(3)不能重载目前C++运算符集合中没有的符号,如#,@,$等。原因有两点,一是难以理解,二是难以确定优先级。

(4)对已经存在的运算符进行重载时,不能改变优先级规则,否则将引起混乱。

8.5 函数内联

8.5.1 用内联取代宏代码

C++ 语言支持函数内联,其目的是为了提高函数的执行效率(速度)。

在C程序中,可以用宏代码提高执行效率。宏代码本身不是函数,但使用起来象函数。预处理器用复制宏代码的方式代替函数调用,省去了参数压栈、生成汇编语言的CALL调用、返回参数、执行return等过程,从而提高了速度。使用宏代码最大的缺点是容易出错,预处理器在复制宏代码时常常产生意想不到的边际效应。例如

#define MAX(a, b) (a) > (b) ? (a) : (b)

语句

result = MAX(i, j) + 2 ;

将被预处理器解释为

result = (i) > (j) ? (i) : (j) + 2 ;

由于运算符‘+’比运算符‘:’的优先级高,所以上述语句并不等价于期望的

result = ( (i) > (j) ? (i) : (j) ) + 2 ;

如果把宏代码改写为

#define MAX(a, b) ( (a) > (b) ? (a) : (b) )

则可以解决由优先级引起的错误。但是即使使用修改后的宏代码也不是万无一失的,例如语句

result = MAX(i++, j);

将被预处理器解释为

result = (i++) > (j) ? (i++) : (j);

对于C++ 而言,使用宏代码还有另一种缺点:无法操作类的私有数据成员。

让我们看看C++
的“函数内联”是如何工作的。对于任何内联函数,编译器在符号表里放入函数的声明(包括名字、参数类型、返回值类型)。如果编译器没有发现内联函数存在错误,那么该函数的代码也被放入符号表里。在调用一个内联函数时,编译器首先检查调用是否正确(进行类型安全检查,或者进行自动类型转换,当然对所有的函数都一样)。如果正确,内联函数的代码就会直接替换函数调用,于是省去了函数调用的开销。这个过程与预处理有显著的不同,因为预处理器不能进行类型安全检查,或者进行自动类型转换。假如内联函数是成员函数,对象的地址(this)会被放在合适的地方,这也是预处理器办不到的。

C++ 语言的函数内联机制既具备宏代码的效率,又增加了安全性,而且可以自由操作类的数据成员。所以在C++
程序中,应该用内联函数取代所有宏代码,“断言assert”恐怕是唯一的例外。assert是仅在Debug版本起作用的宏,它用于检查“不应该”发生的情况。为了不在程序的Debug版本和Release版本引起差别,assert不应该产生任何副作用。如果assert是函数,由于函数调用会引起内存、代码的变动,那么将导致Debug版本与Release版本存在差异。所以assert不是函数,而是宏。(参见6.5节“使用断言”)

8.5.2 内联函数的编程风格

关键字inline必须与函数定义体放在一起才能使函数成为内联,仅将inline放在函数声明前面不起任何作用。如下风格的函数Foo不能成为内联函数:

inline void Foo(int x, int y); // inline仅与函数声明放在一起

void Foo(int x, int y)

{

}

而如下风格的函数Foo则成为内联函数:

void Foo(int x, int y);

inline void Foo(int x, int y) // inline与函数定义体放在一起

{

}

所以说,inline是一种“用于实现的关键字”,而不是一种“用于声明的关键字”。一般地,用户可以阅读函数的声明,但是看不到函数的定义。尽管在大多数教科书中内联函数的声明、定义体前面都加了inline关键字,但我认为inline不应该出现在函数的声明中。这个细节虽然不会影响函数的功能,但是体现了高质量C++/C程序设计风格的一个基本原则:声明与定义不可混为一谈,用户没有必要、也不应该知道函数是否需要内联。

定义在类声明之中的成员函数将自动地成为内联函数,例如

class A

{

public:

void Foo(int x, int y) { … } // 自动地成为内联函数

}

将成员函数的定义体放在类声明之中虽然能带来书写上的方便,但不是一种良好的编程风格,上例应该改成:

// 头文件

class A

{

public:

void Foo(int x, int y);

}

// 定义文件

inline void A::Foo(int x, int y)

{

}

8.5.3 慎用内联

内联能提高函数的执行效率,为什么不把所有的函数都定义成内联函数?

如果所有的函数都是内联函数,还用得着“内联”这个关键字吗?

内联是以代码膨胀(复制)为代价,仅仅省去了函数调用的开销,从而提高函数的执行效率。如果执行函数体内代码的时间,相比于函数调用的开销较大,那么效率的收获会很少。另一方面,每一处内联函数的调用都要复制代码,将使程序的总代码量增大,消耗更多的内存空间。以下情况不宜使用内联:

(1)如果函数体内的代码比较长,使用内联将导致内存消耗代价较高。

(2)如果函数体内出现循环,那么执行函数体内代码的时间要比函数调用的开销大。

类的构造函数和析构函数容易让人误解成使用内联更有效。要当心构造函数和析构函数可能会隐藏一些行为,如“偷偷地”执行了基类或成员对象的构造函数和析构函数。所以不要随便地将构造函数和析构函数的定义体放在类声明中。

一个好的编译器将会根据函数的定义体,自动地取消不值得的内联(这进一步说明了inline不应该出现在函数的声明中)。

8.6 一些心得体会

C++
语言中的重载、内联、缺省参数、隐式转换等机制展现了很多优点,但是这些优点的背后都隐藏着一些隐患。正如人们的饮食,少食和暴食都不可取,应当恰到好处。我们要辨证地看待C++的新机制,应该恰如其分地使用它们。虽然这会使我们编程时多费一些心思,少了一些痛快,但这才是编程的艺术。