给 C++ 增加“取引用”运算符

本贴最后更新于 2159 天前,其中的信息可能已经事过景迁

一篇胡思乱想之作。

1. 程序实践的需要

首先问一个问题:在 C++ 语言中,“int”和“int&”是相同的数据类型吗?我想大家肯定回答:“当然不是,它们连兼容类型(如 int 和 short 的关系)都不是。”甚至有些人会想:这家伙脑袋是不是不正常,怎么问这么弱的问题?

好,既然不是相同的类型,那以它们为区别的函数重载就应该是合法的、没有二义性的,但我相信你不试也知道下面的代码是编译不过去的,因为它有二义性。

	void foo( int a );
	void foo( int& a );
	……
	int m = 10;
	foo( m );     // 错误,二义性

而二义性的原因是程序员无法明确说明他想按引用传参还是按值传参,但这种情况很罕见,因为恐怕没有几个人会这样定义重载函数,所以问题不大。不过下面的情况就不一样了:

	//以下代码是甲写的
	void foo( int a );
	//以下代码是乙写的
	int m = 10;
	foo( m );

乙的代码调用甲的函数,一切良好,编译、链接、测试全部通过。但突然有一天,甲觉得让 foo 的参数是“int&”会更好,所以他改了自己的程序,乙拿到甲的新版代码,发现编译链接毫无问题,就放心的发布了,与此同时,一个严重的 bug 也潜伏下来了。但这关 C++ 什么事吗?甲应该告诉乙他对代码的修改,乙也应该对自己的产品进行完整的测试呀?是的,这两点没错,但我觉得 C++ 也应该对此负责,因为它应该帮助乙避免这种错误,而且它在另外一种类似的情形下也确实这么做了,看下面的例子(参考 Effective C++,Item 26 编写):

	//以下代码是甲写的
	class Base1
	{
	public:
		void doIt();
	};

	class Base2
	{
	private:
		void doIt();
	};
	//以下代码是乙写的
	// Devried没有定义自己的doIt
	class Devried : public Base1, public Base2
	{
	};
	……
	Devried d;
	d.doIt();				//①

大家说 ① 处的代码调的是哪一个 doIt?当然是 Base1 的那个,因为 Base2 的那个是私有的,在这里无法访问;那 ① 处的代码有二义性吗?当然没有,编译器可以很容易得知道其目的;那它能编译过去吗?当然不能,因为编译器不允许“因为改变类成员的保护级别而改变程序的含义”。在这里,如果编译器让它通过了,那么当有一天甲把 Base1 的改为 private,Base2 的改成 public,编译器应该也让它通过。而这时,乙的程序的含义就被偷偷修改了,与前面的例子很相似吧?但因为编译器有自己的那条原则,所以 ① 处的代码编译不了,乙必须明确的用

    d.Base1::doIt(); 

才行。这样,如果甲再做修改,这一行就会出错,乙就能及时跟进了。

其实,我认为这两个问题都属于“潜在的二义性”问题,照理说编译器应该一视同仁,给予相同的处理。可它为什么对第二种大嚷大叫,却对第一种不闻不问呢?原因还是在于,第一种情况下,C++ 没有给乙“明确指出传值还是传引用”的权力。

2. 不完备的 C++ 类型系统

C++ 本身的类型系统庞大而复杂,而且还给了程序员创建与内置类型具有相同行为模式的自定义类型的能力,但它在引用类型上却有一些缺陷。从汇编的角度,我想大家都知道,引用和指针几乎是一模一样的。但指针有一个取指针(取地址)运算符“&”和一个取值运算符“*”,用来在指针和它所指的对象之间作相互转换,而引用却没有,两个都没有。C++ 对此的解决办法是让引用类型必须在定义的时候赋初值,并且终生不能改变其所引用的对象。我们初学 C++ 时就知道引用类型有此限制,但恐怕很少有人想过这是为什么。好了,看下面的代码:

	//假设sometype和整数是兼容类型
	sometype m = 10, n = 11;
	sometype& r = m;
	r = n;                       //①

如果没有那个限制,语句 ① 就有二义性了。程序员是想把 n 的值赋给 r(也就是 m)呢,还是想让 r 成为 n 的引用呢?编译器那个愁啊,最后终于决定:引用类型必须在定义时赋初值[注 1],这里的语义只能是把 n 的值赋给 r。不过这一限制使引用看起来像一个“劣等公民”,让整个 C++ 类型系统显得稍微有些不和谐。

3. 增加一个“取引用”运算符又如何?

在第一节的讨论中,如果有一个“按引用传参”运算符[注 2],C++ 只需规定:对引用类型的参数必须使用按引用传参运算符,就不会有问题了(为了后文讨论方便,我们假设这个运算符用“@”表示)。因为,一旦甲把 foo 的参数改成了“int&”,而乙在调用时没有用“foo( @m )”,这一行就是个编译错误,乙就必须进行修改了。

不过,我们似乎没有理由让它只用于函数调用,它也应该能用于程序的任何位置,就像取指针运算符一样。所以,它应该叫“取引用”运算符,而不是“按引用传参”运算符。更进一步,为了类型系统的完备,我们应该也有一个用于引用的“取值”运算符,为避免混淆,就叫它“解引用”运算符吧,其符号定位“$”。

啊哈!大功告成!现在我宣布,一门全新的语言—C+++—诞生了。让我们看看这门语言激动人心的新特性吧。首先,第一节中的问题彻底成了历史,因为我们有了强大的“@”运算符。其次,引用类型定义时赋初值的限制没有了,因为语义可以通过“@”作明确的表达,如:

	int m = 10, n = 11;
	int& r;
	r = @m;		//让r成为m的引用
	r = n;		//把n的值赋给r
	r = @n;		//让r成为n的引用

不过这些都是“@”的用处,“$”怎么用呢?没问题,接着看下面的例子:

	int m = 10;
	int& r1 = @m;
	int n = $r1;	//①
	int$ r2 = @n;
	r2 = $r1; 	//② 把r1引用的值赋给r2
	r2 = r1;        //让r2也去引用m

怎么样,还不错吧?不过语句 ① 显得有点画蛇添足了,因为这里有没有“”,语义都一样,只能是把r1引用的值赋给n。好,既然如此,那就再对我们的新语言进行一个小修改,规定“”只用于语句 ② 的情况,在 ① 中不能用,也就是说由引用类型向被引用类型赋值不需要“$”。

好像很完美了,但 C++ 允许通过重载“[]”运算符来定义程序员自己的数组类,我们的 C+++ 当然也要支持。而且,为了支持对数组元素的赋值,“[]”的返回值肯定应该是引用类型,所以我们可以写下面的代码:

	//array是程序员自定义的整形数组的实例
	array[0] = 10;
	int n = array[1];        //①
	int& r = @n;
	array[3] = r;            //②

语句 ② 好像有些不大对,array[3]是“int&”类型,所以这一句的含义是让“array 的‘[]’运算符的返回值”去引用“r 的引用对象”,而不是我们所要达到的把“r 引用的值”赋给 array[3]。当然,出现这一问题的原因是我们没有按 C+++ 的语法规则去写代码,只要把它改成

    array[3] = $r; 

就可以了。

可细想一下就又不对了,如果 array 是语言的内置数组类型,那么 array[3]就是“int”类型,我们刚刚规定,这种情况不用“”。而自定义类型的行为模式应该和内置类型保持一致,所以……,我们还是把刚才那条规定废了吧,毕竟多写一个“”不会带来什么问题,C+++ 是新生事物,在一些地方出现反复在所难免,大家说是吧?

但编译器马上叫起来:“语句 ① 有错误,缺少‘’运算符”。我们的编译器真不错,尽职尽责,它要不说我还真就忘了,按我们最新的语法规则,这里确实应该有一个“”。但话还得说回来,如果 array 是内置数组类型,这个“”是不应该加的,自定义类型的行为又何内置类型不一样了,这可怎么办呢?要不咱就再加条规定,让内置类型在这种情况下也加个“”?好,说干就干,现在那段代码变成了:

	//array是程序员自定义的整形数组的实例
	array[0] = 10;
	int n = $array[1];        //①
	int& r = @n;
	array[3] = $r;            //②

重新编译,编译器又叫起来:“语句 ② 有二义性。”什么?二义性?刚才还好好的,加了一条规则怎么就有二义性了呢?仔细看一下,它还真没说错。如果 array 是内置类型,则 array[1]是“int”型,所以语句 ① 加“”就意味着同类型变量之间赋值要加“”。再来看 ②,array[3]是“int&”,r 也是“int&”,所以它的含义又变回了让“array 的‘[]’运算符的返回值”去引用“r 的引用对象”。幸亏聪明的编译器猜出了我们的真实意愿,报了个二义性错误,否则,这样的 bug 怎么找呀?

看来,语法规则还得改。我又重新检查了前面的一系列问题,发现其根源都在于“引用类型可以相互赋值”,只要把它禁用,所有问题都将迎刃而解,而且这也让“$”在普通的表达式中失去存在的必要,从而使语法更简洁。终于,我们的代码又回到了最初的形式(注意:这里并没有规定引用必须在定义时赋初值,也没有规定引用的对象必须终生不变):

	//array是程序员自定义的整形数组的实例
	array[0] = 10;
	int n = array[1];
	int& r = @n;
	array[3] = r;		//①

因为引用之间不能赋值,所以语句 ① 没有任何问题
时间一天天过去,一切都很正常,直到有一天,程序员写出了下面的代码:

	//array是程序员自定义的整形数组的实例
	array[0] = 10;
	int& r = @array[1];	//①
	int n = array[2];
	r = n;               	//②

编译器又抱怨了:“语句 ① 有错误,类型不匹配”。说实话,我的心已经经受不了这种打击了,不过还是得硬着头皮看看到底出了什么事:又是自定义类型和内置类型行为模式不一致。由于 array 是程序员自己定义的,所以 array[1]的类型是“int&”,@array[1]的类型是“int&&”,但表达式的左面却是个“int&”,难怪了(注意:这里是引用的初始化,而不是把“int&&”赋值给“int&”,后者是“由引用类型向被引用类型赋值”,没有错误)。但如果 array 是内置的数组类型,该表达式又没有任何问题。这可如何是好呢?要不让内置类型在这种情况下也不用加“@”?别急,我得好好分析一下,我可不想重蹈覆辙了。

如果内置类型也不用加“@”,那将导致可以通过赋值语句直接让一个“int&”型变量成为一个“int”型变量的引用,可这又会使语句 ② 出现二义性错误,要修正这个错误,就必须规定引用型变量只能在定义时赋初值,但这个规定又让“@”像“$”一样在普通表达式中失去存在的价值。

经过了一系列的调整,“@”和“$”的作用就只剩下在函数调用中指定参数类型了,但前面那么多的问题,使我对它们这唯一的用途也产生了怀疑。而且,残酷的事实也表明,无情的自定义类型并没有放过我最后的这根救命稻草,看下面的代码吧:

	void foo( int a );
	void bar( int& a );
	foo( array[0] );		//array是内置数组类型
	foo( $array[0] );		//array是自定义数组类型
	bar( @array[0] );		//array是内置数组类型
	bar( array[0] );		//array是自定义数组类型

自定义类型和内置类型的不同行为模式,让我的 C+++ 之梦彻底成了泡影。

4. 结论

真没想到增加一个“取引用”运算符会引出这么多事来,不过这也说明了 C++ 没有引入这个运算符的原因。写完这篇文章,突然觉得我们这个世界好像并不喜欢完美的事物,类型系统不完备的 C++ 可以大行其道,类型系统完备的 C+++ 却只能是空中楼阁,真是不可思议。

注释

  1. 实际上,如果 sometype 是函数类型的话,即使没有“定义时赋初值”的限制,语句 ① 也没有二义性。据我所知,VC6 就允许“函数的引用”不在定义时赋初值,但到 VC7 和 VC7.1 就不行了。

  2. C#就引入了这样一个运算符,也就是关键字“ref”,不过从我个人来讲,更希望它是一个符号而不是个单词,另外它的作用和本文所希望的也不是完全一致。

  • B3log

    B3log 是一个开源组织,名字来源于“Bulletin Board Blog”缩写,目标是将独立博客与论坛结合,形成一种新的网络社区体验,详细请看 B3log 构思。目前 B3log 已经开源了多款产品:SymSoloVditor思源笔记

    1090 引用 • 3467 回帖 • 297 关注
  • 技术

    到底什么才是技术呢?

    88 引用 • 179 回帖 • 4 关注
  • C++

    C++ 是在 C 语言的基础上开发的一种通用编程语言,应用广泛。C++ 支持多种编程范式,面向对象编程、泛型编程和过程化编程。

    106 引用 • 152 回帖 • 3 关注

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...