因为声明、定义、赋值和初始化都和 编译原理 有关,所以答案就在 编译器的输出 上。
一、未声明
1.c:
int
main
()
{
a
=
1
;
}
1.c:2:5: error: use of undeclared identifier 'a'
a = 1;
^
1 error generated.
请逐字阅读编译器的输出。说两遍,请逐字阅读这个输出。
注意这里有个词语叫 undeclared
- use of: 使用。谁使用?你,ahh,【你在使用一个】变量。
- undeclared:declare 是【对外宣告】,undeclared形容词 --【没有对外宣告过的】,文绉绉的说法,叫【未声明】
- identifier: identify是【身份证】,也叫ID。*ier是什么人,identifier就是【有身份的人】,文绉绉的说法,叫【标识符】
合起来是,【你在使用一个】【没有对外宣告过的】【身份证】'a'。
这句话是说,a 标识符没有声明,不知道它是个什么东西。
这就叫未声明 undeclared
二、未定义
1.c:
extern
int
a
;
int
main
()
{
a
=
1
;
}
/tmp/ccxhuV7j.o: In function `main':
1.c:(.text+0x6): undefined reference to `a'
collect2: error: ld returned 1 exit status
请逐字阅读编译器的输出。注意这里有个词语叫 undefined
- undefined: 【没有定义过的】意思
- reference:【介绍信】的意思,这里有个文绉绉的说法,叫【引用】
- to 'a': 对于 a
合起来是,对于 a 的【介绍信】,是【没有定义过的】
这句话是说,a 是个名称,它引用的内存实体是不存在的。a 为什么一定要引用一个内存实体?哈哈,你一下子就摸到的计算机语言的灵魂,为了行文的需要,这个疑问不能在这里展开,因为答案太长了,本文倒数的4节是这个问题的答案。
这就叫未定义 undefined
三、不能赋值
1.c:
int
main
()
{
main
=
123
;
}
1.c:2:8: error: non-object type 'int ()' is not assignable
main = 123;
~~~~ ^
1 error generated.
请逐字阅读编译器的输出。注意这里有个词语叫 assignable
这句话是说
合起来就是,非对象类型的函数类型不可以被赋值
main 是返回值为int的函数类型,它为什么不能被赋值呢?
要从对象说起,对象是一块可以操作的内存块。
言外之意,内存中还存在不能被操作的内存?哈哈,你的认知决定了你的高度,是的,内存有
- 向量区 禁止入内
- 文本区 禁止乱涂乱画
- 数据区 自己的可以随便玩,不是自己的禁止拍照
- IO区 只开放给专家学者
对象!一个多么熟悉多么丰富的词语。。。
本例中,main 是一块文本区的内存,不是可操作的内存,所以不能被赋值。
这就叫不能赋值 not assignable
四、不能初始化
1.c:
int
a
=
"foo"
;
int
main
()
{}
1.c:1:9: error: initializer element is not computable at load time
int a = "foo";
^
请逐字阅读编译器的输出。注意这里有个词语叫 initializer
element:是常量"foo",是个字符串地址
is not computable:不是算数
at load time:在程序运行的时候
合起来就是,字符串地址在运行时不能被计算。额,编译器,你管的可真多。
a 这个位置是 int ,字符串就是一个地址,也是 int 。因此,从原理上来说上面程序没有问题。事实上,在老 c 语言中,上述程序正常。
但是,后来语法变了。为什么?为了更加规范和安全,这种行为被禁止了。编译器给出的理由是,初始化的元素在运行时是算不了的。其实,这是一个善意的谎言,指针当然可以计算。但是为了规范有人阻止了你,阻止你的人正是 初始化。初始化是编译器这个大程序的一个子程序。
程序分编译时,运行时。目前看来,编译时越来越庞大,越来越聪明。各种思想方法论被发明出来,典型如c++。
事实上,编译器初始化的一些小聪明展现了他的可怜父母心,巴不得把所有后事都料理完,脏活累活全都不让孩子干。
比如下面的代码
1.c:
char
*
s
=
"bar"
;
int
days
[]
=
{
31
,
28
,
31
,
30
,
31
,
30
,
31
,
31
,
30
,
31
,
30
,
31
};
long
hour
=
60
*
60
*
1000
;
int
main
()
{}
编译器为"bar"分配字符串存储,把days变成数组,hour算成600000,而不是在运行时再算。
这就是初始化器 initializer,一个编译逻辑
五、九年前的一个心结
不知道三年后,这个答案你是否还看。
但是,起码,这个答案了结了我九年前的一个心结。
在2009年左右,我爱去 chinaunix (当时叫CU)上闲逛,这个论坛以脾气大,炮火猛著称。
有次我调侃了某位仁兄,没想到他立马查阅了我以往所有的提问和回答,抓到了我一个小辫子。
这个小辫子是,在某个提问中,我提到对初始化的机制不太清楚。
他当即骂我,连初始化都不懂,还在这里讨论个XX,装什么装,滚!!!
一时间,我百感交集,
一方面,我被他迅雷般的回复、雷霆般的怒火惊呆,这是一次绝妙的反击,他用我最近在另一篇帖子中的提问来充当证据。
另一方面,我为自己不知道初始化的底层细节而感到脸红和委屈。
初始化到底是怎么回事,为什么我一直不能调查清楚呢。
这个问题就像哥德巴赫猜想一样,看着简单,实际却无法证明。
真是哑巴吃黄莲,有苦说不出。我当时只是感觉这个问题很难,但是不敢肯定。
我一时间很受伤,慢慢远离了 CU,也慢慢远离了 C 语言,而我对这个问题依然不甚清楚。
很多年过去了,在一次不成功的项目中,无心插柳柳成荫,明白了初始化的来龙去脉。
六、程序
在讨论声明、定义、赋值和初始化这些概念之前,必须要对程序的历史做一些回顾。
目的是要建立一个信念,程序最好是这个样子(就是现在我们看到这样),或者干脆说必须是这个样子,要不然就没法跟机器打交道。
在二战期间雷达的使用,随后计算机的发明,IBM大型机的研发,以及人类登月,「编程活动」在实践中不断的涌现出种种方法论,编程这门技术日趋成熟。
小型机出现了,它培养并涌现出一批黑客。
后来,桌面机的出现,更是让这门技术开始流向民间,后面的故事大家都熟悉了,比尔盖茨和乔布斯把握住了机会。
回到 1970 年代,即使在 C 语言被发明之前,人们已经意识到了很多宝贵的经验、教条和技巧,C 语言和 UNIX 系统是站在这些巨人的肩膀上建立起来的。
这里只列举几条和本话题相关的几个「肩膀」:
1. File 文件
比如书籍,人类天生喜欢把信息记录在一个个文件中,这是个看似再自然不过的特征。
而为什么是文件,而不是其他形式,比如岩画、结绳记事,或者更猛的诸如爪子抓痕、尿液标记。
答案你懂的,因为文件信息量大,传播方便。结绳记事成本高,时间长就绳子不够了。岩画能被外借阅读吗,不能。所以,当 1956 年 IBM 发明硬盘后,就有人琢磨着把文件在硬盘中表示出来。
大约在 1961 年,方案终于出来了,这个方案叫「文件系统」,每个文件有个名字,可以被看到,它会给你一种假象,仿佛在桌子上看到一个个的纸质文件。
你可以打开它,修改它,关闭它。就像我们写一封信一样,先打开信封,写信,再把它装回信封。
因为程序员选择了用文件去编写程序,并且一直沿用至今,所以文件对程序的影响可以说是方方面面。
2. Virtual Memory 虚拟内存
虚拟内存这项计算机的特征,很大程序上影响了程序(就是供机器执行的那个东西)的组织结构。
它极其难以理解,并且追踪起来非常不便。但是为什么非要采用它呢?
在1940、1950年代,人们编写大型程序会被内存管理的问题困扰,比如内存覆盖这种相互干扰。
我相信,以当时的条件,调试起来简直是手足无措。
最早提出虚拟内存的是 1956 年一位德国物理学家。
虚拟内存方便了多任务、多道程序处理,但是这不是最重要的。
最重要的是,因为加强了系统对程序的控制,所以方便了人的编程活动。
它在系统和程序间架起一道硬件防火墙,使得程序崩溃不会殃及到系统。
虚拟内存如此重要,在高级编程语言发明之前它就成为了计算机重要的特征。因此,它影响了所有语言的设计。
3. Subroutine 子过程
1968 年有人提出goto有害论,随后,结构化编程成为主流。
我们今天看到的函数是子过程的一种。
函数在进入子过程前有一个备份操作,退出子过程前有一个恢复操作,并且函数和栈有密切关系,它有一个调用链。
4. Pointer 指针
1964 年被发明。
在 pdp11 小型机中,有指针和二级指针的寻址模式,直接用指令实现的。
指针形如 (r),二级指针形如 *(r)
我觉得这种写法比 *p, **p 要好点,不过挺遗憾,可能是不能表达三级指针,没有被 C 采用
还有一种寻址是 (r)+ 和 -(r),因为这两种操作用的频率很高,所以没有实现 +(r) 和 (r)-。
(r)+ 和 -(r) 可以望文生义,是个很好的设计。
如果 C 语言采用这个设计,就不会后来对 ++i,i++ 的诟病了吧。
5. Stack 栈
这里说的栈并不是数据结构中前进后出那个栈,而是系统中为栈设计的一整套机制,包括两个通用寄存器和一些指令。
栈具体是什么时候被发明的我不清楚,也许是种学术的结晶吧。
6. text&data 程序和数据
程序应该逻辑和数据分离。事实上数据被进一步细分。
在 C 种程序中,这种划分是
a) 正文区
函数的定义、汇编子过程、中断和陷入向量
在unix系统中,正文区占内核 57.45%
而
函数的定义占正文段 91.49%
子过程占正文段 7.62%
中断和陷入向量占正文段 0.86%
b) 初始化数据区
下列两种情况数据会放入初始化数据区
c代码中明确初始化的全局变量
汇编代码中初始化的数据
在unix系统中,初始化数据区占内核 3.51%
c) bss数据区
c代码中的未初始化变量
汇编代码中的未初始化变量
c代码中的静态局部变量
在unix系统中,bss数据区占内核 39.03%
bss 意思是 block started by symbol
这句话揭示了一定真相,编译器只是把它们当作符号来看待,为其安排地址。
在程序文件中它们不必存在 - 实际是就是不存在,当然这是前辈故意这么设计的。
当程序执行的时候,bss 段才在内存中被安排并被清空,这件事情是操作系统或者说加载器做的。
所以,bss实体是加载器实现的,而初始化数据实体是编译器实现的。bss实体只在内存中,初始化数据实体在程序文件中。
七、声明和定义
前文提到,程序员选择用文件去编写程序,选择文件的原因也分析了。
人和文件要想到达机器那边,需要经过编译器这座桥梁。
一个大型程序会被组织成多个文件,这就给编译带来了难题。
这些文件最终是要被翻译成程序的,可是它们的数量却是变化的。
较小的程序可能有2个文件,较大的程序可能有几百万个文件,甚至只要我想,我可以制造一个10亿文件的代码包。
并且一个文件就像一个画板一样,它终究是有一个大小的。就算一个文件很大很大,它仍然有个大小,你不能制造一个无穷大的文件。
编译器在这个时候,接受了人类喜欢文件这一事实(这么说是想把编译器拟人成一头牛 )。
- 代码是组织在多个文件中的,编译器为了解决这个问题,很自然的就提出了声明和定义这两个概念
声明 Declarations
定义 Definitions
一个变量或函数在内存中只能存在一份,所以在代码中它只能在一个地方登场(登场这个词是两年后想出的),这就是定义。
而这个变量或函数可能被多个文件使用,在每个文件中它都要登场,也就是每个文件中都定义,怎么解决这个矛盾呢?-- 用声明。
如果程序不是存放在多个文件中,那么根本就不需要声明,直接使用,直接登场,根本不用关心声明定义。
程序虽然放在多个文件中,如果它们能相互引用(考,那和一个文件有什么分别),那么也不需要声明。
可是,你知道这些假设都是不可能的,因为人类是用一个一个的文件去表达的。所以,是人类喜欢文件这一特征,造成了编译器必须去这么设计,必须有声明和定义这种语法。
八、赋值和初始化
现在的编译器已经「聪明」到超出你的想象。
即使去看几十年前的老 c 编译器,它的聪明程度也会令你惊叹。
初始化就是这样的一个「聪明」的行为。
可是偏偏初始化使用了和赋值一样的语法,形如
int foo = 123;
结果,导致了这个编译时行为有点耍「小聪明」的味道。
如果我告诉你,在c语言本来的设计中,初始化和赋值是两种截然不同的语法。
你就会恍然大悟了。
int foo 123; /* 初始化,只能用在全局变量 */
int foo = 123; /* 赋值,只能用在局部变量 */
这两种语法出现的场景、作用的对象和含义都不相同,很好区分。
初始化完全是编译器的行为,赋值则是运行时的行为。
标准 C 后来统一了初始化这个概念,全局变量的初始化和自动变量的默认值赋值都叫初始化。
这确实更「高级」了,但是其实这两个初始化差别却存在,全局变量的初始化值只能是常量。
所以这也是 C 的一个遗憾。
八、参考
K&R 1.10节 2.4节 4.3节 4.4节 4.9节 是这个话题是官方的答案。
K&R 这本书我十年后再次重读,让我感到我越读不懂它,这是一本计算机界的奇幻之书。
它把大量的优美的代码、算法隐藏在例子中,
晦涩的机器原理埋藏在它的括号,甚至是语气之内,
艰深的编译原理清晰的列在附录之后,
对历史的讲述、对标准微妙的嘲讽和无奈编撰在序言前言的字里行间,
对软件工程的争论用务实的代码去化解。
两位作者的友情和自谦以及对程序员的谆谆教导贯穿这本不厚也不薄的书。
这是本适合重读,不适合学习的书。
这是本不能用读书计划解决的书。
这是本拿来验证自己的书。