程序的基本概念
程序和编程语言
1.程序
程序(Program )告诉计算机应如何完成一个计算任务,这里的计算可以是数学运算,比如解方 程,也可以是符号运算,比如查找和替换文档中的某个单词。从根本上说,计算机是由数字电路组 成的运算机器,只能对数字做运算,程序之所以能做符号运算,是因为符号在计算机内部也是用数字表示的。此外,程序还可以处理声音和图像,声音和图像在计算机内部必然也是用数字表示的,这些数字经过专门的硬件设备转换成人可以听到、看到的声音和图像。
程序由一系列指令组成, 指令是指示计算机做某种运算的命令,通常包含以下几类:
- 输入(Input):从键盘,文件或者其他设备获取数据.
- 输出(Output):把数据显示到屏幕,或者存入一个文件,或者发送到其他设备.
- 基本运算:执行最基本的数学运算和数据存取.
- 测试和分支:测试某个条件,然后根据不同的测试结果执行不同的后续指令.
- 循环:重复执行一系列操作
基本我们用过的任何一个程序,都是由这几类指令组成的,程序是虽然很复杂,但是编写程序能够用到的指令只有这简单的几种,这中间的东西需要我们来填充,所以编写程序就是一件很复杂的工作.
实质就是:把复杂的问题分解为子任务,把子任务再分解成更为简单的任务,层层分解,直到最后简单得可以用上面的指令来完成.
2.编程语言
编程语言( Programming Language )分为低级语言( Low-level Language )和高级语言( High-level Language)。机器语言( Machine Language)和汇编语言(Assembly Language )属于低级语言,直接用计算机指令编写程序。而C 、C++、Java、Python 等属于高级语言,用语句(Statement )编写程序,语句是计算机指令的抽象表示。举个例子,同样一个语句用 C 语言、汇编语言和机器语言分别表示如下:
编程语言 | 表示形式 |
C语言 | a=b+1 |
汇编语言 | mov 0x804a01c,%eax add $0x1,%eax mov %eax,0x804a18 |
机器语言 | a1 1c a0 04 08 83 c0 01 a3 18 a0 04 08 |
计算机中只能对数字做运算处理,符号,声音等在计算机内部都需要用数组来表示,指令也不例外,上述表中机器语言都是由十六进制数字组成的.最早一批的程序员就是使用机器语言进行编程的,但是是很麻烦的,需要查询大量的表格来确定每个数字代表的意思,编写出来的程序也不是很友好,于是再次基础上有了汇编语言,就是把机器语言中一组一组的数字用助记符表示,直接用这些助记符写出汇编程序,然后让汇编器去查表把助记符替换成数字,也就把汇编语言翻译成了机器语言.从上面的例子可以看出,汇编语言和机器语言的指令是一一对应的,汇编语言有三条指令,机器语言也有三条指令,汇编器就是做一个简单的替换工作,例如在第一条指令中,把mov ?,%eax这种格式的指令替换成机器码a1 ?,?表示一个地址,在汇编指令中是0x804a01c,转换成机器码之后是1c a0 04 08.(小端表示)
从上述例子我们还可以看出来,C语言的语句和低级语言的指令之间并不是简单都得一一对应关系,一语句a=b+1;语句要翻译成三条汇编或者机器指令,这个过程我们称为编译,由编译器来完成,显然编译器的功能比汇编器要复杂的多.用C语言编写的程序必须经过编译转成机器指令才能被计算机指令,编译需要花一些时间,这是用高级语言编译的一个缺点,然而更多的是优点.首先,用C语言编程更容易,写出来的代码更紧凑,可读性更强,出了错也更容易改正.其次,C语言是可移植的或者称为平台无关的.
首先来解释一下平台这个词的意思,可以指的是计算机体系结构,也可以指的是操作系统,也可以指开发平台(编译器,连接器等).不同的计算机体系结构有不同的指令集,可以识别的机器指令格式是不同的,直接用某种体系结构的汇编或机器指令写出来的程序只能在这种体系结构的计算机上运行,然后各种体系结构的计算机都有各自的C编译器,可以把C程序编译成各种不同体系结构的机器指令,这意味着用C语言写的程序只需要稍加修改甚至不用修改就可以在不用的计算机上编译运行.各种高级语言也都是具备C语言的这些优点,所以绝大部分程序都是用高级语言编写的,只有和硬件关系密切的少数程序(例如驱动程序)才会用到低级语言.还要注意一点,即使在相同的体系结构和操作系统下,用不同的C编译器编译同一个程序得到的结果也有可能是不同的,C语言有些语言特性在C标准种并没有明确规定,各编译器有不同的实现方式,编译出来的指令行为特性也会不同,应该尽量避免使用不可移植的语法特性.
总结一下编译执行的过程,首先你用文本编辑器写一个C程序,然后保存成一个文件,例如test.c.这称为源代码或源文件,然后运行编译器对它进行编译,编译的过程并不执行程序,而是把源代码全部翻译成机器指令,再加上一些描述信息,生成一个新的文件,例如a.out,这成为可执行文件,可执行文件可以被操作系统加载运行,计算机执行该文件中由编译器生成的指令.
有些高级语言以解释(Interpret)的方式执行,解释执行过程和C语言的编译执行过程很不一样。
例如编写一个Shell脚本script.sh,内容如下:
1. #! /bin/sh 2. VAR=1 3. VAR=$(($VAR+1)) 4. echo $VAR
定义Shell变量VAR的初始值是1,然后自增1,然后打印VAR的值。用Shell程序/bin/sh解释执行这个脚本,结果如下:
1. $ /bin/sh script.sh 2.
这里的/bin/sh称为解释器(Interpreter),它把脚本中的每一行当作一条命令解释执行,而不需要
先生成包含机器指令的可执行文件再执行。如果把脚本中的这三行当作三条命令直接敲到 Shell 提示符下,也能得到同样的结果:
$ VAR=1 $ VAR=$(($VAR+1)) $ echo $VAR 2
编程语言仍在发展演化。以上介绍的机器语言称为第一代语言(1GL,1st Generation Programming Language),汇编语言称为第二代语言(2GL,2nd Generation Programming Language),C、C++、Java、Python等可以称为第三代语言(3GL,3rdGenerationProgramming Language)。目前已经有了4GL(4th Generation Programming Language)和5GL(5th Generation Programming Language)的概念。3GL的编程语言虽然是用 语句编程而不直接用指令编程,但语句也分为输入、输出、基本运算、测试分支和循环等几种,和 指令有直接的对应关系。而4GL以后的编程语言更多是描述要做什么(Declarative)而不描述具体 一步一步怎么做(Imperative),具体一步一步怎么做完全由编译器或解释器决定,例如SQL语言 (SQL,Structured Query Language,结构化查询
语言)就是这样的例子。
问题一: 解释执行的语言相比编译执行的语言有什么优缺点?
1.主体不同:
编译执行:由编译程序将目标代码一次性编译成目标程序,再由机器运行目标程序
解释执行:将源语言直接作为源程序输入,解释执行解释一句后就提交计算机执行一句,并不形成目标程序。
2.优势不同
编译执行:相比解释执行编译执行效率高,占用资源小,适合复杂程序
解释执行:开发速度快,出现严重BUG的几率小。
3.缺点不同
编译执行:兼容性差,例如在windows平台上写的编译程序一般不可以在unix平台上运行。
解释执行:解析需要时间,不生成目标程序而是一句一句的执行的方式会造成计算机资源的浪费,即执行效率低。
各类语言分类:
- 编译执行:GO语言、C语言、C++
- 解释执行:python
- 半编译半解释型语言:java、C#
自然语言和形式语言
自然语言( Natural Language )就是人类讲的语言,比如汉语、英语和法语。这类语言不是人为设
计(虽然有人试图强加一些规则)而是自然进化的。形式语言( Formal Language )是为了特定应
用而人为设计的语言。例如数学家用的数字和运算符号、化学家用的分子式等。编程语言也是一种
形式语言,是专门设计用来表达计算过程的形式语言。
形式语言有严格的语法( Syntax )规则,例如, 3+3=6 是一个语法正确的数学等式,而 3=+6$ 则不
是, H 2 O 是一个正确的分子式,而 2 Zz 则不是。语法规则是由符号( Token )和结构(Structure)的规则所组成的。Token的概念相当于自然语言中的单词和标点、数学式中的数和运 算符、化学分子式中的元素名和数字,例如3=+6$ 的问题之一在于 $ 不是一个合法的数也不是一个 事先定义好的运算符,而2 Zz 的问题之一在于没有一种元素的缩写是 Zz 。结构是指 Token 的排列方式,3=+6$ 还有一个结构上的错误,虽然加号和等号都是合法的运算符,但是不能在等号之后紧跟加号,而2 Zz 的另一个问题在于分子式中必须把下标写在化学元素名称之后而不是前面。关于Token 的规则称为词法( Lexical )规则,而关于结构的规则称为语法( Grammar )规则.
当阅读一个自然语言的句子或者一种形式语言的语句时,你不仅要搞清楚每个词( Token )是什么
意思,而且必须搞清楚整个句子的结构是什么样的(在自然语言中你只是没有意识到,但确实这样
做了,尤其是在读外语时你肯定也意识到了)。这个分析句子结构的过程称为解析( Parse )。例
如,当你听到 “The other shoe fell.” 这个句子时,你理解 the other shoe 是主语而 fell 是谓语动词,一
旦解析完成,你就搞懂了句子的意思,如果知道 shoe 是什么东西, fall 意味着什么,这句话是在什
么上下文( Context )中说的,你还能理解这个句子主要暗示的内容,这些都属于语(Semantic )的范畴。
虽然形式语言和自然语言有很多共同之处,包括 Token 、结构和语义,但是也有很多不一样的地
方。
现在给出一些关于阅读程序(包括其它形式语言)的建议。首先请记住形式语言远比自然语言紧
凑,所以要多花点时间来读。其次,结构很重要,从上到下从左到右读往往不是一个好办法,而应
该学会在大脑里解析:识别 Token ,分解结构。最后,请记住细节的影响,诸如拼写错误和标点错
误这些在自然语言中可以忽略的小毛病会把形式语言搞得面目全非。
程序的调试
编程是一件复杂的工作,因为是人做的事情,所以难免经常出错。据说有这样一个典故:早期的计
算机体积都很大,有一次一台计算机不能正常工作,工程师们找了半天原因最后发现是一只臭虫钻
进计算机中造成的。从此以后,程序中的错误被叫做臭虫( Bug ),而找到这些 Bug 并加以纠正的
过程就叫做调试( Debug )。有时候调试是一件非常复杂的工作,要求程序员概念明确、逻辑清
晰、性格沉稳,还需要一点运气。调试的技能我们在后续的学习中慢慢培养,但首先我们要区分清
楚程序中的 Bug 分为哪几类。
编译时错误:
编译器只能翻译语法正确的程序,否则将导致编译失败,无法生成可执行文件。对于自然语
言来说,一点语法错误不是很严重的问题,因为我们仍然可以读懂句子。而编译器就没那么
宽容了,只要有哪怕一个很小的语法错误,编译器就会输出一条错误提示信息然后罢工,你
就得不到你想要的结果。虽然大部分情况下编译器给出的错误提示信息就是你出错的代码
行,但也有个别时候编译器给出的错误提示信息帮助不大,甚至会误导你。在开始学习编程
的前几个星期,你可能会花大量的时间来纠正语法错误。等到有了一些经验之后,还是会犯
这样的错误,不过会少得多,而且你能更快地发现错误原因。等到经验更丰富之后你就会觉
得,语法错误是最简单最低级的错误,编译器的错误提示也就那么几种,即使错误提示是有
误导的也能够立刻找出真正的错误原因是什么。相比下面两种错误,语法错误解决起来要容
易得多
运行时错误
编译器检查不出这类错误,仍然可以生成可执行文件,但在运行时会出错而导致程序崩溃。
对于我们接下来的几章将编写的简单程序来说,运行时错误很少见,到了后面的章节你会遇
到越来越多的运行时错误。读者在以后的学习中要时刻注意区分编译时和运行时( Run-
time )这两个概念,不仅在调试时需要区分这两个概念,在学习 C 语言的很多语法时都需要区
分这两个概念,有些事情在编译时做,有些事情则在运行时做。
逻辑错误和语义错误
第三类错误是逻辑错误和语义错误。如果程序里有逻辑错误,编译和运行都会很顺利,看上
去也不产生任何错误信息,但是程序没有干它该干的事情,而是干了别的事情。当然不管怎
么样,计算机只会按你写的程序去做,问题在于你写的程序不是你真正想要的,这意味着程
序的意思(即语义)是错的。找到逻辑错误在哪需要十分清醒的头脑,要通过观察程序的输
出回过头来判断它到底在做什么。
第一个程序
通常一本教编程的书中第一个例子都是打印“Hello, World.”,用C语言写这个程序可以这样写:
#include <stdio.h> /* main: generate some simple output */ int main(void) { printf("Hello, world.\n"); return 0; }
将这个程序保存成main.c,然后编译执行
1. $ gcc main.c 2. $ ./a.out 3. Hello, world.
gcc 是 Linux 平台的 C 编译器,编译后在当前目录下生成可执行文件 a.out ,直接在命令行输入这个可
执行文件的路径就可以执行它。如果不想把文件名叫 a.out ,可以用 gcc 的 -o 参数自己指定文件名:
虽然这只是一个很小的程序,但我们目前暂时还不具备相关的知识来完全理解这个程序,比如程序
的第一行,还有程序主体的 int main(void){...return 0;} 结构,这些部分我们暂时不详细解释,
读者现在只需要把它们看成是每个程序按惯例必须要写的部分( Boilerplate )。但要注意 main 是一
个特殊的名字, C程序总是从main 里面的第一条语句开始执行的,在这个程序中是指 printf 这条语
句.
第 3 行的 /* ... */ 结构是一个注释( Comment ),其中可以写一些描述性的话,解释这段程序在
做什么。注释只是写给程序员看的,编译器会忽略从 /* 到 */ 的所有字符,所以写注释没有语法规
则,爱怎么写就怎么写,并且不管写多少都不会被编译进可执行文件中。
printf 语句的作用是把消息打印到屏幕。注意语句的末尾以 ; 号( Semicolon )结束,下一条语
句 return 0; 也是如此。
C 语言用 {} 括号( Brace 或 Curly Brace )把语法结构分成组,在上面的程序中 printf 和 return 语句套
在 main 的 {} 括号中,表示它们属于 main 的定义之中。我们看到这两句相比 main 那一行都缩进
( Indent )了一些,在代码中可以用若干个空格( Blank )和 Tab 字符来缩进,缩进不是必须的,但
这样使我们更容易看出这两行是属于 main 的定义之中的,要写出漂亮的程序必须有整齐的缩进.
正如前面所说,编译器对于语法错误是毫不留情的,如果你的程序有一点拼写错误,例如第一行写
成了stdoi.h,在编译时会得到错误提示:
1. $ gcc main.c 2. main.c:1:19: error: stdoi.h: No such file or directory
这个错误提示非常紧凑,初学者往往不容易看明白出了什么错误,即使知道这个错误提示说的是
第 1 行有错误,很多初学者对照着书看好几遍也看不出自己这一行哪里有错误,因为他们对符号和
拼写不敏感(尤其是英文较差的初学者),他们还不知道这些符号是什么意思又如何能记住正确的
拼写?对于初学者来说,最想看到的错误提示其实是这样的: “ 在 main.c 程序第 1 行的第 19 列,您试
图包含一个叫做 stdoi.h 的文件,可惜我没有找到这个文件,但我却找到了一个叫做 stdio.h 的文
件,我猜这个才是您想要的,对吗? ” 可惜没有任何编译器会友善到这个程度,大多数时候你所得
到的错误提示并不能直接指出谁是犯人,而只是一个线索,你需要根据这个线索做一些侦探和推
理。
有些时候编译器的提示信息不是 error 而是 warning ,例如把上例中的 printf("Hello, world.\n");改成 printf(1); 然后编译运行:
$ gcc main.c main.c: In function ‘main’: main.c:7: warning: passing argument 1 of ‘printf’ makes pointer from integer without a cast $ ./a.out Segmentation fault
这个警告信息是说类型不匹配,但勉强还能配得上。警告信息不是致命错误,编译仍然可以继续,
如果整个编译过程只有警告信息而没有错误信息,仍然可以生成可执行文件。但是,警告信息也是
不容忽视的。出警告信息说明你的程序写得不够规范,可能有 Bug ,虽然能编译生成可执行文件,
但程序的运行结果往往是不正确的,例如上面的程序运行时出了一个段错误,这属于运行时错误。
各种警告信息的严重程度不同,像上面这种警告几乎一定表明程序中有 Bug ,而另外一些警告只表
明程序写得不够规范,一般还是能正确运行的,有些不重要的警告信息 gcc 默认是不提示的,但这
些警告信息也有可能表明程序中有 Bug 。一个好的习惯是打开 gcc 的 -Wall 选项,也就是让 gcc 提示所
有的警告信息,不管是严重的还是不严重的,然后把这些问题从代码中全部消灭。
比如把上例中的printf("Hello, world.\n"); 改成 printf(0); 然后编译运行:
$ gcc main.c $ ./a.out
编译既不报错也不报警告,一切正常,但是运行程序什么也不打印。如果打开-Wall选项编译就会
报警告了
$ gcc -Wall main.c main.c: In function ‘main’: main.c:7: warning: null argument where non-null required (argument 1)
如果 printf 中的 0 是你不小心写上去的(例如错误地使用了编辑器的查找替换功能),这个警告就
能帮助你发现错误。虽然本书的命令行为了突出重点通常省略 -Wall 选项,但是强烈建议你写每一
个编译命令时都加上 -Wall 选项。