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 | date |
文件与路径操作
ls
命令列举当前目录下的所有文件和路径:
1 | ls |
命令变体:
ls -l
以长字符方式输出,包含额外信息如文件大小,文件拥有者,上次编辑日期等ls -a
输出路径下所有内容,包含隐藏的文件和目录。隐藏的内容会以一点开头.
。ls -la
就是ls -l -a
cd
命令用来重定向当前目录。例如我们想要访问当前目录下的engg1340路径,直接敲cd engg1340
就行:
命令变体:
cd ..
前往当前目录的父级路径cd ~
前往当前用户的主目录cd ~username
前往指定用户的主目录cd .
前往当前目录 (虽说没啥效果就是了)
1 | cd engg1340 |
pwd
命令将会输出当前的工作目录:
1 | pwd |
命令使用说明
Shell里面有许多命令,能将所有命令记住基本上是不现实的。因此,Shell提供了man
命令(Manual的缩写),能够返回命令的详细解释。
例如我想要查看ls命令的详细解释,就可以使用man ls
:
1 | man ls |
1 | LS(1) User Commands LS(1) |
从NAME区域我们可以得知ls
的解释为”list directory contents”。
从DESCRIPTION区域得知,如果在ls后面加入可选参数-l
,代表”use a long listing format”。这会在普通ls
的基础上显示更多信息,包括文件大小,文件拥有者,上次编辑日期等等:
1 | ls -l |
路径和文件管理
在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 | ls lab1 |
就需要使用rm -rf
或者rm -r -f
来移除整个路径:
1 | rm -r -f lab1 |
可以看到路径被成功移除了。
-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 | cat file1.txt |
移动文件
mv
命令也可以用来移动文件或者路径位置:
1 | mv hello.txt mydir |
好比说这串命令会将hello.txt移动到mydir
路径下。
如果文件拥有同样的前缀,你可以使用星号()来选中所有符合条件的项。
例如:将所有拥有“ *myfile ”前缀的文件移动到lab路径下:
1 | ls |
1 | mv myfile* lab |
1 | ls |
1 | ls lab |
复制文件
除此之外还有其他关于文件操作的命令集合:
cp
命令用于复制文件。例如:
1 | cp file1 file2 |
会将”file1”复制至”file2”
1 | cp -r dir1 dir2 |
会将dir1
路径包括路径下的所有内容复制到dir2中。
使用vi编辑器
vi编辑器用来创建并编辑文本。就好比Windows上的记事本,或Linux上的gedit,但是他没有图形用户界面,完全在命令行中使用。
vi编辑器中有两个功能:
- 插入模式(Insert mode):在这个模式中你可以编辑文件内容
- 命令模式(Command mode):命令模式用来执行文件操作,比如保存文件等
要使用vi编辑器打开一个文件,只需要敲入vi
命令即可。例如:
1 | vi file2.txt |
将会打开工作目录下的”file2.txt”文件。如果该文件不存在,编辑器会帮你自动创建一个。
进入vi编辑器后默认为命令模式。因为我们的路径下没有file2.txt,如你所见编辑器目前显示的是空白文件。
按”I”键切换至插入模式。当你在插入模式时,你可以在界面的左下角看到-- INSERT --
:
在插入模式中你可以按照我们熟悉的样子编辑文件。如果编辑完成想要保存文件,则需要退回至命令模式。
在插入模式时,按”esc”以回到命令模式。当你回到命令模式后,你将不会再左下角看到-- INSERT --
字样。这时输入:wq
并按下Enter,你会退出vi编辑器,同时你的文件会被保存。
命令模式下的命令
除了“保存并关闭”(:wq
)命令外,还有下面这些常见命令:
命令 | 执行的操作 |
---|---|
:wq |
保存并退出 |
:w |
保存(但不退出vi编辑器) |
:w filename |
保存到名叫”filename”的新文件下 |
:q |
退出 |
:q! |
不保存并退出vi编辑器 |
想要查阅完整的命令列表的话点我
或者你也可以查阅这张Cheatsheet:
文件权限
Linux系统中的每个文件或目录都被分配了3种类型的所有者。
- 用户(User):用户可以作为文件的拥有者
- 用户组(Group):用户组中可以包含多个用户。若用户组拥有一个文件的权限,则组中的每个用户都有该文件的权限。
- 其他(Other):有权访问文件的任何其他用户。它既没有创建文件,也不属于拥有该文件的组。
每个用户对于上面的三种所有者定义了三种不同的文件权限:
- 读取(Read):拥有文件读取权限的个体可以打开并读取文件中的内容。拥有路径读取权限的个体可以列举出该路径下的内容。
- 写入(Write):拥有文件写入权限的个体可以更改文件中的内容。拥有路径读取权限的个体可以在路径中添加,移除或者重命名内容。
- 执行(Execute):执行权限允许个体来运行程序
不如举个例子看看:使用之前提到过的ls -l
命令来列举出路径下的内容,里面会告诉你文件权限的相关信息:
1 | ls -l |
权限指示
借用上面的例子,我们来仔细讲讲刚才的输出是啥意思:
1 | total 10 |
total 10
代表文件在文件系统中占用了多大空间,单位为千字节(kilobytes)。
由左至右分别是:
条目 | 释义 |
---|---|
-rw------- |
代表文件权限。具体的释义马上就解释( |
1 |
代表文件拥有的硬链接数量。 |
cklai |
代表文件拥有者 |
ra |
文件拥有者存在的用户组 |
50 |
文件大小,单位为字节(byte) |
Jul 23 17:07 |
上次修改时间 |
file1.txt |
文件名 |
现在我们来讲讲权限指示该怎么解读。
权限指示(Permission indicator)是一个十字符长的字符串,通常分为四部分:
这玩意可以分成四部分解读,Type, User permissions, Group permission和Other 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 | touch file |
其中,chmod o+w file
意思是对于其他用户(o)授予(+)写入(w)权限。
Example 2: 更改多个权限
可以在permission
参数内键入多个值来一次性设置多个权限。就刚才的例子往下继续:
1 | ls -l |
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 | 5 Apple 3.5 |
运行下面的命令将会返回文件内所有包含”ke”的行:
1 | grep 'ke' example1.txt |
搜索是很大的一块内容。这里仅用来介绍。后面我们会在搜索章节一起将文件内文件外搜索一并讲清楚的。
文件字数 (wc)
同样使用”example1.txt”作为例子:
运行该命令则会返回如下内容:
1 | wc example1.txt |
其中:
- 第一个数字代表行数,也就是文件共有六行
- 第二个数字代表词数,表明文件中有18个词
- 第三个数字代表文件大小,代指文件大小为71字节
搭配参数-l
使用可以仅仅返回行数:
1 | wc -l example1.txt |
搭配参数-w
使用可以仅仅返回词数:
1 | wc -w example1.txt |
排序
使用sort
命令来对文件内容排序。如果没有指定任何输入参数,则默认排序方式按照字母表顺序排列
1 | sort example1.txt |
你可以使用不同的传入参数来指定排序方法。例如:
- 使用
-n
则按照数字顺序排序:
1 | sort -n example1.txt |
- 使用
-r
则按照倒序顺序排序:
1 | sort -n -r example1.txt |
- 使用
-k
则按照字段排序。需要注意排序字段ID从1开始,而不是0。
例如,将文件按照第三个字段进行排序。在”example1.txt”中,每行最后的那组数字就是第三字段。
1 | sort -k3 -n example1.txt |
- 使用
-t
用来告诉排序程序,我们使用分隔符并非空格而是逗号。
例如我们有一个”example1.txt”的变体,”example1_comma.txt”:
1 | cat example1_comma.txt |
需要在使用sort
时加入参数-t
来指定分隔符为逗号:
1 | sort -t, -k3 -n example1_comma.txt |
剪切文件
这里指的不是我们熟悉的剪切文件到其他目录,而是直接在文件内剪掉一部分内容。
cut
命令将会修剪文件并返回特定的内容。要使用这个命令,我们需要向cut
命令指定分隔符。
指定分隔符的参数为-d
,代表delimiter,注意不要和之前的sort
命令搞混了!该参数不是可选的,即便分隔符为空格,你也需要指定参数-d
。
使用参数-f
来告诉程序你想要返回的字段,说白了就是列。和sort
一样,字段ID从1开始而不是0。
例如,返回”example1.txt”文件内的第一字段和第三字段:
1 | cut -d ' ' -f 1,3 example1.txt |
移除重复行
uniq
命令删除相邻的重复行,只保留一个重复行。
注意咯,它只删除相邻的重复项。
“example2.txt”:
1 | Apple |
想要移除在”example2.txt”中多余的相邻重复行:
1 | uniq example2.txt |
拼写检查
spell
命令返回文件中所有可能出现拼写错误的词:
“example3.txt”:
1 | It's a beautiffful day! |
1 | spell example3.txt |
如果你自己的Linux机子运行这个命令报错,那么你的系统很有可能没有安装需要的软件包。按照下面的步骤修复这个问题吧:
- 切换到Superuser账号:
1 | su |
还记得我们之前说过安装软件包之前需要切换到su账号吗
- 安装
aspell
包:
1 | yum install aspell |
- 退回到你当前的账户:
1 | exit |
文件差异
diff
命令用来显示两个文件的区别。
“fileA.txt”:
1 | aaa |
“fileB.txt”:
1 | eee |
下面的命令则会返回将”fileA.txt”转化为”fileB.txt”的步骤:
1 | diff fileA.txt fileB.txt |
这里我们需要一点解释了。
0a1:
在fileA的第0行之后添加(a, add)一行,添加的内容由下一行> eee表示
2, 3c3:
将fileA中的第2, 3行变成(c, change)fileB的第三行。
完成这一步需要先删除掉两行,由<表示:
< bbb
< ccc
代表删除掉这两行。
分割线隔开后出现了>。这代表我们需要在删除后再次添加一行:
> ddd
代表再添加文本”ddd”
这样就能把”fileA.txt”变成”fileB.txt”了。
再举另一个例子把:
1 | diff fileB.txt fileA.txt |
刚才是由A变B,现在是由B变A了。
1d0:
在fileB中删除(d, delete)第一行,随后的内容就会在第0行被对齐。
< eee表示我们删除了”eee”
3c2,3:
和刚才刚好反过来,将fileB中的第3行变为fileA的2,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 | //add.cpp |
编译文件为一个叫做”add”的可执行文件:
1 | g++ add.cpp -o add |
这时你可以将自己的输入写到文件内,如”input.txt”:
1 | 3 4 |
然后使用”<”传入参数:
1 | ./add < input.txt |
或者你可以更进一步,将输入和输出都独立出来:
1 | ./add < input.txt > output.txt |
为何不能使用 input.txt > ./add > output.txt
这种写法?因为Shell会尝试执行input.txt。
管线
有时候我们需要将一个程序的输出换做另一个程序的输入,我们可以创建并且利用管线来优雅地解决这个问题。
“|”是管线的符号。这个符号用来重定向一个程序的输出到另一个程序的输入,没有中间商赚差价。所谓中间商就是刚才用到的输入输出文本文件。
举个例子,如下是两行命令:
1 | ls -l > 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”中。
还是很简单的。下面是这个过程的视觉表述方式:
Example 2:
目标是在当前路径下对用户,用户组和其他人有运行权限的文件和路径。
1 | ls -l |
对于所有目标都有执行权限的话,则权限指示的第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 | cat mark.txt |
对应格式是:[UID, 名称,性别,分数1,分数2,分数3,分数4,分数5]
然后我们同样有一个C++程序用来处理信息,用于计算平均分:
1 | //mark.cpp |
程序的问题是输入仅有五项成绩,但是我们的”mark.txt”文件内包含UID在内的其他多余信息。
那这就很坏了。
一种方法是我们可以使用管线来先处理”mark.txt”,然后传入程序。我们可以使用cut
命令处理一下:
1 | cut -d' ' -f-8 mark.txt |
然后编译程序运行:
1 | g++ mark.cpp -o mark |
这样程序的输出就会被记录到”result.txt”中:
1 | cat result.txt |
搜索
搜索文件或路径 (find)
各位应该在Windows或MacOS中使用过搜索功能。你也可以在Linux中实现对文件或路径的搜索操作:
使用find
命令可以用来搜索文件或路径。find
命令的格式为:
1 | find [path] [-name] [-type] |
[path]
告诉系统你想从哪里开始搜索操作。[name]
是你想要搜索的文件或路径的名字。[type]
是可选参数,参数包含:-type f
代表仅搜索文件 (files)-type d
命令之搜索路径 (directory)
举个例子,假如在当前位置有下面这几个文件和路径:
1 | ls |
那么在当前目录下搜索”hello.txt”的方法即为:
1 | find . -name "hello.txt" -type f |
上面的命令
在上面的命令中,.
代表当前目录。指定当前目录后find
会在这个目录为基础向下寻找文件。
或者我们也可以在当前位置下搜索”hello”打头的文件:
1 | find . -name "hello.*" -type f |
…或者是搜索路径:
1 | find . -name "hello" -type d |
文件内搜索 (grep)
grep
(Global Regular Expression Print)命令之前已经介绍过了。这里介绍更多细节:
之前用的是不带-E
的使用方式,只是用来返回文件内的匹配行:
“example1.txt”
1 | Hello how are you? |
1 | grep 'hell' example1.txt |
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 | grep -E '.ell' example1.txt |
‘.ell’代表”.”处可以匹配任何单个字符,例如”Cell”, “cell”, “bell”会被匹配。
如果如果你想要在”.”处搜索的字母仅仅是大小写H,则可以使用[Hh]ell
。
行首/行尾匹配
这里启用”example2.txt”:
1 | cat example2.txt |
1 | grep 'apple' example2.txt |
使用”^”从行首开始匹配:
1 | grep -E '^apple' example2.txt |
而使用”$”从行尾开始匹配:
1 | grep -E 'apple$' example2.txt |
由此可知,使用”^”和”$”也可以全字匹配某一行:
1 | grep -E '^apple$' example2.txt |
或者使用”^”, “$”配合”.”来返回只有五个字符的行:
1 | grep -E '^.....$' example2.txt |
?, +, *
“?”
“example3.txt”
1 | apple |
1 | grep -E '^ap?' example3.txt |
没看懂?首先”^”确保行首开始匹配,随后:
- “apple”会被返回,因为在表达式中的p要求出现0次或者1次,这里出现了1次
- “ape”会被返回,因为在表达式中的p要求出现0次或者1次,这里出现了1次
- “angel”会被返回,因为在表达式中的p要求出现0次或者1次,这里出现了0次
那么接下来的东西,”+”和”*”,原理上基本上是一样的了。
+
1 | grep -E '^ap+' example3.txt |
- “apple”会被返回,因为在表达式中的p要求出现1次或者多次,这里出现了多次
- “ape”会被返回,因为在表达式中的p要求出现1次或者多次,这里出现了1次
- “angel”没有被返回,因为在表达式中的p要求出现1次或者多次,这里出现了0次
*
1 | grep -E '^ap*+*' example3.txt |
- “apple”会被返回,因为在表达式中的p要求出现0次或者多次,这里出现了多次
- “ape”会被返回,因为在表达式中的p要求出现0次或者多次,这里出现了1次
- “angel”会被被返回,因为在表达式中的p要求出现0次或者多次,这里出现了0次
接下来是一些进阶一点的用法了:
整合”.”和”*“
如果我们想要匹配字符”a”,然后后方跟随任意字符,直到”ge”再次出现:
1 | grep -E 'a.*ge' example3.txt |
““跟在”.”之后,所以”任意字符匹配”可以被匹配多次,这就是为什么”orange“和”*angel”能被返回的原因。
使用括号”()”
我们可以使用括号来整合子字符串。例如我们想要寻找字符串”co”出现过一次或多次的行:
1 | grep -E '(co)+' example3.txt |
为什么要用括号呢,看看如果不用括号会发生什么:
1 | grep -E 'co*' example3.txt |
因为”*”只会应用到它的前一个字符。没加括号的话就会被应用到”o”,而不是整个子字符串”co”。
匹配集合
可以使用方括号 “[ ]”来定义一个集合,随后进行搜索。你可以按照下面的格式定义集合:
- [0123456789]或者[0-9]用来匹配任意单个数字
- [A-Z]用来匹配任何单个大写字母
- [a-z]用来匹配任何单个小写字母
- [A-Za-z]用来匹配任何单个字母,包括大写和小写字母。
例如我们有一个”example4.txt”:
1 | Apple Juice HKD13 |
查找包含”apple”或者”Apple”的行:
1 | grep -E '[Aa]pple' example4.txt |
查找包含”HKD”,并在其后跟随一个或者多个数字的行:
1 | grep -E 'HKD[0-9]*' example4.txt |
匹配序列
可以使用”{ }”来匹配一个序列 (pattern)。
上”example5.txt”:
1 | 2April2013 |
如果我们想要查找一个日月年的格式,并且日为2个字符,月至少为3个字符,年为4个字符,你可以整成:
1 | grep -E '^[0-9]{1,2}[a-zA-Z]{3,}[0-9]{4}' example5.txt |
这很抽象,所以我们需要简单讲讲:
- [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 |
|
echo
命令用来向控制台输出一行。行中内容可以是变量值,也可以是字符串。
保存并退出,随后我们需要使得文件可以被用户运行:
1 | chmod u+x hello.sh |
然后直接运行:
1 | ./hello.sh |
所有脚本都应以#!
开头,这表示系统应使用哪个程序来处理 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 | which -a bash |
参数-a
代指返回bash的所有路径。如你所见返回的路径不止一个,这代表Bash的路径可能有多个。在本例中我们使用/bin/bash
路径下的那个。
来看看另一个例子。这里我们引入echo
命令的新参数-n
看看会发生什么不同的情况:
ex1_1.sh:
1 |
|
1 | ./ex1_1.sh |
-n
可以表示”no trailing”使下一行直接继续打印在上一行的末尾。
这次我们来看个带C++的例子:
“add.cpp“
1 | //add.cpp |
“input.txt“:
1 | 3 4 |
之前我们使用管线可以封装好整个自动化,不过这里我们试着用Shell脚本:
“ex1_2.sh“:
1 |
|
运行:
1 | $ ./ex1_2.sh |
使用变量
在Shell脚本中只有一种变量类型,那就是字符串。
变量名区分大小写,只支持包含大小写字母,数字和下划线 (_)。
定义并访问变量
我们可以通过这种方式来定义并赋予变量一个值:
1 | pet="dog" |
等号前后不能有空格。
如果我们需要取用变量中的内容,则需要使用美元符号:
1 |
|
输出为dog
。
读取用户输入
read
命令用来等待用户在控制台向脚本提供输入:
1 |
|
当用户输入完值之后,会被自动存储到变量name
中。
比如我们来看这个程序的具体例子:
1 | ./ex2_2.sh |
单引号和双引号
在Shell脚本中区分引用很重要。我们可以用三种方式来确定一个字符串值,分别是不引用,单引号引用和双引号引用。
不引用
我们可以在定义字符串的时候不加任何引用,但是这个方法仅在需要定义的字符串是一个整体,没有空格的情况下才可用:
1 | a=cat |
下面这种情况就会报错:
1 | a=Apple pie |
因为 程序会将Apple当作一个指令来看待,而不是一个字符串。
单引号引用
在单引号引用情况下,带空格的字符串可以被成功处理,但是没办法做到变量替换。
变量替换功能是双引号引用的功能:
双引号引用
双引号引用相比单引号引用支持更多功能,其中一个就是变量替换:
符号 | 解释 |
---|---|
$ |
变量替换 |
\ |
转义符 |
`` | 包含bash命令 |
看不懂?就着下面的例子看看:
1 |
|
运行后可以得到:
1 | ./ex2_3.sh |
第一行会被输出为Hello, $name
,因为我们使用了单引号引用,name
变量不会被替换。
第二行会被输出为Hello Apple
,因为双引号引用下$name
会被替换为变量name
。
第三行会被输出为的替换功能,而第二个$name
会被保持原状正常替换。
命令替换
前面提到过一嘴,不过我们决定在这里展开相关说明。
使用反引号(`),一般是esc键下面的那个键,我们可以在脚本中向变量保存Shell命令的输出,来实现进一步的处理:
1 |
|
“file.txt“:
1 | Apple |
运行输出为:
1 | ./ex2_4.sh |
接下来我们解读一下。在变量b中,首先wc
命令返回3 file.txt
,告诉我们一共有三行。接着我们使用cut
命令截去后面多余的部分,返回一个”3”。
注意在cut
命令的参数部分我们使用了转义符,因为原本是cut -d" " -f1
,但问题在于外面已经有一对双引号了。解决这个问题只能去转义里面的两个引号。
字符串处理
如果我们想要获得一个字符串的长度:
1 |
|
1 | $ ./ex2_5_1.sh |
其中${\#a}
的意思是返回变量a存储的字符串的长度。
子字符串
使用${a:pos:len}
可以用来返回一个字符串的子字符串。例如:
1 |
|
会返回变量a从第6个字符位置开始包含并向后数3个字符长度的子字符串,也就是:Pie
。
和Python一样第一个字符编号为0。
替换字符串内容
使用${a/from/to}
来指定替换一个字符串。先看例子:
1 |
|
输出为Apple Juice
。
该替换会在字符串内寻找from
的第一个匹配,然后将from
替换为to
的值。
按数字计算
我们存储的变量都是字符串,但是如果我们存入的是数字类型的字符串,并且我们想要进行一些数学运算该咋整?
虽然听起来很奇怪,但是我们可以使用let
命令来进行数学运算。支持加减乘除和整数除法。(+, -, *, /, %)
1 |
|
运行为:
1 | ./ex2_5_4.sh |
获取控制台参数数量
控制台参数再Shell脚本中会被列为$0
, $1
, $2
一直到$9
。
作为特殊项,$0
会被解析为Shell脚本的名称。
如果想要引用第十个或者更多参数,则需要将序号使用括号括起来:${10}
, ${11}
这样,否则命令会被解析为$1
和数字的组合。$#
代表控制台内参数的数量。
例如我们有一个Shell脚本如下所示:
1 |
|
运行脚本后输出:
1 | ./ex2_6.sh we are the world |
控制语句
在任何脚本语言中,流程控制都是必不可少的一部分。
if else语句
基本的if else语句语法为:
1 | if [ condition ] |
if语句的重点写做fi还有点搞笑。
注意了if语句的条件判断,方括号里那个,需要前后带一个空格。注意condition前后离着括号都差一个空格。如果不遵守这个规定会爆语法错误。
Shell脚本也有else if功能:
1 | if [ condition1 ] |
在上面的语句中,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 |
|
尤其注意一下if语句带condition的空格问题。留意一下空格都加在哪里了。
运行为:
1 | Do you want to remove all .cpp files? (Y/N) |
学会了?接下来举几个例子:
Example 1
如果g++在编译c++文件时出现了错误,则编译失败,可执行文件将不会生成。所以我们可以使用[ -e file ]
来检查文件是否存在。
同时,我们也可以考虑一下是不是可以查看一下编译失败的log?
比如我们可以使用重定向方法来存储错误log:
1 | g++ hello.cpp -o hello 2> error.txt |
别忘了2>
代表重定向标准错误。
那么看看这个shell脚本吧:
1 |
|
如果hello.cpp
包含语法错误,那么下面就是其中一种可能的输出:
1 | hello,cpp exist |
for循环
for循环可以按照指定次数循环。
1 |
|
for循环以for i in []
开始,需要执行的命令以do
和done
括起来。
例如上面的脚本会被运行为:
1 | This is iteration 1 |
实际上除了遍历数组,我们也可以遍历一个路径中的文件。
比如下面的脚本将会自动将你路径下所有的.cpp
文件记录一个备份:
1 |
|
1 | ls |
有用的操作
在脚本中隐藏命令
Shell脚本会生成自己的错误和输出信息,有时会引爆你的shell脚本输出。
为了解决这一点你可以使用之前的重定向方法:
1 |
|
解析一下,1>/dev/null
用来将cp
命令的标准输出重定向到系统回收站内/dev/null
。挺巧妙的。
2>&1
则会重定向cp
命令的标准错误到同一个位置,只不过我们以&1
表示了。
&
表示在这里指的是文件描述符,而不是文件名或路径。如果没有&
,像2>1
这样的命令将无效,因为shell会尝试将1
解释为文件名,而不是标准输出文件描述符。
输出为:
1 | ./ex4_1.sh |
输出到标准错误
Shell脚本也可以通过echo
命令输出到标准错误中:
1 |
|
当echo
后面跟着>&2
时候,则代表我们将该条信息重定向到标准错误层上进行输出了。接下来如果我们执行这个:
1 | ./ex4_2.sh 2> error.txt |
就可以在”error.txt”中看见我们的错误输出了!
1 | cat error.txt |
经过这两步处理后你的shell脚本的输出看起来就像其他shell命令一样了。
版本控制
在版本控制这一部分,我们主要学习如何使用Git。
Git 是一种常见的现代版本控制系统,用于管理和跟踪计算机文件中的更改以及协调多人对这些文件的工作。主要用于软件开发中的源代码管理。
Git 是一种分布式版本控制系统 (DVCS),与大多数替代版本控制系统相比,它具有更高的性能、安全性和灵活性。
那么什么是版本控制系统?
版本控制系统 (VCS) 是一类软件工具,支持软件开发团队管理源代码随时间的变化。
它在一种特殊的数据库中跟踪每个贡献者对代码的单独更改历史记录。如果出现错误或需要修复错误,开发人员可以返回到源代码的早期版本来解决问题,而不会妨碍其他团队成员的工作流程。
如果软件团队不使用 VCS,他们可能会遇到一些问题,例如在项目的两个独立部分之间创建不兼容的代码或对用户可用的更改一无所知。
在这么多版本控制解决方案中,使用Git的原因是它让开发人员可以在一个地方查看任何项目的变更、决策和进展的整个时间线。
借助 Git 这样的 DVCS,可以随时进行协作,同时保持源代码的完整性。使用分支,开发人员可以安全地对生产代码提出更改建议。
如果你用过Github,你应该对上述这些内容和下面将要讲到的内容比较熟悉。如果不,那么这一节将会很有意思。
Git的使用工作流基本分三步。
git init
初始化工作目录
首先可以使用Git初始化一个你将要使用的工作目录。Git会尝试在目录内跟踪你对文件的改变。
git add
增加一次提交
这一步告诉Git,当前目录下所有被更改的文件有哪些。
git commit
创建一次提交(commit)
最后告诉Git让它创建一次提交,类似于创建一个存档点。每一次提交就是某个特定版本。
开始使用Git
安装Git
要使用Git,我们需要先配置好Git的使用环境。首先我们需要在你的电脑上安装Git。
检查Git是否在你的电脑上安装,执行git version
即可:
1 | git version |
如果没有安装Git,则你可以根据下面的步骤来安装Git。
在Linux上安装
在终端执行几行代码就OK了:
1 | sudo apt-get update |
在MacOS上安装
虽然我不用Mac但是相应的安装方式也是可以提一嘴的。
首先下载Homebrew来更方便快捷的安装软件:
1 | ruby -e "$(curl -fsSL |
在Windows上安装
直接去官网下载安装包即可。
初始化仓库
一个Git仓库包含你工作环境的所有文件,文件夹或者路径等等。配置好Git后Git会自动帮你管理变更。
一共有两种方式来初始化一个本地仓库。你可以从网上clone一个仓库下来,也可以自己创建一个空白仓库使用。
初始化新仓库
- 打开终端并转到你想要初始化的路径:
1 | cd project |
- 在文件夹内我们使用
git init
命令创建一个新仓库:
1 | git init |
执行完这行命令后,一个新的.git/
子路径会在当前路径下生成。这个指令用来设置好Git用来跟踪你的文件的所有前置工作。
- 现在我们可以使用Git了。比如说下一步我们创建一个”work.txt”:
1 | Welcome to my Git tutorial. |
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新仓库
或者你也可以从网上拉取一个新仓库下来。
- 如果你想使用其他人的仓库,你可以直接从代码托管平台上clone一个仓库到你的本地。Github就是全球最大的代码托管平台。
注意注意↑!
如果存在.git
路径,那么你的仓库就会被拉取到那里。如果没有,则仓库会被clone到你的当前工作路径。
- 输入clone命令即可从网上拉取仓库:
1 | git clone https://github.com/[YourUsername]/[YourRepository] |
同样的这里有一些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 | git status |
注意到在Untracked files下存在”work.txt”,这代表这个文本文档被Git可见,但是Git没有开始追踪这个文档的任何更改。解决这个问题我们需要向Git生命暂存更改。
暂存更改
为了让 Git 开始跟踪你在工作目录中所做的更改,你需要先将这些文件添加到暂存区。
这可以通过使用命令git add <filename>
来完成,其中<filename>
是你正在处理的文件的名称,例如我们的”work.txt”文件。
1 | git add work.txt |
现在”work.txt”已被加入暂存目录。
git add
的一些额外变体:
命令 | 解释 |
---|---|
git add . |
将当前目录下所有文件添加到暂存区 |
git add -A or git add --all |
查找整个项目中存在的所有新文件或更新文件,并将其添加到暂存区 |
跟踪工作目录中文件变化
现在我们在”work.txt”新增第三行:
1 | Welcome to my Git tutorial. |
想要查看暂存区和工作目录中同一个文件的区别,我们完全可以使用git diff <filename>
命令来查看区别:
1 | git diff work.txt |
可以在最后一行看出,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 | git commit -m "Added an introduction." |
请注意上面有关你的姓名和电子邮件地址配置的消息,实际上你可以按照说明对配置文件进行相应的更改。
始终输入有意义的提交消息,是程序员们的基本操作之一。这对你后期的开发有非常大的帮助,同时对别人也是。
使用git log
命令可以查看比较旧版本的项目:
1 | git log |
在每个log中会提供如下四个信息:
- 40字长的哈希码,我们叫SHA。这是每个commit的唯一识别码
- 提交的作者,也就是你自己
- 提交的时间和日期
- 提交消息
与他人合作
使用分支系统
分支可以看作是指向 Git 存储库中最新提交的指针。
当我们初始化存储库时,我们正在处理一个称为master
分支的单个分支。我们正在处理的提交称为HEAD
,通常是分支的最新提交。
你可以在某个commit节点创建一个分支。
可以看一下上面这张图。这是一个正在开发的项目的Git图表。每一条线代表一个分支,每一个点代表一个commit。
可以看到橙色的那条线从粉红色分支的一个commit拉出,经过一系列的更改,最后在上面的一个commit合并到粉红色分支。这两个粉红色commit之间区别在于并入了整个橙色分支的内容。
要在当前commit拉出一个分支,可以使用git branch BugFix
命令:
1 | git branch BugFix |
拉出分支成功后当前工作分支还是在master
上。切换到刚拉出来的BugFix
分支我们需要:
1 | git checkout BugFix |
我们也可以使用checkout
命令将 HEAD 移至上一个提交。
如果这样做,我们也会恢复此提交中文件的状态。例如,我们可以移动到提交 BugFix^,这是分支 BugFix 中的上一个提交。
1 | git checkout BugFix^ |
想要移动一个特定的分支,那就用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 | git checkout master |
接下来我们在BugFix分支上也添加一个新文件:
1 | git checkout BugFix |
我们可以选择向BugFix
分支并入master
,也可以向master
并入BugFix
。但是我们可以选择前者,因为这样我们就可以在不干扰master
分支的前提下验证bug修复是否有用。
1 | git checkout BugFix |
如果在master
分支下出现了会影响到BugFix
的更改,则会出现文件冲突。这时Git就会提示你解决冲突。
现在假设我们发现修复没啥问题可以直接并入master
,则我们可以直接快进并入,叫做”fast foward”。
这一步不会创建任何commit。
1 | git checkout master |
下面是一些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 | git clone https://github.com/schacon/ticgit |
想要添加远程仓库,你可以使用git remote add <shortname> <URL>
命令:
1 | git remote add pb https://github.com/paulboone/ticgit |
或者如果你想查看一个远程仓库的信息,就使用git remote show <remote>
:
1 | git remote show origin |
下面有一些有关远程仓库的命令和变体:
命令 | 释义 |
---|---|
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 | // 这是一段注释 |
注释行在进行编译的时候,会被编译器忽略。大胆地在程序里写下你的注释吧,你也不希望回头看不懂你自己写的程序吧。
头文件引用
在上面的程序中,你会发现第一行是一个由井号开头,看起来和程序没有任何关系的一行。
1 |
这一行实际上引用了我们要在程序中使用到的头文件。你可以类比为我们要使用到一个叫做iostream
的工具箱。
#include
指令在这里用来将预先编写的代码(库)引入到程序中,这些库包含我们可以使用的有用函数和工具。
至于后面的<iostream>
,是一个处理输入和输出的标准 C++ 库。这个库为我们提供了在屏幕上显示内容(输出)和从用户那里获取信息(输入)的工具。
具体来说,它为我们提供了 cout
命令。
也就是说如果我们移除程序中的这一行,程序就不知道cout
是什么东西了。
怎么提供呢?实际上方法非常暴力:编译器在将要编译文件时,会找到你引用的这些头文件,然后将其粘贴到文件的开头…
main
函数
main
函数是任何C++程序的切入点 (Starting Point)。大概的意思是,编译器在编译该文件时,会默认从main开始往下读。
main
函数的结构可以像是是这样:
1 | int main() { |
int main()
定义了主函数。int
的意思代表该函数会返回一个整型值。{ }
这个花括号是主函数的主体 (Body)。 我们在主函数的主题内写到的任何命令,就在主函数要执行的范畴之内。return 0;
这一行一般代表我们的程序已经执行完毕。在C++中,在这种情况下返回0
是一个约定。
现在你知道了这些内容,我们再回头解答一下一开始写的那个程序干了什么:
1 |
|
冷知识,如果你把你的主函数写成这样:
1 |
|
程序不会报错,只是主函数刚开始执行就结束了。类似于你定义了一个新房子,但是房子里面什么也没有(
基本输入输出
实际上刚才已经接触过一点点了,这里我们系统性讲解一下:
cout
(输出)
类似控制台输出。用来将内容输出到控制台的屏幕上:
1 | std::cout << "Hello!"; // 在屏幕上输出"Hello!" |
我们涉及到了左移<<
运算符。把它类比于插入操作就行。
同样的有输出就少不了输入:
cin
(输入)
这边就类比于控制台输入了。语句会读取用户在控制台输入的内容:
1 | int age; |
上面是一个非常简单的小程序,首先输出文字提示用户输入内容,然后读取用户输入的内容到变量age中,最后输出。
同样涉及到了右移>>
运算符。与左移相反,把这类比于提取操作就ok。
有关命名空间
Namespace,或者叫命名空间,在上面的程序中使用std::
进行表示。
现在我们先不过多讨论这一部分,而是先记住在cin
和cout
之前,带着std::
命名空间声明就好。
下面是一个用到上述所有内容,编写的一个简单的问好小程序:
1 |
|
现在你可以试试编译这个程序并运行。程序会首先输出”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 | int age; |
先写要定义的数据类型,随后声明你想要的变量名。
变量名有以下命名规则:
- 变量名的第一个字符必须是字母或下划线。
- 变量名只能包含三种字符:大小写字母,数字和下划线。
- 不能与C++预留名称冲突。差不多是不能与C++声明过的变量重名。
同时注意,变量名区分大小写,所以radius
和Radius
, RADIUS
是三个不同的变量。
如何为变量赋值?我们可以选择在定义变量的同时为它们声明一个初始值:
1 | int age = 30; |
或者也可以直接调用变量名,然后为其赋值:
1 | age = 30; |
差不多理解了?下面是一个包含我们介绍过的数据类型的小程序。试试看能不能读懂是什么意思:
1 |
|
std::endl
是”end line”的意思。这个命令会让光标移动到下一行。?
运算符大概能这样理解:(condition ? value_if_true : value_if_false)
,用来直接快速运行单个判断。
常量
除了定义一个变量,你也可以定义一个常量。
有时我们需要一个在程序中永远也不会,同时也不应该会改变的值。这就是常量的作用所在。
常量类似于一个变量,但是常量的值一旦定义无法更改。这是一个固定值。
你可以使用这样的方式声明一个常量:
1 | const int DAYS_IN_WEEK = 7; |
const
: 这个关键字告诉编译器“这是一个常量,它的值不应该被改变”。- 变量名 (例如
DAYS_IN_WEEK
,PI
): 为常量命名,我们通常用大写字母并用下划线分隔单词,以便于识别它们是常量。这是一个约定。 - 值 (例如后面的
7
,3.14159
): 注意定义常量时必须同时为它定义一个初始值。
但是我在Python编程中没有什么常量的概念啊?!
在程序中使用const
,有助于我们编写更安全,更有效的代码,同时也增加了代码的可读性。将一个变量声明为const
,基本上就告诉编译器,这个变量是不可改的。
如果编译器在后期发现有语句尝试更改该值,则会返回错误。这避免了有时意外更改某些值的危险情况。
至于为什么Python没有开箱即用的常量概念,有一部分的原因为Python设计之初是一个动态类型的解释型语言。变量类型在运行时的时候才会进行检查,并调用解释器逐行执行代码。
相比C++,Python更强调程序的自由和灵活性。但不代表我们无法在Python中使用常量,相反我们一般使用“惯例和约定”。
在Python中,我们一般也全部使用全部大写的变量名来声明常量,与C++一样。不过区别在于,没有什么会阻止我们更改这个变量的值——这仅仅是一个约定罢了(
老规矩我们直接上例子:
1 |
|
还记得我刚才说过的,更改常量的值可能会报错嘛:
如果你将程序改成这样,在声明PI
之后尝试更改PI
的值:
1 |
|
试着运行一下,按理讲程序会报错。
这就是使用常量的好处了。能够有效保证某些定义好的量不被意外更改。
运算符
C++里有不少运算符。一个一个看吧:
数学运算符
数学运算符顾名思义,我觉得是数学运算符:看起来像数学运算符,用起来也是数学运算符。
+
:加法-
:减法*
:乘法/
:除法- 注意!:如果你将两个整数相除,结果则依然会是整数,小数部分会被截取。举个例子:
5 / 2
的结果是2
,而不是2.5
。如果我们想要输出小数,可以考虑使用两个浮点数做运算。例如5.0 / 2.0
,输出为2.5
。
- 注意!:如果你将两个整数相除,结果则依然会是整数,小数部分会被截取。举个例子:
%
:取模
比较运算符
比较运算符会比较两个传入值,随后输出布尔类型的值,也就是true
或者false
:
==
:等于。用于检验两个值是否相等!=
:不等于<
:小于>
: 大于<=
:小于等于>=
:大于等于
逻辑运算符
这里牵扯到逻辑运算,与,或,非那堆东西:
&&
:与运算。如果两个传入值都是true
,则返回true
。||
:或运算。如果两个传入值存在true
,则返回true
。!
:非运算。输出相反的true
和false
结果。
搞不懂的话试试运行这个程序吧:
1 |
|
运算顺序
顺序如下:
()
:与数学一样,我们首先计算括号。!
:非运算*, /, %
:乘除和取模运算+, -
:加减运算==, !=, <, >, <=, >=
:比较运算符&&
:与运算||
:或运算=
:赋值符。(顺序为从右往左。赋值语句我们之后再说)
数据类型转换
有时我们想要将一个数据类型转换到另一种数据类型。C++为我们提供了两种转换方式,分别为隐式转换 (Implicit conversion)和显式转换 (Explicit conversion)。
隐式转换
当我们使用隐式转换时,C++则会自动将你的值从一种类型转换到另一种类型。通常我们想要将“较小”的数据类型转换到“较大”的数据类型时,并没有数据损失时,会使用隐式转换:
比如我们想要将int
转换到double
:
1 | int integerValue = 10; |
看第二行,C++自动将整数10
转换到了double10.0
。这个值被赋予给了doubleValue
变量。
这里使用隐式转换是安全的,因为这里实际上没有牵扯任何数据损失。
再举个例子吧:
1 | int intResult; |
在 doubleResult = doubleValue1 + intValue2;
中,intValue2 在加法之前被隐式转换为 double,因此结果为 double。
但是,如果不进行显式转换,则不能直接将 double 赋值给 intResult,因为这会丢失小数部分。
再看一下intResult
。这一行会在编译时出现问题,因为如果尝试将 double 结果分配给 int 变量而没有进行显式转换,C++ 会将其捕获为编译时错误。
一会我们来探讨什么叫显式转换。
显式转换
当您想要强制转换数据类型,或者当 C++ 不会隐式执行转换时(例如,从“较大”类型转换为“较小”类型,这可能会丢失信息),我们则需要使用显式类型转换,也称为”Type Casting”。
我们可以选择使用比较经典的,C语言样式的显式转换:
1 | double price = 29.99; |
或者我们也可以使用比较现代的C++样式进行显式转换。理论来讲这种方式会更加安全,也看起来更干净:
1 | double price = 29.99; |
两种方法都会输出29
。因为我们从“较大”的数据类型通过显式转换变成了“较小”的数据类型,所以你可以发现小数部分被整个切掉了。
也可以通过显式转换将char
转换到int
:
1 | char myChar = 'A'; |
为你提供示例小程序,看懂了就基本上代表你这块直接毕业了:
1 |
|
程序编译与运行
首先我假设你应该会点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++源代码:
定位工作目录和文件,首先你需要找到你要编译的C++源代码。
在命令行中运行:
1 | g++ my_program.cpp -o my_program |
使用g++
调用g++编译器,my_program.cpp
是你要编译的源文件。-o
这个flag允许你在下一个参数定义编译输出文件的文件名。在这个例子中名字就是my_program
。
- 最后运行。定位到文件当前的目录后直接输入下面的命令即可运行:
1 | ./my_program |
注意不是my_program.cpp
,这是你的源代码,不是编译后的程序。
阅读错误信息
很多小白当运行程序时,或者编译程序时碰到了问题,爆了一堆错误信息,他们一般选择看都不看。
孩子们这不对,这些文字中会包含非常有用的信息。
如果你的程序编译失败了,那么就会返回编译错误。语法错误,变量类型错误,引用错误等等都可能会导致编译失败,进而出现错误信息:
比如下面的这个情况是:
1 |
|
相似的程序会爆这样的问题:
1 | hello.cpp: In function 'int main()': |
expected ';' before return
,错误理由已经写在这里了,我们忘了插入分号了。
哦对了在C++中编写程序记得在每行末尾带引号。
或者这种:
1 |
|
相似的程序会爆这样的问题:
1 | type_error.cpp: In function 'int main()': |
属于当时我们提到的数据类型错误。我们没有进行显示转换,就尝试使用+
运算符把std::string
类型的变量和int
加在一起。这是不能被实现的。
还有一种引用错误:
1 |
|
相似的程序会爆这样的问题:
1 | declaration_error.cpp: In function 'int main()': |
这里的问题是编译器根本找不到undeclaredVariable
是什么玩意。用变量之前记得先声明!
控制流
控制流 (Control Flow),简而言之就是决定某些指令何时执行,按何种顺序执行的一种概念。
目前为止我们写过的程序都是线性的,只会按照从上到下一种方向执行。我们可以引入控制流,让我们的程序更灵活,更强大。
我打算介绍下面两种主要的控制流类别:
- 分支 (Branching):涉及在程序中做出决策。
人话讲:“如果此条件为真,则执行这组指令;否则,执行其他操作(或者什么也不做)”。
我们将介绍if
、if-else
和switch
语句。
- 循环 (Looping):也叫做迭代,涉及多次重复代码块。
人话为:“只要此条件为真,就继续执行这组指令”,或者:“执行这组指令一定次数”。
这里我们介绍while
和for
循环。我们还将介绍可以修改循环行为的break
和continue
语句。
除了这两类,我们还会介绍一些会用在控制流中的其他语句。
分支
if
语句
最基础的分支语句差不多是if
语句了。if
语句允许你在某个条件通过的情况下执行特定代码块。
我们一般使用if
语句检查一个条件。如果条件通过,则执行与if
语句相关的代码块;如果没有通过则直接跳过。
下面是一个if
语句的例子:
1 | if (condition) { |
(condition)
处需要写入判断条件。键入的判断语句需要返回true
或者false
。一般比较常见的判断条件有==
, !=
, <
, >
, <=
, >=
, 或者逻辑判断语句||
, &&
, !
。
if...else
语句
if...else
语句建立在if
语句的基础上。if...else
相比if
新加了一个小步骤:如果条件为真执行一段代码,如果为假执行另一段代码。
下面是一个if...else
语句的例子:
1 | if (condition) { |
switch
语句
switch
语句稍微复杂一点。如果你想要根据单个变量的值,并从多个选项中选择一个代码块来执行时,那么我们就可以使用switch
语句。
流程大概是,首先switch
语句会评估一个表达式,然后将该表达式的值与标签进行比较。如果匹配某个标签,就直接执行某个标签包含的代码。
直接上例子吧:
1 | switch (expression) { |
switch
语句是个相对比较新的概念,所以在这里提供一个小程序把:
1 |
|
看懂就毕业。
还没完。这里解释一下为什么switch
语句要在每一个case后面带个break
。看看这个例子:
1 |
|
运行程序你会看见这样的输出:
1 | Case 1 executed. |
为什么输出不仅仅是Case 1 executed.
?因为当Case1完成执行后,没有跳出switch
语句,所以程序会继续向下执行,直到碰到Case2中输出语句下一行的break,这才结束了这次switch
。
循环
循环,或者叫迭代,是编写程序时最基本的控制流结构之一。
在这里主要介绍C++中两种主要的循环类型,while
和for
循环。
while
循环
while
循环是两种主要循环类型中较为简单的一种。只要给定条件为真,它就会重复执行一个代码块。
while
循环受条件控制,也就是说,只要条件成立,循环就不会停止。
只要指定条件为真,while
循环就会重复执行一个代码块。
如何判断跳出时机呢?在循环的每次迭代之前,while
循环都会对条件进行检查。一旦不符合条件,则会直接跳出循环。
例子:
1 | while (condition) { |
如果(condition)
一直为真,循环就不会停止。所以注意一下逻辑咯
for
循环
与while
循环不同,for
循环用来编写更可控的循环逻辑。一般当我们知道该循环多少次的时候,或者想要使用可控的方式遍历一串数值的时候,会用到for
。
1 | for (initialization; condition; increment/decrement) { |
这里需要稍微解释一下循环是如何判定的:
initialization
:这一部分旨在循环开始时,也就是第一次循环开始之前执行一次。
它通常用户初始化循环计数器变量,也就是一般我们常用的i
。
我们可以在这里声明并初始化一个变量,也可以直接初始化一个已有的变量。condition
:这里是判断条件。condition
是一个布尔表达式,会在每次循环开始之前进行检查,就像while循环那样。如果为假就跳出执行了。increment/decrement
:与Python不同,循环计数器的增减被直接集成到了定义for
循环的语句中。
这部分常用于更新循环计数器变量的值,例如在一次循环进行完后,增加或减少一次(或多次)计数器的值。
看个例子吧:
1 |
|
输出为:
1 | Count is: 1 |
其他
break
你已经见过这位兄弟了。
我们主要在switch
语句中见过break
,但是实际上break
在其他的控制流写法中也有应用。比如循环语句。
在循环中,当break
将要被执行,则它所在的循环则会被立即终止,继续执行循环后的代码。
直接解释有点抽象不如直接上例子。这是while
循环的例子:
1 | while (condition) { |
这是for
循环的例子:
1 | for (initialization; condition; increment/decrement) { |
想必是不难理解的。
continue
与break
相反,continue
的作用不是终止循环,而是在继续循环的基础上,跳过当前的这次循环。
对于while
和for
循环来说,continue
会直接重新评估循环的条件,或直接进入递增/递减步骤 (仅限for
循环) ,并开始下一次迭代。
while
循环:
1 | while (condition) { |
和for
循环的例子:
1 | for (initialization; condition; increment/decrement) { |
函数
怎么定义/使用函数?
我们来唠唠函数吧,每个语言必不可少的一部分。
在使用函数之前,首先我们要学会如何定义一个函数:
1 | return_type function_name(parameter_list) { |
非常简单的结构。
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_type
是void
,那么一般我们使用return
作为返回语句,而不是带参数的return
。
如果return_type
不是void
,那么我们就需要保证在函数内必须至少有一个return
语句能够在函数的每一条可能的执行路径中返回一个正确类型的值。人话将就是记得写return
。
比如我们定义一个能够将两个整数加在一起的函数,同样定义了如何调用函数:
1 |
|
当你定义了一个函数后,你就可以在程序的其他位置调用你的函数了。你可以调用后直接赋值到变量:
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
的示例中,num1
和num2
就是形参。
而实际参数 (Actual Arguments, Arguments),中文简写可以叫实参,是在函数调用时传递给函数的实际值。
例如在addNumbers(10, 20)
的函数调用语句中,10
和20
就是实际参数。
实参也可以是表达式,其值经过计算后传递给函数。它们可以是字面形式(如 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 |
|
实际上作用域不仅仅局限于函数。任何用大括号{}
括起来的代码块,包含if
, for
, while
循环等,都可以定义作用域。
在此类代码块中声明的变量是该代码块的局部变量,在代码块之外无法访问:
全局作用域
有局部当然有全局了。
全局作用域 (Global Scope)包括任何函数或代码块之外声明的变量。也就是说,在所有函数和代码块之外声明的变量具有全局作用域。
一般来讲,这些变量会被在文件级别声明,通常位于 .cpp 文件的顶部,在main()
和其他函数之外。
全局变量可从同一文件中的任何函数访问。如果能够正确使用头文件,那么你也可能从其他文件访问这些变量。
有关头文件我们后面再说。
1 |
|
生命周期
变量的生命周期是指程序执行期间变量在内存中存在的时间段。它是从创建变量 (为其分配内存) 到销毁变量 (释放对应变量的内存)的时间。
在函数或者块内声明的变量,aka局部变量,具有自动生命周期。
这些变量会在程序进入声明他们的函数或块的时候,被分配一个内存。而相应的函数或者块执行完毕后,会自动释放相对应的内存。
每次调用函数时,C++都会重新创建局部变量,分配内存,并在特定函数调用结束时销毁它们。下次调用同一函数时,会再次创建局部变量,作为新实例。
稍微有些抽象的一个概念,不过用例子来讲解就不是那么难了:
1 |
|
这个程序的输出为:
1 | Inside myFunction, localVar = 1, staticVar = 1 |
可以自己试着跑跑看,不过我想稍微讲一讲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
说明数组将存储的元素类型,如int
、double
、char
、std::string
等。array_name
声明数组变量的名称。记得遵循变量命名规则!array_size
声明数组将容纳的元素个数。使用数组名称后的方括号[]
指定。
看几个例子:
1 | int numbers[5]; |
那太好了,我们该怎么赋值呢?
与其他变量相同,你可以在初始化阶段赋值:
1 | data_type array_name[array_size] = {value1, value2, value3, ..., valueN}; |
也可以在声明后再赋值。
你可以通过使用索引(位置)访问数组元素来初始化或修改数组元素。和Python一样,在 C++ 中,数组索引从0
开始。
数组的第一个元素位于索引0
,第二个元素位于索引1
,第三个元素位于索引2
,依此类推。对于大小为n
的数组,有效索引从0
到n-1
。
1 | array_name[index] = value; // 向索引位置赋值 |
有数组能忍住不遍历的都是神人了。在C++你可以通过这样的方式实现如此操作:
1 |
|
一般数组可以怎么去用呢?
1 |
|
实际上你也可以声明多维数组:
1 | data_type array_name[number_of_rows][number_of_columns]; |
多维数组的赋值方式与一维少许不同:
1 | data_type array_name[number_of_rows][number_of_columns] = { |
杂七杂八
杂七杂八不代表不重要,所以竖起耳朵听好了。
引用传递
Pass-by-reference。按引用传递可能不是很好的翻译方式,但是能搞清楚我指代的是什么就ok。
众所周知,C++ 默认使用逐值传递。这意味着当您将参数传递给函数时,函数会复制该值,并使用这个值的副本操作。
函数内部的任何更改都只会影响副本,而不会影响原始变量。
而引用传递不是传递值的副本,而是传递一个别名或对原始变量本身的引用,你可以把引用看作原始变量的另一个名称或外号。
它不是一个单独的副本,只是以不同的方式引用存储原始变量的同一内存位置。
当函数参数被声明为引用时,它就直接与函数调用中传递的实际参数相关联。因此,在函数内部对引用参数执行的任何操作都会直接影响调用代码中的原始变量。
实际上这些概念在前面都已经解释过了。
语法定义之前讲过了我没听清楚怎么办
那么我告诉你,在 C++ 中,要将函数参数声明为引用,需要在函数定义的参数列表中的数据类型后使用&
符号。就好比:
1 | return_type function_name(data_type& parameter_name) { // 留意'data_type&'中的'&' |
这就是声明引用参数的语法了。data_type
后面的&
符号表示参数名称是一个引用。
当你调用一个带有引用参数的函数时,函数调用本身的语法与按值传递的语法完全相同;你只需将变量名称作为参数传递,剩下的事情交给&
即可。
看个小例子。加入我们想要写一个调换两个数字的小程序:
1 |
|
程序的输出应该为:
1 | Before swap: num1 = 10, num2 = 20 |
说得好。那么我们什么时候该使用按引用传递?或者说,我们使用按引用传递有什么好处?
直接修改原始参数:最显而易见的原因是我们可以改变原始变量的值。刚才的
swapValues
就是一个典型的例子,目的就是修改原始变量。避免复制大型对象:这不是什么编程初期常需要理解的概念,但是你确实需要去理解一下这个有关编程范式的问题:
对于大型数据结构,比如数组、矢量、类对象 (一会会提到),通过值传递可能效率很低,因为它涉及到创建整个大型对象的副本。
而使用引用传递效率更高,因为它避免了复制;函数直接使用原始数据。但对于
int
、double
这种简单的数据类型,性能上的差异通常可以忽略不计,但对于较大的数据,这种差异就很重要了
指针传递
是不是之前也提过了…值得我们更细节的说一说这个东西:
首先你要知道什么是指针。指针 (Pointer)是保存另一个变量的内存地址的变量。
程序中的每个变量都驻留在计算机内存中的特定位置,内存位置由地址表示,地址一般是一个数值。
指针做的就是存储这个内存地址,看起来像是“指向”另一个变量的内存位置。
定义一个指针很简单:
1 | data_type* pointer_name; // 定义一个指针数据类型变量 |
data_type
还是老样子,用来指定要使用的数据类型;
*
星号代表pointer_name
是一个指针变量;
然后pointer_name
代表变量名。
例如:
1 | int* ptrToInt; // ptrToInt 是一个能够指向int数据类型的指针 |
你有没有想过我们为什么要在按引用传递使用&
符号?当&
放在变量名前的时候,你会获取到该变量的内存地址:
1 | int number = 25; |
而*
的作用正好相反,用来解析内存地址到它所对应的值。我们将这一步叫做”Dereference”。
当我们在指针变量名称前加上*
时,它会“取消引用”指针,意味着它会提供指针所指向的变量的值:
1 | int value = *pointerToNumber; // 将pointerToNumber取消引用,拿到内存地址对应的值 (也就是刚才number中的值) |
现在你应该大彻大悟了。接下来我们讲一讲指针传递。
在指针传递中,我们不传递值或引用,而是将变量的内存地址传递给函数。
在声明阶段,我们使用data_type*
的形式定义形参:
1 | return_type function_name(data_type* parameter_name) { // 注意形参的定义方式 'data_type*' |
因为形参要求传递进一个内存地址,所以在调用的时候…
1 | function_name(&variable_name); |
…记得使用&
,得到变量的内存地址。
下面端上小例子:
1 |
|
你将会得到:
1 | Before function call, myValue = 100 |
那么什么时候要使用指针传递?是不是感觉指针传递能做的事情,引用传递和按值传递也能解决?
确实在现在的 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 | // gcd_single.cpp |
那么有人就可能在想了,如果按照分开编译的思路去重构该程序,是不是可以将gcd
程序分离出来到一个新的.cpp文件中?
事实证明你可以:
1 | // gcd.cpp |
我们将gcd的函数体分离出来,到一个全新的文件gcd.cpp
中。
如果想要允许其他.cpp
文件调用gcd.cpp
,你需要先像下面一样定义一个头文件(Header file)。
为了让分开编译的代码能够互相使用,我们需要用到头文件。可以把头文件想象成一份“合同”或者“声明”。
头文件差不多是用来告诉其他文件:“bro,这个.cpp
文件里有这些函数或者其他东西,你可以直接调用。”
比如,如果你的geometry.cpp
文件里有一个计算面积的函数calculateArea
,你会在geometry.h
文件里声明这个函数。然后,任何想用calculateArea
的文件只需要包含 geometry.h
就可以了
首先头文件内必须要先声明gcd
函数的存在,才能告诉其他文件你可以使用gcd
函数:
1 | // gcd.h |
这是课本上提供的写法。我比较倾向于使用下面的方法:
1 | // gcd.h |
这两种写法有什么区别?为何除了函数定义的哪一行,还要多写类似#ifndef
或者#pragma once
之类的其他内容?
首先我们先从你的代码如何转变为程序,也就是程序的编译过程说起。
编译说白了有点像是进行一边“翻译”,把人能够读懂的代码翻译成机器能读懂的机器码。
- 预处理 (Pre-processing):
首先进入预处理。这一步可以被看做是“准备”阶段。预处理器会查看你的代码,寻找以 # 开头的特殊指令(比如 #include)。
例如,#include <iostream>
告诉预处理器把iostream
库的代码带进来,这样你就可以使用cout
和cin
了。
这个阶段的输出是经过修改的源代码。
- 编译 (Compilation):
编译是我们主要的翻译阶段。编译器会把预处理过的代码翻译成汇编语言。
汇编语言是一种比你的 C++ 代码更低级的语言,更接近机器代码,但仍然可以被人类阅读。
- 汇编 (Assembly):
接着汇编器会把汇编语言代码转换成机器码。机器码是你的电脑处理器能够直接理解的二进制代码。
汇编器的输出是目标文件 (例如,myprogram.o)。
- 链接 (Linking):
在最后的组装阶段。链接器 (Linker) 会把所有生成的目标文件合并成一个单独的可执行程序。这些文件可能来自不同的源代码文件。
它还会把程序用到的库(比如 C++ 标准库)链接进来。
那么那些额外的语句,实则在刚才的预处理已经介绍过了。我们将这些额外的语句叫做保护符 (guards)。保护符的作用是防止我们多次引用同样的内容。
可以想象一下,你的项目下有好几个文件,都引用了同一个头文件。如果这些文件又被包含进另一个文件里,那么这个头文件就会被包含好多次,这可能会导致错误。
包含保护符就是用来避免这种情况的,这玩意确保一个头文件的内容只会被处理一次,不管它被包含了多少次。
头文件是怎么被使用的?
实则简单粗暴。在编译阶段,头文件会被直接暴力复制进当前文件的开头。也就是直接ctrl+c/v。如果包含多次的话则可能会出现问题。
最后想要使用改写后的程序结构,我们只需要在主程序内引用头文件即可:
1 | // gcd_main.cpp |
经过下面的编译步骤:
1 | $ g++ -pedantic-errors -std=c++11 gcd_main.cpp gcd.cpp -o gcd |
你的代码依然可以被正常运行。
那么我们为什么要采用分开编译?分开编译究竟好在哪了?
能够独立编写编译源文件
也可能是最主要的一点吧。我们可以在不同的文件中编写程序的不同部分。例如你就可以把logger写在logger.cpp文件里,而不用把所有东西都塞到主程序中。
测试目标代码
使用分开编译,我们就可以将一个单独的
.cpp
文件编译成目标文件,然后再具体测试这部分特定的代码。
想象一下如果我们要测试一个塞满各种东西的主程序,呃呃节省重新编译时间
如果你根据需求只是修改了一个
.cpp
文件中的一个小部分,那么我们没有必要重新编译整个项目。我们只需要重新编译修改过的文件,然后叫linker来重新链接一下就好了。
加入你在写一个操作系统。如果没有用到分开编译,那么重新写一个小小的地方就要重新编译一整个系统。一下来可能就是好几个小时了。允许以目标代码的形式提供类实现,而不公开源代码
如果你编写了一个库或者一个软件,但是不想做到100%开源。你想让其他人使用你的内容,但是不想让别人访问到你的源代码。这时候你可以使用分开编译,把你的程序以目标代码的形式提供。
如果用户想要使用你的软件,自己调用linker把目标代码串起来就好了。
例如在g++中你可以使用编译器的
-c
flag来创建目标文件:1
$ g++ -c myFile.cpp
这一步会创建一个名为
myFile.o
的目标文件。一旦我们拥有所有的目标文件,就可以再次使用
g++
把它们连起来:1
$ g++ file1.o file2.o -o myProgram
这会告诉g++编译器链接
file1.o
和file2.o
,创建一个名为myProgram
的可执行程序。
Make工具 (Make Tool)
在讲解Make工具之前,我想先介绍一下什么叫文件依赖。
在一个有很多文件的项目里,一些文件可能会依赖于其他文件。例如一个.cpp
文件可能会包含一个头文件。如果你修改了这个头文件的内容,那么包含它的.cpp
文件可能会需要重新编译。
而随着项目规模的扩大,文件的数量以及它们之间的关系会变得非常复杂。
接下来我们就可以用到Make工具了。Make非常强大,会帮助你自动化重新编译和链接程序的过程:Linux 中的make工具在某些源文件更改时智能地重新编译和链接文件。
使用make工具可以帮助你避免每次修改代码后都需要手动输入所有的g++
编译器命令。
Make工具使用一个名字叫做Makefile
的文件(没有后缀)来获取文件的依赖信息,这个文件包含了make用来理解你的项目结构以及如何构建它的规则。
Makefile
文件不是make生成的,而是你写的。
按照刚才的例子,它看起来像这样:
1 | #This file must be named Makefile |
正如你所见一个Makefile
由一系列的规则组成。每个规则指定了如何构建一个特定的目标。包含:
目标 (Target): 要生成的文件
这是make将视图创建的文件。例如一个目标文件或者一个可执行文件。
依赖 (Dependencies):目标所依赖的文件
代指如果我们要创建目标文件,我们会需要的其他内容。
如果任何一个依赖文件都比目标文件新,那么目标文件就必须要重新构建。命令 (Commands):从依赖项生成目标的命令
每个命令都必须要一个Tab字符开头 (不是空格!),这些是make来构建目标文件所要执行的实际命令,例如g++…。
差不多看起来就像是
1 | <Target>: <Dependency1> <Dependency2> <...> |
要使用make工具,我们就可以直接使用make gcd_main
命令来生成指定的目标:
1 | $ make gcd_main |
make工具的工作流大概是:首先检查目标的依赖项。
make 首先查看你指定的目标的依赖项。如果任何一个依赖项本身也有依赖项,make 会继续向下检查(也就是递归),确保所有依赖项都是最新的。
怎么检查最新的?make 使用时间戳来判断一个文件是否需要重新构建。如果一个依赖文件的修改时间比目标文件新,那么目标文件就会被认为是过时的。
如果目标不是最新的,那么 make 会执行 Makefile
中与该目标关联的命令。构建一个最新的目标。
接下来介绍几个新东西。
Makefile变量 (Makefile Variables),基本上类似于“快捷方式”,让你的Makefile
更易于阅读和维护。
我们可以在Makefile
中定义变量以避免重复键入文件名。也就是说,如果你在Makefile
中需要多次使用同一个文件名或编译器选项,你可以把它存储在一个变量中:
1 | TARGET = gcd_main |
也就是使用变量替换了。和下面的写法没有区别:
1 | gcd_main: gcd.o gcd_main.o |
要使用变量的值,只需要将变量名放在圆括号中,并在前面加上美元符号:$(MY_VARIABLE)
。
同时make也提供了一些特殊的内置变量,可以在我们的Makefile
中使用它们。例如:
$@
代表目标 (Target)这个变量会被替换为目标地名称。
$^
代表依赖项列表 (Dependency list)这个变量将会被替代为目标所有依赖项的列表
$<
代表依赖项列表中最左边的项这个稍微特殊一点:会被替换为依赖项列表中的第一个依赖项的名称。
上改写例子:
1 | gcd_main.o: gcd_main.cpp gcd.h |
和这个同理:
1 | gcd_main.o: gcd_main.cpp gcd.h |
伪目标 (Phony Target)是一个全新的概念。伪目标是一种定义你总是想让 make 执行的操作的方式,即使当前目录下存在同名的文件。
通常,make 会检查目标文件是否存在以及是否是最新版本。但是对于伪目标,make 总是会执行相关的命令,而不管是否存在同名的文件。
举个例子看看吧:
1 | # 续刚才的makefile文件接着往下写: |
当我们声明了目标clean
,即使我们当前的文件系统下没有叫做clean
的文件,rm -f gcd_main gcd.o gcd_main.o gcd.tgz
命令仍然会被执行:只需要键入make clean
即可。make tar
同理。
但是这个用法有一个小陷阱。假如文件系统下真的有clean
文件怎么办?make会发现我们的clean
文件是最新的,所以到最后你的命令不会被执行。这就是为什么我们要用到伪目标:
通过.PHONY
声明clean
和tar
是一个伪目标,即便真的有一个最新的同名文件,你的命令也会被执行。
不如来看个最终示例如何:
1 | FLAGS = -pedantic-errors -std=c++11 |
1 | $ ls |
1 | $ make gcd_main |
1 | $ ls |
1 | $ make tar |
1 | $ ls |
1 | $ make clean |
1 | $ ls |
到这里这一小节完结。
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_CAPS
,MAX_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 |
同理你需要引用这个头文件才能使用rand()
和abs()
:
1 |
在使用预定义函数之前,我们首先需要知道并清楚这些东西:
- 你的函数的期望输入。例如
sqrt()
会需要一个数字来作为输入,而不是字符。 - 你的函数产出的期望输出。例如
sqrt()
会返回输入的平方根。
了解预定义函数的最佳方法是阅读c++标准库的文档。像cplusplus.com这样的网站就是很好的资源。
与预定义函数相反就是自定义函数了。
自定义函数是程序员(也就是你)自己创建的,用于执行预定义函数无法执行的特定任务。
比如当你有需要多次使用的特定逻辑片段,或者当你想将程序分解成更小、更有条理的部分时,你就需要定义这些函数。
一般我们在定义一个新函数时,我们需要按照下面的流程考虑:
- 需要什么样的输入才能工作?例如我们可能需要进行两个数字的相加。
- 需要函数产生什么样的输出? 如果我们想要将两个数字相加,那么我们就该输出两个数字的和。
- 函数将遵循哪些逻辑从输入获得输出?虽然在这个例子里就是简单的把两个数相加,那么在更复杂的函数里是不是需要更进一步经过更多复杂的处理?
经过这三步你就可以大概理清楚如何定义,并如何编写这个函数了。
函数定义,调用和声明
现在我们深入了解创建和使用自己的函数的具体细节。
函数定义
函数定义告诉编译器函数是如何工作的。这就像为特定任务提供配方。它有两个主要部分:
函数头是函数定义的第一行。它告诉我们关于函数的几个重要信息,包括:
返回类型
函数将向调用它的程序发送何种数据?它可以是一个整数int
、一个十进制数double
、一个字符串std::string
,或者什么都不是void
。函数名
这是我们用来调用函数的标识符。选择一个描述性的名称,告诉你函数的功能。例如calculateArea
,findMaximum
。
在上一节我们简单提到过函数名的命名规则,所以如果你忘了可以去复习一下。参数(可选)
这些变量在调用函数时接收输入值。您可以在括号()
内指定每个参数的类型和名称。函数可以没有参数、只有一个参数或多个参数。函数体
这是用大括号{}
括起来的代码块,包含函数将执行的实际指令(语句)。
我们定义一个函数看看吧:
1 | double larger(double num1, double num2) { |
double
:表明返回类型是 double
,表示该函数将返回一个十进制数。larger
: 这是函数的名称。(double num1, double num2)
: 这些是参数。函数需要两个输入值,类型都是 double
。在函数内部,这些输入值将被称为 num1
和 num2
。{ ... }
: 这是函数体。它包含比较 num1
和 num2
并返回较大值的逻辑。return num1;
和 return num2;
: return 语句用于将函数值返回给调用者。
虚函数
有时,您可能希望函数执行某个操作,但不返回特定值。例如,向屏幕打印一条信息的函数。在这种情况下,可以使用 void
作为返回类型。
也就是我们CS里面常说的一个流程,Procedure。
void
返回类型: 表示函数不返回任何值。return;
在虚函数里是可选的。 void
函数可以使用 return;
提前退出函数,但不返回任何值。如果不包含 return
语句,函数将直接执行完主体中的最后一条语句,然后返回。
比如下面这个虚函数:
1 |
|
就是一个在控制台打印问好消息的虚函数。
void
表明函数不返回任何内容。greet
: 函数名称。(std::string name)
说明函数接受一个输入,即代表姓名的字符串。
最后函数体会使用提供的名称打印问候信息。
函数调用
要实际使用我们定义的函数(或预定义函数),需要先调用它。这意味着你要告诉程序执行该函数内部的代码。
调用一个函数时,需要在函数名称后加上括号()
。
如果函数的定义中有参数,则在调用函数时需要在括号内提供相应的参数(实际值)。这些参数将传递给函数的参数。
返回值的函数调用可以作为表达式的一部分使用。函数调用将被求值,其返回值将在表达式中使用。
例如我们之前定义过的larger函数:
1 |
|
在下面的这一行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 |
|
在本例中,由于 main()
在给出 larger()
的完整定义之前就调用了 larger()
,因此我们需要在开头进行声明,让编译器知道有一个名为 larger
的函数存在,它返回什么类型的值,以及它期望的参数类型。
这就是正确用法。
控制流
本节主要想要讲清楚程序在涉及函数时如何执行。
首先众所周知我们从 main()
开始: 程序总是从 main()
函数内部的第一条语句开始执行。
随后我们在主函数中顺序执行。函数(包括 main()
)中的语句按照出现的顺序一个接一个地执行。
当出现函数调用时,暂停当前函数,也就是说,当前函数(发出调用的函数)的执行会暂时停止。
下一步是参数值复制(逐值传递)。如果函数使用默认的 “逐值传递”机制(稍后我们将详细讨论),那么在函数调用中提供的参数值将被复制到被调用函数的相应参数中。
最后,控制权转移到被调用函数。一旦参数(可能)被复制,程序的控制权就会跳转到被调用函数的起始位置。
调用函数按顺序执行,直到return
语句结束调用函数: 当被调用函数遇到 return
语句时,其执行就会停止。
如果函数应该返回值(即其返回类型不是 void
),则返回语句中指定的值会传回调用函数。
如果无返回值,也就是 void
函数,return;
语句仅仅用来结束函数的执行。
在被调用函数执行完毕后(通过执行 return
语句或到达终点),程序的控制权将返回到调用函数。
当 main() 函数执行完毕时,整个程序结束(通常是在末尾执行 return 0;
语句)。
非常清晰明了的执行流程。有关于逐值传递相关的内容之前好像讲过,不过一会会再复习一遍的。
变量和作用域
变量的 “作用域” (Scope) 是指程序中可以访问该变量的区域。了解范围对于避免命名冲突和意外行为至关重要。
在函数内部声明的变量(包括函数的参数)称为局部变量。
局部变量只能在声明它们的函数内部使用,并在函数调用时产生,并在函数执行完毕时销毁(释放内存)。这意味着它们不会在同一函数的不同调用之间保留其值。
不同的函数可以使用相同名称的局部变量,而且它们不会相互干扰,因为它们存在于不同的作用域中。
1 |
|
因为x
是一个在myFunction()
中定义的局部变量,在主函数中并没有一个相对应定义的变量,所以会报错。
相反,有局部变量就有全局变量。
在任何函数定义之外(通常在源文件顶部)声明的变量称为全局变量。 全局变量一旦声明,全局变量就可以被声明之后的同一源文件中的任何函数访问。
全局变量在程序的整个运行过程中都存在,也就是说全局变量在程序启动时创建,并一直存在到程序结束。
虽然全局变量在函数间共享数据可能看起来很方便,但通常认为大量使用全局变量是不好的做法。这会使你的代码更难理解、调试和维护,因为你很难跟踪程序的哪一部分在修改全局变量。
大家通常使用全局变量定义需要在整个程序中都能访问的常量,例如 const double PI = 3.14159;
。
1 |
|
在这里,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) |
是的这些就是之前那些原封不动搬过来了。我太懒了。