一、写在开头
要点1:编译时,必须要有声明。链接时,必须有定义。
- 编译器需要根据声明来进行语法检查,验证使用是否有误
- 有些语言如C++或是Windows下的调用规范__stdcall等,其中的符号修饰机制,编译时需要根据函数原型来生成符号。
要点2:如果定义的地方在使用的地方之后,或不在一个文件。那使用之前,是否需要再声明一次?如果需要,有没有必要?
- C、C++、OC、Java等需要导入头文件的,都是需要的,不过后两者相比做了一些编译器优化,同一个文件内,允许全局变量、函数的前向引用(即如果定义是在本文件内调用的后面,不用前置声明)。
- Swift进行了更多的优化,不需要导入头文件/声明,只要在同一个命名空间中的资源都是共享的,而且默认情况下,项目名称就是命名空间
- 理论上,只要编译器做的工作多,那写代码时,就可以省去这些导入头文件(里都是声明/定义)的工作。但如果手动添加了声明,可以降低编译器的复杂度和内存需求,不用临时记录尚未发现声明的符号,等到全局扫描一遍,没发现符号再来报符号未定义错误。
二、声明、定义、初始化
2.1 定义
变量定义(definition)是告诉编译器在何处创建变量的存储,以及如何创建变量的存储。在程序中,变量有且仅有一个定义。
格式:数据类型 一个变量名/多个变量名用逗号隔开;
不带初始化的定义:带有静态存储持续时间的变量会被隐式初始化为 NULL(所有字节的值都是 0),其他所有变量的初始值是未定义的。
2.2 声明
变量声明(declaration)是向编译器保证变量以指定的类型和名称存在,这样编译器在不需要知道变量完整细节的情况下也能继续进一步的编译。变量声明只在编译时有它的意义,在程序链接时编译器需要实际的变量定义。
变量的声明有两种情况:
- 一种是需要建立存储空间的。格式与变量定义相同。如int i; //声明,也是定义。
- 一种是不需要建立存储空间的,通过使用extern关键字声明变量名而不定义它。
例如:extern int a; //声明,不是定义。说明变量a是在别的文件中定义的,这里只是在声明(数据类型、名称存在)
2.3 初始化
初始化(Initialization)是指为数据对象或变量赋初值的做法。用于进行初始化的程序结构则称为初始化器或初始化列表。在C/C99/C++中,初始化器是声明器的可选部分,它由一个’=’以及其后的一个表达式(或含有多个以’,’隔开的带圆括号表达式的单一列表)所组成。
在定义变量之后,系统为变量分配的空间内存储的值是不确定的,所以需要对这个空间进行初始化,以确保程序的安全性和确定性
2.4 注意:
当extern声明的变量右侧手动写有初始化式时,就会被当成定义。
- 只有当extern声明位于函数外部时,才可以被初始化。不过也有warning
- extern就失去了它本身的意义,如果其他文件有同名的变量定义,那会报重定义的错误
三、前向声明、前向引用两个名词
前向声明(Forward Declaration)是指标识符(如数据类型、变量、函数)声明时还没有给出完整的定义。
前向引用(Forward Reference)是指一个标识符在声明前就被使用。(有的地方会将其视为前向声明的同义词)
有的编译器中函数、全局变量可以前向引用。但局部变量的前向引用一般都是禁止的。C++允许在类成员函数中,前向引用成员属性(即上面的成员函数中使用下面要定义的属性)。
四、C/C++为什么禁止前向引用?
4.1 声明的作用 —— 完成编译
借用函数原型中维基百科的原话:函数原型被广泛应用于C、C++ 语言程序代码的上下文中,通过在头文件中放置函数的前向声明来允许将代码拆分为多个翻译单元。即编译器可以单独编译目标文件的这部分内容,然后由链接器组合成可执行文件或库。
- 编译器需要根据声明来进行语法检查,验证使用是否有误
- 有些语言如C++或是Windows下的调用规范__stdcall等,其中的符号修饰机制,编译时需要根据函数原型来生成符号。
4.2 前向引用的代价 —— 编译器的复杂度与内存需求
举例:C++允许在类成员函数中,前向引用成员属性(即上面的成员函数中使用下面要定义的属性)。
1 | class C { |
在此例中,对myValue的两次引用早于它的声明。因此,成员函数accessor不能被编译直到编译器获知成员变量myValue的类型,编译器有责任记住accessor的定义直到它看到myValue的声明.
允许前向引用大大增加了编译器的复杂度与内存需求,并且使它不能成为一次通过型的编译器。
C/C++中,即使函数、全局变量的声明与使用在同一文件中,但只要使用先于声明,使用前,都必须加声明。
C/C++禁止变量、函数的前向引用,OC Java允许函数、全局变量的前向引用:
- 编译器优化了,会扫描完头文件中声明的、本文件从头到尾声明/定义的,找不到再报错
- C纯粹是历史原因,C出来的时候编译机制是自上而下的扫描,编译器还做不到预扫描,后来就约定俗成了。知乎
- C可不可以做?可以,但不好做,如果是只向后扫描完整个文件还好。那如果扫描完所有可能存在的地方(因为C中全是函数,都是全局通用的,所以所有文件都有可能是定义函数/变量处),那编译器工作量就太大了
Swift中允许函数、变量的前向引用,而且不用导入头文件:
Swift进行了相关的一些优化,只要在同一个命名空间中的资源都是共享的,而且默认情况下,项目名称就是命名空间
- 编译器进一步优化,全局搜定义/实现,搜到自动补全。
4.3 禁止前向引用(使用前先添加声明)的好处
- 缩小编译器的压力,自上而下扫描,不用临时记录未发现声明的符号,降低内存需求。只从导入的头文件中取查找声明(对比变量的使用,语法检查是否有误)即可
- 不全局搜索,反向推导行不?涉及到隐式类型转换,或者变长常数之类的,不太能精确推导出来。
五、记录一个我自己的误区:头文件的作用
5.1 头文件和源文件的区别
头文件和源文件在本质上没有任何区别。 只不过一般:
- 后缀为 .h 的文件是头文件,内含函数声明、宏定义、结构体定义等内容。
- 后缀为 .c .m .cpp 的文件是源文件,内含函数实现,变量定义等内容。
- 而且是什么后缀也没有关系,只不过编译器会默认对某些后缀的文件采取某些动作。这样分开写成两个文件是一个良好的编程风格。
之前,一直以为是根据导入的头文件,递归编译同名的源文件.m,没被导入过的头文件,其.m文件就不被编译。前两天突然一细想:那如果就是不导入头文件,通过动态创建对象调用的方法呢,crash?好好的类找不到了?
头文件的作用就是放置一些可重复被各源文件导入的代码:一般是类、函数的声明,变量、宏的声明或定义。
预处理阶段:处理#include指令,将每个源文件内,#include后的文件(一般是头文件)内的内容复制过来,替换#include指令,是递归的。将h文件中所有的内容全部扫描进这个当前的源文件中,形成一个.i文件
编译器在编译时是以源文件为单位进行的(每个源文件都会编译),也就是说如果你的项目中一个C文件都没有,那么你的项目将无法编译,连接器是以目标文件为单位,它将一个或多个目标文件进行函数与变量的重定位,生成最终的可执行文件,在PC上的程序开发,一般都有一个main函数,这是各个编译器的约定。
每个源文件都参与编译链接?那无用代码怎么办,编译过程中的中间代码优化环节,会做相关的优化删除工作。
5.2 头文件是必须的吗?
不是,C语言中不是,OC中也不是随便创建类时都自动创建.h。
- 如果确定这个类不会被别人使用(那创建类还有啥意义?当然,也可以动态使用,根据类名、方法名来调用。闲的,这个问题没意义,只是强调一下头文件的作用(绝大部分场景,是方便让其他文件导入声明)),完全可以将.h中对类的声明挪到.m中,然后将.h删除。
- 就算本文件内有函数、变量、宏需要导出给其他文件,也不是必须要头文件。头文件只是方便一次导入多个声明,且方便被复用。如果不想用,自己在每个用到的文件中一个一个写声明也可以的。不是必须要通过导入头文件才能使用外文件的函数/变量等的
即头文件不是必须的:
- 无函数/变量等需要导出给其他文件使用时,不需要创建头文件
- 有函数/变量等需要导出给其他文件使用时,也不是必须要有头文件才行,自己手写一堆声明即可。(头文件就是一堆公用声明/定义的集合)
5.3 一个源文件对应一个头文件吗?
当然不是。
- 在C语言中,常见几个.c文件对应一个.h文件,因为很多都是内部函数,只能对外暴露的函数才需要放到头文件中让人使用
- 在面向对象中,都是以对象为单位来定义、使用,将该对象的对外保护的属性、方法的声明放到头文件中。
- 而且在面向对象中,偶尔也能看到一个头文件中存放了多个强关联类的声明(一导入这个头文件,就导入了多个类的声明)。
- 为了在导入的时候,避免不必要的代码导入,如果不是真的强关联,一般都是一个类对应一个源文件、一个头文件。