最近在机核网看到了不少编程相关的内容,故也贡献一篇拙作,“娱乐为主”。文中若有错误,还烦请读者在评论区不吝赐教。文中有的没有说明的专有名词使用加粗格式,读者可以自行查找它们的定义。
免责声明:尽管本文的行文看起来很适合新手入门,但事实上涉及了不少前置知识,属于胡侃的文章。如果有学习计算机语言的想法,请移步站内站外其他的文档书籍,以及寻求专业人士的建议。
免责声明2:由于笔者本人此时不在国内,文中的有些链接没有验证国内网络是否可以正常打开。
hello world 程序是计算机编程语言中广泛存在的最简单的示例程序。一般来说,这样的程序仅在屏幕上输出一个简单的句子,即“hello, world”。它常常作为每个计算机语言学习的第一课,展示语言最基本的特性。某种程度上,这样的程序能够反映语言的特性,例如年龄大于家父的 COBOL,它的示例程序是这样的:
[hello.cbl]
000100 IDENTIFICATION DIVISION.
000200 PROGRAM-ID. HELLO.
000300 AUTHOR. JOE PROGRAMMER.
000400 ENVIRONMENT DIVISION.
000500 DATA DIVISION.
000600 PROCEDURE DIVISION.
000700 MAINLINE.
000800 DISPLAY 'Hello World!'.
000900 STOP RUN.
(COBOL今日仍然作为一个活跃的编程语言存在于金融领域,“代码很稳定,便不要动它”。)
而今天越来越招人喜欢的 Python,它的 hello world 程序则是:
print("hello, world")
回到我们的主题,一般而言,C 的 hello world 程序如下:
#include <stdio.h>
int main(void) {
printf("Hello World!\n");
return 0;
}
虽然上一张的最后一句提到逐行分析这个程序,但是分析程序之前,不得不提一提 C 语言的编译。一般而言,计算机语言分为编译型语言和解释型语言。编译型语言编译器(compiler)将代码编译为二进制程序,代码的逻辑可以通过执行编译的二进制程序实现;解释型语言则将代码读入写好的解释器(interpreter),代码的逻辑通过解释器的行为解读。
C 作为一门编译型语言,需要将代码写在一个形如 hello.c 这样的文件中,并且通过一个编译器进行编译操作。编译的结果往往是一个名为 hello(无后缀)或者 hello.exe 的程序。程序的名字和操作系统、编译器、编译时的选项相关。在 Ubuntu 20.04 LTS 中,执行如下命令将会生成一个可执行的二进制文件 a.out 。
cc hello.c
执行这一程序(一般情况下)将会打印出“Hello World!”的字符,并且将0作为返回值返回,代表程序正常地完成了执行。
注意到编译器和语言之间并不是游戏引擎和项目文件那样一个一对一的关系。一门编程语言的设计者不一定需要写出一个特别的编译器,只需要指定语言的语法标准即可。编译器可以是由完全无关的全体或个人遵守语言的语法标准实现的。今天,最常用的 C 语言编译器有 GCC、Clang 等等。有的网站可以在线编译程序,应要求生成不同版本的汇编代码。如 Compiler Explorer 。另外,微软处于某种考量使得在 Windows 系统上编译 C 程序的流程相当复杂(官方推荐的方法好像是使用 Visual Studio 全家桶)。 #include <stdio.h>
其中,# 代表它是一行“预处理器指令”,按照微软文档的说明,
预处理器是将源文件的文本作为翻译的第一阶段操作的文本处理器。 预处理器不会分析源文本,但会将源文本细分为标记来查找宏调用。 尽管编译器一般会在其第一个传递中调用预处理器,但还是可以为了在不进行编译的情况下处理文本而单独调用预处理器。
“#include”是常用的预处理器指令之一,它的作用是引入外部头文件(header file)的定义。被引入的文件名会使用一对尖括号或者一对 dumb quotes 包裹。所谓的 dumb quotes 即不分左右的引号,在常见的中文键盘(QWERTY——以键盘左上角的键命名)中,需要将输入法调整至英文模式,并且按下回车键左边那个按键来输入它。它与中文输入法常见的左右引号是不同的;后者不能构成有效的 include 指令,并且在有的文件编码下可能不能正确地保存。
在 include 指令中使用尖括号或 dumb quotes 会影响编译器寻找头文件的顺序。一般而言,对于标准库,或者使用包管理器安装的库文件,使用尖括号;对于本地的头文件,使用 dumb quotes 是适宜的行为。“#include”语句后没有分号。多个“include”语句之间的顺序有时是重要的。像 clang-format 这样的程序通过设置会按一定的顺序重新排列语句的顺序。相关的讨论参照 StackOverflow 。
像 include 指令这样的预处理器指令对于绝大多数编译器来说是定义好的,有的预处理器指令则未必在所有的编译器中有定义——换言之,它们是“非标准”的,可能没有在一些编译器中实现。常见的非标准的预处理器命令如 “#pragma once”。在头文件中使用这一命令可以保证一个头文件的定义仅会被引入一次——忘记对头文件进行类似的处理并多次导入同一头文件会导致错误。这一操作也可以使用 ifndef-define-endif 操作实现,被称作 include guard 。
在本行代码(#include <stdio.h>)中,include 引入的头文件是 stdio.h 。std 是 standard 的缩写,代表该文件是一个标准库的头文件。io 是 input-output 的缩写,代表着该标准库处理的是输入-输出的问题。在这一库中定义了 printf 这一函数,我们在示例程序的第三行使用了这一函数。虽然,printf 仅仅是这一库中定义的诸多函数之一;该库中常见的函数还有 puts, scanf 等等。有的网站,如 die.net 可以找到该库文件的文档。在 Debian 一类的系统中,这一库的文档应该在已经安装在了系统中,可以通过 man page 找到。 int main(void) {
该行代码同后面几行的代码共同构成了对 main 函数的定义。这里需要区分函数声明(declaration)同函数定义(definition)之间的区别。函数声明定义所谓的函数原型,对函数体则不作定义。此处出现的则是函数定义则定义了函数体的内容。二者的区别,通俗来说,是“做什么”和“怎么做”的区别。假如 main 函数有一个声明,则它的样子(可以)是这样的:
int main(void); // 这样
int main(); // 或是这样
声明以分号结尾而非大括号,语义上是完整的。它表明存在一个叫做 main 的函数,它没有输入的参数(因此括号内是“void”或空,二者没有区别),并且输出一个整数类型的变量(也就是前面的 int 即英文 integer 的缩写)。
main 函数是 C 语言的特殊函数,是它的 entry point 。在一个可运行的 C 程序源文件(复数)中通常应当有且只有一个 main 函数的定义。程序会从 main 函数的定义处开始执行。此函数可以带有零个或两个参数。根据维基百科,有的系统中可以有三个或者四个参数,它们依次定义如下:
int main(void) // 0
int main(int argc, char *argv[]) // 2
int main(int argc, char *argv[], char *envp[]) // 类 unix
int main(int argc, char *argv[], char *envp[], char *apple[]) // 苹果系统
对于带有两个参数的版本,第一个参数 int argc 代表函数接受一个整数类型的变量,在函数内以 argc 的名字作为变量存在,第二个参数 char *argv[] 代表函数接受一组变量,其中的每一个变量的类型是 char * ,是一个指向 char 类型的指针。指向 char 类型的指针在 C 中往往作为字符串类型使用。此处 argc 为 argument count 的缩写,而 argv 则为 argument value 的缩写。两个参数的 main 函数用通俗语言解释的话,指——“声明一个含有两个参数的 main 函数;第一个参数为整数类型,它的名字是 argc,为程序执行时参数的数量;第二个参数为字符串数组,它的名字时 argv,代表程序执行时给予的具体的每一个参数;该函数将会返回一个整数值作为函数的输出”。
只了解 Python 这样更加现代的语言的读者可能不理解为什么需要一个叫做 argc 的参数来给出函数参数的数量。这是因为 C 语言的数组(方括号的语义)并不同于后来者,它的长度并没有一个方法去查询,因此得靠 argc 来给出参数的数量。
另外,之前提到的 main 函数也可以被声明为以下的形式:
int main(int argc, char** argv);
int main(int argc, char const **argv);
int main(int argc, char const *const *argv);
int main(int argc, char const *const *const argv);
原因在于 argv 作为函数参数在编译时不知道大小,且本质上只是指向数组第一项的指针。至于 const 修饰符则用于告诉编译器此处 argv 的值或它指向的值不应该被修改(一般的程序也不应当修改),属于额外的限制,对于 hello world 这一程序而言没有区别。
回到本行代码,int main() 后端的左花括号同第五行的右花括号对应。两者之间的代码(也就是示例的第四行和第五行)描述了 main 函数“作什么”的函数定义。花括号在 C 语言中大致可以理解为隔间,里面的东西被作为一个整体来考虑。考虑以下例子:
if (1) return 0;
if (1) {
return 0;
}
二者的语义是相同的,只不过后者中返回语句被包裹在了一对括号里。这样的分隔有时候是必须的,否则会导致语义不清的问题,如同那个笑话“粮食不卖给共产党”——究竟是“不卖给共产党”,还是“不卖,给共产党”,不加分隔的句子会有歧义。对于伪代码
if P if Q else ...
因为缺乏分隔的方法,就说不清楚 else 后面的内容究竟是对应第一个 if 还是第二个 if。
另外,花括号实际上定义了一个作用域(scope)。对于 C 语言而言,变量的定义是同它的作用域相关的。假如我们修改之前的例子
// 本程序无法编译
#include <stdio.h>
void f() {
a += 1; // 删掉这行则可以编译
}
int main(void) {
int a = printf("Hello World!\n");
f();
return 0;
}
这样的例子是无法通过编译器编译的,因为虽然在 main 中定义了一个整型变量 a,该变量在函数 f 中则未定义,因此编译器“不确定”在函数 f 中出现的 a 的定义,导致编译失败。于此同时,假如将 a 定义为全局变量,则在两个函数中都能找到它的定义,也就不会出现这个编译错误。(随便使用全局变量会导致更多的麻烦,不建议需要这条建议的人随便声明全局变量。)
// 修改后的版本,可以编译
#include <stdio.h>
int a;
void foo() {
a += 1;
}
int main(void) {
a = printf("Hello World!\n");
foo();
return 0;
}
printf("Hello World!\n");
此处我使用了四个空格的缩进来区别第二行到第五行中被花括号包裹的内容。使用缩进来区分不同的代码块是编程中的标准做法,但是对于缩进的具体形式有许多的分歧。有两个空格、四个空格、一个制表符的做法。对 C 语言来说所有的做法都是可以的,编译器并不会理会多余的空格和制表符;缩进只是方便编程者阅读编写代码。这一点同 Python 不同,后者的空格缩进是划分作用域的工具,因此不能够随意添加或删除。
因为 C 中一个“空”的语句是允许的,用分号来缩进也是可以的:
// 依旧可以编译
#include <stdio.h>
int main(void) {
;;;;printf("Hello World!\n");
;;;;return 0;
}
这一行代码调用了 printf 函数(在 include <stdio.h> 中定义——参见第一行的解说),“printf” 意为 print-format,打印的是格式化字符串(format string)。在 hello world 的例子中,格式化字符串没有真正展现出它的特点,因此这一行代码和如下的代码是等价的(注意字符串有区别)。
puts("Hello World");
#include <stdio.h>
int main(void) {
int a = 25;
printf("OCT %o = DEC %d\n", a, a);
}
此处 printf 的参数有三个,第一个是一个格式化字符串 "OCT %o = DEC %d \n",第二个和第三个为之前申明的整型变量 a,值为25。printf 通过分析第一个参数找到了其中的两个占位符 %o 和 %d,将它们的值依次替换为了第二个参数和第三个参数的值(25和25)。其中第二个占位符指定为八进制(Octal),第三个指定为十进制(Decimal)。此外,特殊字符“\n”中的 n 意为 newline,为换行符,在终端打印的效果为不显示字符并且换行。因此最后打印的字符串是“OCT 31 = DEC 25”,新起一行。
此外,printf 函数有一个返回值,代表打印的字符数数量。此处没有使用它的返回值。
使用 printf 在屏幕上打印字符时,有时候字符不会在屏幕上立即显示,这是因为打印的字符会进入缓冲区(buffer)而非直接输出。这一选择是出于优化和效率的考虑。如果需要立即将缓冲区的内容输出,则需要调用另一个函数 fflush 。第一个 f 代表 file ,后面的 flush 可以理解为“冲水”。这里,具体的写法是
fflush(stdout);
这里的 stdout 对应的是 standard output ,逐字翻译的话是“标准-输出”。类似的还有 stdin ,即“标准-输入”,stderr ,即“标准-错误”,一般是用来显示报错信息。
在 C++ 中,使用 std::endl 进行换行的时候,也会清空缓冲区。
return 0;
作用是标记函数的结束并且将0作为返回值返回,对应了函数声明时 int main ... 中的 int 。0 代表函数的执行正常完成。需要注意的是使用 return 会结束当前函数的执行,因此如果同一函数之后仍然有代码则它们不会执行。有的编程规范会限制一个函数中使用的 return 的数量。
对于 hello world 这个例子,本行代码是可以删除的。因为 main 函数的存在很寻常且返回 0 的情况也很寻常,如果省略本行代码编译器会自动将0作为返回值。
main 函数的返回值会作为程序执行的返回值;它常被用来判断是否程序正常地完成了运行。在 Linux 系统中,可以通过 $? 获得上一个命令执行的返回值。
./hello // 执行程序
echo $? // 获取返回值
写这篇文章的主要原因是想写点什么而又找不到什么特别好的主题,所以从熟悉的领域入手,写了一篇娱乐为主的文章。正如我在前言里面说的,它的目的实在不是作教程——事实上它同我教 C 语言的想法正相反,包含了多余的琐碎的知识点,十分不适合新手建立一个关于 C 语言的一般印象。尽管如此,对于对这一编程语言已经有了一定了解的读者,我希望本文能在年末为诸位提供一点小小的娱乐。
评论区
共 14 条评论热门最新