深度剖析C++最小例程的逐字逐句理解

发表时间: 2023-12-05 14:51

以 “Hello World” 例程为载体、线索,在完成 “间接名字空间限定” 写法转换到“直接名字空间限定”的过程,同时掌握函数、主函数、函数调用、级联操作、声明、类型、int、字符串类型、头文件包含、行为数据、流输出操作符、标准输出流对象、标准库名字空间、ADL等20多个C++知识点,打好基础,让后续的学习事半功倍。

0.教程视频

1. 函数

1.1 基本概念

编程语言中,函数通常用于表达“做事情”的概念,它需要描述清楚做事情的三个要素:

  1. 做事情的“初始材料”
  2. 做事情的“操作过程”
  3. 做事情的“返回结果”

“初始材料”指对在做事情之前,需要从调用者处得到的数据的描述;
“操作过程”指对做事情的完整过程描述;
“返回结果”指对做事情可能得到的结果的描述;

除此之外,函数还有一个重要的外在属性:调用者。函数对应做事情,有时为了把“大事化小”,需要将一件大事,拆分成多个小事,然后再由大事调用小事(此处的“大”和“小”仅为相对概念,有时候“小”事也可能调用“大”事)。比如:“妈妈炒菜”是一件事,对应一个函数,在炒菜过程中,如果发现家里没有酱油了,炒菜无法进行下去,此时可以调用另一件事所对应的函数:“小明打酱油”。在这一例子中,“小明打酱油”函数的调用者,即“妈妈炒菜”函数。

第2学堂原创

1.2 基本语法

除此之外,函数还需要有名字,四者的组成语法为:

返回结果类型 函数名 ( 所需初始材料声明 ){       操作过程描述;       return 返回数据;}

其中:

  • “所需材料声明” 整体上构造函数的参数表(也称参数列表);
  • return 语句用于结束函数的操作过程(简称结束函数), “return” 正是 “返回”的意思;
  • 当一个函数声明为必须有返回结果时,对应的 return 语句后面也必须带相应类型的数据。

1.3 函数头、函数声明

三要素中的“初始材料”描述和“返回结果”描述,再加上函数名字,构成了“函数头”。如在函数后面直接加分号(语句分隔符),称为该函数的声明,即:对函数的基本约定,形如:

返回结果类型 函数名 ( 形参表 ); 

函数声明只是对函数所要做的事的基本描述和约定。即:描述了这件事的“入”和“出”:“入”是指需要“拿到”什么初始数据(材料),“出”是指事情完成后,可能返回什么结果数据。

对于同一个函数,函数声明可以多次出现。因此函数声明可视为一个人的“名片”。
函数声明有时也称为“函数原型”,意为:这个函数“大概长什么样子”。

1.4 函数体、函数定义

函数体用于实现函数,它是函数头之后的一对花括号,花括号内是实现具体操作过程的代码。
函数头加函数体,即代表了一个函数的真实实现。以下是一个真实的C++函数定义例子:

int add(int a, int b){     return a+b;}

它表示有一个函数,名为 “add”,它在执行之前,需要从调用者得到两个入参:a和b,二者的类型一个是整数类型,另一个也是整数类型。这个函数执行之后,也会返回(得到)一个整数数据。从实现上看,即返回 a+b 的结果。

一个函数应该只有一个真实实现。


第2学堂原创


2. 主函数

主函数可理解为:对应到整个程序需要做的那件“大事”。

主函数是一个特殊的函数。它的第一个特殊之处,即名字必须固定为 main,小写(C++代码区分大小写)。

主函数的第二个特殊之处,它的调用者是程序的外部运行环境,比如操作系统(但并不一定是操作系统,有些程序会运行在虚拟机内,也有些时候,程序由另一个程序调用)。

主函数之所以函数名需要固定,正是为了和外部环境,也就是它的调用是约定好:请将主函数视为程序的开始位置,因此主函数也时常被称为“入口函数”。

仅是“视为”,C++程序实际上仍然可以要求在进入主函数之前,就“偷偷”做点其它事情(通常用于准备工作)。

主函数也是函数,因此也需要考虑“初始材料”和“返回结果”。

既然调用者是外部运行环境,因此主函数如果需要获得初始材料,也是由外部运行环境传入,方法也正是主函数的参数列表。此处,主函数又有特殊之处,它的参数列表可以二选一:

  1. 不需要参数,即空的参数表;
  2. 需要参数,形如: (int argc, char* argv[]);
    我们在很长时间内,会仅使用第1种形式,因此暂不解释第2种形式。

C++主函数必须返回一个整数,因此在函数头中,需写明其返回数据的类型为整数类型,在C++中使用int(即 integer 缩写)。主函数返回的整数有以下含义:

  • 0:表示程序运行正常结束;
  • 非0:表示程序因出错而意外结束,各整数值可用于不同的错误编号。

在函数体中,主函数又有一个“特权”:可以不写任何 return 语句。该情况下表示该主函数永远执行正确,永远返回0。事实上,这个“特权”的背后,只是编译器在发现一个主函数没有返回语句时,就主动帮我们添加了 “return 0;” 而已。

再次强调:这是主函数的“特权”,其它函数如果在声明自己需要有返回值(哪怕返回的类型也是int)却不写(或忘记写) “return XXX;” 语句,会带来严重后果。


第2学堂原创


3. 认识“流”

上一节课我们所写的代码中,主函数的操作过程就句(事实上还有隐藏的一句“return 0;”):

cout << "Hello World" << endl;

3.1 cout - 标准输出流对象

这其中cout读作c-out,c 是 “character”,out 是 “output-device”,合起来意为“字符输出设备”,即 cout 对应者程序的输出屏幕。

当然,cout 仅用于windows下有控制台或linux/unix下拥有终端的程序,图形界面程序 GUI
的屏幕输出,有另外的实现机制。

由于C++的标准输入输出基于“流”的概念设计及实现,因此cout也称为C++的“标准输出流对象”。关于“流”的概念,本站《STL和boost之“流” 视频辅导》「链接」 课程有深入讲解。现在大家可以简单理解为,就像水流、电流一样,C++中的“流”就是将待输出或待输入的数据,排成一串,在某个“管道”中,按某个方向“流”动。

3.1 cout - 标准输出流对象

这其中cout读作c-out,c 是 “character”,out 是 “output-device”,合起来意为“字符输出设备”,即 cout 对应者程序的输出屏幕。

当然,cout 仅用于windows下有控制台或linux/unix下拥有终端的程序,图形界面程序 GUI
的屏幕输出,有另外的实现机制。

由于C++的标准输入输出基于“流”的概念设计及实现,因此cout也称为C++的“标准输出流对象”。关于“流”的概念,本站《STL和boost之“流” 视频辅导》 课程有深入讲解。现在大家可以简单理解为,就像水流、电流一样,C++中的“流”就是将待输出或待输入的数据,排成一串,在某个“管道”中,按某个方向“流”动。

3.2 << - 流输出操作符

两个小于号组成的符号,称为C++的“流输出操作符”。
以小学所学的加号 + 类比:

  • 需要两个操作数、<< 也是;
  • 执行的操作是将左右两数相加,<< 执行的操作是将右边的数据,输出到左边的对象身上;
  • 的操作结果是相加之和那个数,<< 操作结果则是继续得到它左边的对象,在本例中,即 cout;
  • 支持级联操作,比如:1+2+5,可先计算出3,然后3继续参与计算: 3+5。<< 也支持级联操作: cout << "Hello World"后,得到的仍然是 cout,于是继续参与计算 cout << endl;

在C++中,操作符本质上也是一种函数。

3.3 endl - 操作控制符、“行为”数据

endl 也是一个函数,但此处我们将它当作数据使用,将它输出到 cout 身上。作用是将之前已经送到 cout 身上的数据,完完整整地送到真实的屏幕上,并加上换行。

每次往真实的显示屏幕上输出,比较耗性能(无关数据量);因此,为提高性能,输出的数据可能被cout对象“攒”起来,积累到一定程度再一次性真实输出。

3.4 ; - 语句分隔符(语句结束符)

在编辑器中,我们可以把一行的C++语句,拆成多行写,比如:

cout <<    "Hello World"    << endl;

通常仅在这一语句确实很长时使用,上面的例子显然是在强行换行,不推荐。

也可以将多个语句合成一行(这就很不推荐了):

cout << "Hello World" << endl; return -1;

对于简单的独立语句,C++编译器都是通过分号来分隔前后两行语句。注意,哪怕如本例中只有一行语句,也需要使用。

反过来,也并不是所有形式语句都需要分隔,后面将学习到复合语句,就是使用一对 {} 包含多行简单语句,此时 {} 就是天然的分隔。在本例中你可以将函数体的那一对花括号视为一种复合语句。

更不是所有代码行都需要语句分隔符。比如本例中的第一行:

#include <iostream>

深入区分的话,这是一行“预处理”代码,“预处理”是指在正式编译之前,先读一下代码做一些比较基础的准备工作,因此这些代码不被视为正式的C++语句,如此,这些代码也被硬性按格式要求书写,比如上面的 include 就只能写在一行,不能折行,下面是错误示例:

#include      <iostream>


第2学堂原创

4 出处!出处!!出处!!!

和现实生活中一样,任何一件出处不明的物体,或者人物,都是危险的,所以请再看一眼代表做“大事情”的主函数代码:

int main(){  cout << "Hello World" << endl;}

请问:cout 是谁? << 又是谁? endl 呢?这三个家伙来自哪里?也就是它们的可信出处是?
如果没有出处,严谨的C++编译器不会让这些代码通过编译;而,如果有出处,编译通过了,但程序执行后所要做的事,却不一定真的如我们前面辛苦解释那样的:往屏幕上输出一行对世界的问候——如果它们的出处是由“坏人”提供的话。

4.1 头文件 - 相关符号的统一出处

头文件就像家族族谱,或者松散一点,就像一个名片录或通讯录,可以记载许多符号(比如人名)的出处——在我们名片录或通讯录的人,确实比完全的陌生人,要安全那么一点点了。

#include <XXXX>

这一行代码中 #include 是一种硬性规定,它是一行预编译代码,用于指示编译器:在你正式工作(正式开始编译当前源文件后续内容)之前,请先找到并打开XXXX文件(通常被称为“头文件”),读入其中的内容——因为这些内容对后续的编译工作有关键作用。

一对 <> 也是一种硬性规定,它进一步要求编译器:当你需要在电脑中成千上万的文件中搜索XXXX文件时,一!定!不!要!到!处!乱!翻!(怕你不小心翻到主人的各种学习资料),你只能在编译环境事情约定,或编译参数中配置好的文件夹里去寻找。

4.2 iostream 头文件

iostream 正是来自C++自带的标准库的一个头文件,主要是包含了和“流”相关的各种符号的声明。从字面上看:i 是 input / 输入 ,o 是 output / 输出,stream 就是 “流” 的英文单词。

iostream 正是包含我们在本例中用到的 cout 、<< 、endl 这三个符号的声明,于是这三者相当于了合法的出处:我们出自iostream文件,而后者出自C++自带的标准库,而当前机器上的C++标准库,就是你自己下载、安装到你的电脑上的。

当然,我们的课程用的是线上编程环境,因此iostream等文件,实际在网络所在后台服务器,我们当然也选择相信。

4.3 namespace 名字空间

代码中的各个符号(通常称为标识符)本质就是一个个名字,而一牵涉到名字,就和现实生活一样,会有重名的情况。C++可以“名字空间 / namespace”来避免重名,方法就是将所有相关的名字,统一放在一个“名字空间”内。正如视频所讲,学校中的每个班级,天生就是一个个名字空间,因此“三年一班的李国庆”和“四年二班的李国庆”就得以区分。

当然,这样的前缀,通常只需在两人出现在同一场合的情况下需要。另外,这也解决不了同一名字空间下的重名问题。

标准库也用到了大量的符号,并且它因为是“标准库”,所以就很霸道地占用了很多极为常用的符号名字,比如 “sort”、“find”、“search”、“string”、“begin”、“end”、“list” ……所以,标准库问世后,很快就给自己搞了一个名字空间:std (来自 standard)。

所以,前述的 cout、<<、endl,严格讲其实在 iostream 里声明的全称,解读起来应该是 std::cout、std::<<、std::endl。实际使用时,也应该用上这样的“全称”:

   std::cout std::<< "Hello World" std::<< std::end;

但是,为什么我们现在的代码:cout << “Hello World” << endl 可以编译并正确运行呢?这就是第三行代码的作用:

using namespace std;

它用来告诉编译器,如果在后面的代码,遇到实在找不到出处的符号,可以加上 std::再试试。以 cout 为例,当找不到它的出处时,可将它变成 std::cout 再尝试,于是就成功了。

4.4 关键字

并不是所有在代码中出现的符号都需要担心重名,C++语言(其它语言也一样)规定了一些特别重要的单词(符号)专门给自己使用,其他人(普通程序员)不能把这些单词随意拿作他用,自然,在如此“霸道”的规定下,这些单词就不会有重名的担心了。这样的单词就叫关键字/keyword。

本例中,int、using、namespace,以及被删除掉那一行的 return都是C++的关键字,C++有近100个关键字。

iostream 则是用户(标准库作者)为文件取的名字,main也是(标准库)程序员为C++入口函数取的名字,std是标准库自用的名字空间的名字,它们要么来自标准库,要么是语言标准的硬性规定,显然我们在写代码时,为符号给的名字,还是尽量不要和它们重名的好。

4.5 直接名字空间限定

使用 using namespace XXX 的方法,称为“间接名字空间限定”,它会将XXX下的所有公开的符号名字,都引入当前代码下。这不是推荐的,名字空间的日常方法。我们的例程经常使用它,仅仅是因为我们的代码比较简单,而间接名字空间限定比较方便让代码行变短一些,从而有利于例程排版。

大多数情况下,我们推荐使用 “直接名字空间限定”的方法,下面就是使用这一方法的,新的Hello World 例程的完整代码:

#include <iostream>int main(){    std::cout << "Hello World" << std::endl;}

此时,cout 和 endl 都被直接加上 std:: ,相当于有一个特殊的前缀,直接接明它们来自std这个名字空间,又直观又安全。

  • 直观:把 :: 读成 “的”,就有: “std的cout”及“std的endl”,有如生活中的 “三年一班的李国庆”。
  • 安全:“间接名字空间限定”是在某一符号出处查找失败,才尝试使用间接指定的某个名字空间去套用,自然就有“套用”结果不是程序员本意的意外可能。直接名字空间限定出这种错误的可能性近乎零。

4.5 ADL - 实参依赖查找

眼尖的同学会发现,在直接名字空间限定的写法中:

std::cout << "Hello World" << std::endl;

这行代码,只是用std::限定了 cout 和 endl,<< 却没有限定。

事实上,因为 << 是一个操作符,如果写作 std::<< 反倒会编译失败。
这就涉及到了 ADL(Argument-dependent lookup)Argument-dependent lookup - cppreference.com这一语法知识点,它与编译规则也紧密相关。

简单地说,和现实生活中类似,操作的对象,往往可以反过来帮助推导出操作的实际出处。比如“打屁股”、“打酱油”和“打车”的操作都是“打”,但其实各自含义大不相同(放在《辞海》里的出处自然也不同)。我们,也就是人类是通过打的对象,也就是“屁股”、“酱油”、“车” 倒过来推导出各自前面的“打”的含义。

编译器也有这样的智慧,它们也可以依据操作对象,倒过来推导操作的出处。比如:看到 << 这个操作符,因为没有加名字空间直接限定——事实上操作符的日常使用语法,也规定了不允许为它加名字空间前缀——而找不到其出处,自然无法知道它对应的真实操作……但是,编译器很快发现 << 所要操作的对象中,std::cout 是明确的,于是会到 std 这个名字空间下查找 <<,这就找到了,发现它是标准库的流输出操作符。

课堂作业

点击即可开始免费测试「链接」

第2学堂原创