普林斯顿 COS217 C 语言系统编程笔记(一)
001:课程概述
在本节课中,我们将要学习普林斯顿大学COS 217系统编程课程的总体介绍。我们将了解课程目标、教学资源、评估方式以及本课程将使用的核心计算环境。
课程介绍与目标
我是Christopher Moretti,计算机科学系的讲师。这是我在普林斯顿的第11年,也是第5次教授COS 217课程。我的背景是分布式计算系统,但目前专注于计算机科学教育。
本课程的目标主要分为两大方面。
目标一:大规模编程
第一个目标我们称之为“大规模编程”。这指的是软件工程技术,旨在帮助你编写比COS 126课程中规模更大的程序,并在设计上拥有更大的自主权。
以下是五个我们希望帮助你掌握的具体事项:
-
编写模块化代码:代码应能体现抽象概念,同时隐藏实现细节。
-
测试代码:不仅仅是检查网站上的几个样例是否能通过,而是采用更严谨、结构化的测试方法。
-
调试代码:不仅仅是到处插入
print语句来发现问题,而是使用该领域标准工具进行更严谨、结构化的调试。 -
提升代码性能:并判断何时不值得进行优化。
-
使用工具支持上述活动:学习在Linux环境下使用GNU工具箱中的标准工具进行编程。
目标二:接触低级语言
第二个目标是让你接触一些低级语言。这既是为未来课程做实际准备,也是为你打开一扇可能从未深入思考过、但值得探索的窗口。
在COS 126中,你经常听到“Java会做…”或“计算机会做…”这样的说法,但很少深入探究“为什么Java会那样做”以及JVM底层发生了什么。本课程将探讨一些低级语言,包括那些没有现代高级语言那么多便利功能的老式语言,甚至包括完全不可读的机器语言。
通过这种方式,我们希望揭开引用类型如何工作、内存分配与回收的真实过程等奥秘,为你学习COS 316(操作系统)或计算机组成等课程打下基础。
课程核心理念
计算机系的这三门课程(COS 126, 226, 217)各有不同,但有一个共同目标:帮助你真正获得编程上的成熟,或者说提升你的编程技能。
我们希望你能自信地处理非平凡问题的新规范,能够以结构化的方式编写大型程序,能够将大问题分解为合理的模块化组件,能更了解程序内部运行机制并以此指导编程设计选择,并能熟练使用行业标准工具。
本课程的核心是确保你掌握必要的编程技能,以便在未来的系统和应用课程中表现出色。当你掌握了编程技巧并熟悉了环境和工具,你就能更专注于课程内容知识,而非编程的机械操作。
为什么选择C语言?
一个常见的问题是:为什么选择C语言,而不是Java、Python或Go?
-
C语言支持“向下探索语言栈”的理念。它比Java更底层,与汇编语言关系密切。
-
它为系统编程提供了一个良好的基线,让你全面了解内存分配、指针类型、位运算等知识,从而能更好地理解本课程也将涉及的汇编语言。
-
如前所述,它能为你应对像操作系统这样的系统编程课程(无论是用C++、C、Go还是其他系统语言授课)做好准备。
-
C语言也间接支持“大规模编程”的目标。作为一种低级语言,C提供了更多创建抽象的机会。
-
C语言存在一些缺陷,这些缺陷促使我们讨论软件工程原则,而这些原则在所有语言中仍然非常相关。
学习Linux环境
另一个具体目标是学习Linux。需要明确的是,我们不会深入编程Linux内核本身,而是编写作为操作系统客户端的程序,并使用通常与操作系统捆绑的工具。
Linux及其变体是工业界的绝对标准,特别是在使用GNU工具的Linux环境下编程。Linux的开源和良好规范特性也使其非常适合教育和研究。
Linux环境与GNU工具一起,构成了一个丰富、开源、标准化且可扩展性强的编程环境。
课程资源
接下来,我们看看在整个课程中,有哪些资源可以帮助你学习课程材料。
讲座与观看派对
每周会有两次讲座发布。每次讲座都会有一个“观看派对”,这基本上是我坐在Zoom会议室里,等待大家就讲座主题提问。这是最接近线下讲座问答环节的方式。
讲座内容混合了不同层次的细节,以及C语言特定主题和更广泛的软件工程主题。即使主题层次较高,我保证它们仍然与当前作业材料相关联。
关于如何观看讲座的一个建议:当幻灯片上出现“iClicker”问题时,请暂停视频,思考答案,并最好以某种物理形式(写下来、说出来)记录你的答案,以确保你真正理解了内容。
预科课程
与讲座混合高低层次内容和语言细节与软件工程不同,预科课程更侧重于语言细节,并且与作业直接相关。成功学习本课程的最佳方式就是参加预科课程,积极参与,并在课后学习预科笔记。
课程网站
本课程主要涉及三个网站:
-
课程主网站:包含所有课程工作人员的联系方式、预科课程和办公时间安排、作业说明以及课程政策。
-
Canvas:普林斯顿新的学习管理系统。你可以在这里找到预科课程的Zoom链接、Ed讨论论坛的直接链接、阅读材料链接以及NameCoach的链接。我们强烈建议你使用NameCoach录制你的名字发音。
-
Ed讨论论坛:这是一个问答讨论网站,学生可以提问并从同学和课程工作人员那里获得答案。请注意礼仪,不要随意刷屏,并且务必遵守课程交流政策,不在Ed上透露你的解决方案。
教材与参考资料
本课程有三本核心教材:
-
《C程序设计语言》:一本全面的C语言教科书。
-
《ARM汇编语言》:一本ARM汇编教科书,与本课程使用的汇编特性非常匹配。
-
《程序设计实践》:一本软件设计入门书,作者是历史上为数不多的真正“10倍效率程序员”之一,书中高层次的软件工程智慧对本课程很有帮助。
此外,课程网站上还链接了一些权威的硬件架构和指令集手册,供课程最后几个作业查阅。在整个课程中,你还会大量依赖Linux的man页面来了解工具和库函数的描述。
办公时间与实验室助教
寻求帮助的次佳方式是参加办公时间。课程团队的每位成员每周至少有2小时办公时间,覆盖除周六外的每一天。本学期,Anne将在周日提供办公时间。
我们采用Zoom会议加排队表单的方式。你还可以向实验室助教寻求帮助,他们是学过本课程并表现优秀的本科生,每天提供4-6小时的调试帮助。
课程评估
本课程的评估方式和权重今年有所变化。为了强调在线环境下变化最小的部分,作业占课程总成绩的三分之二。我的座右铭是:提高编程能力的唯一途径就是编程。对于COS 217,只有通过实际完成作业,你才能开始培养编程成熟度。
此外,还有期中考试和期末考试。你的参与度分数将基于预科课程的出勤和参与情况,以及其他可能因素(例如在Ed上积极帮助他人)。
编程作业
有一个介绍性作业(作业0),你应该尽快完成,因为它能帮助你设置计算环境。之后有六个实质性作业,前三个是个人作业,后三个可以与伙伴合作完成。
作业是课程中非常重要的一部分。第一个实质性作业(作业1)的截止日期是9月17日。请务必尽早开始,不要拖延。COS 217的作业比COS 126复杂得多,调试也更为耗时。早开始意味着你有更多时间陷入困境然后摆脱困境,也能利用更多的办公时间和实验室助教时间。
学术诚信
最后,关于课程政策,我想恳请大家:不要作弊。学术不端听证会会耗费教师大量时间,并很可能导致你失去一年的学业时间。请不要这样做。
以上就是管理部分的内容。我知道有些繁琐,但这是确保我们建立正确期望并让你了解可用资源的最佳机会。如果你对这部分内容有任何疑问,请随时在Ed上提问。
计算环境介绍
现在,我们开始深入本学期的第一个真正主题:我们将要使用的计算环境。
如前所述,我们将在整个课程中广泛使用Linux操作系统和GNU工具。Linux是一个操作系统,开源且免费。虽然许多人认为Linux只是超级程序员使用的黑色命令行窗口,但实际上它是许多常见系统(如Chromebook)的支柱。
为了简化并保证所有学生环境一致,本课程主要通过命令行访问Linux服务器。我们采用客户端-服务器模型,作业最终在服务器上运行,这很好,因为C和ARM汇编代码是不可移植的。
我们的服务器名为“armlab集群”,实际上是两台功能强大的机器(armlab01和armlab02),通过别名armlab.cs.princeton.edu进行负载均衡。
客户端是你的电脑。你只需要能够使用安全外壳协议连接到armlab。
术语澄清
需要澄清一些经常被混淆的术语:
-
终端模拟器:你用来SSH连接到服务器的程序(如PuTTY、Mac终端)。它提供键盘和屏幕之间的输入/输出连接,以支持文本用户界面。
-
Shell:一个接受指令以运行其他程序的程序。我们使用的Shell叫做
bash。
所以,流程是:打开终端模拟器 -> 通过SSH连接到armlab -> 登录后,Shell启动 -> 通过Shell在armlab上获得操作界面。
工作流程
因为你只需要能通过互联网SSH连接到armlab,这意味着本课程的工作可以在任何地方完成。
-
许多学生选择在服务器上进行所有开发:SSH登录,使用服务器上的编辑器(如
emacs或vi)编写程序,然后直接在服务器上编译、测试、运行和提交。 -
许多其他学生选择在自己的机器上编写所有代码(可能使用Sublime或VS Code),这完全可以。但你需要找到一种方法将文件传输到armlab上进行构建、测试、调试和最终提交。
构建和测试必须在armlab上进行,因为之前提到的可移植性问题。提交也在armlab上进行,因为文件是提交到该集群的文件系统。
你完全可以找到适合你的工作流程,作业0将通过提供一些合理的选项供你练习来帮助你做到这一点。
总结与待办事项
本节课中,我们一起学习了COS 217课程的总体框架、目标、资源和核心的计算环境。
本课后的待办事项:
-
浏览课程网站,阅读那里的大量信息,特别是课程政策。
-
登录armlab,开始熟悉该环境。
课程的第二部分将深入帮助你完成这些任务。
002:Bash命令行基础教程
在本节课中,我们将学习Linux Bash命令行环境的基础知识。对于许多从CS126课程直接进入CS217的同学来说,命令行可能仍然是一个神秘的工具。虽然CS126中接触过一些命令,但通常是机械地复制使用。在本课程中,我们将更频繁、更深入地使用命令行。因此,尽早熟悉并掌握它是至关重要的。
概述与心态调整
上一节我们介绍了课程背景,本节中我们来看看如何开始使用命令行。
首先,请记住,提高编程技能的最佳方式就是多编程。同样,熟悉Linux环境的最佳方式就是沉浸其中,多尝试、多实践,而不是想方设法避免使用它。
其次,请放心大胆地尝试。你现在使用的账户几乎是全新的,任何因操作失误而丢失的内容,都只是你自己实验的产物。这是一个几乎没有风险的学习和提升技能的绝佳机会。
基础导航与信息获取
现在,我们打开终端模拟器,已经登录到armlab服务器,准备在shell中运行命令。
shell提示符本身就能提供一些信息。它通常显示机器名和当前目录。例如,~ 符号是用户家目录的快捷方式。
以下是获取系统信息的基本命令:
-
hostname:显示完整的机器主机名。请注意,Linux命令是大小写敏感的。 -
whoami:显示当前登录的用户名。 -
pwd:打印当前工作目录的完整路径。pwd代表 print working directory。
目录操作
Linux文件系统是一个树形结构,根目录是 /,子目录用斜杠分隔。
以下是操作目录的核心命令:
-
ls:列出目录内容。ls代表 list。-
不加参数时,列出当前目录。
-
可以跟目录路径作为参数,例如
ls /usr或ls ..。
-
-
cd:改变当前目录。cd代表 change directory。-
cd [目录路径]:切换到指定目录。 -
cd或cd ~:直接返回家目录。 -
cd -:返回上一个所在的目录。
-
-
.和..:这是两个特殊的目录名。-
.代表当前目录。 -
..代表父目录。
-
Tab键自动补全是你的好帮手。输入命令或路径的前几个字母后按Tab键,系统会尝试补全。如果按两次Tab,则会列出所有可能的选项。
创建与删除目录
上一节我们学会了如何查看和切换目录,本节中我们来看看如何创建和删除它们。
以下是管理目录的命令:
-
mkdir:创建新目录。mkdir代表 make directory。-
mkdir dirname:创建单个目录。 -
mkdir -p parent/child:使用-p选项可以一次性创建多级目录(如果父目录不存在也会被创建)。
-
-
rmdir:删除空目录。rmdir代表 remove directory。-
rmdir dirname:只能删除空目录,这是一种安全保护。 -
同样可以使用
-p选项删除一串空目录,例如rmdir -p parent/child。
-
如果命令执行出错,可以查看帮助:
-
命令 --help:例如mkdir --help,会显示用法信息。 -
man 命令:例如man mkdir,会打开更详细的手册页。按空格键翻页,按q键退出。
文件操作
现在我们已经有了一个目录,接下来看看如何操作文件。
以下是文件操作的基础命令:
-
cp:复制文件。cp代表 copy。-
cp source destination:将源文件复制到目标位置。 -
目标可以是文件名(重命名复制)或目录名(保留原名复制到目录)。
-
-
mv:移动或重命名文件。mv代表 move。-
mv oldname newname:重命名文件。 -
mv file /path/to/dir/:将文件移动到另一个目录。
-
-
rm:删除文件。rm代表 remove。-
rm filename:默认会询问是否确认删除。 -
使用
rm -f filename可以强制删除而不询问(-f代表 force)。
-
注意:在默认安全设置下,cp 和 mv 在覆盖已存在文件时也会询问确认。
查看文件内容
创建了文件后,我们自然需要查看它们的内容。
以下是查看文件内容的常用命令:
-
cat:将整个文件内容打印到终端。cat代表 concatenate。cat filename
-
more:分页查看文件,只能向下翻页。-
more filename -
按
空格键向下翻一页,按Enter向下翻一行。
-
-
less:比more更强大的分页查看器,可以上下翻页、搜索等。-
less filename(“less is more”) -
同样使用
空格键和Enter翻页,还可以用上下箭头键,按q退出。
-
-
head:显示文件开头若干行。head -n 10 filename:显示前10行。
-
tail:显示文件末尾若干行。tail -n 5 filename:显示最后5行。
-
wc:统计文件的行数、单词数和字符数。wc代表 word count。-
wc filename -
wc -l filename:只统计行数。
-
输入输出重定向与管道
命令行强大的功能之一是将命令串联起来。你已经在CS126中接触过管道和重定向。
以下是核心概念和操作:
-
输出重定向
>:将命令的输出写入文件,而不是显示在终端。-
cat file1 > file2:将file1的内容覆盖写入file2。 -
echo "hello" > newfile.txt:创建一个包含“hello”的新文件。
-
-
管道
|:将一个命令的输出作为另一个命令的输入。-
head -n 20 largefile.txt | wc -l:先取文件前20行,然后统计这些行数(结果应为20)。 -
ls -l | less:将ls -l的长列表结果用less分页查看。
-
-
创建空文件:
-
touch newfile:如果文件不存在则创建空文件;如果存在则更新其修改时间。 -
> newfile:一个常用技巧,通过重定向一个空命令(或无输出)来创建空文件。
-
注意:像 cat、wc 这样的命令,如果不给文件名参数,它们会等待从“标准输入”读取数据。你可以直接输入内容,然后按 Ctrl+D 发送EOF(End-Of-File)信号来结束输入。
文件详细信息与权限
使用 ls -l 命令可以查看文件的详细信息。
-rw-r--r-- 1 cmeredit users 1988 Aug 25 10:30 bashrc
drwxr-xr-x 2 cmeredit users 4096 Aug 25 10:35 filetest
-
第一列是权限,例如
-rw-r--r--。-
第一个字符表示类型(
-是普通文件,d是目录)。 -
后面9个字符每3个一组,分别代表文件所有者、所属组和其他用户的读(
r)、写(w)、执行(x)权限。
-
-
后续信息包括链接数、所有者、所属组、文件大小(字节)、最后修改时间和文件名。
-
可以使用
chmod命令来修改文件权限,详情请查阅man chmod。
总结与后续
本节课中我们一起学习了Bash命令行环境的基础操作。我们涵盖了如何导航目录、创建删除文件目录、查看编辑文件内容,以及使用强大的管道和重定向功能组合命令。
记住,熟练使用命令行的关键在于多练习。请利用预科班发放的参考资料,尝试里面列出的每一个命令,并探索它们的不同选项。
在下一次讲座中,我们将介绍一些更高级的主题,例如如何配置你的Bash环境(如 .bashrc 文件),使其更高效、更符合现代开发习惯,并分享一些实用的技巧和快捷方式。
003:Emacs与Bash 🖥️
在本节课中,我们将学习如何更高效地使用Emacs编辑器和Bash命令行环境。我们将从一些实用的Emacs配置技巧开始,然后深入探讨Bash的常用命令、历史记录、环境变量、通配符以及程序退出状态等核心概念。掌握这些工具和技巧,将为你后续的C语言编程打下坚实的基础。
Emacs配置技巧 🛠️
上一节我们介绍了基本的开发环境配置。本节中,我们来看看如何通过一些配置让Emacs编辑器用起来更顺手,更像一个现代集成开发环境(IDE)。
显示并删除尾部空格
在团队协作和版本控制中,代码行尾部的多余空格可能会造成困扰。以下配置可以让你在Emacs中直观地看到这些空格。
在默认的Emacs配置文件中,有一个被注释掉的设置。通过取消注释以下行,可以启用尾部空格高亮显示:
(show-trailing-whitespace t)
启用后,行尾的空格会以红色标记显示。同时,配置文件还提供了一个快捷键 Ctrl-x w 来一键删除所有尾部空格。
启用鼠标支持
如果你习惯使用鼠标,可以启用Emacs的鼠标模式。在配置文件中添加或取消注释以下行:
(xterm-mouse-mode 1)
启用后,你就可以在Emacs窗口中点击光标进行定位了。需要注意的是,在某些Windows终端中,这可能会影响从终端复制文本的功能,必要时可以临时禁用此功能。
窗口分割与管理
Emacs支持强大的窗口分割功能,允许你同时查看和编辑多个文件。
以下是管理窗口的常用快捷键:
-
Ctrl-x 2: 水平分割当前窗口。 -
Ctrl-x 3: 垂直分割当前窗口。 -
Ctrl-x o: 在多个窗口间切换焦点。 -
Ctrl-x 0: 关闭当前焦点所在的窗口。 -
Ctrl-x 1: 关闭其他所有窗口,只保留当前窗口。
为了更直观地在窗口间导航,可以配置使用 Shift 加方向键进行切换。在配置文件中添加类似以下设置:
(windmove-default-keybindings)
配置后,按住 Shift 并使用方向键即可在分割的窗口间快速移动。
集成编译与错误跳转
无需离开Emacs即可编译代码并快速定位错误,能极大提升效率。
你可以配置一个快捷键(例如 Ctrl-c m)来调用编译命令。一个智能的配置是:如果当前目录存在 Makefile,则使用 make 命令;否则,直接调用 gcc 编译器。
# 示例的编译命令
gcc217 -c -o program.o -g program.c
编译后,Emacs会自动分割窗口显示编译输出,并将光标跳转到第一个错误或警告所在行。你可以使用 Ctrl-c n 和 Ctrl-c p 在错误间向前和向后导航。修复代码后,在编译缓冲区按 g 键即可重新编译。
代码自动补全
对于提高编码速度和减少拼写错误,自动补全功能非常有帮助。你可以安装并配置 auto-complete 之类的插件。配置后,在输入代码时(例如输入 #include 或变量名),按 Tab 键可以触发补全建议。
以上是一些能显著改善Emacs使用体验的配置。Emacs高度可定制,如果你有其他特定需求,可以随时向课程团队或社区寻求配置建议。
Bash命令行进阶 🐚
现在,让我们从编辑器的具体细节切换到更广泛的Shell使用技巧上。我们将在这个包含多个文本文件的目录中进行演示。
高效输入与历史记录
首先,我们介绍一些能帮助你在命令行中工作得更舒适、更高效的技巧,特别是如何纠正输入错误和减少重复输入。
如果你经常输错字符顺序,可以使用以下快捷键快速交换:
-
Ctrl-t: 交换光标前两个字符的位置。 -
Alt-t: 交换光标前两个单词的位置。
接下来是本节课Bash部分最重要的技巧之一:利用命令历史记录。Bash会记录你执行过的命令(默认为最近1000条)。查看历史记录的命令是:
history
要重复执行历史命令,有几种方法:
-
!!: 执行上一条命令。 -
!n: 执行历史记录中编号为n的命令(通过history查看编号)。 -
!prefix: 执行最近一条以prefix开头的命令。
最强大的方法是使用反向增量搜索:按下 Ctrl-r,然后输入你想查找的命令中的任意连续字符,Bash会实时搜索并显示匹配的历史命令。多次按 Ctrl-r 可以继续向前搜索。
命令查找与环境变量
我们一直在使用各种命令,但Shell是如何找到这些命令的呢?
which 命令可以告诉我们一个可执行文件的位置:
which cat
Shell是根据 PATH 环境变量中定义的目录列表来查找命令的。你可以查看这个变量的值:
echo $PATH
PATH 变量通常在shell的配置文件(如 ~/.bashrc)中设置。export 命令用于将变量导出,使其在子Shell中也可用。
# 在 .bashrc 中设置 PATH 的示例
export PATH="/usr/local/bin:$PATH"
要查看所有环境变量,可以使用 printenv 命令。结合 grep 可以筛选特定变量:
printenv | grep ^PATH
强大的文本搜索:grep
grep 命令用于在文本中搜索匹配指定模式的行。它是一个极其强大的工具。
最基本的用法是在文件中搜索字符串:
grep "pattern" filename
它也可以从标准输入读取数据,因此常与其他命令通过管道(|)结合使用:
echo $PATH | grep "/usr/bin"
grep 支持复杂的正则表达式。例如,使用 -P 选项启用Perl兼容的正则表达式来查找连续出现的“t”:
grep -P "t+" filename
命令别名
有些命令你一直在使用,但它们可能并不是“原装”的。alias 命令可以创建命令的快捷方式或预设常用选项。
查看当前所有别名:
alias
你会发现,像 ls、grep 这样的常用命令都被设置了别名,通常是为了添加颜色输出、安全确认等默认选项。例如,ls 的别名可能包含了 --color=auto 和 -a 选项。
这些别名通常定义在 ~/.bashrc 配置文件中。你可以使用 grep 查看它们:
grep alias ~/.bashrc
你也可以创建自己的别名,例如快速查看天气:
alias weather='curl wttr.in'
Bash编程:循环与命令替换
Bash本身也是一个功能完整的编程语言。下面我们通过一个链条式的例子来演示几个相关概念:通配符、循环、命令替换和 seq 命令。
首先,介绍一个简单命令 seq,用于生成数字序列:
seq 1 5 # 输出 1, 2, 3, 4, 5
seq -w 1 10 # 输出 01, 02, ... 10,-w 选项使宽度一致
命令替换 允许你将一个命令的输出捕获,并作为另一个命令的输入或赋值给变量。有两种语法:
# 使用反引号
files=`ls *.txt`
# 使用 $(),推荐这种方式,更清晰且易于嵌套
files=$(ls *.txt)
现在,我们可以结合 seq 和命令替换来创建一个Bash的 for 循环:
for i in $(seq -w 1 10); do
echo "Number $i" > "file$i.txt"
done
这个循环会生成 file01.txt 到 file10.txt 共10个文件,每个文件内容为对应的数字。
文件名通配与花括号展开
Bash支持使用通配符来匹配文件名,这称为“路径名扩展”或“globbing”。
常用的通配符有:
-
?: 匹配任意单个字符。 -
*: 匹配任意长度的任意字符(包括零个)。 -
[abc]: 匹配方括号内的任意一个字符(如a、b或c)。 -
[^abc]或[!abc]: 匹配不在方括号内的任意一个字符。
例如,列出所有以7结尾的文本文件:
ls *7.txt
列出以6或7结尾的文件:
ls *[67].txt
另一种强大的扩展方式是 花括号扩展,它可以生成任意组合的字符串:
# 生成 file1.txt, file2.txt, file3.txt
echo file{1..3}.txt
# 生成 file-a.txt, file-b.txt
echo file-{a,b}.txt
# 结合前导零生成等宽文件名
echo file{001..003}.txt
程序退出状态
在C语言编程中,main 函数会返回一个整数作为程序的退出状态。在Linux/Unix惯例中:
-
0 表示成功。
-
非0 表示失败(不同的非0值可表示不同类型的错误)。
在Bash中,上一个命令的退出状态保存在特殊变量 $? 中。
true
echo $? # 输出 0
false
echo $? # 输出 1
你可以利用逻辑运算符 && (AND) 和 || (OR) 的短路特性来根据命令成功与否执行后续操作:
# 如果 command1 成功,则执行 command2
command1 && command2
# 如果 command1 失败,则执行 command2
command1 || command2
例如,在失败时打印错误信息:
false || echo "The command failed."
总结 📚
本节课我们一起学习了提升Emacs和Bash使用效率的众多技巧。
在Emacs部分,我们探讨了如何配置以显示尾部空格、启用鼠标、管理多窗口、集成编译调试以及使用自动补全。
在Bash部分,我们涵盖了命令历史的高效利用、环境变量PATH的作用、强大的grep文本搜索、命令别名的设置、以及Bash本身的编程能力如循环和命令替换。我们还学习了文件名通配符的使用和程序退出状态的含义。
这些工具和概念是进行系统编程的基础,熟练掌握它们将使你在后续的C语言学习和项目开发中更加得心应手。接下来,你就可以运用这些知识开始完成作业0了。下周,我们将正式开启C语言的学习之旅。
004:Git入门与作业0设置指南 🚀
在本节课中,我们将学习如何为COS 217课程作业设置Git和GitHub环境。本教程将详细介绍作业0中第2步到第5步的操作流程,帮助你配置Git、创建GitHub账户、建立仓库以及导入课程提供的初始代码库。
概述
本教程将指导你完成以下核心步骤:
-
在本地配置Git。
-
创建并验证GitHub账户。
-
创建你的第一个私有GitHub仓库。
-
学习基本的Git命令(
init,add,commit,push,pull,clone)。 -
将课程提供的作业初始代码库导入到你自己的私有仓库中。
第2步:配置本地Git环境 ⚙️
上一节我们介绍了本教程的目标,本节中我们来看看如何配置你的本地Git环境。首先,你需要确保Git已安装在你的计算机上。
检查Git安装
在终端中运行以下命令来检查Git是否已安装:
git --version
如果系统返回了Git的版本号,说明Git已安装。如果未安装,请根据你的操作系统(Mac、Windows或Linux)按照Git官网的指引进行安装。
配置Git用户信息
安装Git后,你需要配置你的用户名和邮箱。这些信息将与你未来的每一次代码提交(commit)关联。你只需要配置一次。
以下是配置命令:
git config --global user.name "你的姓名"
git config --global user.email "你的邮箱"
git config --global color.ui auto
git config --global core.editor "你喜欢的编辑器命令(如:vim, nano, emacs)"
-
--global参数表示此配置将应用于你在这台电脑上的所有Git仓库。 -
user.name和user.email用于标识提交者。 -
color.ui auto让Git在终端中输出彩色信息,便于阅读。 -
core.editor设置Git使用的默认文本编辑器,用于编写提交信息等。
验证配置
配置完成后,你可以使用以下命令查看所有配置项:
git config --list
此命令会列出包括你刚才设置的所有Git配置。这些配置实际上保存在你的用户主目录下的 .gitconfig 文件中。
第3步:创建GitHub账户 👤
上一节我们完成了本地Git的配置,本节中我们来看看如何创建GitHub账户。GitHub是一个基于Web的Git仓库托管服务,我们将用它来存储和管理作业代码。
注册账户
-
访问 GitHub官网。
-
点击“Sign up”按钮。
-
按照页面提示,输入你的用户名、邮箱和密码。
-
完成人机验证(例如,选择图片中的特定物体)。
-
选择免费计划(Free plan)。
-
你可以跳过后续的个人信息调查,直接完成设置。
验证邮箱
注册后,GitHub会向你提供的邮箱发送一封验证邮件。请登录你的邮箱,点击邮件中的验证链接以激活你的GitHub账户。
第4步:创建你的第一个GitHub仓库并学习基础操作 📂
现在你已拥有GitHub账户,本节我们将学习如何创建一个私有仓库,并掌握最基本的Git工作流程:在本地修改代码,然后推送到远程仓库。
创建新仓库
-
登录GitHub,点击页面右上角的 “+” 图标,选择 “New repository”。
-
为仓库命名(例如
step4-test)。 -
重要: 选择 “Private”(私有)。本课程的所有作业仓库都必须是私有的,以防止你的解决方案公开。
-
暂时不需要勾选 “Initialize this repository with a README” 等选项,直接点击 “Create repository”。
本地操作与远程同步
创建仓库后,GitHub会显示指引。我们将在本地终端中操作:
-
创建本地目录并初始化Git仓库:
mkdir step4-test cd step4-test git initgit init命令将这个目录初始化为一个Git仓库,创建一个隐藏的.git文件夹来存储所有版本控制数据。
-
创建文件并添加到暂存区:
echo “# My First Repo” > README.md git add README.mdgit add命令将文件添加到“暂存区”(staging area),表示你准备将这些更改保存下来。
-
提交更改到本地仓库:
git commit -m “Add README file”git commit命令将暂存区的更改正式保存到本地仓库的历史记录中。-m参数后面是本次提交的说明信息。
-
将本地仓库链接到远程GitHub仓库:
git remote add origin https://github.com/你的用户名/step4-test.git这条命令为你本地的仓库添加了一个名为
origin的远程地址,指向你刚刚在GitHub上创建的仓库。 -
将本地提交推送到远程仓库:
git push -u origin maingit push命令将你本地的提交上传到远程仓库(GitHub)。-u参数设置了上游分支,以后可以直接使用git push。
在另一台机器(如课程服务器)上获取代码
假设你需要在课程服务器(如armlab)上工作,你可以通过 git clone 命令轻松获取代码:
git clone https://github.com/你的用户名/step4-test.git
cd step4-test
这会在服务器上创建一份仓库的完整副本。
同步更改
这是一个典型的双向工作流程:
-
从本地推送到远程/服务器: 在本地
commit后,使用git push。 -
从远程/服务器拉取到本地: 在另一处使用
git pull来获取最新的更改。
第5步:导入课程作业仓库(镜像仓库) 🧩
上一节我们学习了管理自己仓库的基础操作,本节中我们来看看如何获取课程提供的作业初始代码。你不能直接向课程的公共仓库提交代码,而是需要将其“复制”到你自己的私有仓库中,这个过程称为“镜像”或“导入”。
使用Git命令导入
以下是标准步骤:
-
裸克隆课程仓库: 这只会下载仓库的元数据(历史、分支等),不包含实际的工作文件。
git clone --bare https://github.com/COS217/assignment-repository.git这会在当前目录创建一个名为
assignment-repository.git的文件夹。
- 在GitHub上创建新的私有仓库: 例如,命名为
my-assignment-repo。
-
将裸克隆的仓库镜像推送到你的新仓库:
cd assignment-repository.git git push --mirror https://github.com/你的用户名/my-assignment-repo.git--mirror参数会推送所有分支、标签和提交历史,完整地复制整个仓库。
-
克隆你自己的新仓库到本地开始工作:
cd .. git clone https://github.com/你的用户名/my-assignment-repo.git cd my-assignment-repo现在,你本地就有了一个包含课程初始代码的私有仓库副本,你可以在此基础之上开始你的作业,并使用第4节学习的
push和pull命令来同步代码。
总结 🎯
本节课我们一起学习了为COS 217课程设置Git和GitHub的完整流程:
-
我们配置了本地Git环境,设置了用户信息。
-
我们创建并验证了GitHub账户。
-
我们创建了第一个私有GitHub仓库,并练习了
init、add、commit、push、pull、clone等核心Git命令。 -
我们掌握了如何将课程提供的公共作业仓库镜像导入到自己的私有仓库中,为开始实际编程工作做好了准备。
通过这套流程,你可以轻松地在个人电脑和课程服务器之间同步代码,并基于课程提供的框架开始你的作业开发。
005:C语言导论 🚀
在本节课中,我们将要学习C语言的基础知识,包括其历史背景、设计目标、与Java语言的核心区别,以及如何从源代码构建可执行程序。我们将通过三个具体的C程序示例来逐步讲解,为完成作业1打下坚实基础。
C语言的历史与设计哲学 🏛️
上一节我们介绍了课程背景,本节中我们来看看C语言的起源。C语言诞生于新泽西州的贝尔实验室,它是AT&T的研究部门,后来成为朗讯科技,现已被诺基亚收购。C语言最初是作为构建Unix系统工具的工具而设计的,最终它取代了汇编语言版本,成为重新实现Unix内核的语言。这是实现可移植Unix环境的第一步。
C语言的发明者是丹尼斯·里奇。他在其著作《C语言的发展》中的回顾,很好地契合了本课程对C语言的使用定位:它的主流成功、低层级的访问能力(尽管牺牲了可移植性),以及它鼓励程序员简洁地表达自己。
他的观点是:C语言从不试图束缚程序员。它一直吸引着系统程序员,他们喜欢用简洁的方式编写强大的表达式。它允许程序员在牺牲可移植性的同时,直接访问许多机器级特性,否则这些特性需要使用汇编语言。他著名的总结是:C语言古怪、有缺陷,但却取得了巨大的成功。虽然历史的偶然性确实有所帮助,但它显然满足了对一种系统实现语言的需求:效率足以取代汇编语言,同时又足够抽象和流畅,能够在各种环境中描述算法和交互。
C与Java的对比 ⚖️
C语言相对古老,诞生于20世纪70年代初,在70年代末非正式标准化,80年代末正式标准化。此后有各种语言扩展和新标准,直到几年前,还有新的标准在制定中。但这些新标准要么完全是表面上的(例如,注释的认证方式),要么专注于并发编程(例如,可重入安全性和线程效率)。我们课程有意选择使用较旧的版本,以避免新标准的复杂性。
大多数进入217课程的学生都了解Java。因此,本讲接下来的部分将重点比较C语言与Java的设计和基础。
历史上,C和Java是为非常不同的目的而设计的。C被用作构建操作系统及其工具的系统编程语言,而Java最初被认为是下一代伟大的Web语言(尽管结果并非如此)。最终得出的结论是:C语言在设计时不太关心安全性,它被专家用来创建替代本身就不安全的汇编语言工具。同样,对于可移植性,它替代的是完全不可移植的工具。而Java,由于其Web应用的初衷,至少在一定程度上考虑了可移植性和安全性。
一个持续的冲突将是安全性与可移植性 与 低层级访问和速度 之间的权衡,Java在很大程度上代表了前者,而C语言在很大程度上代表了后者。
C程序的特点与构建过程 🔧
沿着这个思路,我们来谈谈C程序的特点,首先从源代码如何变成可运行程序开始。
在我们深入C语言细节之前,让我们退一步,回顾一下我们对Java的了解,以及Java程序是如何构建和运行的。
如果我们有一个程序 MyProg.java,我们会执行命令 javac MyProg.java。这会调用Java编译器 javac,它生成字节码,这是程序的一种与机器无关的表示形式。然后,javac 本身是一个机器语言代码程序,它特定于特定的硬件和操作系统。在我们的例子中,它特定于ArmsLab。它会读取 MyProg.java 作为输入,并写入 MyProg.class 字节码作为输出。当我们运行它时,我们会执行命令 java MyProg,这会再次调用Java解释器 java。同样,这是一个特定于我们机器的机器语言代码程序,但它读取的是可移植的 .class 文件。
与C语言对比,C语言的可执行文件(或由C程序生成的可执行文件)是使用C语言工具(俗称“编译器”)构建的,但这个术语并不完全准确,我们稍后会讨论。它们生成的是机器语言代码,而不是字节码。
与Java不同,在Java中我们必须运行 java 和我们的 .class 文件,而在C语言中,机器代码可以直接由CPU执行。在Java中,字节码是一个中间步骤,字节码本身不可运行,它是在虚拟机上执行的,这增加了一层间接性。JVM的额外间接层增加了复杂性和潜在的低效率,但带来了标准化和可移植性的好处。
可移植性、安全性与效率的权衡 📊
我们提到了可移植性,现在来详细讨论。在源代码层面,Java理论上完全可移植。C语言大部分可移植,但稍差一些,我们很快会看到一些可移植性障碍。在源代码层面以下,Java字节码再次理论上完全可移植。Java解释器的接口在所有平台上都是一致的,即使实现不同,也能将字节码与底层硬件隔离。然而,机器语言程序 MyProg 完全不可移植。机器语言代码特定于特定的硬件平台和操作系统。因此,结论是,Java程序在源代码层面,尤其是源代码层面以下,比C程序更具可移植性。
我们提到的另一个特性是安全性和效率。因此,可移植性显然是Java的优势,我们将在安全性和便利性(如边界检查和垃圾回收)方面看到同样的情况。Java有很多很好的特性,但它们确实有代价:速度和可预测性。将Java视为嵌入式系统的语言。想象一下,你驾驶着高科技汽车,带有自适应制动系统。就在你猛踩刹车的瞬间,你的制动控制器决定这是清理它认为永远不会再使用的内存的好时机。当然,C语言的反驳点是,C语言可能超级快,但如果你从不释放任何内存,它最终会耗尽并崩溃,在这种情况下,可能是字面意义上的“崩溃”。所以这些都是设计决策,不一定有绝对的正确或错误方式,但必须根据语言的用途来考虑。
以下是C语言与Java在关键特性上的对比:
-
边界检查:Java自动进行边界检查,如果越界则抛出异常。C语言不进行边界检查,越界访问会导致未定义行为(通常是崩溃或数据损坏)。
-
内存管理:Java使用垃圾回收器自动管理内存。C语言需要程序员手动管理内存(使用
malloc和free)。 -
速度与可预测性:由于缺少运行时检查和管理开销,C语言通常比Java更快,执行时间更可预测。
它们很好地互补,都是在你最初几门编程课程中学习的好选择。
类比:C语言像什么车? 🚗
想一想它们的区别,采用这个类比:哪辆车可能让你联想到C语言,而不是Java?
许多人可能会选择A,那辆生锈的旧卡车。是的,C语言古老,不太现代,肯定不可移植,那辆卡车当然也不可移植,所以这不是一个坏答案。但C语言仍然运行并被广泛使用,而那辆卡车肯定不是。沃尔沃也不是正确的类比,沃尔沃传统上是安全汽车,它们在50年代末率先使用三点式安全带,有时被认为是无聊的“妈妈车”。所以,如果非要选,这更像是Java,而不是C。那么剩下的就是B,那辆改装过的汽车。它更快一些,但代价是风险更高。这就是C语言,至少与Java或类似语言相比是这样。
第一个C程序:字符计数 📝
如果C语言是你的“热力改装车”,那就让我们开始编程吧。作为警告,本讲剩余部分节奏相当快,请做好准备。它会抛出完成作业1所需的所有内容,希望有足够的内容能让你掌握。但请放心,后续的讲座和辅导课中会有更系统的C语言覆盖。
本讲剩余部分将围绕三个小型C程序展开,它们将作为我们C语言的初始示例,并再次为你完成作业1取得进展做好准备。
我们的第一个程序是 charcount,它相当于你在Linux和Bash概述中看到的 wc 程序,当使用 -c 参数调用以计数字符时。在本课程后面,当你自己用汇编语言实现它时,你会再次看到这个程序的一个更大版本。
charcount 在这个输入上的输出是什么?我们运行它,然后键入标准输入:第一行,回车键,第二行,回车键。然后我们发送信号表示“标准输入完成”。花点时间,暂停视频,做出你的选择并说出来,答案是什么?在第一行,我们有 L I N E、空格、1,然后是换行字符。所以当你按回车键时,它实际上是在流中插入了一个叫做换行符的字符。所以那是7个字符。第二行有 L I N E、空格、2 和换行符,又是7个。EOF,你发送的表示“输入完成”的信号本身不是一个字符。这意味着我们有7加7等于14个字符。正如所承诺的,这与 wc -c 的结果一致。
以下是程序代码,快速浏览一下。有些行对你熟悉Java的眼睛来说会非常熟悉,其他的可能看起来相当陌生。但没关系,我们将几乎逐行分解它。
当你要求shell运行一个可执行文件时,它会加载构建系统生成的机器代码。按照惯例,机器代码以一个运行时系统开始,该系统包含一些我们称为 _start 的基本启动代码。_start 的主要工作是调用 main 函数。因此,对于我们编写的代码,执行从 main 开始。在这个意义上,这就像Java一样。
但请注意,在Java中你有 public static void main(String[] args),而在C语言中它简洁得多。我们将在课程后面学习如何复制Java中的一些冗长描述,但现在,我们只有一个简单的 main 函数,它不接受参数并返回一个 int:int main(void)。
进入 main 函数,这看起来像我们期望从Java中看到的变量定义,除了C语言中 c 是 int 类型,而不是 char。当 c 表示字符时,看到 int c 是一件奇怪的事情。但这里的原因是,我们将用来填充 c 值的函数 getchar 返回一个 int,而不是一个 char。你可能会自然地追问:好吧,那为什么是 int 呢?原因是我们希望能够识别任何有效字符,同时也能识别我们是否完全未能读取一个字符。这意味着我们需要比 char 更多的空间。如果任何有效的 char 都可能被返回,我们就填满了 char 所有可能的值,但仍然没有办法表明我们未能读取一个字符。因此,我们应该将 getchar 返回的值赋给一个 int,因为它返回一个 int,因为它需要那个额外的值来表示:“我没有给你返回一个 char,我做不到。”如果你将返回值赋给一个 char,可能会导致麻烦,因为不一定清楚你读入的值是与“无法读取字符”对应的值(被强制转换为 char)还是字符本身。我们稍后会更详细地看到这一点。
getchar 是来自某个标准库的函数。我们的程序知道它的存在,是因为我们使用了 #include 包含了一个头文件。我们稍后再谈这个。正如前面的例子提到的,没有所谓的EOF字符,EOF只是头文件中定义的一个数字,它是表示“无法写入字符”的值。也许有比EOF(代表文件结束)更好的方式来思考这个问题,你可以称之为“读取失败”或“输入结束”之类的。在我们的系统上,EOF被定义为-1,它在 stdio.h 头文件中,但我们实际上不需要知道或关心这个,我们只使用EOF作为语义上有意义的常量。
此时,我们进入循环,即我们函数的核心。我们使用初始读取(在循环前)的结果,并比较我们是读取了一个有效字符还是从 getchar 得到了那个非字符状态。如果我们读取了一个字符,那么我们就增加计数以匹配。完成递增后,我们将再次调用 getchar 以复制初始读取,并为循环的持续条件重新设置。最终,我们将耗尽标准输入,getchar 将失败;即它将返回EOF。这应该结束循环。循环之后,我们使用 printf 打印结果,这与你从126课程中熟悉的 System.out.printf 或 System.out.println 没什么不同。最后,我们返回。返回的工作方式与Java中相同,它们返回到调用此函数的位置,如果函数未指定为 void 返回类型,则发回一个值。如前所述,main 的调用函数是 _start,这意味着当我们返回到它时,我们回到了 _start,而它所做的只是清理它为 main 设置的任何状态,然后立即以 main 返回的相同退出状态退出。
我们在Linux和Bash讲座中讨论过退出状态。C标准规定,成功的程序必须以退出状态0结束,这与我们在Linux和Bash中讨论的一致。C标准规定,任何失败的程序都应该以退出状态 EXIT_FAILURE 结束。但 EXIT_FAILURE 这个值依赖于系统,在我们的系统上,它是1。值 EXIT_SUCCESS 和 EXIT_FAILURE 定义在 stdlib.h 中,所以这里我们直接返回字面量0,但如果我们想使用 EXIT_SUCCESS 作为0的语义上有意义的常量,我们本可以 #include <stdlib.h>。
C程序的构建过程详解 🛠️
好了,这就是我们之前在高层次上看到的程序,与看到Java构建其程序文件的区别。但之前的讨论集中在GCC创建一个可执行文件,而不是字节码。现在,有了一个用作示例的程序,我们准备更详细地深入研究如何构建C程序,我们将看到为什么称GCC为“编译器”并不完全是全部真相。
在这个幻灯片上,你可以看到快捷命令,这是默认情况。它在一个调用中完成构建过程的所有阶段,从源代码开始,最终生成可执行文件。使用 -o 选项,下一个参数是可执行文件的名称。如果没有 -o 选项,可执行文件将由于奇怪的历史原因被命名为 a.out。请注意,在Linux中,如前一次讲座所述,文件扩展名仅用于人类可读性,通常不赋予可执行文件。我们可以称它为 charcount.exe,这并不比 charcount.out、charcount 单独或 charcount. 有任何更大的意义,任何字符串直到总文件名限制(根据情况是256或4096)都可以。因此,我们实际上通过调用其名称来运行 charcount 可执行文件。回想上一讲,./ 表示“在此目录中查找程序”。
因此,charcount 从标准输入读取字符,直到达到EOF。如何获得EOF的故事与你从126课程的Java中习惯的一样,默认情况下,标准输入绑定到键盘,因此我们可以通过按 Ctrl+D 来指示EOF。再次注意,EOF命名不当,所以如果它绑定到一个文件,我们实际上不必发送 Ctrl+D,当文件结束时就会达到EOF。
再简要提醒一下126课程和之前讲座中的一些内容。我们可以重定向标准输入,从某个文件读取而不是在程序中键入。我们也可以重定向标准输出,让程序写入文件而不是写入终端。
现在为了说明,让我们单独执行每个步骤。我们说过构建过程更复杂,有一个快捷方式可以执行所有步骤。让我们逐个步骤来看。请注意,这是作业1所要求的。我在这里讲得很快,但你会对这些阶段有一个概述,你也将在本周的第二次辅导课中更详细地看到它们。
构建过程的四个阶段 🔄
我们从之前用C语言编写的初始程序开始。对于 getchar 和 printf 的语法,除了知道它们是函数之外,没有任何其他指示。我们不知道它们是否被正确使用,也不知道它们做什么。
构建的四个阶段中的第一个阶段是预处理器。它通过 -E 选项调用(没人记得这个,但我喜欢把它想成E代表“额外的东西”)。这是因为预处理器会在你的源代码中放入一些额外的东西。按照惯例,它生成一个带有 .i 扩展名的文件,所以你可以记住它处理了 #include。因为它所做的是:它从源代码中删除一些注释,并处理预处理指令,即那些以 # 号开头的行。
我们将看到其中的几个,但这里又是我们的第一个 #include,它表示程序依赖的一个接口。预处理器处理 #include 的方式是用整个 stdio.h 文件的内容(加上一些元数据标记)替换那一行。请注意,这个新内容实际上是给编译器的信息转储,告诉它其他接口中可用的一大堆符号(如函数和常量)的详细信息。在 #include 添加之后,预处理器接下来会扫描以替换所有的宏,这里宏用于符号常量。用它们代表的值替换,所以这里的EOF实际上是 stdio.h 中定义的一个宏,它被替换为-1。好了,这就是我们在 .i 文件中拥有的内容,它仍然是C语言,大约如此,那里有一些标记。我们还可以看到另一个使用 int 的原因:getchar 的返回。在我们的系统上,没有字符-1,char 是无符号的,最小值是0。如果我们给-1,它会溢出,从0回到最大的可能 char 值,因此它将不明确。如果我们把 char 放在那里,我们会说,嗯,我们是指最大的 char 值还是指负一?这就不清楚了。这就是为什么我们使用 int。我们还可以看到 #include 是如何工作的。顶部的这些行是声明,它们是给编译器的关于某个符号存在的信息。可以把它看作是:“嘿,编译器,getchar 是一个函数,它不接受参数并返回一个 int。” 这允许编译器最终进行类型检查,并确保它被正确使用。
我们说过编译器是构建过程的第二步。编译是将C语言翻译成汇编语言。调用是通过 -S 选项,我的助记符主要是基于我们实际上将在本课程中编写汇编语言并为其使用 .s 后缀,但你也可以把S看作是同步到一种更低级的语言,这是我们第一次向更低层级下降。
因此,这两个声明是预处理器给编译器的“情书”,告诉它 getchar 和 printf 是存在的。这意味着编译器可以验证对它们的任何调用是否类型检查正确。然后编译器逐步进行翻译。它开始并说,我能生成汇编代码吗?指示 main 的定义。在块内(这里是一个复合语句),编译器将生成汇编语言代码来为变量分配空间,然后生成完成块其余部分的指令。请注意,与Java不同,在C89中,声明必须出现在块的顶部,而不是在块体内其他代码之后。这也是编译器使用声明来完成类型检查的地方,因为它看到了对 getchar 和 printf 的调用,所以它可以检查并确保调用具有它从声明中期望的相同类型。
好了,所以编译器只是一个C代码的类型检查器和语法检查器,然后将C代码翻译成汇编语言。请注意,此时,getchar 和 printf 仍然只是我们被告知是函数的符号,但我们仍然没有它们实际的代码。
编译器之后是汇编器。汇编器只是另一个翻译器,这次是将汇编语言翻译成机器语言。请注意,一个常见的术语混淆是认为汇编器必须生成汇编语言,但事实并非如此,汇编器接受汇编语言并生成机器代码。如果你一直喜欢(而不是抱怨)我对GCC选项的奇怪助记符,这次我喜欢 -c 代表“无法再阅读了”。
因为确实在这一点上,我们有了机器语言,它只是一堆二进制,我们无法再阅读它了,尽管请继续关注,到课程结束时,我们实际上将学会逐位解释一小部分机器语言及其指令。所以这里我们只有一堆二进制,但此时,我们仍然不知道 getchar 和 printf 在哪里,我们只有调用它们的机器语言代码,我们没有它们本身的代码。我们最好尽快找到它们,因为这是四步构建过程的第三步。
最后,构建过程中的第四个工具是链接器。链接器的工作同样是最终去查找程序中尚未解析的机器代码引用。在这种情况下,getchar 和 printf 都存在于标准I/O库中,在我们的系统上,该库与所有其他标准库一起被塞进一个存档文件中,位于 /usr/lib。因此,它将从该库存档中找到正确的代码,并将其合并到最终程序中。链接器运行后,我们就拥有了所需的一切,我们有了一个完整的可执行程序。
这就是构建过程。在前几个作业中,我们将要求你单独完成这些步骤中的每一步,以便了解当你神奇地键入 gcc 时,幕后到底发生了什么。
程序结构优化思考 💡
在我们继续第二个小程序之前,暂停视频,花点时间思考是否有更好的方式来构建我们的 charcount 程序的主体。请注意,这里的D是现有代码,只是使用了奇怪的分页,以便我的脸能放在角落。
暂停视频,看看你是否喜欢其中一个胜过其他。
这是一个判断性的问题。查看每个选项,A和D在不同的地方有 getchar,这有点奇怪。C有一个无限循环和一个 break 语句。有些老师,尤其是高中老师,会抓狂地说:“不,你绝不能使用 break,有更好的方式来构建程序。” B在循环条件内部有一个神秘的赋值语句。B实际上是最地道的C语言写法,尽管有些人可能会说它是最特殊的C语言或最不可读的C语言。我确实倾向于最喜欢B。接下来我会选择D,只是因为我倾向于不喜欢那些没有数学表达式说明循环将执行多少次的 for 循环。但这真的都是判断和个人风格问题。
第二个C程序:转换为大写 🔠
我们的第二个程序将是一个将其所有输入转换为大写的程序,也许我们可以称之为“喊话器”。
要编写这个程序,你必须知道如何识别小写字符。这引发了对字符如何存储的讨论。每个字符实际上存储为一个整数,只是一个非常小的整数,计算机系统的字符代码将每个字符映射到一个唯一的整数。我们的系统对单字节字符使用ASCII,这在大多数现代计算机上通常是一个很好的假设,但事实上不是唯一的选择。那么,看看这个程序的第一个版本。
我们再次声明 int c,因为我们将 getchar 的结果存储在其中。我们有那个神秘的、特殊的、地道的(随你喜欢哪个词)while 循环和赋值语句。然后我们有一个 if 语句,其中内置了97、122和32。从126课程中学到的东西应该跃然纸上。那就是:魔数难以阅读和理解。更糟糕的是,这些魔数是ASCII码。因此,它们不仅难以阅读和理解,还带来了可移植性问题。ASCII很普遍,但并非无处不在。例如,这在一个使用另一种编码(如EBCDIC)的系统上就行不通,因为值是硬编码的。算法本身对于大小写字母不是相隔32个字符的任何编码都不起作用,而且没有理由它们必须如此,所以这是算法的一个相当强的假设。
我们可以通过使用字符字面量来绕过这些魔数,字符字面量像在Java中一样,用单引号表示,一些特殊字符由以反斜杠开头的转义序列表示。所以这是我们的第二个版本,它使用字符字面量。它比前一个版本更好,因为它不假设ASCII。然而,它确实做了这个奇怪的事情,即对 char 进行算术运算。在C语言中,我说过字符字面量与 char 变量相同,它只是一个数字,一个非常小的 int。所以在ASCII上,小写 'a' 等同于97,因此我们可以从中减去东西,我们可以用它进行大于或等于比较,这完全没问题。立即在另一个我提到的系统EBCDIC上,小写 'a' 是129,再次表明我们不能仅仅假设值本身,但我们确实通过使用字符字面量在两者中都绕过了这个问题。因此,对 char 进行算术运算完全没问题。但现在有什么问题呢?嗯,它仍然有我提到的另一个问题,它假设大写字母的数字代码相对于小写字母的数字代码有某个恒定的偏移量。好吧,这里不一定是32,这更好。但有什么能说明它们有恒定的偏移量呢?它假设它们是连续的,这对于EBCDIC来说是不正确的,如果我们想制作一个通用程序,事情真的会很快变得复杂。
使用标准库函数 📚
幸运的是,有一个更好的方法。看手册页。有一个库可以做到。这里的库是 ctype.h,其优势在于你知道标准库已经为你运行的机器配置好了。除了上一张幻灯片上用于识别字符是否属于某个类别的 is 函数外,我们还有两个函数来对字符执行转换。因此,如果我们试图“喊话”,我们在这里自然要做的事情是将 islower 与 toupper 配对使用。
以下是最终的样子。快速回顾一下之前的程序,如果我们省略了 #include <ctype.h> 会怎样?暂停视频,回答它,大声说出来,让自己承诺,然后重新开始。如果我们省略了 #include <ctype.h>,编译器在遇到对 islower 和 toupper 的调用时会生成一个警告,因为它说:“我从未被告知这些东西存在,所以我无法对它们进行类型检查。” 但它不会生成错误,因为它们实际上可能被链接器找到。在这种情况下,它们来自一个标准库,链接器可以找到它们,因此构建将在没有错误的情况下成功。但我们确实希望有这个 #include,这样编译器可以更早地发现我们是否犯了类型错误,也因为通常打印出大量警告似乎不雅观。
好了,关于这段代码还有一个脑筋急转弯,但我会立即揭晓答案:if 语句真的必要吗?这个问题的正确答案是:我不知道,让我去查一下手册页。
在这里的 toupper 手册页上,“返回值”部分说 toupper 不会改变不是小写字母的字符。现在回到完整的问题:if 语句真的必要吗?暂停视频,自己决定这对于正确性是否是必要的,不必要但你会保留它,还是不必要并且你会去掉它。从手册页来看,不是A,它不必要,因为如果它不是小写字符,toupper 无论如何都不会对它做任何事。所以A不是正确答案。B与C,正如我们所见,是一种趋势,是一个判断性的问题。B可以说更不神秘。C可能更快,因为每次循环少了一个函数调用和一个条件分支。但这真的取决于你,我可能会选择B,仅仅是因为如果对所有内容都调用 toupper,你现在依赖于阅读你代码的人去阅读手册页,尽管我一直强调“读手册页,读手册页,读手册页”,但我知道你不会去读手册页,所以我们可以在代码中加上它,代价只是几个时钟周期。
第三个C程序:单词首字母大写 🆒
最后,让我们看第三个程序,它将为你提供几乎所有作业1所需的C语言组件的示例,甚至更好的是,你的程序的总体设计。
我们的第三个程序将是将每个单词的首字母大写,而不是每个字母。为了简单起见,让我们将一个单词定义为一串由空白字符分隔的字符,这是对英语用法的模糊近似,无意进行语言上的诡辩,争论什么 exactly 是单词。
因此,要设计这个程序,我们需要决定每个字母是否应该大写,只有当它位于每个单词的开头时才选择这样做。这听起来像是126课程的考试题,要找出需要大写的字母的正则表达式。但如果它是正则表达式,那么,我们可以构建一个DFA(确定性有限自动机)。感谢Stephen Kleene,34届毕业生。
确定性有限自动机(DFA)设计 🤖
快速回顾一下126课程,确定性有限自动机是一种计算机器,它按顺序处理字符串,只考虑字符串中的每个字母一次,通过随着字符串的每个后续字母在状态之间转换。在126课程和作业1中,每个状态都有一个状态,指示它是接受(成功)状态还是拒绝(失败)状态。这仅适用于机器在该状态停止时,并不是说你一旦到达接受状态就成功了。不,每个状态要么是接受要么是拒绝,只有当机器停止时才重要。好了,但对于这个问题,我们实际上没有成功或失败的标准,我们只是完成每个字符的动作,所以我们不会将任何状态标记为接受或拒绝。
那么,考虑使用这个DFA的算法,我们从“正常”状态开始。只要它不是字母字符,我们还没有开始一个单词,所以我们只是打印它。一旦我们遇到一个字母字符,那就是一个单词的开始,所以现在我们需要将其大写,但从现在开始我们在一个单词内部,所以我们应该转换到另一个状态。在那个状态中,每个字母字符我们都将原样打印。最终,在那个另一个状态中,我们将读入一个非字母字符,在这种情况下我们说:“啊哈,这是一个单词的结束”,所以让我们转换回“正常”状态,我们将打印出非字母字符,现在我们处于“正常”状态,等待下一个单词开始。我们继续这个过程,直到输入耗尽。这是一般算法。让我们看看这在代码中如何实现。
代码实现与优化 🧑💻
这是使用 int 表示状态的DFA的第一次代码尝试。由于这个设计选择,我看到一大堆0和1,没有真正的意义表明哪个是哪个。我的意思是,我知道我曾经有“正常”和“在单词中”。那么,哪个是哪个?我可以回去看看,说:好吧,默认状态、起始状态是“正常”,所以0是这里状态的初始值,所以0必须是“正常”。但真的,有更好的方法。我们可以做得更好。
我们应该力求使程序符合明确指定的原始DFA,原始DFA确实使用了像“IN_WORD”这样语义上有意义的名称。我们可以在C语言中使用枚举类型来实现。枚举类型(或简称 enum)允许你指定自己的命名整数常量,可以显式指定,或者由于我们实际上不关心这里的值是什么,873和4126和0和1一样好,我们可以隐式指定。在这张幻灯片上,我们实际上是隐式的,但方便的是,当没有显式定义时,C语言将从0开始,每个新成员比前一个多1,所以这段代码将给我们0和1,就像硬编码的版本一样。
好了,所以这是我们的程序,使用枚举去除了魔数。它更好。但这里仍然有一个问题。有什么问题?请随时暂停视频,看看你是否能识别出这段代码有什么不太好的地方。
这段代码嵌套得非常深,我们越来越向右移动。也许对于这个每个状态只有一个 if-else 的两状态情况来说,这还不是世界末日,但如果每个状态有更复杂的逻辑要处理,并且如果状态数量激增怎么办?这里的 case 代表DFA中的不同状态。那个 switch 语句会变得非常糟糕,因为没有模块化。
没有模块化的解决方案是,我知道这会让你震惊,模块化。在这种情况下,如果我们担心处理任何一个状态的逻辑变得足够复杂会成为问题,我们可以将所有实际逻辑推入每个状态一个函数中,并使 switch 语句只是一个最小化的框架,它只是说:我应该调用哪个函数?调用它。
让我们看看那是什么样子。我们的代码在这里变得更长了,因为模块化有一些额外定义函数的开销,但这没关系。代码行数并不意味着程序更差。我们的 main 函数现在在每个 switch 分支中都有最少的代码:调用与当前状态和输入字符相关联的动作函数。然后它获取函数返回值并设置新状态,然后是一个 break 来隔离各个分支。这相当不错。但如果我在考试中把它交给你,你需要多长时间才能弄清楚它是做什么的?我相信如果它有描述不同部分的注释,它会读起来更好。
所以让我们称之为A-, admittedly 这是吹毛求疵,因为我们一直从示例中省略注释,只是为了适应幻灯片。但在你的作业代码中,这不会是吹毛求疵,我保证。好了,无论是否夸大其词,这确实作为一个方便的过渡,来谈谈我们的内部文档要求,即,你应该在代码中放什么注释?
函数注释应从调用者的角度描述代码的行为。调用者传递给它什么,函数做什么,以及它返回给调用者什么?这些注释适合用于API,因此不应揭示函数的实现细节。
代码注释规范 📝
这里有一个例子供你思考,请随时再次暂停视频,以便完整阅读两条注释。
第一条注释过于深入地涉及了调用者甚至不需要知道的其他函数的交互细节,以及该函数的内部设计。它几乎就差说“这个函数使用了一个 while 循环”。而后者则只列出了相关信息:函数影响哪些全局状态(标准输入和输出流),函数从其参数和输入计算什么,以及它返回什么值。这正是函数调用者需要知道的。
接下来的四张幻灯片是程序的完整版本,修复了之前版本的一系列问题。一旦你为作业1编写了DFA,你可以使用这段代码作为在C代码中实现DFA结构的绝佳起点。
总结与作业指引 🎯
好了,所以我保证,通过将你的程序设计成模仿 upper1 的最终版本,只是为你将在作业1中需要的更复杂的DFA进行扩展,你将在A1中取得很大进展。
因此,我鼓励你尽快开始作业1,它截止日期是17日星期四,也就是一个半星期后,但你现在已经拥有了完成该作业所需的一切。
在本节课中,我们一起学习了C语言的历史与设计哲学,对比了C与Java在可移植性、安全性和效率上的核心差异,详细剖析了C程序从预处理、编译、汇编到链接的完整构建过程。我们通过字符计数、转换为大写和单词首字母大写三个示例程序,逐步讲解了C语言的基本语法、控制结构、函数使用、标准库调用以及模块化设计和代码注释规范。这些知识为你完成作业1奠定了坚实的基础。
006:C语言速成课
在本节课中,我们将深入学习C语言的核心概念,包括变量声明、赋值、控制语句以及输入输出。我们将探讨语言设计背后的决策,并理解这些决策如何影响C语言的特性。
变量声明
上一节我们介绍了课程概述,本节中我们来看看C语言中一个非常基础的概念:变量。
在C语言中,变量在使用前必须声明,并且必须在声明时指定其类型。这与某些语言(如Bash)不同,后者允许使用未声明的变量。C语言使用声明语句来完成这一任务,并且允许在同一语句中声明多个变量,或在声明时进行初始化。
以下是声明变量的基本语法:
int i; // 声明一个整数变量
int j = 0; // 声明并初始化一个整数变量
int a, b, c; // 在同一语句中声明多个变量
C语言要求局部变量在复合语句(即代码块)的开头,在任何其他语句之前声明。这一规定源于早期编译器优化的需要,但现代标准(如C99)已放宽此限制。
赋值操作
在声明变量后,我们需要为它们赋值。C语言中的赋值使用等号(=)操作符。与某些语言不同,C语言中的赋值是一个操作符,它会返回被赋值变量的新值,而不是旧值。
以下是赋值操作的几个例子:
i = 5; // 简单赋值
j = i = 0; // 链式赋值,i和j都被赋值为0
while ((c = getchar()) != EOF) { // 在条件检查中进行赋值
// 循环体
}
赋值操作符的返回值特性使得链式赋值和条件检查中的赋值成为可能,但这有时会影响代码的清晰度。
控制语句
现在我们已经了解了处理数据的语言特性,接下来让我们看看处理算法的语言特性。
控制语句是编程语言中实现算法逻辑的基础。C语言提供了多种控制结构,包括条件语句和循环语句。
复合语句
复合语句,也称为代码块,是C语言中实现顺序操作的基本结构。它由一对花括号({})包围,可以包含多条语句。
条件语句
C语言使用if和if-else语句来实现条件选择。与Java不同,C语言中的条件可以是任意表达式,而不仅仅是布尔表达式。
以下是条件语句的示例:
if (condition) {
// 语句1
} else {
// 语句2
}
Switch语句
除了if-else语句,C语言还提供了switch语句,用于处理多个离散值的情况。需要注意的是,switch语句中的break语句不是必需的,但省略它会导致“贯穿”行为,即控制流会进入后续的case子句。
以下是switch语句的示例:
switch (value) {
case 1:
// 代码块1
break;
case 2:
// 代码块2
break;
default:
// 默认代码块
}
循环语句
C语言提供了多种循环结构,包括for循环、while循环和do-while循环。这些结构允许我们重复执行代码块,直到满足特定条件。
以下是循环语句的示例:
for (int i = 0; i < n; i++) {
// 循环体
}
<https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-cos217-c-sys-prog/img/86c74e9eb66818027b334914322c7ace_29.png>
while (condition) {
// 循环体
}
do {
// 循环体
} while (condition);
其他控制语句
C语言还提供了break、continue和goto语句。break用于跳出循环或switch语句,continue用于跳过当前循环的剩余部分,而goto则允许无条件跳转到代码中的标签处。尽管goto语句存在,但在现代编程中应尽量避免使用。
输入输出
C语言本身没有内置的输入输出功能,而是通过标准库(如stdio.h)提供的函数来实现。标准库定义了三个默认的I/O流:标准输入(stdin)、标准输出(stdout)和标准错误(stderr)。
字符输入输出
对于字符级别的输入输出,C标准库提供了getchar()和putchar()函数。getchar()从标准输入读取一个字符并返回其整数值,如果失败则返回EOF。putchar()将一个字符写入标准输出。
以下是字符输入输出的示例:
int c;
c = getchar(); // 读取一个字符
putchar(c); // 输出一个字符
格式化输入输出
对于更复杂的输入输出,C标准库提供了scanf()和printf()函数。这些函数使用格式字符串来指定输入或输出的数据类型和格式。
以下是格式化输入输出的示例:
int num;
scanf("%d", &num); // 读取一个整数
printf("The number is %d\n", num); // 输出一个整数
总结
本节课中我们一起学习了C语言的核心语法,包括变量声明、赋值操作、控制语句以及输入输出。我们探讨了语言设计中的一些关键决策,并理解了这些决策如何影响C语言的特性。在接下来的课程中,我们将深入探讨C语言的数据类型及其在内存中的表示。
007:数字系统 📚
在本节课中,我们将学习数字系统的基础知识。理解数字系统对于后续学习C语言中数据类型的表示至关重要。我们将从熟悉的十进制系统开始,逐步介绍二进制、十六进制和八进制系统,并学习它们之间的转换方法。
十进制系统 🔢
我们自小学起就熟悉的数字系统是十进制系统。绝大多数人类文明都使用十进制,这主要是出于生物上的便利性(我们有十个手指)。在十进制系统中,有十个符号:0, 1, 2, 3, 4, 5, 6, 7, 8, 9。
一个数字是由其各位数字乘以相应的10的幂次(从右向左,从0次幂开始)的和构成的。这本质上就是我们小学学习的“个位”、“十位”、“百位”的概念。
公式:一个十进制数 d_n d_{n-1} ... d_1 d_0 的值可以表示为:
值 = d_n * 10^n + d_{n-1} * 10^{n-1} + ... + d_1 * 10^1 + d_0 * 10^0
其他进制系统
除了十进制,还有其他进制的数字系统。好消息是,十进制系统的所有规则同样适用于它们。它们具有相同的特性,只是使用的符号集数量不同,并且我们乘的是其基数的幂,而不是10的幂。
上一节我们介绍了十进制,本节中我们来看看其他几种在计算机科学中至关重要的进制系统。
二进制系统 💻
二进制系统遵循与十进制相同的规则,但它只有两个符号:0和1。数字由这些符号的不同排列组合构成,每个位置代表2的幂次。
数字计算机天然使用二进制,因为它们由数字电路构建,每个电路只有两种状态:开(on)和关(off)。我们可以将1视为开、高电压或电路连通,将0视为关、低电压或电路断开。
以下是二进制系统中的一些术语:
-
比特(Bit):二进制数字(Binary digit)的缩写,是单个二进制符号。
-
字节(Byte):一组连贯的比特。在我们所见的所有情况下,一个字节都是8比特。
-
半字节(Nibble):半个字节,即4比特。
为了清晰,在阅读二进制数时,我们将其读作“一零一零”,而不是“一千零一十”。
以下是一些有用的二进制数值启发式规则:
-
一个1后面跟着全是0的序列是2的幂。具体是2的(0的个数)次幂。
-
全是1的序列比下一个2的幂小1。
-
二进制数
1010对应的十进制数是10。这个助记符是唯一一次我会打破“比特不说十”的规则。
代码示例:在C语言中,二进制字面量没有直接的前缀,但我们可以通过其他方式理解其值。
十六进制系统 🔟➡️🅰️
十六进制(常简称为“hex”)是基数为16的系统。它看起来复杂,但所有你从十进制学到的计数规则都适用。
十六进制使用16个符号:0-9,然后使用字母A-F(大小写均可)代表10-15。在C语言中,我们使用前缀 0x 来表示一个十六进制数。
十六进制在计算机编程中如此常见,是因为它是二进制的便捷缩写。由于16是2的4次方,每个十六进制数字(hex digit,或称“hexit”)正好对应一个4比特的“半字节”(nibble)。
代码示例:在C语言中表示十六进制数:int hexValue = 0x2A; // 这等于十进制的42
八进制系统 🛑
八进制是基数为8的系统。它遵循相同的规则,只是符号集不同(0-7)。
八进制也是二进制的一种便捷缩写,因为8是2的3次方。因此,八进制数的每一位数字代表3个比特。你会在Linux中看到它的应用,例如 chmod 命令使用八进制数来表示文件权限(例如,6代表读写,7代表读写执行)。
代码示例:在C语言中,八进制数以前缀 0 开头:int octalValue = 052; // 这等于十进制的42
进制转换方法
理解了不同进制系统后,我们来看看如何在它们之间进行转换。
二进制转十进制
要将二进制数转换为十进制,只需展开其位置记数法,并计算幂的乘积之和。可以将其视为计算一个多项式。
公式:二进制数 b_n b_{n-1} ... b_1 b_0 的十进制值为:
值 = b_n * 2^n + b_{n-1} * 2^{n-1} + ... + b_1 * 2^1 + b_0 * 2^0
示例:1010₂ = 1*2³ + 0*2² + 1*2¹ + 0*2⁰ = 8 + 0 + 2 + 0 = 10
十进制转二进制
将十进制整数转换为二进制,需要找到合适的位置记数法。以下是两种常用方法:
-
减法法:找到小于目标数的最大2的幂,将其减去,并在该幂次的位置上置1。如果剩余的数小于下一个2的幂,则在该位置置0。重复此过程。
-
除法取余法:反复将十进制数除以2,并记录余数。最后,将余数从下到上(即最后一次除法的余数在最高位)读取,即得到二进制数。这本质上是重复减法的另一种形式。
示例(除法法):将十进制数37转换为二进制。
37 / 2 = 18 余 1
18 / 2 = 9 余 0
9 / 2 = 4 余 1
4 / 2 = 2 余 0
2 / 2 = 1 余 0
1 / 2 = 0 余 1
从下往上读余数:100101,所以 37 = 100101₂。
十六进制与十进制互转
十六进制与十进制之间的转换,原理与二进制类似,只是基数换成了16。
-
十六进制转十进制:展开位置记数法并求和。
示例:
0x2A = 2 * 16¹ + A * 16⁰ = 2 * 16 + 10 * 1 = 32 + 10 = 42 -
十进制转十六进制:使用除法取余法,但除以16。
示例:将十进制数42转换为十六进制。
42 / 16 = 2 余 10 (A) 2 / 16 = 0 余 2从下往上读余数:
2A,所以42 = 0x2A。
二进制与十六进制互转
由于每个十六进制数字对应4个比特,因此二进制与十六进制之间的转换非常直接。
-
二进制转十六进制:从右向左,将二进制数每4位分成一组(最左边不足4位可补0)。然后将每组转换为对应的十六进制数字。
示例:
0010 1010₂->2 A->0x2A -
十六进制转二进制:将每个十六进制数字展开为对应的4位二进制数。
示例:
0x2A->2->0010,A->1010->00101010₂(可省略前导零为101010₂)
快速测验
尝试将二进制数 101010₂ 转换为十进制和十六进制。
-
转换为十进制:
1*2⁵ + 0*2⁴ + 1*2³ + 0*2² + 1*2¹ + 0*2⁰ = 32 + 0 + 8 + 0 + 2 + 0 = 42 -
转换为十六进制:先分组
0010 1010->2 A->0x2A,然后0x2A = 2*16 + 10 = 42
总结
本节课中我们一起学习了数字系统的基础知识。我们从最熟悉的十进制系统出发,理解了其基于位置和幂次的核心原理。随后,我们将这一原理应用到计算机科学中最关键的二进制、十六进制和八进制系统中,并掌握了它们之间相互转换的方法,特别是利用了十六进制与二进制位组的便捷对应关系。
理解这些数字系统是后续学习C语言中各种数据类型(如整数、字符)在内存中如何表示的基础。下一讲,我们将进入速成课程的第二部分,深入探讨数值数据类型。
008:数字类型 📚
在本节课中,我们将要学习C语言中的数字类型,包括它们的声明、操作以及内部表示方式。我们将从整数类型开始,逐步探讨其有符号与无符号的表示、位运算,最后介绍浮点数类型及其特性。
概述
上一讲我们介绍了变量声明、赋值、控制流结构以及C标准I/O库的接口。本节中,我们来看看C语言提供的数字类型、它们的运算规则以及内部表示方式。我们从整数类型开始。
整数类型
C89标准提供了多种整数类型。每种类型都有两个变体:char、short、int和long。char类型占用一个字节,其他类型的确切大小未指定,但彼此之间存在相对大小关系。int通常被视为默认类型,其大小通常与机器的自然字长(如寄存器大小)匹配,但这并非强制规定。
Java语言在设计上做出了不同的选择,它明确指定了所有类型的大小。在Java中,没有“自然字长”的概念,因为底层硬件对Java程序不重要,程序始终运行在JVM环境中。此外,Java将byte作为占用一个字节的基础类型,而char实际上是两个字节。
C语言不设定绝对大小牺牲了可移植性,但考虑到C是作为系统编程语言设计的,这似乎是一个合理的选择。然而,通过提供更多样化的类型(如int8、int16、int32、int64)来同时支持系统和应用编程,成本似乎也很低。
字面值表示
在代码中表示字面值时,需要注意以下规则:
-
一个看起来像整数的数字字面值隐式地具有
int类型。 -
可以通过前缀指定其他进制的整数字面值:
0表示八进制,0x表示十六进制。需要注意的是,八进制的前缀0容易引起混淆,因为数学中前导零通常是多余的。 -
对于
short类型的字面值,没有特定的后缀表示。同样,byte大小的字面值也没有后缀,这可能是因为设计者将其视为字符,而字符字面值有其他表示方式。
Java语言继承了C的这些特性,这表明要么新语言未能纠正旧语言中的这些“缺陷”,要么它们本身并非缺陷,我们只需接受这种不完全平行或一致的设计。
无符号整数
C语言允许类型仅表示非负整数,这是与Java不同的设计选择。C语言更古老,设计时考虑了更多的内存限制。在Java中,如果一个int存储约负20亿到正20亿,要存储30亿就需要使用long。而在C中,设计者允许你将任何给定类型的最大值范围翻倍,将其范围从大约负X到正X,变为0到2X。
C语言也是一种更低级的语言,经常涉及位操作(例如位掩码)。当我们将这些值视为位串而非实际整数时,将整数值视为有符号的没有太大意义。因此,拥有一个在表示中完全不包含符号信息的类型是很方便的。
无符号整数的字面值与有符号类型类似,但需要用U后缀表明其无符号性质,并且short类型仍需要进行强制类型转换。
字符类型
char数据类型属于整数数据类型,因为它们本质上就是非常小的整数。尽管其名称暗示其设计意图是用于处理ASCII字符,但它们只是8位整数。或许它们应该被称为byte或int8。
关于有符号性,signed char的概念可能令人费解——“负A”究竟是什么?对于实际的ASCII字符,有符号和无符号值之间的差异并不重要,因为最高的ASCII字符是127,所以有符号或无符号都可以。但是,如果你需要一个字节类型进行计算,可能应该使用unsigned char,因为任意数据的正负性质更没有意义。
使用char处理任意数据的一个例子是Base64转换算法,该算法必须处理不仅仅是ASCII的任意数据,并且使用一个字节中的所有8位,而不仅仅是ASCII使用的0到127的7位。
尽管我们将char视为小整数,但语言设计者确实提供了表示文本字符用途的字面值。字符字面值使用单引号,例如 'A'。有些字面值在单引号内包含多个字符,但这只是我们表示字面值的方式。例如,'\n'表示换行符。如果我们想通过按回车键来输入一个字面换行符,代码会变得混乱。因此,语言设计者使用反斜杠作为转义字符,提供了一种唯一标识这些字符的方式。
转义指示符表示下一个字符不一定是它看起来的样子。由于每个字符字面值都指代单个字符,因此与字符\后跟字符n的情况没有歧义。类似地,要表示引号字符,我们必须做一些特殊处理,因为我们不能使用三个单引号。第二个单引号会关闭第一个,然后第三个将打开一个永远不会结束的新引号。我们使用反斜杠来表示序列中的第二个引号不意味着关闭字符字面值,因此第三个引号会关闭它。更进一步,反斜杠的字符字面值需要一个反斜杠来转义反斜杠字符,否则它将被解释为转义结束引号。
这些例子带来的另一个重要点是字符字面值'1'和整数值1之间存在很大区别。字符字面值'1'是表示ASCII值为49的char的方式,即字符'1'的ASCII值。这与整数1不同,整数1对应于ASCII值为1的非打印字符。类似地,字符字面值'0'的ASCII值是48,而不是0。值0(即空字节)由带有转义字符\0的字符字面值表示。
还有一个注意事项:奇怪的是,char字面值实际上是int类型。我们从不这样认为,但实际上,在底层,它们是int。C语言能够正常工作只是因为C支持自动降级。这是一个奇怪的选择。你会认为明确与char绑定的字面值应该是char,但它们不是,它们是int。考虑到我们可以对字符进行算术运算,必须做出选择。语言设计者选择在赋值时进行隐式降级,而不是在算术运算时进行隐式提升。这至少在我看来,并不以任何方式影响我们作为程序员,但这是一个有趣的设计怪癖,它让我们必须理解语言设计者在没有明显正确答案的情况下做出决策的考量。
Unicode 与字符编码
那些名字中带有变音符号的人已经痛苦地意识到,ASCII是一组相当有限的字符集。Unicode具有表达更多字符的能力。Unicode定义了超过14万个字符,并有能力容纳超过一百万个字符。标准ASCII字符使用一个字节,大多数基于拉丁语的字母使用两个字节,中文、日文、韩文字符使用三个字节,一些历史文字、数学符号和表情符号使用四个字节。
Java可能会批评C语言的char太小(只有一个字节),但事实证明,Java的两个字节char同样不够。实际上,任何由语言设定字符大小的语言都会遇到这个问题。这回到了语言设计的理念:我们应该基于计算的基本类型来构建语言,也许这些类型会以不同的方式适应不同的应用程序。如果我们为应用程序设计,随着应用程序的发展,我们注定会失败。
既然我们无法改变C语言中char的大小,C如何实现像Unicode这样的变长编码呢?它不在语言本身中,而是在库中实现。在这种情况下,大多数库使用字节字符串。
整数类型总结
以下是C语言中所有整数类型的便捷总结:
| 类型 | 说明 |
| :— | :— |
| char | 1字节整数,通常用于字符 |
| short | 短整数,大小 >= char |
| int | 默认整数类型,通常匹配机器字长 |
| long | 长整数,大小 >= int |
| unsigned 变体 | 对应类型的无符号版本,范围从0开始 |
无符号整数的表示与运算
现在我们已经了解了所有整数类型,接下来讨论它们在底层的表示方式。我们从无符号整数开始,因为它们实际上比有符号整数更容易理解。
自然数的范围当然是无限的。但在有限的宇宙中,特别是在有限的数字计算机中,我们无法很好地处理无穷大,因此任何给定整数类型的范围都受其表示中位数的限制。对于我们的32位或64位计算机来说,这个范围太大,无法真正理解所涉及的过程。因此,让我们想象一个用于此目的的简陋小计算机。我们将使用一台4位机器,找出正确的特性和算法,由于我们可以理解那16个可能的值,然后希望(事实上也会)将其推广到像ARM/Lab这样更大的机器上。
让我们从我们的4位计算机开始。我们将尝试为这16个值中的每一个分配一个在这4位中的唯一表示。从0开始。对于0,我们可以使用全零。接下来是1,不妨从0001开始。这看起来很熟悉。没错,这只是4位二进制。很好,我们对此了如指掌。现在让我们看看对于我们想对这些整数进行的操作,它是如何运作的。
如果我们只使用二进制作为表示形式,那么二进制算术就是我们将进行加法的方式。很简单,对吧?3加10等于13,用二进制表示,我们可以使用与小学十进制相同的从右到左进位算法进行加法运算。
但有一个问题。当我们超过可以存储在4位中的最大值时会发生什么?在数学中,这是溢出。我们可以任意向左进位。但在这里,我们有一组有限的列。溢出是指当我们进位并耗尽列时。结果是,我们绕回0开始。这里的效应是所有结果都对2^4(16)取模,因为我们有4位。这意味着,如果我们不考虑位数进行加法运算,但之后取模16,我们将看到结果。这里的例子是7 + 10 = 17,我们对其取模16得到1。
暂停视频片刻,思考一下如何以编程方式检测溢出?如果和小于其中一个或两个加数,那么就发生了溢出。我们不能将两个无符号数相加得到一个更小的数,除非发生了溢出。
二进制减法同样如此,就像你在小学学习的减法一样,从右到左,必要时借位。如何以编程方式检测溢出?这里的直觉是,我们想要得到负数的结果,这在无符号领域是无法做到的,因此,如果第一个操作数小于第二个操作数,就会导致溢出。
我们想要为无符号整数查看的最后运算符是按位运算。让我们从移位开始。这只是将每个位按指定方向移动。例如,1010右移1位。我们将个位的0向右移动,它从数字的末端掉下来并消失。然后21位的1填充到个位,22位的0填充到21位,23位的1填充到22位,而23位则没有留下任何东西。所以我们只需用0填充。如果我们右移2位,我们将再次执行相同的过程。从结果0101开始,个位的1消失,21位的0最终位于个位,22位的1最终位于21位,23位的0最终位于2^2位,我们再次用0填充,得到0010。
左移的工作方式相同,只是它从2^3位向左移动,所以我们反而掉入了那个方向的数字以太中的巨大位深渊。当我们到达已被腾出的最右边的位时,我们必须在那一侧用0填充。
暂停幻灯片,确保你掌握了重现这些计算的算法,并思考每种操作的算术效应是什么。答案并不神奇。事实上,你从小学就知道这个,只是常规的十进制计数。当你移除最后n位数字时会发生什么?嗯,你是在除以10的幂。例如,12。当我们移除最后一位数字时,我们得到1,那就是除以10的整数除法。这里也是一样,右移n位就是除以2n。对于左移,同样的逻辑也适用,在十进制数字的右侧添加零就是乘以基数,每次乘以10。所以在右侧添加n个零就是乘以2n。
但请注意,在小学时,你从不需要处理溢出,而在这里我们需要。这是最后一个例子。当我们从0011(3左移两位得到)开始时,我们再左移一次。1从左侧掉下来。那就是溢出。但与加法一样,如果我们进行不考虑溢出的计算,然后取模16,我们得到适当的答案11000是24,取模16是8。
好的,这是最长的按位运算,让我们快速浏览剩下的四个。按位取反(~)字面上就是翻转每一位。但警告:记住,我们也必须翻转前导零。在4位算术中很容易记住这一点,但在64位算术中有时很难记住。这可能是期中或期末考试中关于这个主题的最常见错误。
按位与(&)孤立地查看每一列。对于每一对位值,它计算它们之间的与运算,其中1为真,0为假。所以两个1得到1,其他任何组合都得到0。这里的例子展示了一些常见用法:用一个比2的幂小1的数进行与运算,可以将高位清零,只留下位的后缀。用第k个2的幂进行与运算可以隔离第k位,使其单独保留,同时使所有其他位为0。
按位或(|)同样孤立地查看每一列,对于每一对位,这次计算它们之间的或运算。因此0或0得到0,其他任何组合都得到1。在例子中,我们有一种与2的幂进行与运算相反的操作。这将第k位打开为1,同时不影响其他位。
最后是按位异或(^),对于每一位,如果它们相同,则产生0;如果它们不同,则产生1。这有几种用途:与1异或会翻转该位的值;与自身异或会将值设置为0(我们会在汇编语言中经常看到这一点);我们也可以用0异或以保持值不变,但这有点不寻常,除非可能是位掩码中的单个位,即便如此,我们通常使用与1进行与运算,而不是与0进行异或。
有符号整数的表示(补码)
不幸的是,负数确实存在,因此有符号整数类型必须表示它们。既然我们现在处理的是位,我们不能只用一位表示负号吗?实际上,我们能吗?也许我们可以让一位表示负号。在有符号幅度表示中,最左边的位现在是符号位,1实际上表示负号。乍一看,情况看起来不错。它甚至有一个很好的特性,即计算负数很简单,只需翻转高位。但等等,我以前不知道这个,负0到底是什么?如果有两个零,这将对我们用无符号整数看到的标准数学运算造成问题。考虑一下,-5 + 5 等于,嗯,0010是2。所以那既不是两个零中的任何一个。而-0 + 1是-1。是的,这将需要完全不同的算法,必须有更好的方法。
因此,与其仅将最左边的位用作负号,不如让负数转换每一位怎么样?反码取反就是翻转每一位。所以如果5是0101,那么-5就是1010。这仍然有非常简单的取反操作,只需翻转所有位。它在简单运算方面表现稍好一些。-7 + 6 是 1110,即-1。很好。-5 + 5 是 1111,这是一个0值。是负零。这不太好,因为这意味着我们仍然有负零,但它是有效的。然而,并非所有的加法运算都得到了修正。溢出可能是一个问题。考虑-4 + 6,1011加0110得到0001,即1,而不是2。所以我们仍然可以期待更好的东西。
最后一个有希望,如果我们能消除双零,我们就能成功。我们可以通过让负数比正数多一个,或者正数比负数多一个来消除双零。一旦我们做到这一点,加法问题也应该消失。为了解决这个问题,我们将把溢出作为我们的朋友而不是敌人。我们采用反码中翻转所有位的想法,但为了摆脱对两个零的担忧,我们在翻转所有位后加一。这意味着0000翻转为1111,然后加一又变回0000。更好的是,这是一个对称且自反的函数。
考虑5和-5,0101变成1011,即-5,很好。而1011变成0101,即5。所以它反过来也成立,太棒了。这里剩下的唯一怪癖是我们的额外值1000。它的二进制补码是什么?我们翻转所有位得到0111,加一回到1000本身。由于这是我们通过去掉负零得到的一个额外值,这是有道理的。从我们将溢出视为从最高值重置到最低值的角度来看,这也说得通。对于无符号数,这意味着回绕到0。但现在它意味着从+7回绕到-8。-8的负数将是+8,这会溢出回-8。这可以工作。
事实上,二进制补码不仅是一个可行的系统,今天几乎所有的计算机都使用二进制补码来表示有符号整数。它具有良好的特性,这里列出的缺点实际上根本不是缺点,而是一个仍然符合算术和我们溢出直觉的额外数字。但需要注意的一点是,我们必须知道一个数字有多少位,否则当我们翻转所有位然后加一时,我们不知道有多少前导零需要翻转。这同样是考试中常见错误的原因。
另一件很棒的事情是,无符号整数仍然使用完全相同的算法,即我们直观的十进制计数算法,只是列数有限。在这种情况下,有限的列数使算法正常工作,如幻灯片上正数加负数以及负数加负数的例子所示。不过,它仍然提出了溢出的问题。暂停视频,思考如何以编程方式检测溢出。我们这里的答案是如何以编程方式检测溢出:如果加数的符号相同,而结果的符号不同,则发生溢出。例如,如果你将一个正数和一个正数相加,却得到一个负数,那就是溢出。
对于减法,情况类似,我们有两个选择:我们可以执行我们习惯的借位减法算法,或者我们可以将第二个操作数转换为其二进制补码值并相加。因为减去一个正数等于加上一个负数,减去一个负数等于加上一个正数。任何人都可以遵循翻转所有位并加一,或者翻转最右边1左边的所有位的算法,但你的分析能力现在可能正在警觉,心想:但为什么它有效?首先,我们可以证明它确实对每个操作都有效。幻灯片上的第一个代数推导表明,对一个数取负然后对其进行二进制补码运算,等价于模24,这是其他推导的基础。然后第二个推导表明,例如,有符号整数减法在模24下有效。我们可以对每个操作进行类似的推导。乍一看可能不明显,但如果我们进行一系列操作,在不溢出的情况下将被证明是等价的。例如,X + 3,然后X + 2,然后X * 2,与X * 2,然后X + 10。在存在溢出的情况下,它们也将被证明是等价的。就像我们在更简单的情况下看到的那样,溢出后的结果值将恰好是未溢出值取模2^位数后的值。我们可以像上一张幻灯片那样通过推导再次证明这一点,或者我们可以仅仅依赖定义良好的数学性质而不去推导,例如模算术是一个环,并且有一个很好的性质。
有符号整数的位运算
接下来,让我们看看有符号整数的按位运算如何进行。左移是相同的,实际上算术效应也是相同的,即乘以2^n。再次,我们必须考虑溢出。将-3左移两位应该得到-12,但这会溢出,因此我们剩下-12模16等于+4。
然而,右移就不那么简单了。之前我们在左边填充零,这对于非负数继续工作得很好,但对于负数,它会改变符号。此外,因为数字是倒着数的(即全1是-1,而一个1后面全是零是最大的负2的幂),算术效应就不那么明显了。另一种选择是只在左侧用符号位填充。这具有除以2^n的良好特性,就像无符号整数一样。在Java中,实际上有分别对应这两种算法的不同运算符。>>>三重右移将0移入最左边的位置,而>>双重右移将符号位移入最左边的位以保持符号。然而,在C语言中,标准实际上并未在这两种选项之间做出规定。包括ARM/Lab在内的大多数机器实现算术右移,但由于这不是标准,它是语言的一个不可移植的元素,因此应该避免使用。
按位取反(~)、或(|)、与(&)和异或(^)没有这样的复杂性。它们的行为与无符号整数相同。幻灯片上写着最好避免对有符号整数使用这些操作,但我可能会重新表述并扭转一下:如果你想进行这种位操作,你几乎总是不使用整数本身的整数值,因此你可能希望一开始就使用无符号整数。当你真的只是孤立地或组合地查看位时,进行算术操作就好像它有负号潜力一样是没有意义的。
如前所述,Java本身不支持无符号数据类型,这可能反映了稍高的抽象级别,并且你不像传统上C程序员那样需要在Java中进行那么多的位操作。因此,在结束有符号与无符号类型的讨论时,对于Java来说,它还有一个好处,即不必担心有符号和无符号整数之间的转换,在转换中,大的无符号整数实际上会产生溢出,导致负整数。也就是说,我个人喜欢同时包含有符号和无符号数字,即使混合类型表达式带来了复杂性,它至少符合这种自由奔放的精神。如果我必须吹毛求疵,我可能会说对于混合符号表达式,我可能要求显式类型转换,但这是一个相当挑剔的抱怨点。总的来说,即使Java有一些很好的理由说明为什么它没有无符号类型,在这方面我必须支持C。
赋值运算符与自增/自减
我们将以一些与这些类型相关的更多语言特性来结束对整数类型的讨论。C的设计者选择添加特殊用途的赋值运算符,以使用变量作为操作数,然后将操作结果存储回同一个变量中。Java通过C++从C继承了这一点,所以这是你所知道的全部,但即使没有这种经验偏见,这似乎也是一个好的设计决策。第一,这是一个非常常见的代数习惯用法。添加的特性可能被常用而不是很少使用。也许有人会对一些扩展吹毛求疵——你知道<<=有多常用?但一旦它成为一种数字运算符的语法,其他运算符实际上也免费获得了,即使它们的使用频率要低得多。第二,它有利于简洁性。i++比i = i + 1简洁得多。第三,它有利于明确性。i++肯定表明了程序员的意图。i = i + 1,变量i或增量值实际上都可能是一个拼写错误。i++则没有歧义。
也就是说,存在前缀和后缀自增和自减运算符可能比它的价值更令人困惑,并且它引发了一些神秘的习语,程序员会将太多操作都塞进一行,依赖于它们的副作用,这使得代码难以阅读,即使对专家来说也是如此,对新手来说更是完全 bewildering。说到这里,让我们看一些其他例子。再次暂停视频,看看你是否能准确弄清楚这段代码在第三次执行后i和j的结果是什么。
最初i = 5,所以i是5,j是一个未确定的值。j = i++表示i将自增,所以i变成6,但它返回i的旧值,所以j是5。现在,j += ++i。让我们从右向左读。++i再次自增i,所以i变成7并返回i的新值。所以现在j加等于7,因此5 + 7 = 12。所以j是12,i是7。答案是D。
再花一分钟。答案在这里。但花点时间弄清楚为什么答案是E。如果你在追踪时选择了B,恭喜你。你刚刚被switch的贯穿(fall-through)特性坑了,我保证每个人至少会错过一次。希望这是你的第一次。但逐步分析一下:在switch内部,我们将i自增到2,但这是一个后自增操作,所以它评估的是旧值。因此我们根据值1进行切换,而不是新值2。但因为我们根据值1切换,我们执行case 1。我们自增i,所以它现在是3,并且前自增操作评估的是新值,这就是传递给printf调用的值。所以我们打印3。没有break,所以我们贯穿到case 2。再次,这是一个容易错过的地方,如果你在这里被坑了,以后仍然要小心。这里我们再次自增,所以i现在是4,但它是后自增,所以我们评估i的旧值,并将3传递给printf,因此我们打印3。i现在是4,这里的答案是E。
类似地,几乎是相同的问题。即使你以全新的眼光看这个,你可能不会再在贯穿上犯错,但这种奇怪的常规赋值与特殊用途赋值相结合呢?花点时间看看,如果答案不在这里,你会认为这会如何工作。这将i自增到2,然后后自增评估为其旧值,因此赋值的右侧,我们赋值为1。是的,我们立即覆盖了自增后的值。现在我们根据1切换,进入case 1,我们自增i,这是前自增,所以它查看新值,因此我们将2传递给printf,我们再次贯穿。但现在是后自增,所以我们打印2 2,正确答案是D。
正如你在做作业1时可能已经感到沮丧,GCC 217及其所有的警告和严格性。然而,GCC 217在这里做了一些超级 sensible 的事情。它正确地报告,将变量赋给一个自增表达式可能是实现相关的,或者至少可能不是你真正想做的。除非你认为C在这里不合理,请注意Java具有完全相同的行为,并且在Java的情况下,语言规范中严格定义了这是应该发生的事情。但FindBugs和SpotBugs会指出此类情况,并发出与GCC 217编译器调优类似的警告。所以在两种情况下都很奇怪,但至少你的编译器可以拯救你。
sizeof 运算符
整数类型的最后一项实际上适用于所有类型,但由于除了char之外,整数类型的大小完全不是由C标准规定的,因此这里特别相关。在一个程序中,我们经常想知道一个变量需要多少内存。典型的情况是动态内存分配,我们将在第8讲以及第8和第9次预修课中深入探讨。提供此功能使我们能够编写更具可移植性的代码,因为我们可以通过局部值来参数化我们的代码,而不是硬编码一个假设的值作为变量的大小。
因此,sizeof运算符(是的,它看起来像一个函数,但技术上是一个编译时运算符)接受一个表达式作为参数——这有点奇怪,可以接受可以是变量的表达式,但也可以是类型本身——sizeof将返回给我们一个表达式或类型等在内存中占用的字节数。
最后一个小难点。在ARM/Lab上,sizeof表达式的值是多少?这里我们将int i提升为long,因为我们将其与一个long字面值相加,所以我们有一个long和一个long,结果是一个long。long的sizeof是8。所以即使值是3,long的sizeof是8。
逻辑(布尔)类型
接下来我们应该讨论的数字类型是逻辑类型,我知道你可能会说,等等,如果是逻辑类型,它怎么是数字类型?嗯,在C语言中,没有内置的布尔类型。相反,我们使用其他类型来表示逻辑数据。当运算符产生逻辑结果时,它们发出的特定值是int类型的0(假)和1(真)。当我们想要表示逻辑值时,通常我们做同样的事情,使用int或char,其中0被解释为假,非0被解释为真。这虽然可以,但并不理想。例如,char类型的T和F都是真的,因为F的ASCII值是非0,这有点令人困惑。实际上,情况更糟,因为不仅仅是整数类型。我们同样可以做一些像#define TRUE 6.02e23这样的事情,或者#define TRUE 0.0/0.0,因为NaN毕竟不是0。
这也意味着,对于习惯像Java这样具有适当逻辑类型的语言的程序员来说,存在一些陷阱。因此,缺乏逻辑类型意味着来自其他语言的常见习语,例如if (i)或if (!i)在这里常用,但涉及奇怪的转换。在C语言中,这实际上并不被认为是好的做法,因为这对计算机没有好处。一个合理的编译器将为i或i != 0生成相同的代码。并且可以说,如果某人仍在追踪i是一个int,它会损害代码的可读性。所以这除了对那些已经适应布尔习语的人来说,是一种可疑的价值。
然而,更大的问题是诱惑在同一变量中混合逻辑和数字含义。考虑这里的第二个例子,其中i最终成为真子句的计数器。这很奇怪,也很难阅读。习惯布尔类型的人会对此犹豫,而C程序员可能不得不查阅手册来确认:等等,关系运算符是返回1和0,还是只返回某个非0值和0?最好不要冒误解或给代码阅读者增加额外工作的风险。
除了允许可疑的代码之外,缺乏真正的布尔类型也使编译器的工作更加困难。考虑这个片段。想象它是C代码。它做什么?现在提到Java代码。有什么区别?这里的问题是条件应该是i == 5,而不是i = 5。由于Java有一个真正的逻辑数据类型布尔值,它不同于整数数据类型,Java检测到if (i = 5)会发出一个int,因此是错误的,但C不会,所以C认为这完全有效。这是一个真的条件,因为i = 5,而5是非零。所以这里的效应是i的值被改为5(可能并非本意),并且语句1总是被执行(也可能并非本意)。
最后,因为逻辑类型只是int,我们得到了逻辑运算与按位运算的奇怪重载,这可能导致混淆。如果你将逻辑与应用于2 && 1,那么结果是1(真与真是真)。但如果你将按位与应用于2 & 1,那么结果是真与真是假,因为2按位与1是0。所以&&和单个&是非常不同的,其他逻辑和按位对也是如此。当你想要一个时使用了另一个,可能是一个非常难以追踪的错误,要小心这些。
浮点数类型
这就是我们对整数类型、它们的运算符和表示的介绍,但这并不是数字类型的结束。我们在这节课中已经看到了C语言中的整数类型和某种逻辑类型,但还有一个超越整数的世界。接下来就是这个。再次,我们从一个在物理数字机器的有限范围内无法真正工作的无限数学概念开始。因此,计算机科学和计算机工程领域必须设计在这些约束下尽可能接近数学理想的方法。在这种情况下,概念是浮点数。
浮点数引入了这样的思想:通过将非常大或非常小的数字分解为符合给定模式的表达式,我们可以将大部分信息用于变化的细节,而不是模式中相同的部分。在这种情况下,它是一个乘数乘以一个公共基数的指数。由于指数的快速增长,幂本身自然相当小,而基数是常数。因此,我们剩下大部分数据来存储乘数,这是变化最大的部分。
我们从整数类型开始,不妨对浮点类型进行类似的探讨。C提供了三种浮点类型:float、double和long double。与整数一样,大小未指定,但受顺序约束。浮点数字面值包含小数点、E或两者。默认情况下,它们是double类型,但可以用L后缀使其成为long double,或用F后缀使其成为float。
再次反思这里的设计决策,我认为double类型提供了大多数应用所需的范围和精度,所以这说得通,double应该是默认的浮点值。这也符合int作为默认整数值的情况,即有一个默认的中间立场,我们可以向上或向下,更大或更小。然而有趣的是,对于float,为较短版本的字面值定义了后缀,这与整数和short不同。名称也有点奇怪,更多的是历史产物。至少,它们本可以与整数类型保持一致,称为short float、float和long float。或者更好的是,再次像我们讨论整数时那样,类似float32、float64和float96。但无论如何,C++和Java继承了这些相同的古怪类型名称,所以无论我们喜欢与否,似乎都被困住了。
浮点数的表示(IEEE 754)
现在让我们更详细地看看浮点数的表示,而不仅仅是一种模糊的感觉,即我们需要保存一个尾数和一个指数。1976年,英特尔开始计划生产一个浮点协处理器,威廉·卡汉(他最终因在数值分析和数字表示方面的贡献而获得1989年图灵奖)是首席顾问。1977年提出了一个标准草案,1980年的处理器8087是第一个实现它的芯片。该标准于1985年正式通过。因此,IEEE 754标准基本上是现代系统中表示浮点数的唯一方式。有一位用于符号,三分之一或更少的位构成指数,其余位用于构成尾数的二进制小数。所以IEEE 754将其标准化,但这个概念并不新鲜。这个术语本身比IEEE 754早了100多年,而根据值保持可变精度的想法甚至可以追溯到更早。
这是一个浮点数表示中位的具体例子,在这种情况下是一个float。别担心,我们不会要求你计算这个,但我们在这里可以看到,我们有一位留作符号位,这里1表示它是负数;有8位用于指数,它是相对于127的;我们有23位用于尾数,它只是一个指数和,所以在这个意义上是一个二进制小数;当我们把它们放在一起时,我们可以得到最终的表示。
这里需要注意几点:因为这些都只是实数的数值近似,所以有一些不同的规则,我们不能仅仅假设精确性。你可能在126课中学到不要比较浮点数是否相等,因为你最终可能会问像0.999998是否等于1.00002这样的问题,而这两个数字很可能是代数上应该等价的计算结果。正确的方法是使用各种表达式,利用区分1.0与另一个浮点数的最小值,称为机器epsilon,但细节超出了本课程的范围,因为x与下一个高于x的浮点值之间的差异根据x而变化,所以机器epsilon是针对数字1特定的。例如,它不适用于区分0与下一个高于0的浮点数。然而,有一些关于这些近似的体面的近似,因此,如果你想大致了解float或double的精度,可以使用幻灯片上的经验法则。
因此,除了语言中可用的类型以及它们如何表示的模糊概念之外,你真正需要了解的强烈要点是它们表示方式的这些含义。如前所述,相等比较是 fraught 的,此外,你可能在126课中学到,可能存在灾难性抵消和类似问题,其中一个或多个操作会显著增加近似中的相对误差,因此由于小误差的复合,计算可能 wildly 不准确。在实际系统中,通常尽可能使用缩放整数而不是浮点数。
以下代码打印什么?花点时间看看你能想出什么。这实际上很难说,因为除非你真正尝试过,或者除非你非常深入地了解标准,否则有两件不同的事情在进行,你无法真正预测。我们有一个循环检查是否不等于10.0,然后我们有一个总和检查是否等于1.0。结果,循环确实终止了,因为我们可以通过每次加1.0来精确表示10.0。但总和不是1.0,因为我们不能通过每次加0.1来精确表示1.0。同样,这些是你不需要知道具体细节的细节,但这些都是你应该了解并至少能够讨论你需要考虑的因素。
总结
在本节课中,我们一起学习了C语言中的整数类型,包括有符号和无符号的表示方式;了解了C语言中的逻辑类型(无论其价值如何);以及C语言中的浮点数类型及其表示方式。这结束了第二次速成课程讲座。下次,我们将学习一些新的和不同的内容:指针。
009:指针、数组与字符串 🧭📚
在本节课中,我们将学习C语言中三个核心且相互关联的概念:指针、数组和字符串。我们将从全新的指针概念开始,然后探讨C语言中数组和字符串与Java的显著差异,并理解它们如何通过指针紧密联系。
指针基础
上一节我们回顾了课程背景,本节中我们来看看C语言中一个全新的特性:指针。指针是C语言的基础,它允许一个变量引用另一个变量或内存中的某个位置。这与Java中的对象引用有相似之处,但更为底层和灵活。
指针是什么?
指针是一种变量类型,其值是另一个值的内存地址。指针的类型由两部分组成:一个星号(*)表示这是一个指针,以及它所指向的数据类型。
代码示例:
int *pi; // 指向整数的指针
double *pd; // 指向双精度浮点数的指针
int * 和 double * 是不同的类型。
指针的表示与大小
在C语言中,指针的底层表示就是它所指向数据的内存地址。指针的大小取决于计算机架构的地址总线宽度。在ARM架构上,地址是8字节,因此指针的大小总是8字节,无论它指向的是1字节的char还是16字节的long double。
关键操作符
以下是两个处理指针的核心操作符:
-
取地址操作符 (
&):获取变量的内存地址。int cyclic = 42; int *pi = &cyclic; // pi 现在存储了cyclic的地址 -
解引用操作符 (
*):通过指针访问或修改它所指向的数据。*pi = 100; // 将cyclic的值改为100 int value = *pi; // 读取cyclic的值
空指针
指针可以被初始化为NULL,这是一个表示“不指向任何地方”的特殊值。尝试解引用一个空指针会导致程序崩溃,这实际上是一种安全机制,防止访问非法内存。
指针深入示例
为了加深理解,让我们分析一个更复杂的例子,其中涉及指针的比较和赋值。
假设我们有以下变量和内存布局(地址为示意):
int life = 42, jackie = 42;
int *adams = &life; // adams 指向 life
int *brooklyn = &jackie; // brooklyn 指向 jackie
int **meta = &adams; // meta 是一个指向指针的指针,它指向 adams
以下是基于初始状态的七次比较及其结果:
-
adams != brooklyn-> 真 (指向不同地址) -
*adams == *brooklyn-> 真 (指向的值都是42) -
meta == &adams-> 真 (meta的值就是adams的地址) -
meta == &brooklyn-> 假 (meta不指向brooklyn) -
*meta == adams-> 真 (解引用meta得到adams,比较地址) -
*meta == brooklyn-> 假 (adams的地址不等于brooklyn的地址) -
**meta == *brooklyn-> 真 (都是42)
现在,如果我们执行 adams = brooklyn;,让adams也指向jackie,那么上述比较的结果会发生变化。例如,第一次比较 adams == brooklyn 将变为真。
关键建议:要彻底理解指针,请务必在纸上画出内存示意图,跟踪每个变量在每一步的地址和值。
C语言中的数组
理解了指针之后,我们来看看一个更熟悉但实现方式迥异的概念:数组。数组用于将相同类型的元素组合在一起,并通过索引访问。
与Java数组的对比
C语言中的数组与Java数组有几个根本区别:
-
声明语法:在C中,括号放在变量名之后,并且可以在声明时指定大小。
int arr[10]; // 正确:声明一个包含10个整数的数组 // int[10] arr; // 错误:Java语法在C中无效 -
存储位置:C数组可以存储在栈上(固定长度数组),也可以动态分配在堆上。栈上数组的长度必须在编译时已知。
-
长度信息:C数组不是对象,没有像Java那样的
.length字段。在定义数组的函数内部,可以使用sizeof运算符计算长度:int length = sizeof(arr) / sizeof(arr[0]); -
数组名:当单独使用数组名时(不带索引),它会被视为一个指向数组第一个元素的指针(即
&arr[0])。但请注意,数组名本身不是一个存储地址的指针变量,你不能给它重新赋值。
数组与指针的等价性
虽然数组不是指针,但它们关系密切:
-
指针可以像数组一样使用下标运算符
[]来访问元素。 -
函数参数中的数组声明会自动退化为指针。这意味着以下两种函数声明是等价的:
void func(int arr[]); void func(int *arr);在函数内部,对这样的参数使用
sizeof得到的是指针的大小(如8字节),而不是整个数组的大小。
指针算术
数组和指针紧密联系的核心在于指针算术。
当我们对指针进行加/减运算时,指针移动的字节数取决于它所指向类型的大小。
int *p = ...;
p = p + 1; // p 向前移动 sizeof(int) 个字节(通常是4字节)
因此,对于数组arr,访问arr[i]等价于*(arr + i)。下标运算本质上就是指针算术和解引用的组合。
C语言中的字符串
最后,我们来看C语言中的字符串,它完美体现了数组和指针的结合。
字符串的本质
在C语言中,字符串就是一个以空字符('\0')结尾的字符数组。字符串的类型就是char *(指向字符的指针)。
代码示例:
char str1[] = {'H', 'e', 'l', 'l', 'o', '\0'}; // 字符数组初始化
char str2[] = "Hello"; // 字符串字面量初始化,编译器会自动添加 '\0'
// str1 和 str2 是等价的
字符串字面量(如"Hello")在内存中存储时,末尾会自动添加空字符。
字符串处理的挑战
由于C字符串只是普通的字符数组,它没有内置长度信息。要获取字符串长度,必须遍历数组直到遇到'\0'。这导致许多字符串操作的时间复杂度是O(n)。
字符串库函数
为了处理字符串,C标准库提供了<string.h>头文件,其中包含一系列函数:
以下是几个最常用的函数:
-
strlen(const char *s):返回字符串的长度(不包括'\0')。 -
strcpy(char *dest, const char *src):将src字符串复制到dest。 -
strcat(char *dest, const char *src):将src字符串拼接到dest末尾。 -
strcmp(const char *s1, const char *s2):比较两个字符串。 -
strstr(const char *haystack, const char *needle):在haystack中查找needle子串。
注意:使用这些函数时必须确保目标缓冲区有足够的空间,否则会导致缓冲区溢出,这是常见的安全漏洞来源。
总结
本节课中我们一起学习了C语言的三个基石:
-
指针:存储内存地址的变量,通过
&取址,通过*解引用。指针算术是理解数据布局的关键。 -
数组:同类型元素的连续集合。数组名在多数情况下可视为指向其首元素的指针。C数组没有内置长度属性。
-
字符串:以
'\0'结尾的字符数组。字符串处理依赖于标准库函数,并需要程序员谨慎管理内存。
理解指针、数组和字符串之间的关系——特别是“数组退化为指针”和“字符串即字符数组”——对于掌握C语言编程至关重要。在接下来的作业和实践中,你将有机会深入运用这些概念。
010:使用make构建
在本节课中,我们将暂时放下C语言的设计细节,转而关注本学期的另一个核心主题:编程工具。我们将学习如何从“随意编写代码”转变为采用更结构化、更工程化的软件设计方法。具体来说,我们将深入探讨一个名为 make 的强大构建工具,它能帮助我们高效管理多文件项目。
多文件项目的构建挑战
上一周的课程中,我们接触了一个多文件C语言程序的例子。该程序包含一个接口文件 intmath.h,它声明了 gcd 和 lcm 函数。函数的实现在 intmath.c 中,而客户端程序是 testintmath.c。intmath.c 和 testintmath.c 都通过 #include 包含了 intmath.h 头文件。
这意味着,如果 intmath.h 发生改变,实际上相当于 intmath.c 和 testintmath.c 都发生了改变。因此,我们需要重新编译这两个 .c 文件,然后重新链接生成可执行文件。对于小型项目,一次性编译所有文件(例如使用 gcc217 *.c)是可以接受的。
然而,现实世界中的项目可能包含成百上千个文件。例如,Linux内核有超过25000个C文件和20000个头文件。在这种情况下,每次修改都完整编译整个项目可能需要数小时,这严重阻碍了开发效率。
分步构建与部分构建
为了解决这个问题,标准的构建过程分为两步,而不是一次性完成。
-
将每个
.c文件预编译、编译、汇编成独立的.o(目标)文件。gcc217 -c testintmath.c gcc217 -c intmath.c -
将所有的
.o文件链接在一起,生成最终的可执行文件。gcc217 testintmath.o intmath.o -o testintmath
这种分步构建的关键优势在于部分重建。如果我们只修改了 intmath.c,那么只需要重新生成 intmath.o,而 testintmath.o 无需变动。最后,只需将新旧 .o 文件重新链接即可。这可以节省大量编译时间,尤其是在项目庞大时。
但是,手动跟踪哪些文件需要重新编译既繁琐又容易出错。我们需要一个工具来自动化这个过程。
Make工具简介
make 正是这样一个工具。它通过读取一个名为 Makefile 的文本文件,其中定义了项目中文件的依赖关系图。make 会检查文件的修改时间,并自动判断哪些目标需要重新构建,哪些可以跳过。
make 命令本身很简单。通常,你只需在项目目录下运行:
make
默认情况下,make 会寻找名为 makefile 或 Makefile 的文件,并执行其中定义的第一个目标。
Makefile 基础语法
Makefile 的核心规则由目标、依赖和命令三部分组成,格式如下:
target: dependencies
<TAB>command
-
目标:通常是要生成的文件名(如
testintmath)。 -
依赖:生成目标所需要的前提文件(如
.o文件)。 -
命令:生成目标需要执行的具体 shell 命令(必须以 Tab 字符开头)。
make 的工作原理是递归的。当要求构建一个目标时,它会先检查其所有依赖项。如果某个依赖项不存在或比目标“更晚更新”,make 会先尝试去构建那个依赖项,然后再执行构建目标的命令。
让我们为之前的例子编写一个基础的 Makefile:
testintmath: testintmath.o intmath.o
gcc217 testintmath.o intmath.o -o testintmath
testintmath.o: testintmath.c intmath.h
gcc217 -c testintmath.c
intmath.o: intmath.c intmath.h
gcc217 -c intmath.c
运行 make 时,它会按照依赖关系递归地构建所有必要的 .o 文件,最后链接成 testintmath。之后,如果只修改 intmath.c 并再次运行 make,它将只执行 intmath.o 和最终链接的命令,实现部分构建。
伪目标
并非所有目标都是为了生成一个同名文件。有些目标只是为了执行一系列操作,例如清理构建文件。这类目标称为伪目标。
常见的伪目标有:
-
all:通常作为第一个目标,用于构建所有主要的可执行文件。 -
clean:用于删除所有构建过程中产生的文件(如.o文件和可执行文件)。 -
clobber:更彻底的清理,可能包括删除编辑器生成的备份文件等。
以下是包含伪目标的 Makefile 示例:
all: testintmath
testintmath: testintmath.o intmath.o
gcc217 testintmath.o intmath.o -o testintmath
testintmath.o: testintmath.c intmath.h
gcc217 -c testintmath.c
intmath.o: intmath.c intmath.h
gcc217 -c intmath.c
<https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-cos217-c-sys-prog/img/e90c0636e9f101040951de366d26fd2b_19.png>
clean:
rm -f testintmath *.o
<https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-cos217-c-sys-prog/img/e90c0636e9f101040951de366d26fd2b_21.png>
clobber: clean
rm -f *~ *# .#*
注意,clean 和 clobber 并没有创建同名文件。运行 make clean 后,即使没有 clean 这个文件,make 也会成功执行删除命令。伪目标极大地简化了常见的项目维护操作。
使用变量
Makefile 支持变量(有时称为宏),这可以提高可维护性和灵活性。定义和引用变量的语法如下:
CC = gcc217
CFLAGS = -g -O2
testintmath: testintmath.o intmath.o
$(CC) $(CFLAGS) testintmath.o intmath.o -o testintmath
这样,如果需要更换编译器或调整编译选项,只需修改变量的值即可,无需在多个地方修改。
隐式规则和模式规则
make 内置了一些隐式规则,可以自动推导如何从一种文件构建另一种文件。例如,它知道如何用 $(CC) -c 从 .c 文件生成同名的 .o 文件。
我们还可以使用模式规则来进一步简化。例如,可以用一个规则处理所有 .o 文件的构建:
%.o: %.c intmath.h
$(CC) -c $< -o $@
这里,% 是通配符,$< 代表第一个依赖文件,$@ 代表目标文件。
虽然隐式规则和模式规则能让 Makefile 更简洁,但过度使用可能会降低可读性。对于初学者和课程作业,建议优先使用显式规则来确保清晰理解构建过程。
注意事项与最佳实践
-
命令必须以 Tab 开头:这是 Makefile 的硬性规定,使用空格会导致错误。现代编辑器在编辑 Makefile 时通常会正确处理。
-
第一个目标很重要:直接运行
make时会构建第一个目标。通常将all或主要的可执行目标放在首位。 -
小心自动化命令:像
rm -f这样的命令在 Makefile 中很常见,但要确保其正确性,避免误删重要文件。 -
从简单开始:建议从清晰、显式的规则开始编写 Makefile。在熟悉之后,再考虑使用变量和模式规则来优化。
总结
本节课我们一起学习了如何使用 make 工具来管理C语言项目的构建过程。我们了解了分步构建和部分重建的重要性,掌握了 Makefile 的基本语法,包括目标、依赖和命令的编写。我们还探讨了伪目标、变量以及隐式规则等高级特性,它们能帮助我们编写更强大、更灵活的构建脚本。
make 是提高编程效率和项目可维护性的关键工具。掌握它将使你从“随意编写代码”迈向更工程化的软件开发。在接下来的课程中,我们将回归C语言本身,深入探讨动态内存管理等核心主题。
011:结构体、命令行参数与动态内存 🧩
在本节课中,我们将学习C语言中的三个核心主题:结构体、命令行参数以及动态内存管理。这些概念是构建复杂程序的基础,能帮助我们更灵活地组织和处理数据。
结构体
上一节我们介绍了数组,它可以存储多个相同类型的数据。本节中我们来看看结构体,它允许我们将不同类型的数据组合成一个单一的复合类型,类似于Java中的类(但没有实例方法)。
结构体类型的声明使用关键字 struct,后跟结构体名称和其内部的字段列表。声明结束时必须加上分号。
struct point {
double x;
int y;
};
我们可以像初始化数组一样,使用初始化列表来初始化结构体变量。也可以声明指向结构体的指针。
访问结构体字段使用点操作符 .。如果有一个指向结构体的指针,则需要先解引用再使用点操作符,或者使用更简洁的箭头操作符 ->。
struct point p1 = {3.14, 5};
struct point *ptr = &p1;
int y_val = ptr->y; // 等价于 (*ptr).y
以下是关于结构体大小和内存布局的重要说明:
-
结构体的大小至少是其所有字段大小之和。
-
但编译器可能会在字段之间或结构体末尾插入“填充”字节,以满足特定架构的内存对齐要求,这可能导致结构体实际大小大于字段大小之和。
-
C语言标准规定,编译器不能重新排列结构体内部的字段顺序。
结构体可以组成数组,也可以作为值传递给函数,或从函数中返回。与数组不同,结构体支持赋值操作(=),这会执行所有字段的深度拷贝。
struct point arr[3] = {{1.0, 2}, {3.0, 4}};
struct point a = {1.0, 2};
struct point b = a; // 深度拷贝
命令行参数
到目前为止,我们的程序输入方式有限。现在我们来学习如何通过命令行向程序传递参数,这是与程序交互的常见方式。
在C语言中,main函数可以接收两个参数:
-
int argc:参数计数,表示命令行参数的数量。 -
char *argv[]或char **argv:参数向量,是一个字符串数组,存储了每个参数。
需要注意以下几点:
-
argv[0]是程序自身的名称。 -
argc的值至少为1(因为程序名算一个参数)。 -
最后一个数组元素
argv[argc]保证是一个空指针(NULL)。
以下是一个遍历并打印所有命令行参数的程序示例:
#include <stdio.h>
int main(int argc, char *argv[]) {
for (int i = 0; i < argc; i++) {
printf("argv[%d] = %s\n", i, argv[i]);
}
return 0;
}
也可以使用指针算法来遍历:
#include <stdio.h>
int main(int argc, char *argv[]) {
char **p = argv;
while (*p != NULL) {
printf("%s\n", *p);
p++;
}
return 0;
}
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)