ENGG1340笔记

自学ENGG1340写的笔记

前言

惊讶的发现这学期ENGG1340是自学课,没有Lecture。讲道理挺舒服的。

ENGG1340期望你拥有Python编程知识作为基础,然后逐渐引入更深层的新内容,包括Linux / Bash Shell,版本控制(Git),C/C++语言等。

叠甲先:笔记可能出错所以轻喷。内容是根据课上发的笔记转写的,所以本文算是个人梳理吧


Module 1: Linux Environment

Linux如同Windows一样是操作系统。Linux是开源的,这导致Linux相较于其他操作系统拥有更多长处,例如:可自定义程度较强,安全性也比较高(因为你可以直接读到源代码)。
因为Linux是开源的,所以使用Linux也是免费的。在一些相对低价的电子产品上,使用Linux作为操作系统就很划算。

Linux存在不同的发行版,例如Ubuntu, CentOS, Debian等。

在ENGG1340中我们将会使用Ubuntu,虽然现阶段不同发行版不会对课程大纲产生太多影响。


Linux Shell

Shell 是用户使用 Linux 的桥梁。Shell 既是一种命令语言,又是一种程序设计语言。

Shell 是指一种应用程序,这个应用程序提供了一个界面,用户通过这个界面访问操作系统内核的服务。

Linux中有不同种类的shell,比如Korn Shell, Bourne Shell, C Shell,以及Bash Shell。
不同的Shell之间命令可能有些许区别,例如Korn Shell使用print命令输出一个字符串,而Bash Shell使用echo命令。

在ENGG1340里我们主要使用Bash Shell。


Shell Commands

date

向命令行中输入date,Shell会返回当前系统的时间:

1
2
$ date
Sat Jan 25 14:51:19 HKT 2025

文件与路径操作

ls命令列举当前目录下的所有文件和路径:

1
2
3
4
5
6
7
8
$ ls 
Documents/ logon.bat* public_html/ t.txt Downloads/
Pictures/ Templates/
Videos/
Music/
Desktop/ engg1340/
Public/
test.txt

命令变体

  • ls -l 以长字符方式输出,包含额外信息如文件大小,文件拥有者,上次编辑日期等
  • ls -a 输出路径下所有内容,包含隐藏的文件和目录。隐藏的内容会以一点开头.
  • ls -la 就是ls -l -a

cd命令用来重定向当前目录。例如我们想要访问当前目录下的engg1340路径,直接敲cd engg1340就行:

命令变体:
cd .. 前往当前目录的父级路径
cd ~ 前往当前用户的主目录
cd ~username 前往指定用户的主目录
cd . 前往当前目录 (虽说没啥效果就是了)

1
2
3
$ cd engg1340 
$ ls
lab1/ lab2/ lab3/

pwd命令将会输出当前的工作目录:

1
2
$ pwd
/home/d003/h978645312

命令使用说明

Shell里面有许多命令,能将所有命令记住基本上是不现实的。因此,Shell提供了man命令(Manual的缩写),能够返回命令的详细解释。

例如我想要查看ls命令的详细解释,就可以使用man ls

1
$ man ls
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
LS(1)                                               User Commands                                               LS(1)

NAME
ls - list directory contents

SYNOPSIS
ls [OPTION]... [FILE]...

DESCRIPTION
List information about the FILEs (the current directory by default). Sort entries alphabetically if none of
-cftuvSUX nor --sort is specified.

Mandatory arguments to long options are mandatory for short options too.

-a, --all
do not ignore entries starting with .

-A, --almost-all
do not list implied . and ..

--author
with -l, print the author of each file

-b, --escape
print C-style escapes for nongraphic characters

--block-size=SIZE
with -l, scale sizes by SIZE when printing them; e.g., '--block-size=M'; see SIZE format below

.
.
.

-l use a long listing format

Manual page ls(1) line 1 (press h for help or q to quit)

从NAME区域我们可以得知ls的解释为”list directory contents”。
从DESCRIPTION区域得知,如果在ls后面加入可选参数-l,代表”use a long listing format”。这会在普通ls的基础上显示更多信息,包括文件大小,文件拥有者,上次编辑日期等等:

1
2
3
4
5
6
$ ls -l
total 26044
drwxr-xr-x 17 1000 1000 4096 Nov 4 01:17 Python-3.11.0
-rw-r--r-- 1 root root 26333656 Oct 24 2022 Python-3.11.0.tgz
drwxr-xr-x 4 root root 4096 Nov 1 13:25 napcat
-rw------- 1 root root 317274 Nov 4 01:38 nohup.out

路径和文件管理

在Linux中根目录用/表示。根目录包含所有的文件和路径。

例如在根目录下有一个”home”路径,”home”下有另一个路径”kit”,那么这个”kit”路径的绝对路径就是:/home/kit

有关相对路径和绝对路径的内容一会介绍。


相对路径和绝对路径

绝对路径总是以/打头。举几个简单的例子:

  • /home/file3是在home路径下的文件file3的绝对路径。
  • /home/kit/Desktop/file4是file4的绝对路径。

而相对路径和当前的工作目录有联系,相对路径不会以/打头。

例如我们使用上面的file3做例子,如果当前的工作目录为/home,则file3的相对路径为file3。注意看刚才这个file3的路径不以/打头,所以能判断出这是一个相对路径。

file4同理。如果当前工作目录是/home/kit,那么file4的相对路径就是Desktop/file4


如果目录A包含另一个目录B,则A称为B的父目录,B称为A的子目录。例如在刚才的例子中,kit是home的子目录,home是kit的父目录。

一旦shell启动,它当前的工作目录就是主目录。我们总是可以通过命令pwd获得当前的工作目录。


主目录

对于任何用户,都有一个属于该用户的主目录。例如,用户 kit 的主目录位于/home/kit

~用来表示用户的主目录。你可以使用cd ~命令回到当前用户的主目录,也可以使用cd ~username来前往其他用户的主目录。

路径管理

创建路径

mkdir命令可以用来创建路径 新建文件夹

1
$ mkdir lab

上面的命令会在当前的工作目录下创建一个lab路径。

如果你想在当前工作目录下创建多个路径,可以直接:

1
$ mkdir lab1 lab2

如果想在路径名中包括空格,可以在创建路径时将路径名用引号引起来:

1
$ mkdir "lab 1"

移除路径

rmdir命令可以用来移除空路径:

1
$ rmdir lab

如果想要移除的路径不是空路径,例如:

1
2
$ ls lab1
file1.txt file2.txt

就需要使用rm -rf或者rm -r -f来移除整个路径:

1
2
3
$ rm -r -f lab1
$ ls lab1
ls: cannot access 'lab1': No such file or directory

可以看到路径被成功移除了。

-r参数代表系统会按照递归方式向下遍历目标路径下的文件和路径,而-f可以确保删除过程不需要你来进行确认。如果当前路径下文件巨多,不带-f就会很麻烦了。


重命名路径

mv命令虽然叫move,但是也可以用来重命名:

1
$ mv lab "lab 1"

这个命令会把lab路径重命名至lab 1

如果mv命令下第一个参数和第二个参数的类型一致,例如参数1和参数2都是文件类型,则该命令会将参数1重命名至参数2。


文件管理

创建文件

touch命令可以用来创建一个空文件。例如:

1
$ touch file1.txt

可以创建一个名叫”file1.txt”的空文件。


展示文件内容

cat,命令可以用来展示文件内容:

1
2
3
$ cat file1.txt
Hello, this is the content of file1.txt
Bye Bye!

移动文件

mv命令也可以用来移动文件或者路径位置:

1
$ mv hello.txt mydir

好比说这串命令会将hello.txt移动到mydir路径下。

如果文件拥有同样的前缀,你可以使用星号()来选中所有符合条件的项。
例如:将所有拥有“ *myfile
”前缀的文件移动到lab路径下:

1
2
$ ls
myfile1.txt myfile2.txt lab/
1
$ mv myfile* lab
1
2
$ ls
lab/
1
2
$ ls lab
myfile1.txt myfile2.txt

复制文件

除此之外还有其他关于文件操作的命令集合:

cp 命令用于复制文件。例如:

1
$ cp file1 file2

会将”file1”复制至”file2”

1
$ cp -r dir1 dir2

会将dir1路径包括路径下的所有内容复制到dir2中。


使用vi编辑器

vi编辑器用来创建并编辑文本。就好比Windows上的记事本,或Linux上的gedit,但是他没有图形用户界面,完全在命令行中使用。

vi编辑器中有两个功能:

  1. 插入模式(Insert mode):在这个模式中你可以编辑文件内容
  2. 命令模式(Command mode):命令模式用来执行文件操作,比如保存文件等

要使用vi编辑器打开一个文件,只需要敲入vi命令即可。例如:

1
$ vi file2.txt

将会打开工作目录下的”file2.txt”文件。如果该文件不存在,编辑器会帮你自动创建一个。

Open file

进入vi编辑器后默认为命令模式。因为我们的路径下没有file2.txt,如你所见编辑器目前显示的是空白文件。

按”I”键切换至插入模式。当你在插入模式时,你可以在界面的左下角看到-- INSERT --

Insert

在插入模式中你可以按照我们熟悉的样子编辑文件。如果编辑完成想要保存文件,则需要退回至命令模式。

在插入模式时,按”esc”以回到命令模式。当你回到命令模式后,你将不会再左下角看到-- INSERT --字样。这时输入:wq并按下Enter,你会退出vi编辑器,同时你的文件会被保存。


命令模式下的命令

除了“保存并关闭”(:wq)命令外,还有下面这些常见命令:

命令 执行的操作
:wq 保存并退出
:w 保存(但不退出vi编辑器)
:w filename 保存到名叫”filename”的新文件下
:q 退出
:q! 不保存并退出vi编辑器

想要查阅完整的命令列表的话点我

或者你也可以查阅这张Cheatsheet:

Cheatsheet


文件权限

Linux系统中的每个文件或目录都被分配了3种类型的所有者。

  • 用户(User):用户可以作为文件的拥有者
  • 用户组(Group):用户组中可以包含多个用户。若用户组拥有一个文件的权限,则组中的每个用户都有该文件的权限。
  • 其他(Other):有权访问文件的任何其他用户。它既没有创建文件,也不属于拥有该文件的组。

每个用户对于上面的三种所有者定义了三种不同的文件权限:

  • 读取(Read):拥有文件读取权限的个体可以打开并读取文件中的内容。拥有路径读取权限的个体可以列举出该路径下的内容。
  • 写入(Write):拥有文件写入权限的个体可以更改文件中的内容。拥有路径读取权限的个体可以在路径中添加,移除或者重命名内容。
  • 执行(Execute):执行权限允许个体来运行程序

不如举个例子看看:使用之前提到过的ls -l命令来列举出路径下的内容,里面会告诉你文件权限的相关信息:

1
2
3
4
5
6
$ ls -l

total 10
-rw------- 1 cklai ra 50 Jul 30 17:07 file1.txt
-rw------- 1 cklai ra 48 Jul 31 15:14 file2.txt
drwx------ 2 cklai ra 2 Jul 30 17:00 lab/

权限指示

借用上面的例子,我们来仔细讲讲刚才的输出是啥意思:

1
2
total 10
-rw------- 1 cklai ra 50 Jul 30 17:07 file1.txt

total 10代表文件在文件系统中占用了多大空间,单位为千字节(kilobytes)。
由左至右分别是:

条目 释义
-rw------- 代表文件权限。具体的释义马上就解释(
1 代表文件拥有的硬链接数量
cklai 代表文件拥有者
ra 文件拥有者存在的用户组
50 文件大小,单位为字节(byte)
Jul 23 17:07 上次修改时间
file1.txt 文件名

现在我们来讲讲权限指示该怎么解读。
权限指示(Permission indicator)是一个十字符长的字符串,通常分为四部分:

permission

这玩意可以分成四部分解读,Type, User permissions, Group permissionOther permission


Type:

代指权限指示的第一个字符。

  • 如果第一个字符是短横线 “-“,则代表它是一个正常文件
  • 如果第一个字符是字母d “d”,则代表这是一个路径。

还有其他的类型不过这里涵盖不到。可以自己上网查询。


User permissions

第2-4个字符表示用户的权限。三个位置会显示”r”, “w”或者”x”。

比如这里显示了”rw-“,就代表:

  • 可以读取文件 (read, r)
  • 可以写入文件 (write, w)
  • 但是不能执行文件。如果文件可以被执行,第三个位置会显示”x”

Group permission

5-7个字符表示了用户组的权限。同样用rwx表示权限。


Other permission

最后三位表示了文件对其他所有人的权限。

如果显示”—-“,则表示其他人都不可以读取(r),写入(w),并且也不能执行(x)


更改权限

你可以使用chmod命令来更改你拥有的文件或者路径的权限:

chmod命令格式为:

1
$ chmod [who][operator][permissions] filename

  • who

在参数who内,可以键入四个不同的字母来指定要更改的对象群体:

释义
u 指定用户权限
g 指定用户组权限
o 指定其他权限
a 指定所有群体的权限

  • operator

在参数operator内,共有三个运算符,用来代指具体的操作:

释义
+ 增加权限
- 移除权限
= 设定权限

  • permission

在参数permission内,就是我们熟悉的r, w和x了。这些参数用指定要用来增加或者减少的权限:

释义
r 读取权限
w 写入权限
x 运行权限

举例

Example 1:更改权限

在Linux中,可以首先使用touch创建一个新文件。touch命令创建的文件对用户的默认权限为可读可写(rw-)。
想要更改权限,就可以使用chmod来指定文件的权限。例如:

1
2
3
4
5
6
7
8
9
10
11
$ touch file
$ ls -l

total 26
-rw------- 1 cklai ra 0 Aug 1 11:48 file

$ chmod o+w file
$ ls -l

total 26
-rw-----w- 1 cklai ra 0 Aug 1 11:48 file

其中,chmod o+w file意思是对于其他用户(o)授予(+)写入(w)权限。


Example 2: 更改多个权限

可以在permission参数内键入多个值来一次性设置多个权限。就刚才的例子往下继续:

1
2
3
4
5
6
7
8
$ ls -l

-rw-----w- 1 cklai ra 0 Aug 1 11:48 file

$ chmod o+rx file
$ ls -l

-rw----rwx 1 cklai ra 0 Aug 1 11:48 file*

chmod o+rx file的释义为:对其他用户(o)授予(+)读取(r)和运行(x)权限。

可以看到更新完权限后,文件名后面出现了一个星号”*”,这代表文件现在是可执行的。


其他常用命令

这里会介绍很多新的命令:

命令 释义
grep 'abc' file 返回在文件中包含”abc”的行。可以使用更高级的描述来实现更复杂的搜索。
cut -d, -f2 file 返回特定的数据列。
它根据标志-d指定的定界符分隔每行,并返回标志-f指定的列(字段编号从1开始)。
diff file1 file2 显示两个文件的区别。
wc file 返回文件中的行数,字数和字符数。
sort file 按照字母顺序将文件中的每行进行排序
uniq file 用于删除相邻的重复行,最后只会留下一个重复行。
spell file 检查文件中所有的拼写错误。
su 将用户模式更改为超级用户模式。
yum install prog
或者
apt-get install prog
这个命令会链接特定服务器来下载并安装程序。
一般来讲你需要先运行su才能使用这些命令来安装程序。
man cmd 显示命令cmd的手册页。找到使用命令的其他选项对你非常有用。

接下来我们会细致介绍这些命令的用法


文件内搜索 (grep)

grep命令适用于搜索文件内的行。

例如这里有一个”example1.txt“文件包含如下内容:

1
2
3
4
5
6
5 Apple 3.5
4 Chicken 50
1 Coke 5.5
10 Jelly 5
3 Chocolate 15
2 Milk 8

运行下面的命令将会返回文件内所有包含”ke”的行:

1
2
3
$ grep 'ke' example1.txt
4 Chicken 50
1 Coke 5.5

搜索是很大的一块内容。这里仅用来介绍。后面我们会在搜索章节一起将文件内文件外搜索一并讲清楚的。



文件字数 (wc)

同样使用”example1.txt”作为例子:

运行该命令则会返回如下内容:

1
2
$ wc example1.txt
6 18 71 example1.txt

其中:

  • 第一个数字代表行数,也就是文件共有六行
  • 第二个数字代表词数,表明文件中有18个词
  • 第三个数字代表文件大小,代指文件大小为71字节

搭配参数-l使用可以仅仅返回行数:

1
2
$ wc -l example1.txt
6 example1.txt

搭配参数-w使用可以仅仅返回词数:

1
2
$ wc -w example1.txt
18 example1.txt

排序

使用sort命令来对文件内容排序。如果没有指定任何输入参数,则默认排序方式按照字母表顺序排列

1
2
3
4
5
6
7
$ sort example1.txt
1 Coke 5.5
10 Jelly 5
2 Milk 8
3 Chocolate 15
4 Chicken 50
5 Apple 3.5

你可以使用不同的传入参数来指定排序方法。例如:

  • 使用-n则按照数字顺序排序:
1
2
3
4
5
6
7
$ sort -n example1.txt
1 Coke 5.5
2 Milk 8
3 Chocolate 15
4 Chicken 50
5 Apple 3.5
10 Jelly 5
  • 使用-r则按照倒序顺序排序:
1
2
3
4
5
6
7
$ sort -n -r example1.txt
10 Jelly 5
5 Apple 3.5
4 Chicken 50
3 Chocolate 15
2 Milk 8
1 Coke 5.5
  • 使用-k则按照字段排序。需要注意排序字段ID从1开始,而不是0。

例如,将文件按照第三个字段进行排序。在”example1.txt”中,每行最后的那组数字就是第三字段。

1
2
3
4
5
6
7
$ sort -k3 -n example1.txt
5 Apple 3.5
10 Jelly 5
1 Coke 5.5
2 Milk 8
3 Chocolate 15
4 Chicken 50
  • 使用-t用来告诉排序程序,我们使用分隔符并非空格而是逗号。

例如我们有一个”example1.txt”的变体,”example1_comma.txt”:

1
2
3
4
5
6
7
$ cat example1_comma.txt
5,Apple,3.5
4,Chicken,50
1,Coke,5.5
10,Jelly,5
3,Chocolate,15
2,Milk,8

需要在使用sort时加入参数-t来指定分隔符为逗号:

1
2
3
4
5
6
7
$ sort -t, -k3 -n example1_comma.txt
5,Apple,3.5
10,Jelly,5
1,Coke,5.5
2,Milk,8
3,Chocolate,15
4,Chicken,50

剪切文件

这里指的不是我们熟悉的剪切文件到其他目录,而是直接在文件内剪掉一部分内容。

cut命令将会修剪文件并返回特定的内容。要使用这个命令,我们需要向cut命令指定分隔符。

指定分隔符的参数为-d,代表delimiter,注意不要和之前的sort命令搞混了!该参数不是可选的,即便分隔符为空格,你也需要指定参数-d

使用参数-f来告诉程序你想要返回的字段,说白了就是列。和sort一样,字段ID从1开始而不是0。

例如,返回”example1.txt”文件内的第一字段和第三字段:

1
2
3
4
5
6
7
$ cut -d ' ' -f 1,3 example1.txt
5 3.5
4 50
1 5.5
10 5
3 15
2 8

移除重复行

uniq命令删除相邻的重复行,只保留一个重复行。

注意咯,它只删除相邻的重复项。

“example2.txt”:

1
2
3
4
5
6
Apple 
Apple pie
Apple pie
Apple
Apple
Apple pie

想要移除在”example2.txt”中多余的相邻重复行:

1
2
3
4
5
$ uniq example2.txt 
Apple
Apple pie
Apple
Apple pie

拼写检查

spell命令返回文件中所有可能出现拼写错误的词:

“example3.txt”:

1
2
It's a beautiffful day!
I am so happpy todday.
1
2
3
4
$ spell example3.txt
beautiffful
happpy
todday


如果你自己的Linux机子运行这个命令报错,那么你的系统很有可能没有安装需要的软件包。按照下面的步骤修复这个问题吧:

  1. 切换到Superuser账号:
1
$ su

还记得我们之前说过安装软件包之前需要切换到su账号吗

  1. 安装aspell包:
1
$ yum install aspell
  1. 退回到你当前的账户:
1
$ exit

文件差异

diff命令用来显示两个文件的区别。

“fileA.txt”:

1
2
3
aaa
bbb
ccc

“fileB.txt”:

1
2
3
eee
aaa
ddd

下面的命令则会返回将”fileA.txt”转化为”fileB.txt”的步骤:

1
2
3
4
5
6
7
8
9
$ diff fileA.txt fileB.txt
0a1
> eee
2,3c3
< bbb
< ccc

---
> ddd

这里我们需要一点解释了。

0a1

fileA的第0行之后添加(a, add)一行,添加的内容由下一行> eee表示

2, 3c3

fileA中的第2, 3行变成(c, change)fileB的第三行。
完成这一步需要先删除掉两行,由<表示:

< bbb
< ccc
代表删除掉这两行。

分割线隔开后出现了>。这代表我们需要在删除后再次添加一行:

> ddd

代表再添加文本”ddd”

这样就能把”fileA.txt”变成”fileB.txt”了。


再举另一个例子把:

1
2
3
4
5
6
7
8
9
$ diff fileB.txt fileA.txt 
1d0
< eee
3c2,3
< ddd

---
> bbb
> ccc

刚才是由A变B,现在是由B变A了。

1d0

fileB中删除(d, delete)第一行,随后的内容就会在第0行被对齐。

< eee表示我们删除了”eee”

3c2,3

和刚才刚好反过来,将fileB中的第3行变为fileA2,3行。先删去”ddd”,然后加入”bbb”和”ccc”


标准I/O,文件重定向和管道

标准IO和文件重定向

标准输入,输出和标准错误

在Shell中使用命令时,Shell将每个进程与一些打开的文件关联起来,并使用称为文件描述符 (File descriptors)的数字引用这些打开的文件。

文件描述符 文件
0 标准输入(stdin) - 输入文件
1 标准输出(stdout) - 输出文件
2 标准错误(stderr) - 输出错误的文件

重定向运算符

一般来讲当我们执行命令时,输出会在屏幕上通过Shell打印出来。我们可以使用重定向运算符 (Redirection operator) “>“ 来重定向输出到一个文件内。

例如,下面的命令将会保存路径中的文件到”file.txt”中:

1
$ ls -l 1> files.txt

或者也可以用这样的短格式:

1
$ ls -l > files.txt

如果使用了上述命令,则输出将不会显示在屏幕上,而是会被记录到文件”files.txt”中。当用来排查数据,debug或者就是简单保存信息时,这招很有用。

如果多次执行上述命令,系统可能会显示”cannot overwrite existing file“。
为了解决这个问题,可以先手动删除已有的文件,或者使用>|强制写入文件。如下所示:

1
$ ls -l >| files.txt



下面是一些常见的重定向运算符和他们的释义:

命令 解释
command 1> file
或者
command > file
将标准输出发送到 file
command 1>> file
或者
command >> file
将标准输出追加到 file
command 2> file 将标准错误发送到*file*中
command 2>> file 将标准错误追加到*file*中
command < fileA > fileB 2>fileC 首先命令会从fileA中读取,执行后将输出发送到fileB,将标准错误发送到fileC


>>>的区别在于,”>”将会在每次执行时覆盖文件内容,而”>>”则会在文件后面追加内容。


如果我们这里有一个C++程序,我们也可以使用这样的运算符来进行操作。例如下面的程序”add.cpp”会由两个输入,将两个输入加起来之后获得一个输出:

1
2
3
4
5
6
7
8
9
//add.cpp
#include <iostream>
int main() {
int a;
int b;
std::cin >> a;
std::cin >> b;
std::cout << a+b;
}

编译文件为一个叫做”add”的可执行文件:

1
$ g++ add.cpp -o add

这时你可以将自己的输入写到文件内,如”input.txt”:

1
3 4

然后使用”<”传入参数:

1
2
$ ./add < input.txt
7

或者你可以更进一步,将输入和输出都独立出来:

1
$ ./add < input.txt > output.txt

为何不能使用 input.txt > ./add > output.txt这种写法?因为Shell会尝试执行input.txt。



管线

有时候我们需要将一个程序的输出换做另一个程序的输入,我们可以创建并且利用管线来优雅地解决这个问题。

“|”是管线的符号。这个符号用来重定向一个程序的输出到另一个程序的输入,没有中间商赚差价。所谓中间商就是刚才用到的输入输出文本文件。

举个例子,如下是两行命令:

1
2
$ ls -l > files.txt
$ grep "Jan 26" < files.txt

简而言之就是使用ls命令列出所有内容到”files.txt”中,然后从”files.txt”中搜索所有含有”Jan 26”的行。

太复杂了。

整个操作可以换做下面的一行:

1
$ ls -l | grep "Jan 26"

通过使用|,可以将|之前的命令输出直接当作|后面的命令传入。在这个例子中具体是将ls -l的输出当作grep命令的传入来用了。


举几个例子吧。

Example 1:

假设我们现在有一个”data.txt”文件,我们需要按照价格将其排序,然后仅仅将商品的名称和价格保存到”result.txt”文件夹中,要求只能使用一行命令。

来看看标准题解:

1
$ sort -k3 -n data.txt | cut -d' ' -f2,3 > result.txt

首先我们使用sort命令,对”data.txt”的第三个字段(-k3)按照数字顺序(n)进行排序。
然后你看见了|。这代表这一步的输出即将被传入到下一个命令,也就是cut
cut中我们以空格作为分隔符(-d’ ‘),并保留第二第三字段(-f2,3),最后存入到”result.txt”中。

还是很简单的。下面是这个过程的视觉表述方式:

procedure


Example 2:

目标是在当前路径下对用户,用户组和其他人有运行权限的文件和路径。

1
2
3
$ ls -l
total 190
-rwx--x--x 1 kit gopher 0 Sep 12 10:30 add.o

对于所有目标都有执行权限的话,则权限指示的第4, 7, 10位应该是x。但是我们应该如何检索呢?看看下面这行命令

1
$ grep -E '^...x..x..x' [传入]

使用-E参数可以告诉Shell我们需要做样式匹配搜索了。对于'^...x..x..x'来讲:

  • “^”表示从行首开始匹配。
  • “.”表示可以匹配任意字符。

加在一起的话,'^...x..x..x'的意思大概就是,从行首开始匹配,返回在第4, 7, 10个位置有字母”x”的行。

那么一行就能解决了:

1
$ ls -l | grep -E '^...x..x..x'

结构上和上一个例子一样所以这里不解释了。



Example 3

加入你想移除文件里的某几列,然后将其输入到程序里。举个例子,”mark.txt”,包含学生信息的单行数据:

1
2
$ cat mark.txt
2011111111 John M 98.5 100 62.5 88 75.5

对应格式是:[UID, 名称,性别,分数1,分数2,分数3,分数4,分数5]

然后我们同样有一个C++程序用来处理信息,用于计算平均分:

1
2
3
4
5
6
7
8
//mark.cpp
#include <iostream>
int main {
double a1, a2, a3, a4, a5;
std::cin >> a1 >> a2 >> a3 >> a4 >> a5;
std::cout << "The overall mark the student get is: ";
std::cout << (a1 + a2 + a3 + a4 + a5) / 5;
}

程序的问题是输入仅有五项成绩,但是我们的”mark.txt”文件内包含UID在内的其他多余信息。

那这就很坏了。

一种方法是我们可以使用管线来先处理”mark.txt”,然后传入程序。我们可以使用cut命令处理一下:

1
2
$ cut -d' ' -f-8 mark.txt
98.5 100 62.5 88 75.5

然后编译程序运行:

1
2
$ g++ mark.cpp -o mark
$ cut -d' ' -f4-8 mark.txt | ./mark > result.txt

这样程序的输出就会被记录到”result.txt”中:

1
2
$ cat result.txt
The overall marks the student get is: 84.9



搜索

搜索文件或路径 (find)

各位应该在Windows或MacOS中使用过搜索功能。你也可以在Linux中实现对文件或路径的搜索操作:

使用find命令可以用来搜索文件或路径。find命令的格式为:

1
find [path] [-name] [-type]
  • [path]告诉系统你想从哪里开始搜索操作。
  • [name]是你想要搜索的文件或路径的名字。
  • [type]是可选参数,参数包含:
    • -type f代表仅搜索文件 (files)
    • -type d命令之搜索路径 (directory)

举个例子,假如在当前位置有下面这几个文件和路径:

1
2
$ ls
hello/ hello.cpp hello.txt

那么在当前目录下搜索”hello.txt”的方法即为:

1
2
$ find . -name "hello.txt" -type f
./hello.txt

上面的命令
在上面的命令中,.代表当前目录。指定当前目录后find会在这个目录为基础向下寻找文件。


或者我们也可以在当前位置下搜索”hello”打头的文件:

1
2
3
$ find . -name "hello.*" -type f
./hello.cpp
./hello.txt

…或者是搜索路径:

1
2
$ find . -name "hello" -type d
./hello

文件内搜索 (grep)

grep (Global Regular Expression Print)命令之前已经介绍过了。这里介绍更多细节:

之前用的是不带-E的使用方式,只是用来返回文件内的匹配行:

“example1.txt”

1
2
Hello how are you?
I am using the bash shell like a pro!
1
2
$ grep 'hell' example1.txt
I am using the bash shell like a pro!

grep命令区分大小写!这就是为什么返回的是包含”shell”的第二行,而不是包含”Hello”的第一行。


grep可以加入可选参数-E,代表按样式匹配。格式为:

1
grep -E [regular experssion] [filename]

regular expression中,你可以按照下面的符号来进行自定义匹配:

符号 释义
. 匹配任何单个字符
^ 仅从行首开始匹配
$ 仅从行尾开始匹配
? 一个字符后面跟一个”?”,则该字符可被匹配0次或1次
+ 一个字符后面跟一个”+”,则该字符可被匹配1次或多次
* 一个字符后面跟一个”“,则该字符可被匹配*0次或多次
[] 被”[]”括起来的字符会被全字匹配
括起来的字符可以是单个字符,也可以是多个字符
你可以用”-“符号来自定义区间
例如,与其使用[12345],可以使用[1-5]代替
\ 转义符
使用转义符来取消特殊字符的特殊效用
例如我们想要寻找”.”而不发挥它“匹配单个字符的属性”,就写成\.
pattern {n} 匹配”n”次出现的pattern
pattern {n,} 匹配至少“n”次出现的pattern
pattern {n,m} 匹配出现”n”和”m”次数之间的pattern
(ab){3} 出现三次”ab”排列
例如(ab){3}会被匹配做”ababab”,不是”abbb”

接下来我们使用刚才”example1.txt”,挑重点解释。


任意单字符匹配

1
2
3
$ grep -E '.ell' example1.txt
Hello how are you?
I am using the bash shell like a pro!

‘.ell’代表”.”处可以匹配任何单个字符,例如”Cell”, “cell”, “bell”会被匹配。
如果如果你想要在”.”处搜索的字母仅仅是大小写H,则可以使用[Hh]ell


行首/行尾匹配

这里启用”example2.txt”:

1
2
3
4
$ cat example2.txt
apple
pineapple
apple pie
1
2
3
4
$ grep 'apple' example2.txt
apple
pineapple
apple pie


使用”^”从行首开始匹配:

1
2
3
$ grep -E '^apple' example2.txt
apple
apple pie


而使用”$”从行尾开始匹配:

1
2
3
$ grep -E 'apple$' example2.txt
apple
pineapple


由此可知,使用”^”和”$”也可以全字匹配某一行:

1
2
$ grep -E '^apple$' example2.txt
apple


或者使用”^”, “$”配合”.”来返回只有五个字符的行:

1
2
$ grep -E '^.....$' example2.txt
apple

?, +, *

“?”

“example3.txt”

1
2
3
4
5
6
apple
coco
cherries
orange
ape
angel
1
2
3
4
$ grep -E '^ap?' example3.txt
apple
ape
angel

没看懂?首先”^”确保行首开始匹配,随后:

  • apple”会被返回,因为在表达式中的p要求出现0次或者1次,这里出现了1次
  • ape”会被返回,因为在表达式中的p要求出现0次或者1次,这里出现了1次
  • angel”会被返回,因为在表达式中的p要求出现0次或者1次,这里出现了0次

那么接下来的东西,”+”和”*”,原理上基本上是一样的了。


+

1
2
3
$ grep -E '^ap+' example3.txt
apple
ape
  • apple”会被返回,因为在表达式中的p要求出现1次或者多次,这里出现了多次
  • ape”会被返回,因为在表达式中的p要求出现1次或者多次,这里出现了1次
  • “angel”没有被返回,因为在表达式中的p要求出现1次或者多次,这里出现了0次

*

1
2
3
4
$ grep -E '^ap*+*' example3.txt
apple
ape
angel
  • apple”会被返回,因为在表达式中的p要求出现0次或者多次,这里出现了多次
  • ape”会被返回,因为在表达式中的p要求出现0次或者多次,这里出现了1次
  • angel”会被被返回,因为在表达式中的p要求出现0次或者多次,这里出现了0次

接下来是一些进阶一点的用法了:

整合”.”和”*“

如果我们想要匹配字符”a”,然后后方跟随任意字符,直到”ge”再次出现:

1
2
3
$ grep -E 'a.*ge' example3.txt
orange
angel

“跟在”.”之后,所以”任意字符匹配”可以被匹配多次,这就是为什么”orange“和”*angel”能被返回的原因。


使用括号”()”

我们可以使用括号来整合子字符串。例如我们想要寻找字符串”co”出现过一次或多次的行:

1
2
$ grep -E '(co)+' example3.txt
coco

为什么要用括号呢,看看如果不用括号会发生什么:

1
2
3
$ grep -E 'co*' example3.txt
coco
cherries

因为”*”只会应用到它的前一个字符。没加括号的话就会被应用到”o”,而不是整个子字符串”co”。


匹配集合

可以使用方括号 “[ ]”来定义一个集合,随后进行搜索。你可以按照下面的格式定义集合:

  • [0123456789]或者[0-9]用来匹配任意单个数字
  • [A-Z]用来匹配任何单个大写字母
  • [a-z]用来匹配任何单个小写字母
  • [A-Za-z]用来匹配任何单个字母,包括大写和小写字母。

例如我们有一个”example4.txt”:

1
2
3
Apple Juice HKD13
apple pie USD 4
Banana phone HKD

查找包含”apple”或者”Apple”的行:

1
2
3
$ grep -E '[Aa]pple' example4.txt
Apple Juice HKD13
apple pie USD 4

查找包含”HKD”,并在其后跟随一个或者多个数字的行:

1
2
3
$ grep -E 'HKD[0-9]*' example4.txt
Apple Juice HKD13
Banana phone HKD

匹配序列

可以使用”{ }”来匹配一个序列 (pattern)。

上”example5.txt”:

1
2
3
2April2013
30-1-2013
13December2013

如果我们想要查找一个日月年的格式,并且日为2个字符,月至少为3个字符,年为4个字符,你可以整成:

1
2
3
$ grep -E '^[0-9]{1,2}[a-zA-Z]{3,}[0-9]{4}' example5.txt
2April2013
13December2013

这很抽象,所以我们需要简单讲讲:

  • [0-9]{1,2}定义一个数字集合,并且需要至少出现一次,最多出现2次
  • [a-zA-Z]{3,}定义一个字母(包含大小写)集合,并且需要至少3个字符长
  • [0-9]{4}定义一个数字集合,并且必须要是正正好好4个字符长

加上行首匹配”^”就ok了。


其他常用匹配写法

表述 释义
[a-z]* 匹配任意数量字符的小写字母
....$ 匹配包含四个字符的行
abc.*abc 匹配出现两次”abc”的行。两个”abc”之间可以有任意数量的字符
[0-9]{2}-[0-9]{2}-[0-9]{4} 匹配dd-mm-yyyy格式。请注意这不会验证日期是否合法!
^.{n,m}$ 匹配行长介于n和m个字符之间的行
(bye)+ 匹配一次或多次出现过”bye”的行,例如”bye”, “byebye”, “byebyebye”,空行不会被匹配。


至此,Module 1全部完结!



Module 2: Shell Script & Version Control

先来深入一下Shell脚本把。

Shell脚本

Shell 脚本 (Shell Script, .sh) 是一种由 Linux shell 运行的计算机程序。
Shell Script是一种解释型语言,而不是编译型语言。因此与 C++ 不同,我们不需要在执行程序之前将 shell 编译为二进制可执行格式。
相反,每次执行用 Shell 脚本编写的程序时,shell 都会对其进行解析和解释。

解释型语言允许我们通过简单地编辑脚本来更快地修改程序。但是,程序通常较慢,因为在执行期间需要进行解析和解释。
假设我们有一系列 Shell 命令,我们不想在每次要执行时重新输入这些命令。我们可以将它们保存在一个文件中,并将其称为 shell 脚本。

注意注意↑!
在 Windows 中编辑脚本文件并导入到 Linux 可能会导致执行失败,因为 Windows 中使用的行尾 (EOL) 不同。
你应该在 Linux 系统内创建和编辑脚本文件(例如,在 SSH 中使用 vi 编辑器,在 X2Go 中使用 gedit)。否则,如果从 Windows 导入脚本文件,则需要确保 Windows 环境中的文本编辑器中的行尾选项设置为 UNIX 格式 (LF) 而不是 Windows 格式 (CRLF)。



我们先来创建一个hello.sh脚本写个典中典Hello world:

1
$ vi hello.sh
1
2
3
#!/bin/bash
echo "Hello world!"
echo "Welcome to ENGG1340"

echo命令用来向控制台输出一行。行中内容可以是变量值,也可以是字符串。

保存并退出,随后我们需要使得文件可以被用户运行:

1
$ chmod u+x hello.sh

然后直接运行:

1
2
3
$ ./hello.sh
Hello world!
Welcome to ENGG1340

所有脚本都应以#!开头,这表示系统应使用哪个程序来处理 shell 脚本。在本例中,它是 bash 程序的路径。
从Module 1 中我们知道有许多不同的 shell(例如,C shell、Korn shell、Bash shell)。而由于我们使用 Bash shell,因此我们需要提供 bash 程序的路径,以便操作系统知道如何解释 bash shell 命令。
在本例中,Bash shell的路径为 /bin/bash。

你也可以使用which -a命令来找到Bash shell的具体位置:

1
2
3
$ which -a bash
/usr/local/bin/bash
/bin/bash

参数-a代指返回bash的所有路径。如你所见返回的路径不止一个,这代表Bash的路径可能有多个。在本例中我们使用/bin/bash路径下的那个。


来看看另一个例子。这里我们引入echo命令的新参数-n看看会发生什么不同的情况:

ex1_1.sh:

1
2
3
4
#!/bin/bash
echo -n "Hello world!"
echo "Welcome to ENGG1340"
echo "bye"
1
2
3
$ ./ex1_1.sh
Hello world!Welcome to ENGG1340
bye

-n可以表示”no trailing”使下一行直接继续打印在上一行的末尾。


这次我们来看个带C++的例子:

add.cpp

1
2
3
4
5
6
7
8
9
//add.cpp
#include <iostream>
int main() {
int a;
int b;
std::cin >> a;
std::cin >> b;
std::cout << a + b;
}

input.txt“:

1
3 4

之前我们使用管线可以封装好整个自动化,不过这里我们试着用Shell脚本:

ex1_2.sh“:

1
2
3
4
5
6
7
#!/bin/bash

# Compile the code
g++ add.cpp -o add
# Run the code and display result
./add < input.txt > output.txt
cat output.txt

运行:

1
2
$ ./ex1_2.sh
7

使用变量

在Shell脚本中只有一种变量类型,那就是字符串。

变量名区分大小写,只支持包含大小写字母,数字和下划线 (_)。


定义并访问变量

我们可以通过这种方式来定义并赋予变量一个值:

1
pet="dog"

等号前后不能有空格。

如果我们需要取用变量中的内容,则需要使用美元符号:

1
2
3
4
#!/bin/bash

pet="dog"
echo $pet

输出为dog


读取用户输入

read命令用来等待用户在控制台向脚本提供输入:

1
2
3
4
5
#!/bin/bash

echo "What is your name?"
read name
echo "Hello $name"

当用户输入完值之后,会被自动存储到变量name中。

比如我们来看这个程序的具体例子:

1
2
3
4
$ ./ex2_2.sh
What is your name?
Kit
Hello, Kit



单引号和双引号

在Shell脚本中区分引用很重要。我们可以用三种方式来确定一个字符串值,分别是不引用单引号引用双引号引用


不引用

我们可以在定义字符串的时候不加任何引用,但是这个方法仅在需要定义的字符串是一个整体,没有空格的情况下才可用:

1
a=cat

下面这种情况就会报错:

1
a=Apple pie

因为 程序会将Apple当作一个指令来看待,而不是一个字符串。


单引号引用

在单引号引用情况下,带空格的字符串可以被成功处理,但是没办法做到变量替换。

变量替换功能是双引号引用的功能:


双引号引用

双引号引用相比单引号引用支持更多功能,其中一个就是变量替换:

符号 解释
$ 变量替换
\ 转义符
`` 包含bash命令

看不懂?就着下面的例子看看:

1
2
3
4
5
6
#!/bin/bash

name="Apple"
echo 'Hello, $name'
echo "Hello, $name"
echo "\$name = $name"

运行后可以得到:

1
2
3
4
$ ./ex2_3.sh
Hello, $name
Hello, Apple
$name = Apple

第一行会被输出为Hello, $name,因为我们使用了单引号引用,name变量不会被替换。
第二行会被输出为Hello Apple,因为双引号引用下$name会被替换为变量name
第三行会被输出为的替换功能,而第二个$name会被保持原状正常替换。



命令替换

前面提到过一嘴,不过我们决定在这里展开相关说明。

使用反引号(`),一般是esc键下面的那个键,我们可以在脚本中向变量保存Shell命令的输出,来实现进一步的处理:

1
2
3
4
5
6
7
#!/bin/bash

a="`cat file.txt`"
echo $a

b="`wc -l file.txt | cut -d\" \"" -f1`"
echo "There are $b lines in file"

file.txt“:

1
2
3
Apple
Banana
Cherry

运行输出为:

1
2
3
$ ./ex2_4.sh
Apple Banana Cherry
There are 3 lines in file

接下来我们解读一下。在变量b中,首先wc命令返回3 file.txt,告诉我们一共有三行。接着我们使用cut命令截去后面多余的部分,返回一个”3”。

注意在cut命令的参数部分我们使用了转义符,因为原本是cut -d" " -f1,但问题在于外面已经有一对双引号了。解决这个问题只能去转义里面的两个引号。



字符串处理

如果我们想要获得一个字符串的长度:

1
2
3
4
#!/bin/bash

a="Apple"
echo ${#a}


1
2
$ ./ex2_5_1.sh
5

其中${\#a}的意思是返回变量a存储的字符串的长度。


子字符串

使用${a:pos:len}可以用来返回一个字符串的子字符串。例如:

1
2
3
4
#!/bin/bash

a="Apple Pie"
echo ${a:6:3}

会返回变量a从第6个字符位置开始包含并向后数3个字符长度的子字符串,也就是:Pie

和Python一样第一个字符编号为0。



替换字符串内容

使用${a/from/to}来指定替换一个字符串。先看例子:

1
2
3
4
5
6
#!/bin/bash

a="Apple Pie"
from="Pie"
to="Juice"
echo ${a/from/to}

输出为Apple Juice

该替换会在字符串内寻找from的第一个匹配,然后将from替换为to的值。



按数字计算

我们存储的变量都是字符串,但是如果我们存入的是数字类型的字符串,并且我们想要进行一些数学运算该咋整?

虽然听起来很奇怪,但是我们可以使用let命令来进行数学运算。支持加减乘除和整数除法。(+, -, *, /, %)

1
2
3
4
5
6
7
8
9
10
11
12
#!/bin/bash

a=10

let "b = $a = 9"
echo $bash

let "c = $a * $a"
echo $C

let "d = $a % 9"
echo $d

运行为:

1
2
3
4
$ ./ex2_5_4.sh
19
100
1



获取控制台参数数量

控制台参数再Shell脚本中会被列为$0, $1, $2一直到$9
作为特殊项,$0会被解析为Shell脚本的名称。
如果想要引用第十个或者更多参数,则需要将序号使用括号括起来:${10}, ${11}这样,否则命令会被解析为$1和数字的组合。
$#代表控制台内参数的数量。

例如我们有一个Shell脚本如下所示:

1
2
3
4
5
6
7
8
#!/bin/bash

echo "There are $# arguments"
echo "$0"
echo "$1"
echo "$2"
echo "$3"
echo "$4"

运行脚本后输出:

1
2
3
4
5
6
7
$ ./ex2_6.sh we are the world
There are 4 arguments
./ex2_6.sh
we
are
the
world



控制语句

在任何脚本语言中,流程控制都是必不可少的一部分。

if else语句

基本的if else语句语法为:

1
2
3
4
if [ condition ]
then
*perform some action
fi

if语句的重点写做fi还有点搞笑。
注意了if语句的条件判断,方括号里那个,需要前后带一个空格。注意condition前后离着括号都差一个空格。如果不遵守这个规定会爆语法错误。

Shell脚本也有else if功能:

1
2
3
4
5
6
7
8
9
if [ condition1 ]
then
echo "condition 1 met"
elif [ condition2 ]
then
echo "condition 2 met"
else
echo "No condition met"
fi



在上面的语句中,condition的语法条件比较特殊。下面的列表展示了可以当作condition来用的语句:

  • 比较字符串

注意$string1$string都被双引号引用了,所以在$string1$string2内即使有空格也可以正确比较内容。

String comparisons Description
[ "$string" ] 如果$string的长度不为空,则返回true
[ -z "$string" ] 如果$string的长度为空,则返回true
[ "$string1" == "$string2" ] 如果$string1$string2完全一致,返回true
[ "$string1" != "$string2" ] 如果$string1$string2不一致,返回true
[ "$string1" \> "$string2" ] 如果$string1按照字母顺序排序排在$string2后面,则返回true
[ "$string1" \< "$string2" ] 如果$string1按照字母顺序排序排在$string2前面,则返回true

注意排序那两个符号前面带转义符。


  • 比较文件或路径

可以用来判断文件或者路径的状态。包括但不限于:

文件检查 解释
[ -e $file ] 如果file存在,返回true
[ -f $file ] 如果file是一个文件,返回true
[ -d $file ] 如果file是一个路径,返回true
[ -s $file ] 如果file的大小>0,返回true
[ -r $file ] 如果file可读,返回true
[ -w $file ] 如果file可写,返回true
[ -x $file ] 如果file可执行,返回true



  • 比较数字

虽然shell里只有字符串一种变量,但我们也可以按下面的方式来比较数字:

数字比较 解释
[ $a -eq $b ] 如果a = b,返回true (equal)
[ $a -ne $b ] 如果a != b,返回true (not equal)
[ $a -lt $b ] 如果a < b,返回true (less than)
[ $a -le $b ] 如果a <= b,返回true (less or equal)
[ $a -gt $b ] 如果a > b,返回true (greater than)
[ $a -ge $b ] 如果a >= b,返回true (greater or equal)

还是比较好记的


例如我们想要写一个小脚本,询问用户想不想要移除所有后缀为.cpp的文件:

1
2
3
4
5
6
7
8
9
#!/bin/bash

echo "Do you want to remove all .cpp files? (Y/N)"
read ans =
if [ "$ans" == "Y" ]
then
rm -rf *.cpp
echo "All .cpp files are removed!"
fi

尤其注意一下if语句带condition的空格问题。留意一下空格都加在哪里了。

运行为:

1
2
3
Do you want to remove all .cpp files? (Y/N)
Y
All .cpp files are removed!



学会了?接下来举几个例子:

Example 1

如果g++在编译c++文件时出现了错误,则编译失败,可执行文件将不会生成。所以我们可以使用[ -e file ]来检查文件是否存在。
同时,我们也可以考虑一下是不是可以查看一下编译失败的log?

比如我们可以使用重定向方法来存储错误log:

1
$ g++ hello.cpp -o hello 2> error.txt

别忘了2>代表重定向标准错误。

那么看看这个shell脚本吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#!/bin/bash

if [ -e hello.cpp ]
then
echo "hello.cpp exist"
g++ hello.cpp -o hello 2> error.txt
if [ -e hello ]
then
./hello
else
echo "Compilation failed!"
echo "Here are the error message"
cat error.txt
fi
else
echo "hello.cpp not found!"
fi

如果hello.cpp包含语法错误,那么下面就是其中一种可能的输出:

1
2
3
4
5
6
7
hello,cpp exist
Compilation failed!
Here are the error message
hello.cpp: In function 'int main()':
hello.cpp:5:5: error: expected ';' before 'return'
return 0;
^~~~~~



for循环

for循环可以按照指定次数循环。

1
2
3
4
5
6
7
#!/bin/bash

list="1 2 3 4 5"
for i in $list
do
echo "This is iteration $i"
done

for循环以for i in []开始,需要执行的命令以dodone括起来。

例如上面的脚本会被运行为:

1
2
3
4
5
This is iteration 1
This is iteration 2
This is iteration 3
This is iteration 4
This is iteration 5



实际上除了遍历数组,我们也可以遍历一个路径中的文件。
比如下面的脚本将会自动将你路径下所有的.cpp文件记录一个备份:

1
2
3
4
5
6
7
#!/bin/bash

list="`ls *.cpp`"
for filename in $list
do
cp $fileName "$fileName.backup"
done
1
2
3
4
5
$ ls
a.cpp backup.sh* b.cpp c.cpp
$ ./backup.sh
$ ls
a.cpp a.cpp.backup backup.sh* b.cpp b.cpp.backup c.cpp c.cpp.backup



有用的操作

在脚本中隐藏命令

Shell脚本会生成自己的错误和输出信息,有时会引爆你的shell脚本输出。

为了解决这一点你可以使用之前的重定向方法:

1
2
3
4
5
6
7
8
9
#!/bin/bash

cp file123 fileabc 1>/dev/null 2>&1
if [ -e fileabc ]
then
echo "Copy successful"
else
echo "$0: Oops. Copy failed :("
fi

解析一下,1>/dev/null用来将cp命令的标准输出重定向到系统回收站内/dev/null。挺巧妙的。

2>&1则会重定向cp命令的标准错误到同一个位置,只不过我们以&1表示了。

&表示在这里指的是文件描述符,而不是文件名或路径。如果没有&,像2>1这样的命令将无效,因为shell会尝试将1解释为文件名,而不是标准输出文件描述符。

输出为:

1
2
$ ./ex4_1.sh
./ex4_1.sh: Oops. Copy failed :(



输出到标准错误

Shell脚本也可以通过echo命令输出到标准错误中:

1
2
3
4
5
6
7
8
9
#!/bin/bash

cp file123 fileabc 1>/dev/null 2>&1
if [ -e fileabc ]
then
echo "Copy successful"
else
echo "$0: Oops. Copy failed :(" >&2
fi

echo后面跟着>&2时候,则代表我们将该条信息重定向到标准错误层上进行输出了。接下来如果我们执行这个:

1
$ ./ex4_2.sh 2> error.txt

就可以在”error.txt”中看见我们的错误输出了!

1
2
$ cat error.txt
./ex2_6.sh: Oops. Copy failed :(

经过这两步处理后你的shell脚本的输出看起来就像其他shell命令一样了。


版本控制

在版本控制这一部分,我们主要学习如何使用Git。

Git 是一种常见的现代版本控制系统,用于管理和跟踪计算机文件中的更改以及协调多人对这些文件的工作。主要用于软件开发中的源代码管理。
Git 是一种分布式版本控制系统 (DVCS),与大多数替代版本控制系统相比,它具有更高的性能、安全性和灵活性。

那么什么是版本控制系统?

版本控制系统 (VCS) 是一类软件工具,支持软件开发团队管理源代码随时间的变化。
它在一种特殊的数据库中跟踪每个贡献者对代码的单独更改历史记录。如果出现错误或需要修复错误,开发人员可以返回到源代码的早期版本来解决问题,而不会妨碍其他团队成员的工作流程。
如果软件团队不使用 VCS,他们可能会遇到一些问题,例如在项目的两个独立部分之间创建不兼容的代码或对用户可用的更改一无所知。

在这么多版本控制解决方案中,使用Git的原因是它让开发人员可以在一个地方查看任何项目的变更、决策和进展的整个时间线。
借助 Git 这样的 DVCS,可以随时进行协作,同时保持源代码的完整性。使用分支,开发人员可以安全地对生产代码提出更改建议。

如果你用过Github,你应该对上述这些内容和下面将要讲到的内容比较熟悉。如果不,那么这一节将会很有意思。


Git的使用工作流基本分三步。

  1. git init初始化工作目录

首先可以使用Git初始化一个你将要使用的工作目录。Git会尝试在目录内跟踪你对文件的改变。

  1. git add增加一次提交

这一步告诉Git,当前目录下所有被更改的文件有哪些。

  1. git commit创建一次提交(commit)

最后告诉Git让它创建一次提交,类似于创建一个存档点。每一次提交就是某个特定版本。



开始使用Git

安装Git

要使用Git,我们需要先配置好Git的使用环境。首先我们需要在你的电脑上安装Git。

检查Git是否在你的电脑上安装,执行git version即可:

1
2
$ git version
git version 2.45.2.windows.1

如果没有安装Git,则你可以根据下面的步骤来安装Git。


在Linux上安装

在终端执行几行代码就OK了:

1
2
3
$ sudo apt-get update
$ sudo apt-get upgrade
$ sudo apt-get install git


在MacOS上安装

虽然我不用Mac但是相应的安装方式也是可以提一嘴的。

首先下载Homebrew来更方便快捷的安装软件:

1
2
3
4
$ ruby -e "$(curl -fsSL
https://raw.githubusercontent.com/Homebrew/install/master/install)"
$ brew doctor
$ brew install git


在Windows上安装

直接去官网下载安装包即可。

https://git-scm.com/downloads



初始化仓库

一个Git仓库包含你工作环境的所有文件,文件夹或者路径等等。配置好Git后Git会自动帮你管理变更。

一共有两种方式来初始化一个本地仓库。你可以从网上clone一个仓库下来,也可以自己创建一个空白仓库使用。


初始化新仓库

  1. 打开终端并转到你想要初始化的路径:
1
$ cd project
  1. 在文件夹内我们使用git init命令创建一个新仓库:
1
2
$ git init
Initialized empty Git repository in /home/research/ra/1801/cklai/project/.git/

执行完这行命令后,一个新的.git/子路径会在当前路径下生成。这个指令用来设置好Git用来跟踪你的文件的所有前置工作。

  1. 现在我们可以使用Git了。比如说下一步我们创建一个”work.txt”:
1
2
Welcome to my Git tutorial.
Today we will learn how to get started with Git.

git init还有其他的一些可选变体可以使用:

命令 解释
git init 创建新的本地仓库
git init --quiet
or
git init -q
静默模式,仅输出重要信息,警告和错误。
git init --bare 创建一个裸仓库。
裸仓库没有工作目录,因此不能在上面进行实际的编辑和修改文件的操作,仅仅是用来存储并共享版本控制的历史记录。
git init --template=<template_directory> 给本地仓库指定一个模板
git init --separate-git-dir=<git dir> 创建一个包含具体工作目录路径的文本文档



clone新仓库

或者你也可以从网上拉取一个新仓库下来。

  1. 如果你想使用其他人的仓库,你可以直接从代码托管平台上clone一个仓库到你的本地。Github就是全球最大的代码托管平台。

注意注意↑!
如果存在.git路径,那么你的仓库就会被拉取到那里。如果没有,则仓库会被clone到你的当前工作路径。

  1. 输入clone命令即可从网上拉取仓库:
1
2
3
4
5
6
git clone https://github.com/[YourUsername]/[YourRepository]
Cloning into `Spoon-Knife`...
remote: Counting objects: 10, done.
remote: Compressing objects: 100% (8/8), done.
remove: Total 10 (delta 1), reused 10 (delta 1)
Unpacking objects: 100% (10/10), done.

同样的这里有一些git clone的附加变体:

命令 解释
git clone --branch <branch_name> 告诉Git你想要拉取的分支是哪一条。有关分支后面再说
git clone --bare 将仓库以裸仓库的形式拉取下来
git clone --mirror clone远程存储库的所有扩展引用,并隐式调用 -bare 参数
git clone --template=<template_directory> <repo_location> clone位于<repo location>的仓库,并应用来自<template_directory>的仓库模板



查看工作路径

当你在当前工作路径内工作时,你也可以使用git status命令来查看当前工作目录版本情况的信息:

1
2
3
4
5
6
7
8
9
10
11
$ git status
On branch master

No commits yet

Untracked files:
(use "git add <file>..." to include in what will be committed)

work.txt

nothing added to commit but untracked files present (use "git add" to track)

注意到在Untracked files下存在”work.txt”,这代表这个文本文档被Git可见,但是Git没有开始追踪这个文档的任何更改。解决这个问题我们需要向Git生命暂存更改。



暂存更改

为了让 Git 开始跟踪你在工作目录中所做的更改,你需要先将这些文件添加到暂存区。
这可以通过使用命令git add <filename>来完成,其中<filename>是你正在处理的文件的名称,例如我们的”work.txt”文件。

1
2
3
4
5
6
7
8
9
10
$ git add work.txt
$ git status
On branch master

No commits yet

Changes to be committed:
(use "git rm --cached <file>..." to unstage)

new file: work.txt

现在”work.txt”已被加入暂存目录。


git add的一些额外变体:

命令 解释
git add . 将当前目录下所有文件添加到暂存区
git add -A
or
git add --all
查找整个项目中存在的所有新文件或更新文件,并将其添加到暂存区



跟踪工作目录中文件变化

现在我们在”work.txt”新增第三行:

1
2
3
Welcome to my Git tutorial.
Today we will learn how to get started with Git.
We start with the Basic Git Workflow.

想要查看暂存区和工作目录中同一个文件的区别,我们完全可以使用git diff <filename>命令来查看区别:

1
2
3
4
5
6
7
8
$ git diff work.txt 
diff --git a/work.txt b/work.txt
index 90ac5e6..f0f679f 100644 --- a/work.txt
+++ b/work.txt
@@ -1,2 +1,3 @@
Welcome to my Git tutorial.
Today we will learn how to get started with Git.
+We start with the Basic Git Workflow.

可以在最后一行看出,Git已经成功登记了文件的更改。如果想要将当前的状态加入暂存,随时可以执行git add work.txt命令。

下面是一些git diff命令的变体:

命令 名称
git diff --base <filename> 查看与基础文件的冲突
git diff <sourcebranch> <targetbranch> 在合并更改之前预览变更

如果git diff的输出过长,则Git会使用分页器显示内容。在这种情况下我们可以按Q来退出分页器。
你也可以在参数中添加--no-pager告诉Git不要使用pager。这个设置对于绝大多数的Git命令都可以使用。



提交更改

从暂存区存到仓库我们需要使用git commit来提交我们所做的所有更改,建立一个存档。

git commit在使用时需要跟一个参数-m,用来写入你这次更改的内容注释:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ git commit -m "Added an introduction."
[master (root-commit) 8327731] Added an introduction.
Committer: Chan Tai Man <tmchan@academy11.cs.hku.hk>
Your name and email address were configured automatically based
on your username and hostname. Please check that they are accurate.
You can suppress this message by setting them explicitly. Run the
following command and follow the instructions in your editor to edit
your configuration file:

git config --global --edit

After doing this, you may fix the identity used for this commit with:

git commit --amend --reset-author

1 file changed, 3 insertions(+)
create mode 100644 work.txt

请注意上面有关你的姓名和电子邮件地址配置的消息,实际上你可以按照说明对配置文件进行相应的更改。

始终输入有意义的提交消息,是程序员们的基本操作之一。这对你后期的开发有非常大的帮助,同时对别人也是。


使用git log命令可以查看比较旧版本的项目:

1
2
3
4
5
6
$ git log
commit 8327731fa6a9108fb6b54d0b38b9b59c7fbf316c (HEAD -> master)
Author: Chan Tai Man <tmchan@academy11.cs.hku.hk>
Date: Mon Jan 14 10:38:43 2019 +0800

Added an introduction.

在每个log中会提供如下四个信息:

  • 40字长的哈希码,我们叫SHA。这是每个commit的唯一识别码
  • 提交的作者,也就是你自己
  • 提交的时间和日期
  • 提交消息



与他人合作

使用分支系统

分支可以看作是指向 Git 存储库中最新提交的指针。
当我们初始化存储库时,我们正在处理一个称为master分支的单个分支。我们正在处理的提交称为HEAD,通常是分支的最新提交。

你可以在某个commit节点创建一个分支。

branching

可以看一下上面这张图。这是一个正在开发的项目的Git图表。每一条线代表一个分支,每一个点代表一个commit。

可以看到橙色的那条线从粉红色分支的一个commit拉出,经过一系列的更改,最后在上面的一个commit合并到粉红色分支。这两个粉红色commit之间区别在于并入了整个橙色分支的内容。

要在当前commit拉出一个分支,可以使用git branch BugFix命令:

1
$ git branch BugFix

拉出分支成功后当前工作分支还是在master上。切换到刚拉出来的BugFix分支我们需要:

1
2
$ git checkout BugFix
Switched to branch 'BugFix'



我们也可以使用checkout命令将 HEAD 移至上一个提交。
如果这样做,我们也会恢复此提交中文件的状态。例如,我们可以移动到提交 BugFix^,这是分支 BugFix 中的上一个提交。

1
2
3
4
5
6
7
8
9
10
11
12
13
$ git checkout BugFix^
Note: checking out 'BugFix^'.

You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by performing another checkout.

If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -b with the checkout command again. Example:

git checkout -b <new-branch-name>

HEAD is now at 8327731 Added an introduction.

想要移动一个特定的分支,那就用git log吧。

下面是一些用于分支的可用命令:

命令 释义
git branch 列出所有分支
git branch branchname 创建一个分支
git checkout branchname 切换到分支
git checkout -b branchname 创建并切换到分支
git branch -m branchname new_branchname 重命名分支
git branch --merged 显示所有与当前分支合并过的分支
git branch -d branchname 删除合并完成的分支
git branch -D branch_to_delete 删除未合并的分支



合并分支

用刚才的那张图做例子,橙色分支最后并入到了粉红色分支上,并入操作我们叫做(merge).

1
2
3
4
5
6
7
8
$ git checkout master
Switched to branch 'master'
$ echo "This is some new file in master." > master.txt
$ git add master.txt
$ git commit -m "Added master.txt"
[master 832ceba] Added master.txt
1 file changed, 1 insertions(+)
create mode 100644 master.txt

接下来我们在BugFix分支上也添加一个新文件:

1
2
3
4
5
6
7
8
$ git checkout BugFix
Switched to branch 'BugFix'
$ echo "This is some new file in BugFix" > bugfix.txt
$ git add bugfix.txt
$ git commit -m "Added bugfix.txt"
[BugFix ed600da] Added bugfix.txt
1 file changed, 1 insertions(+)
create mode 100644 bugfix.txt

我们可以选择向BugFix分支并入master,也可以向master并入BugFix。但是我们可以选择前者,因为这样我们就可以在不干扰master分支的前提下验证bug修复是否有用。

1
2
3
4
5
6
7
$ git checkout BugFix
Already on 'BugFix'
$ git merge master -m "Apply changes in master"
Merge made by the 'recursive' strategy.
master.txt | 1 +
1 file changed, 1 insertion(+)
create mode 100644 master.txt

如果在master分支下出现了会影响到BugFix的更改,则会出现文件冲突。这时Git就会提示你解决冲突。

现在假设我们发现修复没啥问题可以直接并入master,则我们可以直接快进并入,叫做”fast foward”。
这一步不会创建任何commit。

1
2
3
4
5
6
7
$ git checkout master
Switched to branch 'master'
$ git merge BugFix
Fast-forward
bugfix.txt | 1 +
1 file changed, 1 insertion(+)
create mode 100644 bugfix.txt

下面是一些merge命令的变体:

命令 解释
git merge branchname
git merge --ff-only branchname
git merge --no-ff branchname
git merge --abort
git cherry-pick 073791e7



在远程仓库上工作

目前为止我们只在本地仓库上作法。 如果你想要加入任何在线的Git项目,你需要去了解如何管理远程仓库。

使用git remote命令来查看目前所有的远程仓库,git remote -v命令允许你去查看这些远程仓库的URL:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ git clone https://github.com/schacon/ticgit
Cloning into 'ticgit'...
remote: Enumerating objects: 1857, done.
remote: Total 1857 (delta 0), reused 0 (delta 0), pack-reused 1857
Receiving objects: 100% (1857/1857), 334.04 KiB | 395.00 KiB/s, done.
Resolving deltas: 100% (837/837), done.

$ cd ticgit

$ git remote
origin

$ git remote -v
origin https://github.com/schacon/ticgit (fetch)
origin https://github.com/schacon/ticgit (push)


想要添加远程仓库,你可以使用git remote add <shortname> <URL>命令:

1
2
3
4
$ git remote add pb https://github.com/paulboone/ticgit 
$ git remote
origin
pb


或者如果你想查看一个远程仓库的信息,就使用git remote show <remote>

1
2
3
4
5
6
7
8
9
10
11
12
$ git remote show origin 
* remote origin
Fetch URL: https://github.com/schacon/ticgit
Push URL: https://github.com/schacon/ticgit
HEAD branch: master
Remote branches:
master tracked
ticgit tracked
Local branch configured for 'git pull':
master merges with remote master
Local ref configured for 'git push':
master pushes to master (up to date)

下面有一些有关远程仓库的命令和变体:

命令 释义
git remote 查看远程仓库配置
git remote -v 带着URL查看远程仓库配置
git remote add <shortname> <URL> 添加远程仓库
git remote rm <shortname> 删除在本地记录的远程仓库
git remote rename <old-name> <new-name> 重命名在本地记录的远程仓库
git branch --merged 显示所有与当前分支合并过的分支
git branch -d branchname 删除合并完成的分支
git branch -D branch_to_delete 删除未合并的分支



推送到远程仓库

当你在本地对远程仓库做出了更改,并且想要远程仓库同步你本地的版本,就可以使用git push将本地的代码推送到远程仓库。

1
$ git push origin master

此命令仅在您从具有写入权限的服务器克隆且在此期间无人推送时才有效。
如果你和其他人同时克隆,并且他们推送上游,然后您推送上游,您的推送将被拒绝。
这种情况下必须先获取他们的工作并将其合并到当前版本的工作中,然后才允许推送。

命令 释义
git push <remote> <branch> 将某个特定分支推送同步到远程仓库
git push <remote> --force 和上面一样,但是本命令会强制将本地推送到远程仓库
git push <remote> -all 将本地所有分支推送同步到远程仓库
git push <remote> --tags 将所有的本地标签推送到远程仓库



从远程仓库拉取

有点像是下载,也可以算是同步。本命令基本上用来以远程仓库为基准,更新本地仓库。

1
$ git pull
命令 释义
git pull <remote> 将远程仓库的内容同步到本地仓库
git pull --no-commit <remote> 从远程仓库拉取,但是不在本地创建提交
git pull --verbose 从远程仓库拉取时返回verbose内容



Module 3: C++ Basics

在这门课中我们要接触一门全新的语言:C++。

我不想过多介绍有关环境配置和其他的内容,一方面是ENGG1340已经为我们提供了可以直接使用的Linux环境,内置gcc,而在Windows系统上的C++环境配置也不复杂。有需要的自己上网一搜就行。

东西挺多所以我决定每一部分写的少一点,尽可能概括到所有需要掌握的知识点。



C++基本语法和程序结构

注释

我们先从最简单的开始说起吧:

在程序中插入一段注释,只需要使用双斜杠//就可以达到你的目的:

例如在下面的程序中:

1
2
3
4
5
6
7
// 这是一段注释
#include <iostream>

int main() {
std::cout << "Hello, World!"; //这行用来输出文本
return 0;
}

注释行在进行编译的时候,会被编译器忽略。大胆地在程序里写下你的注释吧,你也不希望回头看不懂你自己写的程序吧。



头文件引用

在上面的程序中,你会发现第一行是一个由井号开头,看起来和程序没有任何关系的一行。

1
#include <iostream>

这一行实际上引用了我们要在程序中使用到的头文件。你可以类比为我们要使用到一个叫做iostream的工具箱。

#include指令在这里用来将预先编写的代码(库)引入到程序中,这些库包含我们可以使用的有用函数和工具。

至于后面的<iostream>,是一个处理输入和输出的标准 C++ 库。这个库为我们提供了在屏幕上显示内容(输出)和从用户那里获取信息(输入)的工具。
具体来说,它为我们提供了 cout命令。

也就是说如果我们移除程序中的这一行,程序就不知道cout是什么东西了。

怎么提供呢?实际上方法非常暴力:编译器在将要编译文件时,会找到你引用的这些头文件,然后将其粘贴到文件的开头…


main函数

main函数是任何C++程序的切入点 (Starting Point)。大概的意思是,编译器在编译该文件时,会默认从main开始往下读。

main函数的结构可以像是是这样:

1
2
3
4
int main() {
// 我们想要写的任何code
return 0;
}
  • int main()定义了主函数。int的意思代表该函数会返回一个整型值。
  • { }这个花括号是主函数的主体 (Body)。 我们在主函数的主题内写到的任何命令,就在主函数要执行的范畴之内。
  • return 0;这一行一般代表我们的程序已经执行完毕。在C++中,在这种情况下返回0是一个约定。



现在你知道了这些内容,我们再回头解答一下一开始写的那个程序干了什么:

1
2
3
4
5
6
#include <iostream>

int main() { // 主函数从这里开始
std::cout << "Hello, World!"; // 你写的code
return 0; // 程序在这里成功执行完毕
}

冷知识,如果你把你的主函数写成这样:

1
2
3
4
5
6
#include <iostream>

int main() {
// 啥也不写
return 0;
}

程序不会报错,只是主函数刚开始执行就结束了。类似于你定义了一个新房子,但是房子里面什么也没有(



基本输入输出

实际上刚才已经接触过一点点了,这里我们系统性讲解一下:

cout(输出)

类似控制台输出。用来将内容输出到控制台的屏幕上:

1
2
3
std::cout << "Hello!";  // 在屏幕上输出"Hello!"
std::cout << 123; // 输出数字 123
std::cout << "The answer is: " << 42; // 输出 "The answer is: 42"

我们涉及到了左移<<运算符。把它类比于插入操作就行。

同样的有输出就少不了输入:

cin (输入)

这边就类比于控制台输入了。语句会读取用户在控制台输入的内容:

1
2
3
4
int age;
std::cout << "Please enter your age: ";
std::cin >> age; // 等待用户输入内容后按下回车
std::cout << "You are " << age << " years old.";

上面是一个非常简单的小程序,首先输出文字提示用户输入内容,然后读取用户输入的内容到变量age中,最后输出。

同样涉及到了右移>>运算符。与左移相反,把这类比于提取操作就ok。


有关命名空间

Namespace,或者叫命名空间,在上面的程序中使用std::进行表示。
现在我们先不过多讨论这一部分,而是先记住在cincout之前,带着std::命名空间声明就好。



下面是一个用到上述所有内容,编写的一个简单的问好小程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>
#include <string>

int main() {
std::string name; // 定义一个叫做"name"的变量

std::cout << "Please enter your name: ";
std::cin >> name; // 读取用户输入,存储到name中

std::cout << "Hello, " << name << "!"; // 输出

return 0
}

现在你可以试试编译这个程序并运行。程序会首先输出”Please enter your name: “。输入任何文本后回车,程序会输出一段向你问好的语句。

std::string name定义了一个类型为string的变量。有关变量的内容马上就来。



变量和数据类型

正如你所想的那样,变量就像是一个容器,可以用来存储信息。而数据类型则是定义了一个容器会存储什么样的信息。

每一个变量都会有一个名称,这个名称是你作为用户定义的。同时你也必须要声明该变量的数据类型。

数据类型用来声明该变量存储了了什么类型的数据。下面是一些常见的数据类型:

  • int (整数):顾名思义,用来存储整数。例如”-1”, “0”, “5”, “100”
  • double (浮点数):浮点数基本上可以被理解为带小数点的数字。
  • char (字符):用来存储单个字符。字母,数字符号什么的都可以。例如’a’, ‘B’等等。单个字符会被引用在单引号''中,而并非双引号。
  • bool (布尔值):存储两个状态:True 和 False。
  • string (字符串):用来存储字符序列。比如一个单词或一个句子。例如”Hello”或者”C++”。与字符不同点在于,字符串会被引用在双引号中。
    格外注意,如果我们想要使用字符串,一定要记得引用头文件#include <string>



在C++中定义变量挺简单的说是:

1
2
3
4
5
int age;
double price;
char initial;
bool isRaining;
std::string name;

先写要定义的数据类型,随后声明你想要的变量名。

变量名有以下命名规则:

  • 变量名的第一个字符必须是字母或下划线
  • 变量名只能包含三种字符:大小写字母数字下划线
  • 不能与C++预留名称冲突。差不多是不能与C++声明过的变量重名。

同时注意,变量名区分大小写,所以radiusRadius, RADIUS是三个不同的变量。



如何为变量赋值?我们可以选择在定义变量的同时为它们声明一个初始值:

1
2
3
4
5
int age = 30;
double price = 19.99;
char initial = 'D';
bool isRaining = false;
std::string name = "Alice";

或者也可以直接调用变量名,然后为其赋值:

1
2
3
4
5
age = 30;
price = 19.9
initial = 'D';
isRaining = false;
name = "Alice";



差不多理解了?下面是一个包含我们介绍过的数据类型的小程序。试试看能不能读懂是什么意思:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>
#include <string>

int main() {
int numberOfStudents = 25;
double averageGrade = 85.5;
char gradeLetter = 'B';
bool isPass = true;
std::string courseName = "Introduction to C++";

std::cout << "Course: " << courseName << std::endl;
std::cout << "Number of students: " << numberOfStudents << std::endl;
std::cout << "Average grade: " << averageGrade << std::endl;
std::cout << "Grade letter: " << gradeLetter << std::endl;
std::cout << "Pass status: " << (isPass ? "Pass" : "Fail") << std::endl; // 逻辑判断
}

std::endl是”end line”的意思。这个命令会让光标移动到下一行。
?运算符大概能这样理解:(condition ? value_if_true : value_if_false),用来直接快速运行单个判断。



常量

除了定义一个变量,你也可以定义一个常量。
有时我们需要一个在程序中永远也不会,同时也不应该会改变的值。这就是常量的作用所在。

常量类似于一个变量,但是常量的值一旦定义无法更改。这是一个固定值。

你可以使用这样的方式声明一个常量:

1
2
3
4
const int DAYS_IN_WEEK = 7;
const double PI = 3.14159;
const char YES_CHAR = 'Y';
const std::string GREETING_MESSAGE = "Welcome!";
  • const: 这个关键字告诉编译器“这是一个常量,它的值不应该被改变”。
  • 变量名 (例如DAYS_IN_WEEK, PI): 为常量命名,我们通常用大写字母并用下划线分隔单词,以便于识别它们是常量。这是一个约定。
  • 值 (例如后面的7, 3.14159): 注意定义常量时必须同时为它定义一个初始值



但是我在Python编程中没有什么常量的概念啊?!

在程序中使用const,有助于我们编写更安全,更有效的代码,同时也增加了代码的可读性。将一个变量声明为const,基本上就告诉编译器,这个变量是不可改的。
如果编译器在后期发现有语句尝试更改该值,则会返回错误。这避免了有时意外更改某些值的危险情况。

至于为什么Python没有开箱即用的常量概念,有一部分的原因为Python设计之初是一个动态类型的解释型语言。变量类型在运行时的时候才会进行检查,并调用解释器逐行执行代码。
相比C++,Python更强调程序的自由和灵活性。但不代表我们无法在Python中使用常量,相反我们一般使用“惯例和约定”。
在Python中,我们一般也全部使用全部大写的变量名来声明常量,与C++一样。不过区别在于,没有什么会阻止我们更改这个变量的值——这仅仅是一个约定罢了(



老规矩我们直接上例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>

int main() {
const double PI = 3.14159; // 将pi声明为常量...
double radius;

std::cout << "Enter the radius of the circle: ";
std::cin >> radius;

double circumference = 2 * PI * radius; // ...然后调用pi

std::cout << "The circumference is: " << circumference << std::endl;

return 0;
}

还记得我刚才说过的,更改常量的值可能会报错嘛:

如果你将程序改成这样,在声明PI之后尝试更改PI的值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>

int main() {
const double PI = 3.14159;
double radius;

std::cout << "Enter the radius of the circle: ";
std::cin >> radius;

double circumference = 2 * PI * radius;

// Change the value of PI and see what will happen
PI = 3.14

std::cout << "The circumference is: " << circumference << std::endl;

return 0;
}

试着运行一下,按理讲程序会报错。
这就是使用常量的好处了。能够有效保证某些定义好的量不被意外更改。



运算符

C++里有不少运算符。一个一个看吧:

数学运算符

数学运算符顾名思义,我觉得是数学运算符:看起来像数学运算符,用起来也是数学运算符。

  • +:加法
  • -:减法
  • *:乘法
  • /:除法
    • 注意!如果你将两个整数相除,结果则依然会是整数,小数部分会被截取。举个例子:
      5 / 2的结果是2,而不是2.5。如果我们想要输出小数,可以考虑使用两个浮点数做运算。例如5.0 / 2.0,输出为2.5
  • %:取模



比较运算符

比较运算符会比较两个传入值,随后输出布尔类型的值,也就是true或者false

  • ==:等于。用于检验两个值是否相等
  • !=:不等于
  • <:小于
  • >: 大于
  • <=:小于等于
  • >=:大于等于



逻辑运算符

这里牵扯到逻辑运算,与,或,非那堆东西:

  • &&:与运算。如果两个传入值都是true,则返回true
  • ||:或运算。如果两个传入值存在true,则返回true
  • !:非运算。输出相反的truefalse结果。



搞不懂的话试试运行这个程序吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <iostream>

int main() {
int num1 = 10;
int num2 = 5;

std::cout << "Arithmetic Operators:" << std::endl;
std::cout << num1 << " + " << num2 << " = " << (num1 + num2) << std::endl;
std::cout << num1 << " - " << num2 << " = " << (num1 - num2) << std::endl;
std::cout << num1 << " * " << num2 << " = " << (num1 * num2) << std::endl;
std::cout << num1 << " / " << num2 << " = " << (num1 / num2) << std::endl;
std::cout << num1 << " % " << num2 << " = " << (num1 % num2) << std::endl;
std::cout << std::endl;

std::cout << "Comparison Operators:" << std::endl;
std::cout << num1 << " == " << num2 << " is " << (num1 == num2) << std::endl;
std::cout << num1 << " != " << num2 << " is " << (num1 != num2) << std::endl;
std::cout << num1 << " < " << num2 << " is " << (num1 < num2) << std::endl;
std::cout << num1 << " > " << num2 << " is " << (num1 > num2) << std::endl;
std::cout << num1 << " <= " << num2 << " is " << (num1 <= num2) << std::endl;
std::cout << num1 << " >= " << num2 << " is " << (num1 >= num2) << std::endl;
std::cout << std::endl;

std::cout << "Logical Operators:" << std::endl;
bool isTrue = true;
bool isFalse = false;
std::cout << "true && false is " << (isTrue && isFalse) << std::endl;
std::cout << "true || false is " << (isTrue || isFalse) << std::endl;
std::cout << "!true is " << (!isTrue) << std::endl;

return 0;
}

运算顺序

顺序如下:

  1. ():与数学一样,我们首先计算括号。
  2. !:非运算
  3. *, /, %:乘除和取模运算
  4. +, -:加减运算
  5. ==, !=, <, >, <=, >=:比较运算符
  6. &&:与运算
  7. ||:或运算
  8. =:赋值符。(顺序为从右往左。赋值语句我们之后再说)



数据类型转换

有时我们想要将一个数据类型转换到另一种数据类型。C++为我们提供了两种转换方式,分别为隐式转换 (Implicit conversion)显式转换 (Explicit conversion)

隐式转换

当我们使用隐式转换时,C++则会自动将你的值从一种类型转换到另一种类型。通常我们想要将“较小”的数据类型转换到“较大”的数据类型时,并没有数据损失时,会使用隐式转换:

比如我们想要将int转换到double

1
2
3
int integerValue = 10;
double doubleValue = integerValue; // 在这里应用了隐式转换
std::cout << doubleValue << std::endl;

看第二行,C++自动将整数10转换到了double10.0。这个值被赋予给了doubleValue变量。
这里使用隐式转换是安全的,因为这里实际上没有牵扯任何数据损失。


再举个例子吧:

1
2
3
4
5
6
7
int intResult;
double doubleValue1 = 5.5;
int intValue2 = 3;
intResult = doubleValue1 + intValue2; // double向int的隐式转换么,我看不太行
double doubleResult = doubleValue1 + intValue2; // 但是我们可以从int隐式转换到double
std::cout << doubleResult << std::endl; // 输出为 8.5
std::cout << intResult << std::endl; // 会出错,因为你无法将double值赋予到int变量中...

doubleResult = doubleValue1 + intValue2; 中,intValue2 在加法之前被隐式转换为 double,因此结果为 double。
但是,如果不进行显式转换,则不能直接将 double 赋值给 intResult,因为这会丢失小数部分。

再看一下intResult。这一行会在编译时出现问题,因为如果尝试将 double 结果分配给 int 变量而没有进行显式转换,C++ 会将其捕获为编译时错误。

一会我们来探讨什么叫显式转换。



显式转换

当您想要强制转换数据类型,或者当 C++ 不会隐式执行转换时(例如,从“较大”类型转换为“较小”类型,这可能会丢失信息),我们则需要使用显式类型转换,也称为”Type Casting”。

我们可以选择使用比较经典的,C语言样式的显式转换:

1
2
3
double price = 29.99;
int integerPrice = (int)price // 使用C语言样式显式转换double到int
std::cout << integerPrice << std::endl;

或者我们也可以使用比较现代的C++样式进行显式转换。理论来讲这种方式会更加安全,也看起来更干净:

1
2
3
double price = 29.99;
int integerPrice = static_cast<int>(price); // 使用static_cast显式转换double到int
std::cout << integerPrice << std::endl;

两种方法都会输出29。因为我们从“较大”的数据类型通过显式转换变成了“较小”的数据类型,所以你可以发现小数部分被整个切掉了。


也可以通过显式转换将char转换到int

1
2
3
4
5
char myChar = 'A';
int asciiValue = static_cast<int>(myChar);
char backToChar = static_cast<char>(65); // 'A'的ASCII值是65
std::cout<< "ASCII value of " << myChar << " is " << asciiValue << std::endl;
std::cout << "Character for ASCII 65 is " << backToChar << std::endl;



为你提供示例小程序,看懂了就基本上代表你这块直接毕业了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>

int main() {
int integerNumber = 15;
double doubleNumber = 7.8;

double implicitConversion = integerNumber; // 隐式转换,int → double
int explicitConversion = static_cast<int>(doubleNumber); // 显式转换,double → int

std::cout << "Original integer: " << integerNumber << std::endl;
std::cout << "Implicitly converted to double: " << implicitConversion << std::endl;
std::cout << "Original double: " << doubleNumber << std::endl;
std::cout << "Explicitly converted to int: " << explicitConversion << std::endl; // 注意小数位的数据丢失

char characterA = 'A';
int asciiValueOfA = static_cast<int>(characterA); // char → int
char charFromAscii = static_cast<char>(66); // int → char (ASCII码 66 对应 'B')

std::cout << "Character: " << characterA << ", ASCII value: " << asciiValueOfA << std::endl;
std::cout << "ASCII value: 66, Character: " << charFromAscii << std::endl;

return 0;
}



程序编译与运行

首先我假设你应该会点Python,因为这是修1340之前的必修课(差不多吧)。即使你没学过Python,那如果你是来自西恩大陆,中学期间的计算机课也给你介绍过一些常见Python用法了。

C++需要编译器来编译程序,然后处理器才能运行你的C++程序。如果你之前用过Python,你大概对编译器这个概念很陌生。反倒可能听说过解释器这么个东西。

与Python不同,C++是一种编译语言。你的处理器无法直接理解C++代码,但是处理器可以理解机器码,这是一种由 0 和 1 组成的非常低级的语言。
编译器充当“翻译官”的角色。它将人类可读的C++代码转换为计算机可以直接执行的机器代码。这样你的处理器就能运行了。

Python是一种解释语言,靠的是解释器。当你运行Python程序时,解释器会逐行读取 Python 代码并直接执行。
通常不需要担心将代码编译为机器代码的单独步骤 (尽管 Python 确实会进行一些后台编译以转换为字节码,但这与 C++ 编译有本质上的不同)


编译程序带来的好处很明显。通常编译程序比解释程序的运行速度快的多,因为编译器只需要编译一次,把你的代码转换成机器码就ok。通常这样生成的机器码经过高度优化,所以处理器可以直接快速执行。这就是为什么C++经常被用来编写对性能要求较高的程序,比如游戏开发,操作系统等。

另一个好处是编译可以产生一个独立的可执行文件。比如在Windows上的exe文件。我们可以无需编译器,也无需源代码,来直接运行程序。比较利于程序的分发。



g++编译器

简单过一下如何在Linux环境下编译C++源代码:

  1. 定位工作目录和文件,首先你需要找到你要编译的C++源代码。

  2. 在命令行中运行:

1
g++ my_program.cpp -o my_program

使用g++调用g++编译器,my_program.cpp是你要编译的源文件。
-o这个flag允许你在下一个参数定义编译输出文件的文件名。在这个例子中名字就是my_program

  1. 最后运行。定位到文件当前的目录后直接输入下面的命令即可运行:
1
./my_program

注意不是my_program.cpp,这是你的源代码,不是编译后的程序。



阅读错误信息

很多小白当运行程序时,或者编译程序时碰到了问题,爆了一堆错误信息,他们一般选择看都不看。

孩子们这不对,这些文字中会包含非常有用的信息。

如果你的程序编译失败了,那么就会返回编译错误。语法错误,变量类型错误,引用错误等等都可能会导致编译失败,进而出现错误信息:

比如下面的这个情况是:

1
2
3
4
5
6
#include <iostream>

int main() {
std::cout << "Hello, World!" << std::endl // 这里末尾没带分号
return 0;
}

相似的程序会爆这样的问题:

1
2
3
4
5
hello.cpp: In function 'int main()':
hello.cpp:5:5: error: expected ';' before 'return'
return 0
^~~~~~
;

expected ';' before return,错误理由已经写在这里了,我们忘了插入分号了。

哦对了在C++中编写程序记得在每行末尾带引号。


或者这种:

1
2
3
4
5
6
7
8
9
10
11
#include <iostream>
#include <string>

int main() {
std::string message = "Hello";
int number = 123;
std::string combined = message + number; // 数据类型错误!

std::cout << combined << std::endl;
return 0;
}

相似的程序会爆这样的问题:

1
2
3
4
type_error.cpp: In function 'int main()':
type_error.cpp:6:14: error: invalid operands of types 'std::string' and 'int' to binary 'operator+'
std::string message = "Hello" + 123;
^~~~~~~~~~~~~

属于当时我们提到的数据类型错误。我们没有进行显示转换,就尝试使用+运算符把std::string类型的变量和int加在一起。这是不能被实现的。

还有一种引用错误:

1
2
3
4
5
6
7
#include <iostream>

int main() {
value = 10; // value 在被使用之前没有被声明
std::cout << value << std::endl;
return 0;
}

相似的程序会爆这样的问题:

1
2
3
4
declaration_error.cpp: In function 'int main()':
declaration_error.cpp:5:5: error: 'undeclaredVariable' was not declared in this scope
undeclaredVariable = 10;
^~~~~~~~~~~~~~~~~~

这里的问题是编译器根本找不到undeclaredVariable是什么玩意。用变量之前记得先声明!



控制流

控制流 (Control Flow),简而言之就是决定某些指令何时执行,按何种顺序执行的一种概念。
目前为止我们写过的程序都是线性的,只会按照从上到下一种方向执行。我们可以引入控制流,让我们的程序更灵活,更强大。

我打算介绍下面两种主要的控制流类别:

  • 分支 (Branching):涉及在程序中做出决策。
    人话讲:“如果此条件为真,则执行这组指令;否则,执行其他操作(或者什么也不做)”。
    我们将介绍 ifif-elseswitch 语句。
  • 循环 (Looping):也叫做迭代,涉及多次重复代码块。
    人话为:“只要此条件为真,就继续执行这组指令”,或者:“执行这组指令一定次数”。
    这里我们介绍 whilefor 循环。我们还将介绍可以修改循环行为的 breakcontinue 语句。

除了这两类,我们还会介绍一些会用在控制流中的其他语句。



分支

if语句

最基础的分支语句差不多是if语句了。if语句允许你在某个条件通过的情况下执行特定代码块。

我们一般使用if语句检查一个条件。如果条件通过,则执行与if语句相关的代码块;如果没有通过则直接跳过。

下面是一个if语句的例子:

1
2
3
if (condition) {
// 如果条件通过,则会执行的代码
}

(condition)处需要写入判断条件。键入的判断语句需要返回true或者false。一般比较常见的判断条件有==, !=, <, >, <=, >=, 或者逻辑判断语句||, &&, !



if...else语句

if...else语句建立在if语句的基础上。if...else相比if新加了一个小步骤:如果条件为真执行一段代码,如果为假执行另一段代码。

下面是一个if...else语句的例子:

1
2
3
4
5
if (condition) {
// 如果判断为真,要执行的代码
} else {
// 如果条件为假,要执行的代码
}



switch语句

switch语句稍微复杂一点。如果你想要根据单个变量的值,并从多个选项中选择一个代码块来执行时,那么我们就可以使用switch语句。

流程大概是,首先switch语句会评估一个表达式,然后将该表达式的值与标签进行比较。如果匹配某个标签,就直接执行某个标签包含的代码。

直接上例子吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
switch (expression) {
case constant1:
// 如果 expression == constant1,要执行的代码块
break; // 注意!我们要使用break来退出switch语句

case constant2:
// 如果 expression == constant2,要执行的代码块
break;

case constant3:
// 如果 expression == constant3,要执行的代码块
break;

default:
// 如果 expression 与任何一个case都不匹配,则执行default下的代码。

break; // *理论来讲这里不加break也没问题,但是我们最好保证编程范式统一
}


switch语句是个相对比较新的概念,所以在这里提供一个小程序把:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <iostream>

int main() {
int choice;

std::cout << "Menu:" << std::endl;
std::cout << "1. Option One" << std::endl;
std::cout << "2. Option Two" << std::endl;
std::cout << "3. Option Three" << std::endl;
std::cout << "Enter your choice (1-3): ";
std::cin >> choice;

switch (choice) {
case 1:
std::cout << "You selected Option One." << std::endl;
break;
case 2:
std::cout << "You selected Option Two." << std::endl;
break;
case 3:
std::cout << "You selected Option Three." << std::endl;
break;
default:
std::cout << "Invalid choice. Please enter 1, 2, or 3." << std::endl;
break;
}

std::cout << "End of menu program." << std::endl;

return 0;
}

看懂就毕业。



还没完。这里解释一下为什么switch语句要在每一个case后面带个break。看看这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>

int main() {
int number = 1;

switch (number) {
case 1:
std::cout << "Case 1 executed." << std::endl;
// break; // 如果我们将case 1的break注释掉...
case 2:
std::cout << "Case 2 executed." << std::endl;
break;
case 3:
std::cout << "Case 3 executed." << std::endl;
break;
default:
std::cout << "Default case executed." << std::endl;
break;
}

return 0;
}

运行程序你会看见这样的输出:

1
2
Case 1 executed.
Case 2 executed.

为什么输出不仅仅是Case 1 executed.?因为当Case1完成执行后,没有跳出switch语句,所以程序会继续向下执行,直到碰到Case2中输出语句下一行的break,这才结束了这次switch



循环

循环,或者叫迭代,是编写程序时最基本的控制流结构之一。

在这里主要介绍C++中两种主要的循环类型,whilefor循环。



while循环

while循环是两种主要循环类型中较为简单的一种。只要给定条件为真,它就会重复执行一个代码块。

while循环受条件控制,也就是说,只要条件成立,循环就不会停止。

只要指定条件为真,while循环就会重复执行一个代码块。

如何判断跳出时机呢?在循环的每次迭代之前,while循环都会对条件进行检查。一旦不符合条件,则会直接跳出循环。

例子:

1
2
3
while (condition) {
// 当condition为真的时候,要执行的代码块
}

如果(condition)一直为真,循环就不会停止。所以注意一下逻辑咯



for循环

while循环不同,for循环用来编写更可控的循环逻辑。一般当我们知道该循环多少次的时候,或者想要使用可控的方式遍历一串数值的时候,会用到for

1
2
3
for (initialization; condition; increment/decrement) {
// 当condition为真的时候执行的代码
}

这里需要稍微解释一下循环是如何判定的:

  • initialization:这一部分旨在循环开始时,也就是第一次循环开始之前执行一次。
    它通常用户初始化循环计数器变量,也就是一般我们常用的i
    我们可以在这里声明并初始化一个变量,也可以直接初始化一个已有的变量。

  • condition:这里是判断条件。condition是一个布尔表达式,会在每次循环开始之前进行检查,就像while循环那样。如果为假就跳出执行了。

  • increment/decrement:与Python不同,循环计数器的增减被直接集成到了定义for循环的语句中。
    这部分常用于更新循环计数器变量的值,例如在一次循环进行完后,增加或减少一次(或多次)计数器的值。


看个例子吧:

1
2
3
4
5
6
7
8
9
10
11
12
#include <iostream>

int main() {
// 使用for循环从1数到5
for (int i = 1; i <= 5; i++) { // initialization: i=1, condition: i<=5, increment: i++
std::cout << "Count is: " << i << std::endl; // 循环体: 输出循环计数器的值
}

std::cout << "Loop finished!" << std::endl;

return 0;
}

输出为:

1
2
3
4
5
6
Count is: 1
Count is: 2
Count is: 3
Count is: 4
Count is: 5
Loop finished!



其他

break

你已经见过这位兄弟了。

我们主要在switch语句中见过break,但是实际上break在其他的控制流写法中也有应用。比如循环语句。

在循环中,当break将要被执行,则它所在的循环则会被立即终止,继续执行循环后的代码。

直接解释有点抽象不如直接上例子。这是while循环的例子:

1
2
3
4
5
6
7
8
while (condition) {
// 写啥都行
if (some_condition) {
break; // 立刻终止循环
}
// 这里也是写啥都行
}
// 当break语句执行后,程序会继续执行这里的代码 (while循环外)

这是for循环的例子:

1
2
3
4
5
6
7
8
for (initialization; condition; increment/decrement) {
// 啥都行
if (some_condition) {
break; // 立刻终止循环
}
// 也是写啥都行
}
// 当break语句执行后,程序会继续执行这里的代码 (for循环外)

想必是不难理解的。



continue

break相反,continue的作用不是终止循环,而是在继续循环的基础上,跳过当前的这次循环。
对于whilefor循环来说,continue会直接重新评估循环的条件,或直接进入递增/递减步骤 (仅限for循环) ,并开始下一次迭代。

while循环:

1
2
3
4
5
6
7
8
while (condition) {
// 可写入任意代码
if (some_condition) {
continue; // 跳到下一次循环
}
// 如果continue发力了,那么这里的代码在当前这次循环不会被执行
}
// 当while循环结束,或使用break跳出了循环,开始执行这里的代码

for循环的例子:

1
2
3
4
5
6
7
8
for (initialization; condition; increment/decrement) {
// 可写入任意代码
if (some_condition) {
continue; // 跳到下一次循环
}
// 如果continue发力了,那么这里的代码在当前这次循环不会被执行
}
// 当for循环结束,或使用break跳出了循环,开始执行这里的代码



函数

怎么定义/使用函数?

我们来唠唠函数吧,每个语言必不可少的一部分。

在使用函数之前,首先我们要学会如何定义一个函数:

1
2
3
4
5
return_type function_name(parameter_list) {
// 函数主体

return return_value
}

非常简单的结构。

  • return_type告诉编译器函数在完成执行之后,返回什么样类型的值。举例说这可以是int, double, std::string或者其他数据类型。
    如果你的函数是一个步骤,也就是不会有任何返回值,那么你需要记得在return_type这里使用关键字void

  • function_name是你定义的函数的方程名,函数名按理来讲应该讲述了函数用来做什么,比如calculateSum等。没有硬性要求,但这是一个编程范式。
    说到编程范式,最好保持你的函数命名规则统一。

  • parameters_list是你在调用函数时可以使用的传入参数。通过使用参数,你可以向函数提供数据,这样就能在每次调用的时候使用不同的值。
    参数列表实际上是可选的,你的函数也可以不定义任何传入参数,留下一个空括号也没问题,具体看你需求。
    如果又要定义的参数,则你需要指定每个参数的数据类型和他们的名称,中间用逗号隔开:例如(int num1, int num2, std::string message)

  • return return_value坐落在函数主体内。这一句定义了函数的返回值,当函数执行完毕后用来返回值用的。
    return_value必须要遵守你定义的return_type类型。如果return_value的类型能够被隐式转换到return_type类型,也没必要完全一致。
    如果return_typevoid,那么一般我们使用return作为返回语句,而不是带参数的return
    如果return_type不是void,那么我们就需要保证在函数内必须至少有一个return语句能够在函数的每一条可能的执行路径中返回一个正确类型的值。人话将就是记得写return


比如我们定义一个能够将两个整数加在一起的函数,同样定义了如何调用函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>

// 定义addNumbers函数
int addNumbers(int num1, int num2) {
int sum = num1 + num2; // 在函数内计算加和...
return sum; // ...然后返回这个值
}

int main() {
int result;
result = addNumbers(5, 3); // 使用传入参数 (5和3)调用addNumbers函数,返回值会被录入到result中
std::cout << "The sum is: " << result << std::endl;
return 0;
}


当你定义了一个函数后,你就可以在程序的其他位置调用你的函数了。你可以调用后直接赋值到变量:

1
return_type result_variable = function_name(argument_list);

…或者直接在程序中调用:

1
std::cout << "Result: " << function_name(argument_list) << std::endl;

甚至用于其他函数的传入值:

1
another_function(function_name(argument_list));



函数声明

C++的函数也可以先声明,在调用,无需先定义函数体。

在目前的例子中,我们在调用函数之前都为函数设置了一个函数体,但是设想一下你在编写一个大型项目:
有时我们可能希望在main()之后或在单独的文件中定义函数。在这种情况下,就需要在调用函数之前提供一个函数原型(也称为函数声明)。

函数原型会告诉编译器函数的名称、返回类型和参数,这样即使完整的函数定义在后面出现,编译器也知道如何正确处理函数调用。

函数原型与函数头非常相似,但它以分号结束,而且没有函数体:

1
int addNumbers(int num1, int num2);

我们可以将该原型放在main()之前,然后在main()之后或另一个文件中定义完整的函数定义(包括主体)。



函数参数

我们实际上在刚才已经接触过函数传入参数了,但是实际上这也是个深坑。

首先我们需要学会区分形参与实参,在讨论函数以及如何将数据传递给函数时,经常会用到这些术语。

形式参数 (Formal Parameters),简写为形参,是在函数定义的参数列表中声明的变量
它们充当函数调用时将传入函数的值的占位符,定义了函数期望接收的输入数据的类型和名称。

1
int addNumbers(int num1, int num2);

比如在这个addNumbers的示例中,num1num2就是形参。

实际参数 (Actual Arguments, Arguments),中文简写可以叫实参,是在函数调用时传递给函数的实际值

例如在addNumbers(10, 20)的函数调用语句中,1020就是实际参数。

实参也可以是表达式,其值经过计算后传递给函数。它们可以是字面形式(如 10“Alice”),也可以是变量或更复杂的表达式。



那么了解这些,我们要深入讨论一些传递值问题了。

逐值传递 (Pass by value),是C++ 中的默认参数传递方式。

对于基本数据类型(如 int、double、char 等),C++ 将逐值传递作为向函数传递参数的默认机制,对于更复杂的类型也是如此。

那么问题来了,什么是逐值传递?

使用逐值传递时,会创建实际参数值的副本并传递给函数的形式参数。
然后,函数将使用该副本,而不是调用代码中的原始变量。这样函数内部对形式参数所做的任何更改都不会影响调用代码中的原始实际参数。

逐值传递提供了数据保护,这样函数不会意外修改作为参数传递的原始变量。


那么相反,如果我们想要将传入的数据进行更改怎么搞?

C++ 提供了其他机制:引用传递 (Pass by reference)按指针传递 (Pass by pointer)
这些机制比较先进,我们现在可以简单介绍一下,以加深认识,后期我们会提到这两位大仙。


与其传递副本,引用传递会直接传入原始变量的引用,也就是原始变量的内存地址,而不是它的分身。
如果想要指明想要使用引用传递,则需要在你的传入参数按照这样的方式定义实参:

1
void modifyByReference(int& parameterValue);


而指针是一个相对深层次的概念了,不过简单来讲就是一个指路牌,上面写着目标的内存地址。
通过按指针传递,你可以选择将指针传入函数。要使用按指针传递,你需要像这样定义形参:

1
void modifyByPointer(int* parameterPtr);

并在调用的时候使用&访问变量的地址:

1
modifyByPointer(&originalValue)



返回值

你应该已经知道返回值可以是C++的基础数据类型,比如int, std::string这种,但是我想展开篇幅讲一讲void

在上文我们已经看到void是一种返回类型。void返回类型的函数不返回任何值:执行操作,但不产生返回结果。
像之前greetUser这样的函数或向控制台打印输出的函数通常都是void函数,因为它们的主要目的是执行操作(比如显示消息),而不是计算和返回值。

这样的操作一般不叫作函数,有一个专业的名字叫做流程 (Procedure)。

当函数的主要目的是执行某些操作或副作用,而不是计算和返回特定值时,就会使用返回类型为void的函数。
void函数的常见用途包括:

  • 向控制台打印输出: 如std::cout << ....,用来向用户显示信息、菜单或结果的函数通常是 void 函数。
  • 修改通过引用或指针传递的数据结构或变量: 虽然逐值传递不会修改原始数据,但逐引用/指针传递允许函数更改自身作用域之外的数据。
    这类函数可能是无效的,因为它们的作用是修改本身,而不是返回一个值。
  • 执行设置或初始化任务: 因为配置某些东西、打开文件或设置初始条件的函数,作用在于执行设置操作,所以它们大概率不需要返回值。
  • 事件处理程序: 响应用户操作(如点击按钮)或系统事件的函数可能是无效的,因为触发这些函数是为了对事件做出反应,而不是产生计算结果。
    比如图形用户界面编程,诶就是个挺好的例子。

对于void函数来讲,使用return;实际上是可选的。
void函数会在函数体结束时自动返回,不过,我们也可以在void函数内部使用return;来按人话要求函数提前退出。例如程序捕获到错误条件,则跳出函数。



作用域和生命周期

这个词听起来有点陌生,不过当你知道作用域叫做”Scope“,生命周期是”Lifetime“,可能就能让你恍然大悟了。
二位决定了变量在代码不同部分的可访问性和存在性。就函数而言,这些概念对于理解函数内部和外部声明的变量的行为尤为重要。

那么什么是作用域?变量的作用域是指程序中可以访问(使用和修改)该变量的区域。它基本上定义了变量的 “可见性 ”或 “可达性”。

局部作用域

首先介绍局部作用域 (Local Scope),也叫做函数作用域 (Function Scope)

在函数体 (也就是大括号{}内) 声明的变量具有局部作用域,它们是该函数的局部变量。这意味着它们只能在声明它们的函数中访问。不能从函数外部(如从 main() 或其他函数)直接访问它们。

同时与其他函数或全局作用域中声明的同名变量无关。即使在不同作用域中有同名变量,它们也会被视为不同的独立变量。

比如这是一个能解释什么是局部作用域的小例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>

void myFunction() {
int localVar = 10; // localVar 在 myFunction 被定义,存在于局部作用域里
std::cout << "Inside myFunction, localVar = " << localVar << std::endl; // 在函数里访问 localVar 如你可见没啥问题
}

int main() {
myFunction();

// std::cout << "In main, localVar = " << localVar << std::endl;
// 'localVar' 没有在主程序的作用域里定义。写下这行会爆一个编译错误

return 0;
}



实际上作用域不仅仅局限于函数。任何用大括号{}括起来的代码块,包含if, for, while循环等,都可以定义作用域。
在此类代码块中声明的变量是该代码块的局部变量,在代码块之外无法访问:



全局作用域

有局部当然有全局了。
全局作用域 (Global Scope)包括任何函数或代码块之外声明的变量。也就是说,在所有函数和代码块之外声明的变量具有全局作用域。

一般来讲,这些变量会被在文件级别声明,通常位于 .cpp 文件的顶部,在main()和其他函数之外。

全局变量可从同一文件中的任何函数访问。如果能够正确使用头文件,那么你也可能从其他文件访问这些变量。

有关头文件我们后面再说。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream>

int globalVar = 5; // globalVar 在任何函数外被定义,所以它存在于全局作用域中

void myFunction() {
std::cout << "Inside myFunction, globalVar = " << globalVar << std::endl; // 访问globalVar没问题
}

int main() {
std::cout << "In main, globalVar = " << globalVar << std::endl; // 这里也是
myFunction();
return 0;
}



生命周期

变量的生命周期是指程序执行期间变量在内存中存在的时间段。它是从创建变量 (为其分配内存) 到销毁变量 (释放对应变量的内存)的时间。

在函数或者块内声明的变量,aka局部变量,具有自动生命周期
这些变量会在程序进入声明他们的函数或块的时候,被分配一个内存。而相应的函数或者块执行完毕后,会自动释放相对应的内存。

每次调用函数时,C++都会重新创建局部变量,分配内存,并在特定函数调用结束时销毁它们。下次调用同一函数时,会再次创建局部变量,作为新实例。

稍微有些抽象的一个概念,不过用例子来讲解就不是那么难了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>

void myFunction() {
int localVar = 1; // 当myFunction被调用时,localVar会被创建,分配内存
static int staticVar = 1; // 一个static类型的变量,这些变量有不同的生命周期

std::cout << "Inside myFunction, localVar = " << localVar << ", staticVar = " << staticVar << std::endl;

localVar++; // 递增两个变量的值
staticVar++;
} // 因为myFunction结束了,超出了localVar的生命周期,所以它被销毁了(内存被释放)

int main() {
myFunction(); // 分三次调用程序
myFunction();
myFunction();

return 0;
}

这个程序的输出为:

1
2
3
Inside myFunction, localVar = 1, staticVar = 1
Inside myFunction, localVar = 1, staticVar = 2
Inside myFunction, localVar = 1, staticVar = 3

可以自己试着跑跑看,不过我想稍微讲一讲static关键字。

static int staticVar = 1;这一行定义了一个static(静态)局部变量。因为我们使用了static关键字,这个变量的生命周期会与其他的有所不同:

  • 当程序执行过程中并首次达到它的生命,该变量只会被初始化一次

  • 当函数退出时,它们不会被销毁。相反它们在整个程序执行过程中一直存在于内存中。

  • 在对函数的后续调用中,它们不会被重新初始化。它们保留上一次函数调用的值。

在我们的示例中staticVar仅在第一次调用myFunction时初始化为1,而在后续调用中,它不会被重新初始化了。
往后它保留前一次调用的值,然后递增。这就是为什么我们会看到staticVar在函数调用(1、2、3)中增加,而localVar在每个函数调用开始时始终为 1。

我想这大概不是一个很复杂的概念。



简而言之,作用域与可见性和可访问性有关。
局部变量的作用域是其函数或代码块,全局变量的作用域是全局的。在编程过程中非必要尽可能不创建全局变量。

而生命周期是指在内存中的存在时间。
局部变量有自动生命周期(在函数或块进入时创建,退出时销毁)。全局变量有程序生命周期(在整个程序运行过程中存在)。
static静态局部变量只需初始化一次,并在函数调用中持续存在。



数组

我假设你拥有Python的编程经验,至少懂得什么是数组。

首先说明,数组 (Array)是一块连续的内存块,可保存固定数量的相同数据类型的元素。

在C++中,使用数组之前需要提前定义:

1
data_type array_name[array_size];

要在 C++ 中使用数组,首先需要声明数组。 声明数组时,需要指定

data_type说明数组将存储的元素类型,如intdoublecharstd::string 等。
array_name声明数组变量的名称。记得遵循变量命名规则!
array_size声明数组将容纳的元素个数。使用数组名称后的方括号[]指定。

看几个例子:

1
2
3
4
5
6
int numbers[5];
double temperatures[10];
char letters[26];
std::string names[3];

// 记住[]内声明的是数组大小!



那太好了,我们该怎么赋值呢?

与其他变量相同,你可以在初始化阶段赋值:

1
2
3
4
5
6
7
8
9
data_type array_name[array_size] = {value1, value2, value3, ..., valueN};

int scores[5] = {85, 92, 78, 95, 88};
double prices[3] = {19.99, 24.50, 12.75};
char vowels[5] = {'a', 'e', 'i', 'o', 'u'};
std::string weekdays[7] = {"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"};

int smallArray[3] = {1, 2};
int evenSmallerArray[3] = {};

也可以在声明后再赋值。


你可以通过使用索引(位置)访问数组元素来初始化或修改数组元素。和Python一样,在 C++ 中,数组索引从0开始。

数组的第一个元素位于索引0,第二个元素位于索引1,第三个元素位于索引2,依此类推。对于大小为n的数组,有效索引从0n-1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
array_name[index] = value; // 向索引位置赋值
value = array_name[index]; // 读取索引位置的值
// 注意在非声明阶段,方括号的作用就转变成了索引地址


int numbers[5]; // 初始化存储int数据类型,长度为5的数组

numbers[0] = 100; // 赋值流程
numbers[1] = 200;
numbers[2] = 300;
numbers[3] = 400;
numbers[4] = 500;

std::string colors[4];
colors[0] = "Red";
colors[1] = "Green";
colors[2] = "Blue";
colors[3] = "Yellow";



有数组能忍住不遍历的都是神人了。在C++你可以通过这样的方式实现如此操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream>

int main() {
int numbers[5] = {10, 20, 30, 40, 50};
int arraySize = 5; // 在C++中,最好一并存储数组的大小

std::cout << "Elements of the array are:\n";
for (int i = 0; i < arraySize; ++i) { // 从索引 0 遍历到arraySize,但不包括5
std::cout << "Element at index " << i << ": " << numbers[i] << std::endl; // 访问内容
}

return 0;
}



一般数组可以怎么去用呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>

int main() {
const int NUM_TEMPS = 5; // 使用变量定义数组的大小,是很好的coding习惯
double temperatures[NUM_TEMPS];
double sum = 0.0;

std::cout << "Enter " << NUM_TEMPS << " temperature values:\n";
for (int i = 0; i < NUM_TEMPS; ++i) {
std::cout << "Temperature " << i + 1 << ": ";
std::cin >> temperatures[i]; // 读取温度,随后存储到第i个索引位置
sum += temperatures[i]; // 加和
}

double averageTemperature = sum / NUM_TEMPS; // 计算平均

std::cout << "\nEntered temperatures are:\n";
for (int i = 0; i < NUM_TEMPS; ++i) {
std::cout << "Temperature " << i + 1 << ": " << temperatures[i] << std::endl; // 输出
}

std::cout << "\nAverage temperature: " << averageTemperature << std::endl; // 输出

return 0;
}



实际上你也可以声明多维数组:

1
2
3
4
5
6
7
8
9
data_type array_name[number_of_rows][number_of_columns];

int matrix[3][4]; // 声明一个叫做'matrix'的,含有3行4列的2D数组
double grid[5][5]; // 以此类推
char chessboard[8][8];
std::string gameBoard[3][3];

int cube[3][3][3]; // 或者声明一个三位数组
double space[10][10][10][10]; // 甚至四维

多维数组的赋值方式与一维少许不同:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
data_type array_name[number_of_rows][number_of_columns] = {
{row1_value1, row1_value2, ..., row1_valueN}, // 第一行
{row2_value1, row2_value2, ..., row2_valueN}, // 第二行
...
{rowM_value1, rowM_value2, ..., rowM_valueN} // 第M行
};


int partialMatrix[3][3] = {
{1, 2}, // 第0行设置为:matrix[0][0]=1, matrix[0][1]=2, matrix[0][2]=0 (留空为0)
{3}, // 第1行设置为: initializes matrix[1][0]=3, matrix[1][1]=0, matrix[1][2]=0
{} // 第2行初始化所有值为0
};

// 或者你可以声明一个空多维数组:
int zeroMatrix[2][2] = {}; // 所有元素都会被默认设置为0



杂七杂八

杂七杂八不代表不重要,所以竖起耳朵听好了。



引用传递

Pass-by-reference。按引用传递可能不是很好的翻译方式,但是能搞清楚我指代的是什么就ok。

众所周知,C++ 默认使用逐值传递。这意味着当您将参数传递给函数时,函数会复制该值,并使用这个值的副本操作。

函数内部的任何更改都只会影响副本,而不会影响原始变量。

而引用传递不是传递值的副本,而是传递一个别名或对原始变量本身的引用,你可以把引用看作原始变量的另一个名称或外号。

它不是一个单独的副本,只是以不同的方式引用存储原始变量的同一内存位置。

当函数参数被声明为引用时,它就直接与函数调用中传递的实际参数相关联。因此,在函数内部对引用参数执行的任何操作都会直接影响调用代码中的原始变量。

实际上这些概念在前面都已经解释过了。



语法定义之前讲过了我没听清楚怎么办

那么我告诉你,在 C++ 中,要将函数参数声明为引用,需要在函数定义的参数列表中的数据类型后使用&符号。就好比:

1
2
3
4
return_type function_name(data_type& parameter_name) { // 留意'data_type&'中的'&'
// 函数体
// ... 此时调用的参数会被直接更改
}

这就是声明引用参数的语法了。data_type后面的&符号表示参数名称是一个引用。

当你调用一个带有引用参数的函数时,函数调用本身的语法与按值传递的语法完全相同;你只需将变量名称作为参数传递,剩下的事情交给&即可。


看个小例子。加入我们想要写一个调换两个数字的小程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>

void swapValues(int& a, int& b) { // 'a' 和 'b' 按照引用传递进函数
int temp = a;
a = b;
b = temp;
std::cout << "Inside swapValues function: a = " << a << ", b = " << b << std::endl; // 展示函数干的好事
}

int main() {
int num1 = 10;
int num2 = 20;

std::cout << "Before swap: num1 = " << num1 << ", num2 = " << num2 << std::endl;

swapValues(num1, num2); // 调用函数, 并传入参数
//

std::cout << "After swap in main: num1 = " << num1 << ", num2 = " << num2 << std::endl; // 检查一下函数是否工作正常

return 0;
}

程序的输出应该为:

1
2
3
Before swap: num1 = 10, num2 = 20
Inside swapValues function: a = 20, b = 10
After swap in main: num1 = 20, num2 = 10



说得好。那么我们什么时候该使用按引用传递?或者说,我们使用按引用传递有什么好处?

  • 直接修改原始参数:最显而易见的原因是我们可以改变原始变量的值。刚才的swapValues就是一个典型的例子,目的就是修改原始变量。

  • 避免复制大型对象:这不是什么编程初期常需要理解的概念,但是你确实需要去理解一下这个有关编程范式的问题:
    对于大型数据结构,比如数组、矢量、类对象 (一会会提到),通过值传递可能效率很低,因为它涉及到创建整个大型对象的副本。
    而使用引用传递效率更高,因为它避免了复制;函数直接使用原始数据。

    但对于intdouble这种简单的数据类型,性能上的差异通常可以忽略不计,但对于较大的数据,这种差异就很重要了



指针传递

是不是之前也提过了…值得我们更细节的说一说这个东西:

首先你要知道什么是指针。指针 (Pointer)是保存另一个变量的内存地址的变量。

程序中的每个变量都驻留在计算机内存中的特定位置,内存位置由地址表示,地址一般是一个数值。

指针做的就是存储这个内存地址,看起来像是“指向”另一个变量的内存位置。


定义一个指针很简单:

1
data_type* pointer_name; // 定义一个指针数据类型变量

data_type还是老样子,用来指定要使用的数据类型;

*星号代表pointer_name是一个指针变量;

然后pointer_name代表变量名。

例如:

1
2
3
int* ptrToInt;       // ptrToInt 是一个能够指向int数据类型的指针
double* ptrToDouble; // ptrToDouble 是一个能够指向double数据类型的指针
char* ptrToChar; // ptrToChar 同理


你有没有想过我们为什么要在按引用传递使用&符号?当&放在变量名前的时候,你会获取到该变量的内存地址:

1
2
3
4
int number = 25;
int* pointerToNumber;

pointerToNumber = &number; // 将number的内存地址赋值给pointerToNumber

*的作用正好相反,用来解析内存地址到它所对应的值。我们将这一步叫做”Dereference”。

当我们在指针变量名称前加上*时,它会“取消引用”指针,意味着它会提供指针所指向的变量的值:

1
2
3
int value = *pointerToNumber; // 将pointerToNumber取消引用,拿到内存地址对应的值 (也就是刚才number中的值)

// 现在value会存入number的值,也就是25

现在你应该大彻大悟了。接下来我们讲一讲指针传递。



在指针传递中,我们不传递值或引用,而是将变量的内存地址传递给函数。
在声明阶段,我们使用data_type*的形式定义形参:

1
2
3
4
return_type function_name(data_type* parameter_name) { // 注意形参的定义方式 'data_type*'
// 函数体
// 记得在函数体内,使用'*parameter_name'来取消引用,访问地址所对应的值。
}

因为形参要求传递进一个内存地址,所以在调用的时候…

1
function_name(&variable_name);

…记得使用&,得到变量的内存地址。


下面端上小例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>

void incrementByPointer(int* numberPtr) { // 'numberPtr' 是一个指向 int 数据类型的指针
std::cout << "Inside incrementByPointer, value before increment: " << *numberPtr << std::endl; // 首先Dereference

*numberPtr = *numberPtr + 1; // Dereference,然后更改值

std::cout << "Inside incrementByPointer, value after increment: " << *numberPtr << std::endl;
}

int main() {
int myValue = 100;

std::cout << "Before function call, myValue = " << myValue << std::endl;

incrementByPointer(&myValue); // 调用函数。注意参数的传入方法!

std::cout << "After function call, myValue = " << myValue << std::endl; // 看看函数是否正常工作

return 0;
}

你将会得到:

1
2
3
4
Before function call, myValue = 100
Inside incrementByPointer, value before increment: 100
Inside incrementByPointer, value after increment: 101
After function call, myValue = 101



那么什么时候要使用指针传递?是不是感觉指针传递能做的事情,引用传递和按值传递也能解决?

确实在现在的 C++ 中,当主要目标是修改参数时,引用传递通常比指针传递更受欢迎。在这种情况下,引用传递通常被认为在语法上更安全、更简洁,因为:

  • 语法更简单: 使用逐参传递时,可以直接使用参数名(就像普通变量一样)来修改原始值。而使用逐指针传递时,总是需要使用反引用操作符*来访问和修改被指向的值,这可能稍显不便。
  • 安全性赫赫,喜欢我空指针吗
    C++ 中的引用通常总是指向一个有效的对象,所以它们不能像指针那样为“空”或“未初始化”。
    好埃ver,另一方面,指针可以为空,也就是我们常说的nullPointer,因此在取消引用指针之前,需要小心检查指针是否为空,以避免程序崩溃。

但是实际上指针传递仍然是必不可少的,并常用于多种情况:

  • 动态内存分配: 指针是处理动态内存分配的基础 (比如在运行时使用 new 和 delete 来分配和删除内存)

  • 数据结构和算法: 在实现涉及内存地址操作的各种数据结构 (例如链表、树等) 和算法时,我们大量使用指针。

  • 与 C 代码和系统库交互: 许多旧的 C 库和系统级 API 广泛使用指针,而 C++ 经常需要与这些代码交互。

  • (可选参数指示): 在某些coding风格中,通过指针传递(pass-by-pointer)在风格上明确表示函数可能会修改传递给它的参数。
    参数类型中的*可以作为 “该参数可能会被函数修改 ”的视觉提示。不过,逐参传递和良好的文档也能达到清晰的效果。

  • 空指针作为有效值: 指针可以设置为nullptr,或旧 C++ 中的NULL,这是一个特殊的值,表示指针当前并不指向任何有效的内存位置。
    这可以用来表示 “可选”参数,或表示指针可能并不总是指向有效对象。相反,一般来说,引用不能以同样的方式 “为空”。



好了这些内容对于Module 3应该足够了。可能会有不少超出课件的内容,不过能知道多一点也是好事。



Module 4: Makefile, Programming Style, Basic Debugging

实际上我搞错了。原来C++的内容会一直持续到本课程结束,所以我实际上没必要把几乎所有东西都在上一个Module展开的。

不过展开也没啥大问题,就当个介绍引入吧。



Makefile

分开编译 (Separate Compilation)

你可以在项目中选择单独编译单个文件,不过使用分开编译的好处在于,你可以将程序不同的部分在不同的地方定义好,然后在最后组装到一起。

在C++中,分开编译就像是把你程序的不同功能,例如不同类型的函数或者类,写在不同的.cpp文件里。

不如我们来看个例子吧:



例如我们有一个能够读取两个整数,并输出它们的最大公约数的程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// gcd_single.cpp
// This program finds the GCD of two numbers
#include <iostream>
using namespace std;

int gcd(int a, int b);

int main() {
int a, b, c;
cout << "Please input two positive numbers: ";
cin >> a >> b;
c = gcd(a, b);
cout << "GCD is " << c << endl;
}

// for simplicity, we assume both inputs to be positive

int gcd(int a, int b) {
while(a != b) {
if(a > b) {
a -= b;
} else {
b -= a;
}
}
return a;
}

```

前四行是程序的Header。
`int gcd(int a, int b);` 是方程的声明,但不是定义。

主程序由`int main() {}`包裹,后面的`int gcd(int a, int b) {}`是方程`gcd`的定义。

嗯程序写的挺好,那么我们该如何去运行呢:

```bash
$ g++ -pedantic-errors -std=c++11 gcd_single.cpp -o gcd_single

$ ./gcd_single
Please input two positive numbers: 18 24
GCD is 6

那么有人就可能在想了,如果按照分开编译的思路去重构该程序,是不是可以将gcd程序分离出来到一个新的.cpp文件中?

事实证明你可以:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// gcd.cpp
#include <iostream>
#include "gcd.h"
using namespace std;

// for simplicity, we assume both inputs to be positive
int gcd(int a, int b) {
while(a != b) {
if(a > b) {
a -= b;
} else {
b -= a;
}
}
return a;
}

我们将gcd的函数体分离出来,到一个全新的文件gcd.cpp中。

如果想要允许其他.cpp文件调用gcd.cpp,你需要先像下面一样定义一个头文件(Header file)

为了让分开编译的代码能够互相使用,我们需要用到头文件。可以把头文件想象成一份“合同”或者“声明”。

头文件差不多是用来告诉其他文件:“bro,这个.cpp文件里有这些函数或者其他东西,你可以直接调用。”

比如,如果你的geometry.cpp文件里有一个计算面积的函数calculateArea,你会在geometry.h文件里声明这个函数。然后,任何想用calculateArea的文件只需要包含 geometry.h就可以了


首先头文件内必须要先声明gcd函数的存在,才能告诉其他文件你可以使用gcd函数:

1
2
3
4
5
6
7
// gcd.h
#ifndef GCD_H
#define GCD_H

int gcd(int a, int b);

#endif

这是课本上提供的写法。我比较倾向于使用下面的方法:

1
2
3
4
// gcd.h
#pragma once

int gcd(int a, int b);

这两种写法有什么区别?为何除了函数定义的哪一行,还要多写类似#ifndef或者#pragma once之类的其他内容?

首先我们先从你的代码如何转变为程序,也就是程序的编译过程说起。


编译说白了有点像是进行一边“翻译”,把人能够读懂的代码翻译成机器能读懂的机器码。

  • 预处理 (Pre-processing):

首先进入预处理。这一步可以被看做是“准备”阶段。预处理器会查看你的代码,寻找以 # 开头的特殊指令(比如 #include)。
例如,#include <iostream> 告诉预处理器把iostream库的代码带进来,这样你就可以使用coutcin了。
这个阶段的输出是经过修改的源代码。

  • 编译 (Compilation):

编译是我们主要的翻译阶段。编译器会把预处理过的代码翻译成汇编语言。
汇编语言是一种比你的 C++ 代码更低级的语言,更接近机器代码,但仍然可以被人类阅读。

  • 汇编 (Assembly):

接着汇编器会把汇编语言代码转换成机器码。机器码是你的电脑处理器能够直接理解的二进制代码。
汇编器的输出是目标文件 (例如,myprogram.o)。

  • 链接 (Linking):

在最后的组装阶段。链接器 (Linker) 会把所有生成的目标文件合并成一个单独的可执行程序。这些文件可能来自不同的源代码文件。
它还会把程序用到的库(比如 C++ 标准库)链接进来。



那么那些额外的语句,实则在刚才的预处理已经介绍过了。我们将这些额外的语句叫做保护符 (guards)。保护符的作用是防止我们多次引用同样的内容。

可以想象一下,你的项目下有好几个文件,都引用了同一个头文件。如果这些文件又被包含进另一个文件里,那么这个头文件就会被包含好多次,这可能会导致错误。
包含保护符就是用来避免这种情况的,这玩意确保一个头文件的内容只会被处理一次,不管它被包含了多少次。

头文件是怎么被使用的?

实则简单粗暴。在编译阶段,头文件会被直接暴力复制进当前文件的开头。也就是直接ctrl+c/v。如果包含多次的话则可能会出现问题。



最后想要使用改写后的程序结构,我们只需要在主程序内引用头文件即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
// gcd_main.cpp
// This program finds the GCD of two numbers
#include <iostream>
#include "gcd.h"
using namespace std;

int main() {
int a, b, c;
cout << "Please input two positive numbers: ";
cin >> a >> b;
c = gcd(a, b);
cout << "GCD is: " << c << endl;
}

经过下面的编译步骤:

1
$ g++ -pedantic-errors -std=c++11 gcd_main.cpp gcd.cpp -o gcd

你的代码依然可以被正常运行。



那么我们为什么要采用分开编译?分开编译究竟好在哪了?

  1. 能够独立编写编译源文件

    也可能是最主要的一点吧。我们可以在不同的文件中编写程序的不同部分。例如你就可以把logger写在logger.cpp文件里,而不用把所有东西都塞到主程序中。

  2. 测试目标代码

    使用分开编译,我们就可以将一个单独的.cpp文件编译成目标文件,然后再具体测试这部分特定的代码。
    想象一下如果我们要测试一个塞满各种东西的主程序,呃呃

  3. 节省重新编译时间

    如果你根据需求只是修改了一个.cpp文件中的一个小部分,那么我们没有必要重新编译整个项目。我们只需要重新编译修改过的文件,然后叫linker来重新链接一下就好了。
    加入你在写一个操作系统。如果没有用到分开编译,那么重新写一个小小的地方就要重新编译一整个系统。一下来可能就是好几个小时了。

  4. 允许以目标代码的形式提供类实现,而不公开源代码

    如果你编写了一个库或者一个软件,但是不想做到100%开源。你想让其他人使用你的内容,但是不想让别人访问到你的源代码。这时候你可以使用分开编译,把你的程序以目标代码的形式提供。

    如果用户想要使用你的软件,自己调用linker把目标代码串起来就好了。

    例如在g++中你可以使用编译器的-c flag来创建目标文件:

    1
    $ g++ -c myFile.cpp

    这一步会创建一个名为myFile.o的目标文件。

    一旦我们拥有所有的目标文件,就可以再次使用g++把它们连起来:

    1
    $ g++ file1.o file2.o -o myProgram

    这会告诉g++编译器链接file1.ofile2.o,创建一个名为myProgram的可执行程序。



Make工具 (Make Tool)

在讲解Make工具之前,我想先介绍一下什么叫文件依赖。

在一个有很多文件的项目里,一些文件可能会依赖于其他文件。例如一个.cpp文件可能会包含一个头文件。如果你修改了这个头文件的内容,那么包含它的.cpp文件可能会需要重新编译。

而随着项目规模的扩大,文件的数量以及它们之间的关系会变得非常复杂。

接下来我们就可以用到Make工具了。Make非常强大,会帮助你自动化重新编译和链接程序的过程:Linux 中的make工具在某些源文件更改时智能地重新编译和链接文件。

使用make工具可以帮助你避免每次修改代码后都需要手动输入所有的g++编译器命令。

Make工具使用一个名字叫做Makefile的文件(没有后缀)来获取文件的依赖信息,这个文件包含了make用来理解你的项目结构以及如何构建它的规则。

Makefile文件不是make生成的,而是你写的。

按照刚才的例子,它看起来像这样:

1
2
3
4
5
6
7
8
9
10
11
#This file must be named Makefile
#Comments start with #

gcd.o: gcd.cpp gcd.h
g++ -c gcd.cpp

gcd_main.o: gcd_main.cpp gcd.h
g++ -c gcd_main.cpp

gcd_main: gcd.o gcd_main.o
g++ gcd.o gcd_main.o -o gcd_main

正如你所见一个Makefile由一系列的规则组成。每个规则指定了如何构建一个特定的目标。包含:

  • 目标 (Target): 要生成的文件

    这是make将视图创建的文件。例如一个目标文件或者一个可执行文件。

  • 依赖 (Dependencies):目标所依赖的文件

    代指如果我们要创建目标文件,我们会需要的其他内容。
    如果任何一个依赖文件都比目标文件新,那么目标文件就必须要重新构建。

  • 命令 (Commands):从依赖项生成目标的命令

    每个命令都必须要一个Tab字符开头 (不是空格!),这些是make来构建目标文件所要执行的实际命令,例如g++…。

差不多看起来就像是

1
2
<Target>: <Dependency1> <Dependency2> <...>
<Commands>



要使用make工具,我们就可以直接使用make gcd_main命令来生成指定的目标:

1
2
3
4
$ make gcd_main
g++ -c gcd.cpp
g++ -c gcd_main.cpp
g++ gcd.o gcd_main.o -o gcd_main

make工具的工作流大概是:首先检查目标的依赖项。

make 首先查看你指定的目标的依赖项。如果任何一个依赖项本身也有依赖项,make 会继续向下检查(也就是递归),确保所有依赖项都是最新的。

怎么检查最新的?make 使用时间戳来判断一个文件是否需要重新构建。如果一个依赖文件的修改时间比目标文件新,那么目标文件就会被认为是过时的。

如果目标不是最新的,那么 make 会执行 Makefile 中与该目标关联的命令。构建一个最新的目标。



接下来介绍几个新东西。

Makefile变量 (Makefile Variables),基本上类似于“快捷方式”,让你的Makefile更易于阅读和维护。

我们可以在Makefile中定义变量以避免重复键入文件名。也就是说,如果你在Makefile中需要多次使用同一个文件名或编译器选项,你可以把它存储在一个变量中:

1
2
3
4
5
TARGET = gcd_main
OBJECTS = gcd.o gcd_main.o

$(TARGET): $(OBJECTS)
g++ $(OBJECTS) -o $(TARGET)

也就是使用变量替换了。和下面的写法没有区别:

1
2
gcd_main: gcd.o gcd_main.o
g++ gcd.o gcd_main.o -o gcd_main

要使用变量的值,只需要将变量名放在圆括号中,并在前面加上美元符号:$(MY_VARIABLE)



同时make也提供了一些特殊的内置变量,可以在我们的Makefile中使用它们。例如:

  • $@代表目标 (Target)

    这个变量会被替换为目标地名称。

  • $^代表依赖项列表 (Dependency list)

    这个变量将会被替代为目标所有依赖项的列表

  • $<代表依赖项列表中最左边的项

    这个稍微特殊一点:会被替换为依赖项列表中的第一个依赖项的名称。

上改写例子:

1
2
3
4
gcd_main.o: gcd_main.cpp gcd.h
g++ -c $<
gcd_main: gcd.o gcd_main.o
g++ $^ -o $@

和这个同理:

1
2
3
4
gcd_main.o: gcd_main.cpp gcd.h 
g++ -c gcd_main.cpp
gcd_main: gcd.o gcd_main.o
g++ gcd.o gcd_main.o -o gcd_main



伪目标 (Phony Target)是一个全新的概念。伪目标是一种定义你总是想让 make 执行的操作的方式,即使当前目录下存在同名的文件。

通常,make 会检查目标文件是否存在以及是否是最新版本。但是对于伪目标,make 总是会执行相关的命令,而不管是否存在同名的文件。

举个例子看看吧:

1
2
3
4
5
6
7
# 续刚才的makefile文件接着往下写:  

clean:
rm -f gcd_main gcd.o gcd_main.o gcd.tgz
tar:
tar -czvf gcd.tgz *.cpp *.h
.PHONY: clean tar

当我们声明了目标clean,即使我们当前的文件系统下没有叫做clean的文件,rm -f gcd_main gcd.o gcd_main.o gcd.tgz命令仍然会被执行:只需要键入make clean即可。make tar同理。

但是这个用法有一个小陷阱。假如文件系统下真的有clean文件怎么办?make会发现我们的clean文件是最新的,所以到最后你的命令不会被执行。这就是为什么我们要用到伪目标:

通过.PHONY声明cleantar是一个伪目标,即便真的有一个最新的同名文件,你的命令也会被执行。



不如来看个最终示例如何:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
FLAGS = -pedantic-errors -std=c++11 

gcd.o: gcd.cpp gcd.h
g++ $(FLAGS) -c $<

gcd_main.o: gcd_main.cpp gcd.h
g++ $(FLAGS) -c $<

gcd_main: gcd.o gcd_main.o
g++ $(FLAGS) $^ -o $@

clean:
rm -f gcd_main gcd.o gcd_main.o gcd.tgz

tar:
tar -czvf gcd.tgz *.cpp *.h

.PHONY: clean tar
1
2
$ ls
Makefile gcd.cpp gcd.h gcd_main.cpp
1
2
3
4
$ make gcd_main
g++ -pedantic-errors -std=c++11 -c gcd.cpp
g++ -pedantic-errors -std=c++11 -c gcd_main.cpp
g++ -pedantic-errors -std=c++11 gcd.o gcd_main.o -o gcd_main
1
2
$ ls 
Makefile gcd_main gcd.cpp gcd.h gcd.o gcd_main.cpp gcd_main.o
1
2
3
4
5
$ make tar 
tar -czvf gcd.tgz *.cpp *.h
gcd.cpp
gcd_main.cpp
gcd.h
1
2
$ ls 
Makefile gcd_main gcd.cpp gcd.h gcd.o gcd.tgz gcd_main.cpp gcd_main.o
1
2
$ make clean 
rm -f gcd_main gcd.o gcd_main.o gcd.tgz
1
2
$ ls 
Makefile gcd.cpp gcd.h gcd_main.cpp

到这里这一小节完结。

CMake更强大,能够用来生成Makefile,不过暂且不表。



Programming Style

接下来我们讲一讲编程范式。

对于C++来讲,我们可以前往这个链接:

https://google.github.io/styleguide/cppguide.html

来查看C++的完整编程范式。

当然网址里面包含巨量的编程范式,作为C++编程新手,这里列举一些比较初级的编程范式:



命名规则

  • 基本命名规则

对于C++中的所有名称,都必须具有实际意义,并且使用ASCII编码定义。

避免使用缩略词或者缩写,除非这些缩写广为人知。例如HTML, CSS。

可读性应该强于简洁性。如果你可以选择将一个变量重新命名,应该优先考虑可读性。



  • 文件名命名规则

对于头文件或其他实现文件,需要按照这样的格式命名:

lowercase_with_underscores.h / .cc

不是说必须要叫做”lowercase_with_underscores”,而是文件需要使用小写字母,并且单词之间使用下划线隔开。



  • 类型命名规则

类名,结构名和类型名应该使用每词首字母大写命名格式,也就是UpperCamelCase

尽量使用简洁但具有描述性的名称,如MyClass,并避免使用过于通用的术语。



  • 变量命名规则

对局部变量和类数据成员使用小写带分号的变量名,也就是lowercase_with_underscores,和文件名一样。

类成员变量以 m_开头,以示区别。例如m_variable



  • 常量命名规则

常量应该使用全大写字母,并使用下划线命名:

ALL_CAPSMAX_SPEED



  • 函数命名规则

函数的话应当使用每词首字母大写的形式命名,和类型一样也是UpperCamelCase。例如:

ComputeValue



  • 命名空间命名规则

命名空间是Namespace,命名空间的命名和我们讲的命名规则关系不大。

我们使用小写字母,或使用下划线来命名:

lowercase或者lowercase_with_underscores都可以。

我们通常根据项目名称对其进行唯一命名。



  • 枚举器命名规则

使用UpperCamelCase来命名枚举器。比如:Color

同样,枚举中的值也应使用大写。例如 Red, Blue



  • 宏命名规则

我们使用带下划线的全大写:ALL_CAPS_WITH_UNDERSCORES



注释命名规则

注释相对来说规则没有那么严格,你可以使用//,也可以使用/* */,但是//更加常用。

主要的原则是写的注释要清晰,明了,并且风格一致。如果你选择都使用//,那就都用//,最好不要混用。



空格还是Tab?

在缩进时,最好只使用空格,并每次缩进 2 个空格。

我们使用空格进行缩进。不要在代码中使用制表符,也就是Tab。您应该将编辑器设置为在按下制表符键时发出空格。



Basic Debugging

之后的章节中我们会讲到如何使用Debugger,但是在这里简单探讨一下,使用Debugger之前我们应该如何测试我们的程序。

你可以使用黑箱测试法。输入一个数字,来看看输出是否达到你的预期。如果你的程序不会要求输入,那就想办法看看输出是否合理。

在测试的时候,可以使用一些比较难绷的输入值,可能把你程序搞爆的那种输入值。比如你的输入要求是一个整数,那么输入小数程序会不会检测到并处理,会不会有问题,字符串呢?测试传入值后也可以测试正常传入值,来看看输出的值是否符合预期。

另外我们也可以在程序中插入logger,也就是隔着几步插一个printf或者cout,来向控制台输出信息。这样你就有方法知道程序有可能在哪里出了问题。

最后在编写程序的过程中,可以使用预防性编程,来在源头解决问题。这样就不会把大部分精力浪费到测试阶段了。在编程的过程中脑子里想着,我需要让程序在最糟糕的情况下成功运行,也就是尽可能多的考虑到边界情况。比如说用户可能不知道要求输入数字而非字符串,那么你就需要在设计的时候挡下字符串输入。



Module 5: Functions & Recursion

方程和递归属于是编程里面很基础的一部分了。


函数

首先来讲讲函数。

想象一下,我们面临一个例如盖房子的艰巨任务。我们该如何去规划我们的工作流?

我们在使用函数的时候,最好采用自上而下的设计理念。该方法认为,与其试图一次性建好整栋房子,不如将其分解成更小、更易于管理的任务。
类似的小任务也许是打地基、砌墙、盖屋顶、安装管道等等。

在编程中,尤其是在使用 C++ 编程时,我们也在做同样的事情。当我们遇到一个复杂的问题时,我们会将其分解成更小、独立的代码单元,这些单元被称为函数。你可以将函数视为主程序中的小程序。每个函数都被设计用于完成一项特定的任务。

“分而治之”的理念意味着你将大问题“划分”成更小的“可攻克”的子问题,而你解决这些子问题时编写的代码就叫做函数。

在 C++ 中,主函数是程序开始运行的地方。从main()函数可以调用其他函数来执行这些较小的任务。

那么为什么使用函数是一个好主意?

  • 更易于构建和调试
    编写和测试一个只执行单一功能的小函数比编写和调试大量代码块要容易得多。如果出现问题,我们大致知道该去哪里查找,然后消灭bug。

  • 支持并行工作
    在大型项目中,不同的程序员可以同时处理不同的函数,从而加快开发速度。

  • 促进代码复用
    如果你编写了一个函数来执行特定的计算,则可以在程序的不同部分甚至其他程序中使用相同的函数,而无需重写代码。

  • 提高可读性
    main() 函数仅调用其他命名明确的函数时,程序的整体流程将变得更加易于理解。它将复杂的细节隐藏在各个函数中。



预定义函数 & 自定义函数

既然我们现在已经理解什么是函数,那么我们就可以展开聊一聊在C++中两大类函数。

预定义函数 (Pre-defined Function)

我们可以把预定义函数这些看作别人为你打造好,并放进工具箱(在编程中我们称之为库)里的工具。C++ 自带许多这样的现成函数,能够完成常见的任务。

常见的预定义函数,诸如计算一个数的平方根sqrt()、将一个数提升到某个幂次pow()、生成随机数rand()或求一个数的绝对值abs()等都是预定义的函数。

这些预定义的函数被组织到某个库中。要使用库中的函数,我们需要在 C++ 代码的开头包含一个特殊的文件,称为头文件。这些头文件包含声明,向编译器说明该库中可用的函数。

比如你需要在文件的开头像这样引用一个头文件,就可以使用sqrt()pow()

1
#include <cmath>

同理你需要引用这个头文件才能使用rand()abs()

1
#include <cstdlib>



在使用预定义函数之前,我们首先需要知道并清楚这些东西:

  • 你的函数的期望输入。例如sqrt()会需要一个数字来作为输入,而不是字符。
  • 你的函数产出的期望输出。例如sqrt()会返回输入的平方根。

了解预定义函数的最佳方法是阅读c++标准库的文档。像cplusplus.com这样的网站就是很好的资源。



与预定义函数相反就是自定义函数了。

自定义函数是程序员(也就是你)自己创建的,用于执行预定义函数无法执行的特定任务。

比如当你有需要多次使用的特定逻辑片段,或者当你想将程序分解成更小、更有条理的部分时,你就需要定义这些函数。

一般我们在定义一个新函数时,我们需要按照下面的流程考虑:

  1. 需要什么样的输入才能工作?例如我们可能需要进行两个数字的相加。
  2. 需要函数产生什么样的输出? 如果我们想要将两个数字相加,那么我们就该输出两个数字的和。
  3. 函数将遵循哪些逻辑从输入获得输出?虽然在这个例子里就是简单的把两个数相加,那么在更复杂的函数里是不是需要更进一步经过更多复杂的处理?

经过这三步你就可以大概理清楚如何定义,并如何编写这个函数了。



函数定义,调用和声明

现在我们深入了解创建和使用自己的函数的具体细节。

函数定义

函数定义告诉编译器函数是如何工作的。这就像为特定任务提供配方。它有两个主要部分:

函数头是函数定义的第一行。它告诉我们关于函数的几个重要信息,包括:

  • 返回类型
    函数将向调用它的程序发送何种数据?它可以是一个整数int、一个十进制数double、一个字符串std::string,或者什么都不是void

  • 函数名
    这是我们用来调用函数的标识符。选择一个描述性的名称,告诉你函数的功能。例如calculateAreafindMaximum
    在上一节我们简单提到过函数名的命名规则,所以如果你忘了可以去复习一下。

  • 参数(可选)
    这些变量在调用函数时接收输入值。您可以在括号()内指定每个参数的类型和名称。函数可以没有参数、只有一个参数或多个参数。

  • 函数体
    这是用大括号{}括起来的代码块,包含函数将执行的实际指令(语句)。

我们定义一个函数看看吧:

1
2
3
4
5
6
7
double larger(double num1, double num2) {
if (num1 > num2) {
return num1;
} else {
return num2;
}
}

double:表明返回类型是 double,表示该函数将返回一个十进制数。
larger: 这是函数的名称。
(double num1, double num2): 这些是参数。函数需要两个输入值,类型都是 double。在函数内部,这些输入值将被称为 num1num2
{ ... }: 这是函数体。它包含比较 num1num2 并返回较大值的逻辑。
return num1;return num2;: return 语句用于将函数值返回给调用者。



虚函数

有时,您可能希望函数执行某个操作,但不返回特定值。例如,向屏幕打印一条信息的函数。在这种情况下,可以使用 void 作为返回类型。

也就是我们CS里面常说的一个流程,Procedure。

void 返回类型: 表示函数不返回任何值。
return;在虚函数里是可选的。 void 函数可以使用 return; 提前退出函数,但不返回任何值。如果不包含 return 语句,函数将直接执行完主体中的最后一条语句,然后返回。

比如下面这个虚函数:

1
2
3
4
5
#include <iostream>

void greet(std::string name) {
std::cout << "Hello, " << name << "!" << std::endl;
}

就是一个在控制台打印问好消息的虚函数。

void表明函数不返回任何内容。
greet: 函数名称。
(std::string name)说明函数接受一个输入,即代表姓名的字符串。
最后函数体会使用提供的名称打印问候信息。



函数调用

要实际使用我们定义的函数(或预定义函数),需要先调用它。这意味着你要告诉程序执行该函数内部的代码。

调用一个函数时,需要在函数名称后加上括号()
如果函数的定义中有参数,则在调用函数时需要在括号内提供相应的参数(实际值)。这些参数将传递给函数的参数。
返回值的函数调用可以作为表达式的一部分使用。函数调用将被求值,其返回值将在表达式中使用。

例如我们之前定义过的larger函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>

double larger(double num1, double num2) {
if (num1 > num2) {
return num1;
} else {
return num2;
}
}

int main() {
double a = 5.7;
double b = 3.2;
double maximum = larger(a, b); // 调用larger函数,并传入a,b两个值。
std::cout << "The larger number is: " << maximum << std::endl;
return 0;
}

在下面的这一行double maximum = larger(a, b);,调用了larger函数,并且将ab两个值传入进函数。



形参与实参

了解两种参数之间的区别非常重要:

形参(Parameters)是函数定义中列出的变量。它们是传递到函数中的值的占位符。可以把它们看作是容纳输入的 “占位符”。
实参(Arguments)是调用函数时提供的实际值。它们是 “插入 ”到这些参数槽中的值。



函数声明

函数声明,也称为原型(prototyping),是一种在函数实际定义之前就告诉编译器函数的存在的操作。它向编译器提供函数的名称、返回类型和参数类型。

那么我们为什么需要编写原型?假如你在提供函数的完整定义之前尝试在代码中调用该函数,编译器可能不知道该函数是什么或如何使用它。而使用原型可以解决这个问题。

函数声明的语法与函数头非常相似,但以分号;结尾。声明中的参数名是可选的;您只需指定它们的类型。

看个例子吧:

1
double larger(double num1, double num2); // 原型声明

或者:

1
double larger(double, double); // 原型声明 - 但没有定义参数名

都是可用的原型声明。区别在于参数名是否被定义出来。

这些声明该被放在哪里呢?函数声明通常放在源文件的开头、main() 函数之前或头文件中(前提是需要将头文件在源文件中include出来)。

什么情况下需要声明?如果在代码中调用函数后才定义该函数,通常需要声明。如果在调用之前定义了函数,则声明通常不是严格意义上的必需(尽管包含声明仍是一种好的做法,尤其是在大型项目中)。

再来看个例子吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>

double larger(double, double); // 'larger'的函数声明

int main() {
double a = 5.7;
double b = 3.2;
double maximum = larger(a, b);
std::cout << "The larger number is: " << maximum << std::endl;
return 0;
}

double larger(double num1, double num2) { // 'larger'函数的定义 (出现在调用之后)
if (num1 > num2) {
return num1;
} else {
return num2;
}
}

在本例中,由于 main() 在给出 larger() 的完整定义之前就调用了 larger(),因此我们需要在开头进行声明,让编译器知道有一个名为 larger 的函数存在,它返回什么类型的值,以及它期望的参数类型。

这就是正确用法。



控制流

本节主要想要讲清楚程序在涉及函数时如何执行。

首先众所周知我们从 main() 开始: 程序总是从 main() 函数内部的第一条语句开始执行。

随后我们在主函数中顺序执行。函数(包括 main())中的语句按照出现的顺序一个接一个地执行。

当出现函数调用时,暂停当前函数,也就是说,当前函数(发出调用的函数)的执行会暂时停止。

下一步是参数值复制(逐值传递)。如果函数使用默认的 “逐值传递”机制(稍后我们将详细讨论),那么在函数调用中提供的参数值将被复制到被调用函数的相应参数中。

最后,控制权转移到被调用函数。一旦参数(可能)被复制,程序的控制权就会跳转到被调用函数的起始位置。

调用函数按顺序执行,直到return 语句结束调用函数: 当被调用函数遇到 return 语句时,其执行就会停止。

如果函数应该返回值(即其返回类型不是 void),则返回语句中指定的值会传回调用函数。

如果无返回值,也就是 void 函数,return;语句仅仅用来结束函数的执行。

在被调用函数执行完毕后(通过执行 return 语句或到达终点),程序的控制权将返回到调用函数。

当 main() 函数执行完毕时,整个程序结束(通常是在末尾执行 return 0; 语句)。

非常清晰明了的执行流程。有关于逐值传递相关的内容之前好像讲过,不过一会会再复习一遍的。



变量和作用域

变量的 “作用域” (Scope) 是指程序中可以访问该变量的区域。了解范围对于避免命名冲突和意外行为至关重要。

在函数内部声明的变量(包括函数的参数)称为局部变量。

局部变量只能在声明它们的函数内部使用,并在函数调用时产生,并在函数执行完毕时销毁(释放内存)。这意味着它们不会在同一函数的不同调用之间保留其值。

不同的函数可以使用相同名称的局部变量,而且它们不会相互干扰,因为它们存在于不同的作用域中。

1
2
3
4
5
6
7
8
9
10
11
12
#include <iostream>

void myFunction() {
int x = 10; // 定义一个函数内的局部变量x,赋值为10
std::cout << "Inside myFunction, x = " << x << std::endl;
}

int main() {
myFunction();
// std::cout << x << std::endl; // 会报错,因为x在这里无法被访问到
return 0;
}

因为x是一个在myFunction()中定义的局部变量,在主函数中并没有一个相对应定义的变量,所以会报错。


相反,有局部变量就有全局变量。

在任何函数定义之外(通常在源文件顶部)声明的变量称为全局变量。 全局变量一旦声明,全局变量就可以被声明之后的同一源文件中的任何函数访问。

全局变量在程序的整个运行过程中都存在,也就是说全局变量在程序启动时创建,并一直存在到程序结束。

虽然全局变量在函数间共享数据可能看起来很方便,但通常认为大量使用全局变量是不好的做法。这会使你的代码更难理解、调试和维护,因为你很难跟踪程序的哪一部分在修改全局变量。

大家通常使用全局变量定义需要在整个程序中都能访问的常量,例如 const double PI = 3.14159;

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream>

int globalVar = 20; // 是的孩子们这是个全局变量

void anotherFunction() {
std::cout << "Inside anotherFunction, globalVar = " << globalVar << std::endl;
}

int main() {
std::cout << "Inside main, globalVar = " << globalVar << std::endl;
anotherFunction();
return 0;
}

在这里,globalVar可以被main()anotherFunction()访问到。



不同类型的变量的作用域有不同的定义:

  • 代码块作用域
    局部变量的作用域通常从它被声明的位置开始,直到它被声明的代码块(大括号{})结束。这包括在函数中声明的变量,以及在其他代码块(如 if 语句或循环)中声明的变量。

  • 文件范围
    全局变量的作用域从其声明开始一直延伸到源文件的末尾。

  • 内部代码块可以访问外部代码块
    如果有嵌套代码块(如函数中的 if 语句),外部代码块中声明的变量通常可以在内部代码块中访问(只要它们是在使用前声明的)。

  • 内部代码块可以 “隐藏 ”外部代码块变量
    如果在内部代码块中声明的变量与外部代码块中的变量同名,那么内部变量将在内部代码块的作用域内 “隐藏 ”外部变量。这意味着,当你在内部代码块中使用该变量名时,你将引用内部变量。一般来说,最好避免这种名称阴影,因为它会使代码变得混乱。



传递值

当你调用一个函数并提供参数时,C++ 需要一种方法将这些值传递给函数的参数。这主要有两种机制:

按值传递 (Pass-by-Value)

这是 C++ 中传递参数的默认方式。按值传递参数时,会创建参数值的副本并存储在函数的相应参数中。
在函数内部对参数变量所做的任何更改都只会影响副本。调用函数中的原始参数保持不变。
就好像复印一份文件。如果对复印件进行更改,原始文件将保持不变。


那么相反,如果我们想要将传入的数据进行更改怎么搞?

C++ 提供了其他机制:引用传递 (Pass by reference)按指针传递 (Pass by pointer)


与其传递副本,引用传递会直接传入原始变量的引用,也就是原始变量的内存地址,而不是它的分身。
如果想要指明想要使用引用传递,则需要在你的传入参数按照这样的方式定义实参:

1
void modifyByReference(int& parameterValue);


而指针是一个相对深层次的概念了,不过简单来讲就是一个指路牌,上面写着目标的内存地址。
通过按指针传递,你可以选择将指针传入函数。要使用按指针传递,你需要像这样定义形参:

1
void modifyByPointer(int* parameterPtr);

并在调用的时候使用&访问变量的地址:

1
modifyByPointer(&originalValue)

是的这些就是之前那些原封不动搬过来了。我太懒了。



作者

ntcs

发布于

2025-01-25

更新于

2025-04-11

许可协议

评论