条款25 避免对指针和数字类型重载
什么是零?
1
2
3
|
void
f(
int
x);
void
f(string *ps);
f(0);
// 调用f(int)还是f(string*)?
|
>0是一个int, 一个字面上的整数常量, 所以被f(int)调用;
Problem: 不是所有的人期望他这样执行; [有人希望是传个空指针NULL - #define成了0]
这是C++中特有的情况: 当人们认为某个调用应该具有多义性时, 编译器却不会那样做;
如果想办法用符号名(NULL表示null指针)来解决这类问题, 实现起来却很难;
声明一个称为NULL的常量, 常量要有类型, NULL的类型要兼容所有的指针类型, 满足条件的唯一类型是void*; 想要把void*指针传给某类型的指针, 必须要有一个显式的类型转换;
1
2
3
4
|
void
*
const
NULL = 0;
// 可能的NULL 定义
f(0);
// 还是调用f(int)
f(
static_cast
<string*>(NULL));
// 调用f(string*)
f(
static_cast
<string*>(0));
// 调用f(string*)
|
>不好看也不方便; 但是用NULL来表示void*常量的方法比原来的好一点, 因为用NULL表示null指针可以避免歧义:
1
2
3
|
f(0);
// 调用f(int)
f(NULL);
// 错误! — 类型不匹配
f(
static_cast
<string*>(NULL));
// 正确, 调用f(string*)
|
>现在把运行时的错误(对0调用"错误的"f函数)变成了编译时的错误(void*传给string*参数);
即使使用预处理, 也解决不了问题:
1) #define NULL 0 或 2) #define NULL ((void*) 0)
1)是字面上的0, 本质上还是一个整数常量; 2) 又回到 传void*指针给某种类型的指针 的麻烦中;
C++认为 从long int 0到null指针的转换 和 从long int到int的转换 一样, 没有问题; 可以利用这一点, 将多义性引入到上面那个可能认为有"int/指针"问题的地方:
1
2
3
4
|
#define NULL 0L // NULL 现在是一个long int
void
f(
int
x);
void
f(string *p);
f(NULL);
// 错误!——歧义
|
但是, 想重载long int和指针时, 他又有问题:
1
2
3
4
|
#define NULL 0L
void
f(
long
int
x);
// 这个f 现在的参数为long
void
f(string *p);
f(NULL);
// 正确, 调用f(long int)
|
[NULL is supposed to be a point.]
实际编程中, 这样比起把NULL定义为int可能会安全些, 但他只是转移了问题, 并没有消除问题;
Solution: 使用C++语言的特性: 成员函数模板
成员函数模板是在类的内部为类生成的成员函数的模板, 简称成员模板;
对于NULL, 需要一个"对每个T类型, 运作起来都想static_cast<T*>(0)表达式"的对象; 使NULL成为一个"包含一个隐式类型转换符"的类的对象, 这个类型转换运算符可以适用于每种可能的指针类型;
1
2
3
4
5
6
7
8
9
10
|
// 一个可以产生NULL 指针对象的类的第一步设计
class
NullClass {
public
:
template
<
class
T> operator T*()
const
{
return
0; }
// 为所有类型的T产生operator T*;
};
// 每个函数返回一个null 指针
//...
const
NullClass NULL;
// NULL 是类型NullClass的一个对象
void
f(
int
x);
// 和以前一样
void
f(string *p);
// 同上
f(NULL);
// 将NULL 转换为string*, 然后调用 f(string*)
|
[值为0, 但是类型为 T]
改进:
1) 实际上只需要一个NullClass对象, 所以没必要给定一个类名字; 只需要定义一个匿名类, 并使NULL成为这种类型;
2) 既然是想让NULL可以转换成任何类型的指针, 那就要能够处理成员指针; 这就需要定义第二个成员模板, 他的作用是为所有的类C和所有的类型T, 将0转换为类型T C::*(指向类C里类型为T的成员, 条款30)
3) 要防止用户取NULL的地址, 希望NULL的行为并不是像指针那样, 而是像指针的值(e.g. 0x453AB002), 指针的值是没有地址的;
改进后的NULL:
1
2
3
4
5
6
7
8
9
10
|
const
// 这是一个const 对象...
class
{
public
:
template
<
class
T>
// 可以转换任何类型 的null 非成员指针
operator T*()
const
{
return
0; }
template
<
class
C,
class
T>
// 可以转换任何类型 的null 成员指针
operator T C::*()
const
{
return
0; }
private
:
void
operator&()
const
;
// 不能取其地址(见条款27)
} NULL;
// 名字为NULL
|
>实际编程中可能会给类一个名字, 否则编译器里指向NULL类型的信息很难理解;
重要的一点是, 以上的产生的能正确工作的NULL设计方案, 只有在你自己是调用者的时候才有意义; 如果是一个给别人使用的NULL, 就没有多大用处, 因为你不能强迫别人使用你写的NULL;
用户还是会有这样的操作:
1
|
f(0);
// 还是调用f(int), 因为 0 还是int
|
Note 作为重载函数的设计者, 原则是只要可能, 就要避免对一个数字和一个指针类型的重载;
条款26 当心潜在的二义性
C++认为潜在的二义性不是错误;
e.g.
1
2
3
4
5
6
7
8
9
10
|
class
B;
// 对类B 提前声明
//
class
A {
public
:
A(
const
B&);
// 可以从B 构造而来的类A
};
class
B {
public
:
operator A()
const
;
// 可以从A 转换而来的类B
};
|
>这些类的声明没错, 可以在相同的程序中共存;
但是当把两个类结合起来使用, 在一个输入参数为A的函数里实际传进一个B的对象:
1
2
3
|
void
f(
const
A&);
B b;
f(b);
// 错误!——二义
|
[不要写奇怪的代码...命名规则和namespace用起来]
>编译器对f的调用, 必须产生一个类型A的对象:
1) 调用类A的构造函数, 以b为参数构造一个新的A对象; 2) 调用类B里自定义的转换运算符, 将b转换成一个A对象; 两种途径都可行, 编译器无法选择;
在没遇到二义性的情况下, 程序运行没问题, 这正是二义性具有潜伏的危害性; 他会潜伏在程序中, 一旦某天程序员真的做了具有二义性的操作, 混乱就会爆发; e.g. 你发布了一个函数库, 它可能在二义的情况下被调用, 你却不知道自己正在这么做;
另一种类似的二义形式源于C++语言的标准转换, 甚至没有涉及到类:
1
2
3
4
|
void
f(
int
);
void
f(
char
);
double
d = 6.02;
f(d);
// 错误!——二义
|
>d转换成int还是char都可行, 编译器无法选择;
Solution: 通过显式类型转换可以解决;
1
2
|
f(
static_cast
<
int
>(d));
// 正确, 调用f(int)
f(
static_cast
<
char
>(d));
// 正确, 调用f(char)
|
多继承(条款43)充满了潜在的二义性的可能; 最常常发生的情况是当一个派生类从多个基类继承了相同的成员名字时:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
class
Base1 {
public
:
int
doIt();
};
class
Base2 {
public
:
void
doIt();
};
class
Derived:
public
Base1,
public
Base2 {
// Derived 没有声明一个叫做doIt 的函数
};
//...
Derived d;
d.doIt();
// 错误!——二义
|
>当Derived继承两个具有相同名字的函数时, C++没有报错, 这时二义性只是潜在的; 当对doIt()的调用迫使编译器做出选择时, 除非显式地通过指明函数的基类来消除二义, 否则函数调用就会报错:
1
2
|
d.Base1::doIt();
// 正确, 调用Base1::doIt
d.Base2::doIt();
// 正确, 调用Base2::doIt
|
如果给以上的代码加上访问权限会怎样:
1
2
3
4
5
6
7
8
9
|
class
Base1 { ... };
// 同上
class
Base2 {
private
:
void
doIt();
// 此函数现在为private
};
class
Derived:
public
Base1,
public
Base2 { ... };
// 同上
//...
Derived d;
int
i = d.doIt();
// 错误! — 还是二义!
|
Note 即使只有Base1中的函数可访问, 调用还是二义性的; 返回值不同也不会影响二义性;
如果想正确调用, 就必须指明类名 Class::func();
C++中有一些最初看起来很不直观[不符合直觉]的规定; 为什么消除"对类成员的引用所产生的二义"时不考虑访问权限?
有一个理由: 改变一个类成员的权限不应该改变程序的含义;
比如上例, 假设它考虑了访问权限, 于是表达式d.doIt()决定调用Base1::doIt, 因为Base2的版本不能访问; 假设Base1的doIt版本由public改为protected, Base2的版本由private改为public; 一下子, 同样的表达式d.doIt()将导致另一个完全不同的函数调用, 即使调用代码和被调用函数本身都没有被修改; 这很不直观, 编译器甚至无法产生警告; 可见, 对多继承的成员的使用要显式地消除二义性是有道理的;
既然写程序和库时会有多种不同的情况产生潜在的二义性, 那就应该: 时时小心; 想找出所有的潜在二义性的根源是几乎不可能的, 特别是当程序员将不同的独立开发的库结合起来使用时(条款28); 在了解导致经常产生二义性的那些情况后, 就可以字啊软件设计和开发中将二义性出现的可能性降低;