本节书摘来自异步社区《你必须知道的495个C语言问题》一书中的第1章,第1.1节,作者 傅道坤,更多章节内容可以访问云栖社区“异步社区”公众号查看
1.21
问:怎样建立和理解非常复杂的声明?例如定义一个包含N个指向返回指向字符的指针的函数的指针的数组?
答:这个问题至少有以下3种答案:
(1) char ((*a[N])())();
(2) 用typedef逐步完成声明:
typedef char *pc; /* pointer to char */
typedef pc fpc(); /* function returning pointer to char */
typedef fpc *pfpc; /* pointer to above */
typedef pfpc fpfpc(); /* function returning... */
typedef fpfpc *pfpfpc; /* pointer to... */
pfpfpc a[N]; /* array of... */
(3) 使用cdecl程序,它可以在英文描述和C语言源码之间相互翻译。你只需要提供用自然语言描述的类型,cdecl就能翻译成对应的C语言声明:
cdecl> declare a as array of pointer to function returning
pointer to function returning pointer to char
char *(*(*a[])())()
cdecl也可以用于解释复杂的声明(向它提供一个复杂的声明,它就会输出对应的英文解释)。对于强制类型转换和在复杂的函数定义中弄清参数应该进入哪一对括号,cdecl也大有裨益。在comp.sources.unix的第14卷可以找到cdecl的各种版本(参见问题18.20和文献[19])(如同在上述的复杂函数定义中)。参见问题18.1。
C语言中的声明令人困惑的原因在于,它们由两个部分组成:基本类型和声明符,后者包含了被声明的标识符(即名称)。声明符也可以包含字符*、[]和(),表明这个名称是基本类型的指针、数组以及为返回类型的函数或者某种组合[3]。例如,在
char *p;
中,基本类型是char,标识符是pc,声明符是pc;这表明pc是一个char(这也正是“声明模仿使用”的含义)。
解读复杂C声明的一种方法是遵循“从内到外”的阅读方法,并谨记[]和(``)比*的结合度更紧。例如,对于声明
char *(*pfpc )();
我们可以看出pfpc是一个函数(从()看出)的指针(从内部的看出),而函数则返回char型的指针(从外部的可以看出)。当我们后来使用pfpc的时候(pfpc)()(pfpc所指的函数的返回值指向的值)是一个char型。
另一种分析这种复杂声明的方法是,遵循“声明模仿使用”的原则逐步分解声明:
(pfpc)() 是一个 char
(*pfpc)() 是一个 指向char的指针
(*pfpc) 是一个 返回char型指针的函数
pfpc 是一个 指向返回char型指针的函数的指针
如果你希望将复杂声明像这样表达得更加清楚,可以用一系列的typedef把上面的分析表达出来,如前文所述的第2种方法所示。
这些例子中的函数指针声明还没有包括函数的参数类型信息。如果参数中又有复杂类型,这时候的声明就真的有些混乱了。(现代版本的cdecl同样会有所帮助。)
参考资料:[19, Sec. 5.12 p. 122]
[35, 3.5ff (esp.3.5.4)]
[8, Sec. 6.5ff (esp. Sec. 6.5.4)]
[11, Sec. 4.5 pp. 85-92, Sec. 5.10.1 pp. 149-150]
1.22
问:如何声明返回指向同类型函数的指针的函数?我在设计一个状态机,用函数表示每种状态,每个函数都会返回一个指向下一个状态的函数的指针。可我找不到任何方法来声明这样的函数——感觉我需要一个返回指针的函数,返回的指针指向的又是返回指针的函数……,如此往复,以至无穷。
答:你不能直接完成这个任务。一种方法是让函数返回一个一般的函数指针(参见问题4.13),然后在传递这个指针的时候进行适当的类型转换:
typedef int (*funcptr)(); /* generic function pointer */
typedef funcptr (*ptrfuncptr)(); /* ptr to fcn returning g.f.p. */
funcptr start(), stop();
funcptr state1(), state2(), state3();
void statemachine()
{
ptrfuncptr state = start;
while(state != stop)
state = (ptrfuncptr)(*state)();
}
funcptr start()
{
return (funcptr)state1;
}
(第二个类型定义ptrfuncptr隐藏了一些十分隐晦的语法。如果没有这个定义,变量state就必须声明为funcptr(state)(),而调用的时候就得用(funcptr ()())(*state)()这样令人困惑的类型转换了。)
另一种方法(由Paul Eggert、Eugene Ressler、Chris Volpe和其他一些人提出)是让每个函数都返回一个结构,结构中仅包含一个返回该结构的函数的指针。
struct functhunk {
struct functhunk (*func)();
};
struct functhunk start(), stop();
struct functhunk state1(), state2(), state3();
void statemachine()
{
struct functhunk state = {start};
while(state.func != stop)
state = (*state.func());
}
struct functhunk start ()
{
struct functhunk ret;
ret.func = state1;
return ret;
}
注意,这些例子中使用了对函数指针较老的显式调用。参见问题4.12和问题1.17。
数组大小
1.23
问:能否声明和传入数组大小一致的局部数组,或者由其他参数指定大小的参数数组?
答:很遗憾,这办不到。参见问题6.15和6.19。
1.24
问:我在一个文件中定义了一个extern数组,然后在另一个文件中使用:
file1.c: file2.c: int array[] = {1, 2, 3}; extern int array[];
为什么在file2.c中,sizeof取不到array的大小?
答:未指定大小的extern数组是不完全类型。不能对它使用sizeof,因为sizeof在编译时发生作用,它不能获得定义在另一个文件中的数组的大小。
你有3种选择。
(1) 在定义数组的文件中声明、定义并初始化(用sizeof)一个变量,用来保存数组的大小:
file1.c: file2.c:
int array[] = {1, 2, 3}; extern int array[];
int arraysz = sizeof(array); extern int arraysz;
参见问题6.23。
(2) 为数组大小定义一个明白无误的常量,以便在定义和extern声明中都可以一致地使用:
file1.h:
define ARRAYSZ 3
file1.c: file2.c:
include "file1.h" #include "file1.h"
int array[ARRAYSZ]; extern int array[ARRAYSZ];
(3) 在数组的最后一个元素放入“哨兵”值(通常是0、1或者NULL),这样代码不需要数组大小也可以确定数组的长度:
file1.c: file2.c:
int array[] = {1, 2, 3, -1}; extern int array[];
很明显,选择在一定程度上取决于数组是否已经被初始化。如果已经被初始化,则选择(2)就不太好了。参见问题6.21。
参考资料:[11, Sec. 7.5.2 p.195]
声明问题
有时候,无论你觉得已经多么仔细地创建了那些声明,编译器都还是坚持报错。这些问题揭示了一些原因。(第16章收集了一些类似的莫名其妙的运行时问题。)
1.25
问:函数只定义了一次,调用了一次,但编译器提示非法重声明了。
答:在作用域内没有声明就调用(可能是第一次调用在函数的定义之前)的函数被认为声明为:
extern int f();
即未声明的函数被认为返回int型且接受个数不定的参数,但是参数个数必须确定,且其中不能有“窄”类型。如果之后函数的定义不同,则编译器就会警告类型不符。返回非int型、接受任何“窄”类型参数或可变参数的函数都必须在调用前声明。(最安全的方法就是声明所有函数,这样就可以用函数原型来检查参数传入是否正确)。
另一个可能的原因是该函数与某个头文件中声明的另一个函数同名。
参见问题11.4和15.1。
参考资料:[18, Sec. 4.2 p. 70]
[19, Sec. 4.2 p. 72]
[8, Sec. 6.3.2.2]
[11, Sec. 4.7 p. 101]
1.26
问:main的正确定义是什么?void main正确吗?
答:参见问题11.17。(这样的定义不正确。)
1.27
问:我的编译器总在报函数原型不匹配的错误,可我觉得没什么问题。这是为什么?
答:参见问题11.4。
1.28
问:文件中的第一个声明就报出奇怪的语法错误,可我看没什么问题。这是为什么?
答:参见问题10.9。
1.29
问:为什么我的编译器不允许我定义大数组,如double array256?
答:参见问题19.28,可能还有问题7.20。
命名空间
命名的问题似乎并非一个问题,可它的确是个问题。为函数和变量命名不像为书、建筑物或者孩子命名那么困难——你不需要考虑公众是否会喜欢你程序中的名称——但你的确需要确保这些名称尚未被占用。
1.30
问:如何判断哪些标识符可以使用,哪些被保留了?
答:命名空间的管理有些麻烦。问题(可能并不总是那么清楚)是你不能使用那些已经被实现使用过的标识符,这会导致一堆“重复定义”错误,或者更坏的情况下,静悄悄地替换了实现的标识符,然后把一切都搞得一团糟。同时你可能也想确保后续版本不会侵占你所保留的名称[4]。(拿一个已经调试的、正常工作的生产程序在新版的编译器下编译、连接,结果却因为命名空间或其他的问题导致编译失败,没有什么比这更令人沮丧了。)因此,ANSI/ISO C标准中包含了相当详尽的定义,为用户和实现开辟了不同的命名空间子集。
要理解ANSI的规则,在我们说一个标识符是否被保留之前,我们必须理解标识符的
3个属性:作用域、命名空间和连接类型。
C语言有4种作用域(标识符声明的有效区域):函数、文件、块和原型。(第4种类型仅仅存在于函数原型声明的参数列表中。参见问题11.6。)
C语言有4种命名空间:行标(label,即goto的目的地)、标签(tag,结构、联合和枚举的名称。这3种命名空间相互并不独立,即使在理论上它们可能独立)、结构/联合成员(每个结构或联合一个命名空间),以及标准所谓的其他的“普通标识符”(函数、变量、类型定义名称和枚举常量)。另一个名称集(尽管标准并没有称其为“命名空间”)包括了预处理宏。这些宏在编译器开始考虑上述4种命名空间之前就会被扩展。
标准定义了3种“连接类型”:外部连接、内部连接和无连接。对我们来说,外部连接就是指全局、非静态变量和函数(在所有的源文件中有效);内部连接就是指限于文件作用域内的静态函数和变量;而“无连接”则是指局部变量及类型定义(typedef)名称和枚举常量。
根据文献 [35,Sec. 4.1.2.1]([8,Sec. 7.1.3])的规定,对规则的解释如下。
规则1:所有以下划线打头,后跟一个大写字母或另一个下划线的标识符永远保留(所有的作用域,所有的命名空间)。
规则2:所有以下划线打头的标识符作为文件作用域的普通标识符(函数、变量、类型定义和枚举常量)保留[5]。
规则3:被包含的标准头文件中的宏名称的所有用法保留。
规则4:标准库中的所有具有外部连接属性的标识符(即函数名)永远保留用作外部连接标识符。
规则5:在标准头文件中定义的类型定义和标签名称,如果对应的头文件被包含,则在(同一个命名空间中的)文件作用域内保留。(事实上,标准声称“所有作用于文件作用域的标识符”,但规则4没有包含的标识符只剩下类型定义和标签名称了。)
由于有些宏名称和标准库标识符集被保留作“未来使用”,这使得规则3和规则4变得愈加复杂。后续版本的标准可能定义符合特定模式的新名称。下表定义了包含标准头文件时,保留作“未来使用”的名称模式。
[A-Z]表示“任何大写字母”;同样,[a-z]和[0-9]分别表示小写字母和数字。*号表示“任何字符”。例如,如果你包含了,则所有的以str打头、后跟一个小写字母的标识符都被保留。
这5条规则到底是什么意思?如果你希望确保安全:
1、2。不要使用任何以下划线开始的名称。
3。不要使用任何匹配标准宏(包括保留作“未来使用”)名称。
4。不要使用任何标准库中已经使用或者保留作“未来使用”的函数和全局变量名称。(严格地讲,“匹配”是指匹配前6个字符,不分大小写。参见问题11.29。)
不要重定义标准库的类型定义和标签名称。
事实上,上面的列表有些保守。如果你愿意,也可以记住下面的例外:
1、2。你可以使用下划线打头、后接一个数字或小写字母的名称来命名函数、块或者原型作用域内的行标和结构/联合成员。
3。如果你不包含定义了标准宏的头文件,可以使用匹配它们的宏名称。
4。可以使用标准库函数名作为静态或局部变量名称。(严格地讲,是用作内部连接或无连接类型的的标识符。)
5。如果你不包含声明标准类型定义和标签的头文件,则可以使用这些名称。
然而,在使用上述“例外”的时候,必须注意有些是非常危险的(尤其是例外3和5,因为你可能在后续版本中意外地包含进相关的头文件。比如,通过一系列的嵌套包含)。其他的,尤其是1、2,是一个用户命名空间和实现保留的命名空间之间的“无人地带”。
提供这些例外的原因之一是允许各种附加库的实现者以某种方式声明他们自己的内部或者“隐藏”标识符。如果你利用这些例外,则不会和标准库发生任何冲突,但可能会和你使用的第三方库发生冲突。(另一方面,如果你是某个第三方附加库的实现者,那么只要足够小心,就可以使用这些名称。)
通常,使用例外4中的标准库函数或匹配保留作“未来使用”模式的函数名称作为函数参数名称或者局部变量名称的确是安全的。例如,“string”就是一个常见而且合法的参数或局部变量名。
参考资料:[35, Sec. 3.1.2.1, Sec. 3.1.2.3, Sec. 4.1.2.1, Sec. 4.13]
[8, Sec. 6.1.2.1, Sec. 6.1.2.2, Sec. 6.1.2.3, Sec. 7.1.3, Sec. 7.13]
[11, Sec. 2.5 pp. 2103, Sec.4.2.1, p. 67, Sec. 4.2.4 pp. 69-70, Sec. 4.2.7 p. 78, Sec. 10.1 p. 284]
初始化
变量的声明当然也可包含对变量的初始化,但是不赋显式的初始值的时候,某种特定的缺省初始化也可能会执行。
1.31
问:对于没有显式初始化的变量的初始值可以作怎样的假定?如果一个全局变量初始值为“零”,它可否作为空指针或浮点零?
答:具有静态(static)生存期的未初始化变量(包括数组和结构)——即在函数外声明的变量和静态存储类型的变量)可以确保初始值为零,就像程序员键入了“=0”或“={0}”一样。因此,这些变量如果是指针就会被初始化为正确类型的空指针(参见第5章),如果是浮点数则会被初始化为0.0。[6]
具有自动(automatic)生存期的变量(即非静态存储类型的局部变量)如果没有显式地初始化,则包含的是垃圾内容。对垃圾内容不能作任何有用的假定。
这些规则也适用于数组和结构(称为“聚集”)。对于初始化来说,数组和结构都被认为是“变量”。
用malloc和realloc动态分配的内存也可能包含垃圾数据,因此必须由调用者正确地初始化。用calloc获得的内存为全零,但这对指针和浮点值不一定有用(参见问题7.35和第5章)。
参考资料:[18, Sec. 4.9 pp. 82-84]
[19, Sec. 4.9 pp. 85-86]
[8, Sec. 6.5.7, Sec. 7.10.3.1, Sec. 7.10.5.3]
[11, Sec. 4.2.8 pp. 72-73, Sec. 4.6 pp. 92-93, Sec. 4.6.2 pp. 94-95, Sec. 4.6.3 p. 96,
Sec. 16.1 p. 386]
1.32
问:下面的代码为什么不能编译?
int f()
{
char a[] = "Hello, world!";
}
答:可能你使用的是ANSI前的编译器,还不支持“自动聚集”(automatic aggregate,即非静态局部数组、结构和联合)的初始化。参见问题11.31。
有4种办法可以完成这个任务:
(1) 如果数组不会被写入,或者后续的调用中不需要更新其中的内容,可以把它声明为static(或者也许可以声明成全局变量)。
(2) 如果数组不会被写入,也可以用指针代替它:
f()
{
char *a = "Hello, world!";
}
初始化局部char*变量,使之指向字符串字面量总是可以的(但请参考1.34)。
(3) 如果上边的条件都不满足,你就得在函数调用的时候用strcpy手工初始化了。
f()
{
char a[14];
strcpy(a, "Hello, world!");
}
(4) 找一个兼容ANSI的编译器。
参见问题11.31。
1.33
问:下面的初始化有什么问题?编译器提示“invalid initializers”或其他信息。
char *p = malloc(10);
答:这个声明是静态或非局部变量吗?函数调用只能出现在自动变量(即局部非静态变量)的初始式中。
1.34
问:以下的初始化有什么区别?
char a[] = "string literal";
char *p = "string literal";
当我向p[i]赋值的时候,我的程序崩溃了。
答:字符串字面量(string literal)—–C语言源程序中用双引号包含的字符串的正式名称—–有两种稍有区别的用法:
(1) 用作数组初始值(如同在chara[]的声明中),它指明该数组中字符的初始值;
(2) 其他情况下,它会转化为一个无名的静态字符数组,可能会存储在只读内存中,这就导致它不能被修改。在表达式环境中,数组通常被立即转化为一个指针(参见第6章)因此第二个声明把p初始化成指向无名数组的第一个元素。
(为了编译旧代码)有的编译器有一个控制字符串是否可写的开关。另外有些编译器则提供了选项将字符串字面量正式转换为const char型的数组(以利于出错处理)。
参见问题1.32、6.1、6.2和6.8。
参考资料:[19, Sec. 5.5 p. 104]
[8, Sec. 6.1.4, Sec. 6.5.7]
[14, Sec. 3.1.4]
[11, Sec. 2.7.4 pp. 31-32]
1.35
问:char a{[3]} = "abc"; 是否合法?
答:是的。参见问题11.24。
1.36
问:我总算弄清楚函数指针的声明方法了,但怎样才能初始化呢?
答:用下面这样的代码:
extern int func();
int (*fp)() = func;
当一个函数名出现在这样的表达式中时,它就会“退化”成一个指针(即隐式地取出了它的地址),这有点类似数组名的行为。
通常函数的显式声明需要事先知道(也许在一个头文件中),因为此处并没有隐式的外部函数声明(初始式中函数名并非函数调用的一部分)。
参见问题1.25和4.12。
1.37
问:能够初始化联合吗?
答:参见问题2.21。
[1] 此处是对非负整数而言。下同。——译者注
[2] 在这个简单例子typedef struct{int i;}simplestruct;
中,结构名和它的typedef
类型名同时被定义为“simplestruct
”,同时可以看到这里并没有结构标签。
[3] 还有,存储类型(static
、register
等)也可能和基本类型一起出现,而类型限定词(const
、volatile
)也可能会点缀在基本类型和声明符之间。参见问题11.10。
[4] 这里不仅需要关注公用符号,对实现的内部、私有函数也得小心。
[5] 意即这些标识符被编译器用作文件作用域内的普通标识符了。这些规则是从C语言的实现(即编译器)的角度描述的。下同。——译者注
[6] 这意味着,在内部使用非零值表示空指针或浮点0的机器的编译器和连接器无法利用未初始化的、以0填充的内存,必须用正确的值进行显式的初始化。