建构子乃用来从零开始建立对象。
建构子就像个「初始化函数」;它把一堆散乱的字节成一个活生生的对象。最低限
度它会初始化内部用到的字段元,也可能会配置所须的资源(内存、档案、semaphore
、socket 等等)。
"ctor" 是建构子 constructor 最常见的缩写。
========================================
Q18:怎样才能让建构子呼叫另一个同处一室的建构子?
没有办法。
原因是:如果你呼叫另一个建构子,编译器会初始化一个暂时的区域性对象;但并没
有初始化“这个”你想要的对象。你可以用预设参数(default parameter),将两
个建构子合并起来,或是在私有的 "init()" 成员函数中共享它们的程序代码。
========================================
Q19:解构子(destructor)是做什么的?
解构子乃对象之葬礼。
解构子是用来释放该对象所配置到的资源,譬如:Lock 类别可能会锁住一个
semaphore,解构子则用来释放它。最常见的例子是:当建构子用了 "new" 以后,解
构子用 "delete"。
解构子是个「去死吧」的运作行为(method),通常缩写为 "dtor"。
=========================
■□ 第6节:运操作数多载
=========================
Q20:运操作数多载(operator overloading)是做什么的?
它可让使用类别的人以直觉来操作之。
运操作数多载让 C/C++ 的运操作数,能对自订的型态(对象类别)赋予自订的意义。它
们形同是函数呼叫的语法糖衣 (syntactic sugar):
class Fred {
public:
//...
};
#if 0
Fred add(Fred, Fred); //没有运操作数多载
Fred mul(Fred, Fred);
#else
Fred operator+(Fred, Fred); //有运操作数多载
Fred operator*(Fred, Fred);
#endif
Fred f(Fred a, Fred b, Fred c)
{
#if 0
return add(add(mul(a,b), mul(b,c)), mul(c,a)); //没有...
#else
return a*b + b*c + c*a; //有...
#endif
}
========================================
Q21:哪些运操作数可以/不能被多载?
大部份都可以被多载。
不能的 C 运操作数有 "." 和 "?:"(和以技术上来说,可算是运操作数的 "sizeof")。
C++ 增加了些自己的运操作数,其中除了 "::" 和 ".*". 之外都可以被多载。
底下是个足标(subscript)运操作数的例子(它会传回一个参考)。最前面是“不用
”多载的:
class Array {
public:
#if 0
int& elem(unsigned i) { if (i>99) error(); return data[i]; }
#else
int& operator[] (unsigned i) { if (i>99) error(); return data[i]; }
#endif
private:
int data[100];
};
main()
{
Array a;
#if 0
a.elem(10) = 42;
a.elem(12) += a.elem(13);
#else
a[10] = 42;
a[12] += a[13];
#endif
}
========================================
Q22:怎样做一个 "**"「次方」运操作数?
无解。
运操作数的名称、优先序、结合律以及元数(arity)都被语言所定死了。C++ 里没有
"**" 运操作数,所以你无法替类别订做一个它。
还怀疑的话,考虑看看 "x ** y" 和 "x * (*y)",这两者是完全一样的(换句话说
,编译器会假设 "y" 是个指针)。此外,运操作数多载只是函数呼叫的语法糖衣而已
,虽然甜甜的,但本质上并未增加什么东西。我建议你多载 "pow(base,exponent)"
这个函数(它的倍精确度版本在 <math.h> 中)。
附带一提:operator^ 可以用,但它的优先序及结合律不符「次方」所需。
===================
■□ 第7节:伙伴
===================
Q23:伙伴(friend)是什么?
让别的类别或函数能存取到你的类别内部的东西。
伙伴可以是函数或其它类别。类别会对它的伙伴开放存取权限。正常情况下,程序员
会下意识﹑技术性地控制该类别的伙伴与运作行为(否则当你想更动类别时,还得先
有其它部份的拥有者之同意才行)。
========================================
Q24:「伙伴」违反了封装性吗?
若善用之,反而会「强化」封装性。
我们经常得将一个类别切成两半,当这两半各有不同的案例个数及生命期时。在此情
形之下,它们通常需要直接存取对方的内部(这两半“本来”是在同一个类别里面,
所以你并未“增加”存取数据结构的运作行为个数;你只是在“搬动”这些运作行为
所在之处而已)。最安全的实作方式,就是让这两半互为彼此的「伙伴」。
若你如上述般的使用伙伴,你依然是将私有的东西保持在私有的状态。遇到上述的情
况,如果还呆呆的想避免使用伙伴关系,许多人不是采用公共资料(糟透了!),就
是弄个公共的 get/set 存取运作行为来存取彼此的资料,事实上这些都破坏了封装
性。只有在类别的外面该私有资料「仍有其意义」(以使用者的角度来看)时,开放
出私有资料的存取运作行为才称得上是恰当的做法。多数情况下,「存取运作行为」
就和「公共资料」一样糟糕:它们对私有资料成员只隐其“名”而已,却未隐藏其“
存在”。
同样的,如果将「伙伴函数」做为另一种类别公共存取函数的语法,那就和违反封装
性的成员函数一样破坏了封装。换句话说,对象类别的伙伴及成员都是「封装的界线
」,如同「类别定义」本身一样。
========================================
Q25:伙伴函数的优缺点?
它提供了某种接口设计上的自由。
成员函数和伙伴函数都有同等的存取特权(100% 的权利),主要的差别在于:伙伴
函数用起来像是 "f(x)",而成员函数则是 "x.f()"。因此,伙伴函数可让对象类别
设计者挑选他看得最顺眼的语法,以降低维护成本。
伙伴函数主要的缺点在于:当你想做动态系结(dynamic binding)时,它需要额外
的程序代码。想做出「虚拟伙伴」的效果,该伙伴函数应该呼叫个隐藏的(通常是放在
"protected:" 里)虚拟成员函数;像这个样子:"void f(Base& b) { b.do_f(); }"
。衍生类别会覆盖(override)掉那个隐藏的成员函数("void Derived::do_f()")
,而不是该伙伴函数。
========================================
Q26:「伙伴关系无继承及递移性」是什么意思?
伙伴关系的特权性无法被继承下来:伙伴的衍生类别不必然还是伙伴(我把你当朋友
,但这不代表我也一定会信任你的孩子)。如果 "Base" 类别宣告了 "f()" 为它的
伙伴,"f()" 并不会自动对由 "Base" 衍生出来的 "Derived" 类别所多出来的部份
拥有特殊的存取权力。
伙伴关系的特权无递移性:伙伴类别的伙伴不必然还是原类别的伙伴(朋友的朋友不
一定也是朋友)。譬如,如果 "Fred" 类别宣告了 "Wilma" 类别为它的伙伴,而且
"Wilma" 类别宣告了 "f()" 为它的伙伴,则 "f()" 不见得对 "Fred" 有特殊的存取
权力。
========================================
Q27:应该替类别宣告个成员函数,还是伙伴函数?
可能的话,用成员函数;必要时,就用伙伴。
有时在语法上来看,伙伴比较好(譬如:在 "Fred" 类别中,伙伴函数可把 "Fred"
弄成是第二个参数,但在成员函数中则一定得放在第一个)。另一个好例子是:二元
中序式算数运操作数(譬如:"aComplex + aComplex" 可能应该定义成伙伴而非成员函
数,因为你想让 "aFloat + aComplex" 这种写法也能成立;回想一下:成员函数无
法提升它左侧的参数,因为那样会把引发该成员函数的对象所属之类别给改变掉)。
在其它情况下,请选成员函数而不要用伙伴函数。
====================================================
■□ 第8节:输入/输出:<iostream.h> 和 <stdio.h>
====================================================
Q28:该怎样替 "class Fred" 提供输出功能?
用伙伴函数 operator<<:
class Fred {
public:
friend ostream& operator<< (ostream& o, const Fred& fred)
{ return o << fred.i; }
//...
private:
int i; //只为了说明起见而设的
};
我们用伙伴而不用成员函数,因为 "Fred" 是第二个参数而非第一个。输入的功能亦
类似,只是要改写成:
istream& operator>> (istream& i, Fred& fred);
// ^^^^^------- 不是 "const Fred& fred"!
========================================
Q29:为什么我该用 <iostream.h> 而不是以前的 <stdio.h>?
增加型别安全、减少错误、增进效率、有延展性、提供衍生能力。
Printf 还好,而 scanf 除了容易写错之外也还算可以,然而和 C++ 的 I/O 系统相
比,它们都有其限制。C++ 的 I/O(用 "<<" 及 ">>" ),和 C( "printf()" 和
"scanf()" )相比:
* 型别安全--要做 I/O 的对象,编译器会静态地事先得知其型别,而不是动态地
由 "%" 一栏查知。
* 不易出错--冗余的信息会增加错误的机会。C++ 的 I/O 就不需要多余的 "%"。
* 更快速--printf 是个小型语言的「解译器」,该语言主要是由 "%" 这种东西
构成的;在执行期它用这些字段来选择正确的格式化方式。C++ 的 I/O 系统则是
静态的依各自变量真正的型别来挑选子程序,以增进执行效率。
* 延展性--C++ I/O 机制可在不改动原有程序代码的情况下,就加进使用者新设计
的型态(能想象如果大家同时把互不兼容的 "%" 字段塞入 printf 和 scanf,会
是怎样的混乱场面?!)。
* 可衍生(subclassable)--ostream 和 istream(C++ 的 FILE* 代替品)都是
真正的类别,因此可以被衍生下去。这意味着:你可以让其它自定的东西有着和
stream 雷同的外表与行为,但实际上做的却是你想做的特定事情。你自动就重用
了数以万计别人(你甚至不认识它们)写好的 I/O 程序代码,而他们也不需要知道
你所做的「延伸 stream」类别。
========================================
Q30:为什么我处理输入时,会超过档案的结尾?
因为 eof(档案结尾)的状态,是到「将要超过档案结尾的动作」才会被设定。也就
是说,读档案的最后一个字节并不会设定 eof 的状态。
【译注】这也是 C 常见的错误。
如果你的程序像这样:
int i = 0;
while (! cin.eof()) {
cin >> x;
++i;
// work with x
}
你的 i 变量就会多了一。
你真正该做的是这样:
int i;
while (cin >> x) {
++i;
// work with x
}
========================================
Q31:为什么我的程序执行完第一次循环后,会对输入的要求不加理睬?
因为读取数值的程序,把非数字的字符留在输入缓冲区 (input buffer) 里头了。
【译注】这也是 C,甚至 Pascal 常见的错误。
如果你的程序如下:
char name[1000];
int age;
for (;;) {
cout << "Name: ";
cin >> name;
cout << "Age: ";
cin >> age;
}
你应该这样写:
for (;;) {
cout << "Name: ";
cin >> name;
cout << "Age: ";
cin >> age;
cin.ignore(INT_MAX, '\n');
}
========================================
Q32:在 DOS 及 OS/2 的 binary 模式下,要怎样来 "reopen" cin 及 cout?
有这个问题,最典型的情况就是:有人想对 cin、cout 做 binary 的 I/O,但是作
业系统(像是 DOS 或 OS/2)却总是会做 CR-LF 的转换动作。
解决法:cin、cout、cerr 这些事先定义好的串流,都是 text 的串流,没有标准做
法能把它们弄成 binary 模式。把串流关掉再设法以 binary 模式 reopen 它们,可
能会导致不可预期的结果。
在这两种模式有不同行为的系统上,一定有办法让它们变成 binary 串流,但是你得
去查查该系统的文件。