关于C语言声明、指针、数组、函数、typedef等等的一通“超级扯”

时间:2022-06-01 20:13:01
关于C语言声明、指针、数组、函数、typedef等等的一通“超级扯”

按:在CSDN论坛上,有坛友这样提议:

typedef int (*PF)();
应该被写作:
typedef int (*)() PF;
才是“严谨”、“合乎逻辑”的。

对此,我来说说吧……

typedef 关键字的意思是“关于类型的定义”。

为什么要有“类型”这个观念?

我们都知道,在计算机的内部,一切数据和功能性代码(比如函数、命令、指令等)都是以0、1组成的有序排列来进行存储和运算等处理的。所以,我们总是会这麽说:“计算机只认得‘二进制’”。

在C编程语境中,当我们写下:

int a;

的时候,实际上是在告诉编译器这位老Boss:请您为我的程序在存储器中预留一块地方,这块地方不论将来会存储什么样的东东,您一律把那些东东视作“整数”来处理。

老Boss根据自己所在的计算机架构或操作系统环境,确定1个“整数”需要在存储器里面占有多大的地盘。而我们经常使用的架构或环境,决定了,这块地盘的大小,通常是4Bytes即32bits。

而“a”这个符号,是我们的程序用来称呼那块地盘的代号。也就是说,在我们的程序中,将来凡是用到了“a”这个符号,就是在告诉老Boss:我是让您在那块地盘上做些什么,如读取那里的数据(当a作为右值),或向那里刷入什么数据(当a作为左值)。当然,这所有的处理,均是把那块地盘上既有的数据或将要刷入的数据,作为“整数”来看待。

以上这一切,就叫做“定义性声明(defining declaration)”,我们可以根据不致引起歧义的某种习惯,简称它为“定义”或“声明”。(严格地说,变量的定义与声明,两个概念之间,有一些微妙的联系与区别,在此不再赘述。)

C语言采用了不同于其他语言的规则,来让程序员进行这种声明工作。

作为比较,我们可以观察在Ada语言中,类似的工作,是如何进行的:

a:integer;

就这个简单的例子看来,我们除了看到Ada语言似乎利用了一种更加接近人类语言习惯的规则(语序以及冒号)之外,与C还是大同小异的,但是,后面所讲到的例子,就会把这种特性上的差异,扩大地展现出来。

此外,我们也不难意识到,C的这个规则,会跟基于对象(Object-Based)的某些思路,有一些“暗合”的地方,比如说:

当我们利用class SomeClass{... ...};来定义一个类之后,我们可以利用

SomeClass AnObject;

来将SomeClass这个类实例化出来一个具体的对象,即 AnObject。

我们已经了解到,SomeClass其实是对AnObject与其他具有相同“结构”的实例们的一种抽象。那么,我们可以反过来推:

int a;

就可以用“变量a其实是int这个‘类’的一个实例化”这样的思路,来揣度(仅仅是一种类比性思维)C语言中声明变量的这件事情。

以上这些,都是比较容易理解的。但是C语言不会就这麽轻易地放过我们。

在C语言编程语境中,把一个“符号”的“类型”或“性质”表达出来,有远比“类型名 变量名”更为复杂的方式。比如:

char a[n];

在这个例子中,引入了方括号。围绕符号“a”的事情就复杂了一层。方括号紧跟在符号“a”的右边,说明符号“a”是用来称呼一段连续分布在存储器中的多个数据,俗称“数组”。而这一段数据中的单位数据,即每个元素的“类型”是char,且所有元素均被视为char类型,概莫能外!

“类型名 变量名”的简单做法,已经失效了。在C中,我们不能用

char array a;



array_of_char a;

这样的写法来声明一个元素均为char类型的数组。

但是,在Ada语言的规则里,就可以这麽写:

a : array (1..n) of Integer;

在C语言的“侄子”——Perl语言中,用“@”来声明一个数组:

@a=(value_alpha..value_omega);

而在C语言中,即没有array关键字,也没有用来声明数组的@符号,甚至C语言本身都不会对数组元素的下标是否越界做出令程序员知晓正误的判断 —— 这一切,不得不让我狗胆包天地,下了这个狂妄的断言:“在C语言中,压根儿就没有‘数组’这个东东!”(哎哟——一只鞋子袭来!)

不那么狂了,把话说回来:在C语言中,通过引入方括号,来声明一个数组。注意,这个方括号对,与用“数组名[下标]”的写法来指明具体某个元素这种方法里的方括号对,是完全不同的。

在指明(术语叫“引用”)数组中某个元素的时候,也是用方括号对。这里的方括号对,叫做“取下标操作符”。其实“取下标操作符”是一种被包装起来的指针运算符,即一种存储器上的偏移量运算,所以,我们可以大胆猜测:在C语言中,这种利用方括号对来引用数组中某个元素的做法,应该是承袭于某些更加低级的(更加接近计算机存储机制底层的)语言。

这是C语言承袭老语言的一方面,那么,C语言在其内部扩展的一方面,就是把方括号对,作为数组声明的一个符号。

当“*”——这个邪恶的星号,被C语言引进来了之后,关于声明的事情,就变得更加复杂和诡异了。比如:

char *a[n];

我们如果把星号看成一个“读取指针所指向的存储器某个位置上的数据”的符号,又顽固地抱持着以“类型名 变量名”来解读声明的这种思路的时候,上面这行语句,就彻底无解了 —— 详述如下:

如果星号与a先结合构成一个意群,那么,该语句的意思就是:读取指针变量a所指向的位置上的数据,然后这个数据……数组……完全说不通了!

如果a与方括号对先结合构成一个意群,那么,该语句的意思就是:一个名叫a的数组成为一个指针,该指针指向一个类型为char的数据……可是,一个数组(即多个元素组成的序列)能成为一个指针吗?这同样也是不可想象的事情。

乱猜是没有结果的。我们必须用C语言自己的规定,来解读和运用这种写法。合乎C语言规定的思路是:

符号a先与紧跟其后的方括号对结合,显明了a是指代一个数组。而数组的每一个元素,都是一个指针变量,这些指针变量乃均为指向某些char类型的数据之用。

根据上述思路,该行语句可写为:

char* a[n];

即char与星号先结合,构成一个意群,我们且以Ptr2Char代之。那么,该行语句即恒等变换为:

Ptr2Char a[n];

与“char* a[n];”比较,解读思路是一致的,即,一切放在“a[n]”顶头的东东,均指明了数组里每个元素的类型。(不妨强迫自己这样记住:数组是没有类型的,数组只是一段连续分布在存储器中的数据而已,而这些单位数据即元素,是有类型的。)

当我们讨论到这里,就可以把关于typedef的概念自然地引入了。

我在上面说到,char可以与星号先结合构成一个意群,且可以用Ptr2Char这个新符号代表它,那么,typedef关键字就可以登场了:

typedef int* Ptr2Char;

Ptr2Char a[n];

在这里,不妨这样理解typedef的用途,它把“int*”打了包,取名为“Ptr2Char”(即所谓“别名”),注意,这个别名不是变量名,而是类型名。由于“Ptr2Char”是一个类型名,所以,“Ptr2Char”可以被用来声明该类型的变量(或者由一群该类型的变量构成的数组)。

我们依然可以用“类 vs 对象(实例化)”的类比性思路,来理解这个过程:

在第一行里,“int*”是一个抽象,“Ptr2Char”是该抽象的一个实例化;

在第二行里,“Ptr2Char”是一个抽象,“a[n]”这个数组里的每个元素,分别都是该抽象的一个实例化(一共有n个实例化)。

之所以,我们依然可以用“类 vs 对象”这个类比性思路,必须有一个前提,那就是:“typedef T S”中的T与S之间不是(由T到S的)单射关系,而是可以是“一对多”的。也就是说:

typedef int* Ptr2Char_A;
typedef int* Ptr2Char_B;
Ptr2Char_A a[n];
Ptr2Char_B b[n];

是正确的。

既然,我们可以用“类 vs 对象”的类比性思路,来理解typedef,如同理解“数据类型名 变量名”一样,那么,为什么下面这样写就是错误的呢?

int* Ptr2Char;

Ptr2Char a[n];

在上面的第一行代码中, Ptr2Char是“int*”这个类型抽象的一个实例化出来的变量。而只有用了typedef关键字,才能让“int*”这个类型抽象实例化出来一个类型,而后者又是实例化的变量的一个抽象。

接下来,我们回到“char *a[n];”这个例子。

上面说了,由于把星号、方括号引入了对于符号的声明,使得C语言的声明方法,变得特别复杂,甚至非常晦涩。

我们之所以感到“char *a[n];”这样的声明很复杂,可能有以下几点原因:

第一,我们对声明的客体,即“我们到底是为谁而声明?”这个问题,回答起来,不是那么有底气。

第二,即便是知道了我们为谁而声明,我们也不知道这个东东的“性质”到底是个啥?整数?字符?指针(又是指向另外某一个谁的)?还是函数?

我们必须要做出明确的解答:

对于第一个原因中的问题,答案是:声明的客体,是该声明中出现的唯一的一个“不明确”的符号。所谓“明确的”,就是已经被定义 、声明、初始化的。比如在

char *a[n];

里面,其中的n一定是被事先定义 、声明、初始化了的。“char”是关键字,星号和方括号以及句尾的分号均是语言的保留部分。那么,唯一的一个“不明确”的符号,就是“a”。所以,我们可以放心大胆地认定:整个语句的声明客体是符号“a”,即我们是为“a”而声明。

对于第二个原因中的问题,就是难点了。符号“a”是被其他的“已明确的”符号、星号、方括号、各种关键字所层层包围或粘黏起来的,而不像Ada语言中的那么明显(符号被清晰地放在冒号的左边)。

—— 这就是C语言的特色了:

———— 一个符号的“性质”,由它所出现的位置,即它的处境、它被什么样的东东(按什么样的次序)所包围或粘黏,来决定!

根据我很浅薄的观察和很笨拙的思考,得出一些结论,希望能对大家有所帮助:

(1)在声明中,符号出现在方括号的左边,符号的性质一定是:指代一个数组,即作为数组名。

(2)在声明中,符号出现在圆括号的左边,符号的性质一定是:指代一个函数,即作为函数名。

(3)在声明中,符号出现在星号的右边,符号的性质一定是:它跟右边所构成的整体的其中之元素(在情况(1)下)或返回值(在情况(2)下)是指针,且指针所指向的数据之类型,一定由星号左边的关键字来确定。

根据以上结论所确定的原则,我们就不难解读“char *a[n];”。

既然可以根据上述的原则,来解读“数据类型名 变量名或混杂的东西”这种声明,也就可以利用这些原则,来解读typedef语句。我们可以把

typedef int* Ptr2Char;

Ptr2Char a[n];

改写成:

typedef int* Ptr2Char[n];

Ptr2Char a;

上下两段代码,是完全等效的!!

请看, 在typedef 语句中,“Ptr2Char”这个符号被夹在了星号与方括号当中,但是这个符号的意义完全没有失真。

这就是C语言的声明规则以及typedef的用法,所体现出的它的特色。你说它晦涩也好,说它变态也好,甚至说它有缺陷、有陷阱也好…… 但是,它保持了高度的逻辑性、可理喻性,以及由此带来的高度的可扩展性。

我在上面,曾经说过“在计算机的内部,一切数据和功能性代码(比如函数、命令、指令等)都是以0、1组成的有序排列来进行存储和运算等处理的。”  

所有前面所讲述的,都是关于数据,即变量、由连续存储的变量所构成的数组,它们在存储器中的存在形态,均是0和1构成的序列。由于这些序列在存储器中都有确定的位置,所以,指针这个机制的存在,就有了确定性的基础,也就是说,C语言可以允许我们利用指针,来操控(指代、指向、引用、解引用、读取、刷入)这些分布在存储器中各个位置上的数据。

那么,功能性代码,也是如此。我们的程序赖以运行的最基本结构——函数,也是以0和1所构成的序列,于存储器中存在的。而我们又知道“计算机只认得‘二进制’”,那么,从这个角度上说,不论是数据,还是函数,在计算机内部那头看来,都是一样的东西,计算机本身是“无法区分”数据和函数的 —— 这就是黑客的基本原理之一。

也就是说,我们同样也可以利用指针,来操控(指代、指向、引用、解引用、读取、刷入)函数。所以,在C语言中,存在着这麽一些指针变量,它们所指向的,是某个函数,更具体地说,它们分别指向了某些函数可以被切入运行的那个“入口”。

接下来,我们要注意到一个事实。在C语言中,对于函数的声明,并没有用到一个类似于“function”这样的关键字。

function foo();

上面的这种写法,是JavaScript中的。而且,在JavaScript中,这样的写法,实际上,是在显式地为“function”这个类,实例化出一个名叫foo的对象!也就是说, 在JavaScript中,一切函数,均是“function”这个类实例化出来的对象。

那么,在C语言中,是怎么声明函数的呢?

在C语言中的函数,与数学上的函数一样,有三个要素:定义域(输入)、对应法则(加工处理输入用以将来输出)、值域(输出)。

具体地说,在C语言的函数的三要素中:输入部分,即是函数的参数; 对应法则,即是函数体所实现的功能;输出部分,即是函数的返回值。

对于参数部分,必须确定一个或多个参数中每个参数的数据类型;对于返回值部分,返回值只能是以一个整体的单元而存在,那么就必须确定该单元的数据类型;对于函数体部分,在声明中,则不必和盘托出,在声明之后,自然须要写明(定义)。

所以,对于函数的声明,只要包含了关于对于参数部分的声明、对于返回值部分的声明,当然还有声明的客体,即函数名,就完备了。

上述三者,必须按次写出,如下:

返回值类型 函数名(关于参数的声明);

函数体即实现函数内在功能的部分,属于函数的定义,不在函数声明的范围之内。上述这个声明的规范,在ANSI C中,叫做“函数原型”。

再援引之前的例子,

char* a[n];

当这里的数组a,是由一个函数的返回值来确定的时候,比如是由foo这个函数的返回值来确定,那么,代码就应该是:

char* (foo())[n];

但是,很遗憾,上面这种写法是错误的。逻辑上并没有错,只是C语言中有一条硬性的规定:函数不能返回一个数组,即函数的返回值,不能是一个数组。上面的写法,会使得编译器报错:

error: ‘foo’ declared as function returning an array


不过,C语言允许函数返回一个指向任何数据类型的变量或数据结构的指针。那么,指针当然可以被用来指向一个数组。所以,我们若想利用函数的返回值来确定一个数组,可以令函数返回一个指向该数组的指针 —— 用这种“迂回曲折”的方式,来实现我们的意图。也就是:

char* (*foo())[n];

根据之前已经提到的原则: foo后面紧跟圆括号,那么foo首先圆括号结合,foo这个符号一定是用来指代函数,即作为函数名;后来,这个函数整体的左边紧贴着星号,意思是,该函数的返回值,是一个指针;这个指针作为一个整体被夹在一个圆括号里面,后面又紧跟着一个方括号,那么,该整体是用来确定(其实不是直接确定,而是“指向”)一个数组,由于是利用指针指向数组,而不是直接确定数组,所以,这样做的话,跟用数组名符号(如上面的“a”)来指代数组,还是有一些微妙的区别。

顺别说一句,在这个函数的定义里,按照我们例子的意图,返回语句必须是:

return &a;

如果写成“return a;”,也可以编译通过,但是老Boss会骂一声,之后运行结果无异。

我们之前一开始介绍函数的时候,提到了,可以用指针来操控函数,也就是说,我们可以用一个指针,来指向某个函数。那么,现在我们用ptr2foo这个指针变量来指向foo这个函数。既然是声明指针变量,那么它左边所紧贴的,一定是星号。于是,代码就应该是:

char* (*(*ptr2foo)())[n];

如果要解读的话,还是老规矩:在ptr2foo这个符号所在的圆括号里面,该符号右边无东东,那么直接先跟左边的星号结合,表示该符号所指代的,一定是一个指针。然后,整个这个圆括号就跟之前的 foo这个函数名等效。

显然,在这一切的例子中,foo这个函数,是没有参数的 —— 在原型中,最好写成foo(void)以警醒自己。现在,我们有这麽一个函数叫bar,它跟foo干的事情差不多,但是它有一个参数,其类型为int。类似上面的,ptr2bar是指向函数bar的一个指针变量。那么,关于bar这个函数的代码,如下:

char* (*(*ptr2bar)(int))[n];

如果bar函数的参数,不是一个int类型的数据,而是一个指向int类型的数据的指针的话,代码就应该是这样的:

char* (*(*ptr2bar)(int *))[n];

怎么样?如果再让const、volatile什么的关键字穿插其中的话,那么,上述代码,假若作为考试题(考察ptr2bar 到底是个什么性质的东东)的话,也算是一道很厉害的难题了。(其实遇到const、volatile关键字,也不要怕!规则也非常简单:(i)如果它们的左边没有星号的话,那么它们就跟紧邻的关键字结合;(ii)如果它们的左边紧贴星号的话,那么它们就立即先与星号结合了再说,表示星号(再)右边的符号所指代的指针,是“坚贞不屈”的。)

之前还提到:在C语言中,一个符号的“性质”,由它所出现的位置,即它的处境、它被什么样的东东(按什么样的次序)所包围或粘黏,来决定 —— 那么,这个 ptr2bar被一层层的星号、圆括号、方括号、关键字等等所围绕和粘黏,但是,由于这些星号、圆括号、方括号、关键字等等是按着某种“次序”排列的,所以,ptr2bar的“性质”,就在这种“次序”的存在方式中,得到了确定,而永远不会失真和发生歧义。

所以,我们可以把ptr2bar抽象出来,用typedef来“打包”,代码如下:

typedef char* (*(*P2FB)(int *))[n];

这也就是:P2FB作为一切跟ptr2bar指针具有相同性质的指针的一个抽象。如果要将这个抽象实例化的话,就这样写:

P2FB ptr2bar_obj = bar; //bar函数的指针。

如果我们在此前,已经把“int *”用typedef打包为“P2I”的话,那么,上述声明就可以分解地写为:

typedef int * P2I;

typedef char* (*(*P2FB)(P2I))[n];

上述两行代码,实际上是对应了下面这个最初的复杂的声明:

char* (*(*ptr2bar)(int *))[n];; //合乎C规范的声明。

现在,我们要把“int *”类型抽离出来,我们只抽离“int *”它一个,那么,按照那位坛友的设想,可以写作:

typedef char* (*(*ptr2bar)())[n] P2I;

但是,恐怕连那位坛友也肯定同意:这样写没有任何意义!因为“P2I”到底是什么?没有任何合理的信息。所以,只能通过“typedef int * P2I;”把“P2I”定义出来。

那么,在“typedef int * P2I;”之后,我们就可以这样声明:

char* (*(*ptr2bar)(P2I))[n];

现在,我们想要把“ptr2bar”抽离出来,那么,按照那位坛友的设想,可以写作:

typedef char* (*(*)(P2I))[n] P2FB;

那位坛友一定会说“P2FB”必然是第三个星号要修饰的东东,这是按照那位坛友所发明的新规则;我说,我也可以发明一个新规则,即typedef可以重新定义已经被定义过的类型。

在合乎C语言固有规范的情况下,加上我的这个“新发明”,不会发生冲突(总是对最早被定义的符号[没有被定义的符号视为无穷久远]按照最后一次的定义为准);但是,在那位坛友的“新规则”下,我的这个“新发明”就会引起麻烦:

typedef所定义的类型,可以是任何数据类型。(规则1,C语言规范所固有的)

比如,我完全可以这样做:

typedef char P2FB;

现在,我期望“typedef char* (*(*)(P2I))[n] P2FB;”这行语句,根据那位坛友的“新规则”来对 P2FB进行重新的定义,即期望它只是指向函数的指针的一个抽象。

但是,我不一定就把P2FB安插在第三个星号的右边,我完全可以把它安插在第三个星号的左边,因为“(char *)(...)”的写法,无论如何,都是合法的,而且根据规则1,这完全成立。

那么,“typedef char* (*(*)(P2I))[n] P2FB;”就成了:

typedef char* (*(P2FB*)(P2I))[n];

这时候,这行代码的意思就是:

typedef char* (*(char*)(P2I))[n];

如果我之前并没有将P2I设置为某个类型的别名,而是将它声明为一个整数类型的变量并且将其初始化为0的话,“(char*)(P2I)”就变成了(char *)(0),这将导致得到一个(nil)指针,即什么也不指向的指针。

这个什么也不指向的指针,作为指向char* a[n]这个数组(即数组a)的指针,结果就得到一个完全不存在的数组。

那么,我原本期望通过“typedef char* (*(*)(P2I))[n] P2FB;”这行语句所达到的目的,结果会变成啥?

typedef语句不一定为我重新定义了P2FB,而是有可能将n这个符号重定义了:n由原来的某个整数被重新定义为一个根本不存在的数组的元素数量所属于的数据类型,它好像除数为0的商数一样诡异。

…… 我兜了这麽大的圈子,使了这麽多的“坏招”,其实,就是希望那位坛友明白一点:

在C语言中,一个符号的“性质”,由它所出现的位置,即它的处境、它被什么样的东东(按什么样的次序)所包围或粘黏,来决定!

—— 这是最严谨、最符合逻辑、也是最经济地声明一个符号的方式了!

以上超“扯”了一通,仅供参考!

有什么错误,希望各位大虾拍砖指正!谢谢!