如何阅读C和C++中的声明:
声明,可能是C++中最为常见的语句了。例如:
int x;
这样的声明自然是简单易懂,但如果是一个复杂结构的声明,可就没有这么直观了。
例如:
//你能认到第几个? int a; //整型变量 : a int a10[10]; //大小为10的整型数组 : a10 int(&ra10)[10] = a10; //对大小为10的整型数组的引用:ra10 int(&frra10(int, int))[10]; //返回一个 对大小为10的整型数组的引用 的接受两个整型作为参数的函数frra10
本文就来这些复杂的符号游戏内在的逻辑与规律:如何阅读C和C++中的声明?
答案很简单:解方程。
一些定义
首先需要明确讨论中用到的定义或概念:
声明(declaration):将一个名字与一个类型关联起来的过程。
名字(name): 名字可以是标识符,比如x,也可以是一个表达式,比如x.y。
类型(type): 类型决定了一个名字所指称的实体。
举个栗子:int x;这条语句就将x这个名字与int这个类型建立关联,这个过程就是声明。当然这一条语句本质上除了声明还有其他的作用,比如存储分配。声明只是告诉编译器有这么一个名字,仅此而已。顺带一提,定义在下图中,就是为变量赋初值的过程。
器
咳咳,跑偏了,再讲就变成编译原理导论了。对于这些概念有兴趣,可以参考:
http://msdn.microsoft.com/zh-cn/library/csdhb88k.aspx>
声明的构成
回到正题,那么一条声明又是由哪些东西所构成的呢?
四部分:描述符(modifier) 基础类型(specifier) 声明符(declarator) 可选的初始式(initializer specifiesinitializerspecifiesInitializer )。
描述符就是类似前面virtual externstatic这种东西,在此我们并不关心。
可选的初始式属于定义的一部分,也不在讨论的范畴。
我们所关心的,就是声明中的基础类型(specifier),与声明符(declarator)。
如:static int (&ra10)[10] = a10;
在这个例子中static是描述符,int是基础类型,(&ra10)[10]是声明符,= a10是初始式。
而x,在这里就是被声明所引入的名字。它的类型是:int[10] &,即对长度为10整型数组的引用。
如何推断名字的类型——一个实例
好吧,那么应该怎么看出来ra10到底是什么类型呢?很简单,就是解方程,令方程左右两边的类型分别为类描述符的类型与声明符的类型,求解声明符中的名字的类型。
左边的类型= 右面名字对应类型
在上面的例子中:
int = (&ra10)[10] //假设T为名字ra10的类型,则
int = (&T)[10] //对类型T先取别名,再索引,得到int类型 //它表达了这样一层意思:对于类型T,先取它的别名,再用下标运算符进行索引,得到的类型就是int。
int[10] = &T //对类型T取别名,会得到一个int[10]类型
int[10] & = T //类型T,是一个对int[10]的引用。
千万要注意:& 和 * [], (), 这些,在声明中和在表达式中代表不同的含义的。
在表达式里,&叫做取址运算符,而在声明里,它叫做 引用声明符。他们仅仅是长的一样而已,在语义上有可类比之处,但实际功能是完全不同的,千万不要混为一谈。
另外,如果我们把上式中左侧的类型int 换为int*,解法依然是一样的。
int * = (&ra10)[10]
int *[10] = &ra10
int *[10] & = ra10
这样我们就知道了名字ra10的类型。
读法从左向右:
首先看到一个int,哦这是一个int
然后看到一个*,哦,不对,这是一个int*
然后又看到一个[10],哦,不对,这是一个里面放着int*的大小为10的数组。
最后看到&,哦,这也不是数组嘛,这是对数组的一个引用。
但是,聪明的人会敏锐的发现一个问题:优先级。这么多修饰符,我怎么知道先拆哪一个呢?
声明运算符的优先级
先说一下声明运算符:
* | 指针 | 前缀 |
*const | 常量指针 | 前缀 |
& | 引用 | 前缀 |
[] | 数组 | 后缀 |
() | 函数 | 后缀 |
也就是说,这些声明运算符都是用来修饰一个名字的,这些声明运算符和名字一起构成了声明符。并且,它们去修饰名字的优先级和它们作为普通运算符的优先级是一致的!这一点非常重要。
比如
int *p[10];
int(*p)[10];
第一个声明int *p[10],解方程,因为下标运算符[]的优先级高于间接寻址运算符*。因此解方程时,名字p和数组声明符[]之间的结合,要比p和指针声明符*之间的结合更加紧密。 按照柿子先捡软的捏的原则,先把*移到左边得到 int* = p[10],哦原来p这个数组中的每一个元素都是int* ,再把[]移到左边,得到了int* [10],读作:存放着十个整型指针的数组。
那么第二个声明依此类推:括号的优先级最高,把名字p和*紧紧地绑在一起,只好先拆散(*p)和[]之间的关系了。得到 int[10] = *p ,哟,p这个指针是指向一个int[10]的数组啊,也就是 p = int[10] * 读作:指向存有十个整型的数组的指针。
好吧,经过了以上的分析,下面我们来处理最为棘手的函数声明吧。
int(&frra10(int,int))[10];
这个声明所引入的名字是: frra10. 我起这个名字的意思是Function ReturnReference Array[10]。
那么怎么分析呢?
int = (&frra10(int, int))[10]
int[10] = &frra10(int, int) //函数调用运算符()的优先级,高于取址运算符&,所以相比联系较强的函数声明运算符(),应当先拆散联系较弱的引用声明运算符&。于是得到:
int[10] & = frra10(int, int); //啊哈,结果出来了,这是一个函数,接受两个int作为参数,返回一个对int[10]的引用。
思考
如果我们考虑到在C#与Java中所采用的统一声明模式,就会发现,C/C++的声明方式,存在一种内在的不一致性:即,声明运算符可以同时出现在类型方程的左侧或者右侧。这就意味着:对于同一个类型int *,int* x;与 int *x;存在两种声明方式。而对于一个更加复杂的类型,上述类型方程中的每一步,都可以作为一种合法的声明方式。对于复杂的声明来说,这样的设计极大地降低了可读性。
我认为C#与Java的做法,即将类型说明符统一放在类型方程左侧,而方程右侧只放名字的做法是最好的,这也是我们解类型方程最终得到的结果,便于人类阅读。C/C++的推荐做法则恰恰与此相反,将修饰符与名字放在一侧。这个设计最初的动机可能是为了写出形如:int x,*p这样一行定义多个甚至多种类型变量的丑陋代码。除了省了几个代码文件字节(说不定还给客户省了钱)之外估计找不出什么好处了。这种设计有着很大的问题,不过让人高兴的是,C/C++提供的typedef,C++11提供的尾置返回类型,一定程度上缓解了这一问题。
解决方案。
事实上,读复杂的声明跟吃屎一样难受。
作为一个文艺的程序员,应当不断加强自己的代码品味。利用typedef将一个复杂的类型定义成一个新的类型,方便而简洁。
注意:
typedef int[10] ReturnType //这么写是错的
typedefint (&ReturnType)[10] //这么写才是正确的。本质还是解方程。
于是上面那个函数声明就可以写成:ReturnType frra10(int,int);了。
当然这里还是能看到类型方程的余毒。
所以C++11还有一种更为优雅的解决方案,那就是尾置返回类型:比如:
Auto frra10(int,int)-> int[10] & 这下就更清楚了。可惜适用面还是比较小。