跳至内容
Topos Uranos

摘要

本课开启本课程的第一单元,这一单元专讲用 C++ 编程的入门。本课说明这一单元的用途:把从未编过程序的人,从一个用日常语言陈述的问题,带到一个能够编译、运行、读取数据并写出结果的程序,同时清楚途中每一种工具做了什么;介绍本单元的六节课和贯穿它们的线索,从用中文写出的算法,一直到错误的分类;说明课程假定已有的那一点知识,即学校里的算术、按书面指示办事的习惯以及文件和文件夹的操作;提出本单元要解决的问题,即如何把一个会用手算的计算,变成一台机器能够不出错、不知疲倦地执行的过程;最后指出它通向何处,即后面的各单元和“C++ 程序设计 II”课程。

学习目标

  1. 描述本单元内容的顺序,并说明每节课为其后的那节课提供了什么。
  2. 认识本课程假定的预备知识(整数和小数的运算、求商和余数、按书面指示办事、把文件保存到指定的文件夹),必要时加以复习。
  3. 结合一个例子,说明本单元教人走过的道路:从题目陈述到规格说明,从规格说明到算法,再从算法到能编译、能运行的程序。
  4. 认识学完本单元时应当会做什么,并把它与后面各单元将要学习的内容区分开。

入门测评

在学习本单元之前,先测一测自己的起点是有益的。这项测评是自我测评:不计分,唯一的目的是为学习指引方向。由于这是本课程的第一单元,所以没有关于前面单元的题目;取而代之的是,前三道题检查课程假定已有的知识:整数除法的商和余数、一个过程中各步骤的顺序,以及文件的名称、扩展名和所在文件夹。其后的七道题按顺序涉及本单元六节课的核心思想。从未编过程序的人不必指望答对,这也无妨:每个答案,无论对错,都会解释要点,并指出讲解这一要点的那节课。同样的能力会在单元末尾的总结课中再次测量。

入门测评

开始本单元之前,先测一测你的起点。这是一次简短的自我测评:不计分,结果只保存在你的浏览器中。

  1. 第 1 题来自前面的单元把 47 张纸分给 6 个人,每人分得同样多的整张,不够再分一整轮的纸留下。每人得到几张,留下几张?

  2. 第 2 题来自前面的单元用电子邮件发送一个附件有四个步骤:A,填写收件人的地址;B,点击“发送”;C,新建一封邮件;D,添加附件。哪种顺序是恰当的?

  3. 第 3 题来自前面的单元一个文件保存在 C:\Users\ana\programas\suma.cpp。它的名称是什么,扩展名是什么,在哪个文件夹里?

  4. 第 4 题一份食谱写道:“如果面团是干的,就加一勺水并揉面;重复,直到面团不再干。”面团恰好需要三勺水才不再干。一共要问几次面团是不是干的?

  5. 第 5 题下面哪一条指示,任何人或者一台机器都能以完全相同的方式执行?

  6. 第 6 题一个程序编译时没有错误也没有警告,用许多数据运行都正常,而用某一个特定的数据运行时,它停了下来,并给出消息“division by zero”。错误是在什么时刻产生的?

  7. 第 7 题已经安装了 Visual Studio Code 及其 C++ 扩展,可是在终端中输入 g++ --version 时,终端回答说不认识 g++。最可能的原因是什么?

  8. 第 8 题编译器输出一条以 warning: 开头的消息,尽管如此,它还是生成了可执行文件。这意味着什么?

  9. 第 9 题一个 C++ 程序从哪里开始执行?

  10. 第 10 题一个程序声明了 int age{};,执行 std::cin >> age; 来读取年龄,而用户输入的是 abc。会发生什么?

本单元的内容

本单元有六节课,它们按顺序走过从一个问题到一个能正常工作的程序的道路。每节课都以在它之前的那节课为前提,而第一节课不以语言的任何知识为前提。

  • 从问题到算法。课程从没有代码的地方开始,因为算法先于程序。这节课教人用输入、输出以及两者之间的关系(前置条件和后置条件)来规约一个问题;用编号步骤毫不含糊地描述算法;借助跟踪表手工执行算法;在算法中认出顺序、选择和重复;并借助一个每一轮都减小的量,论证一个重复会终止。
  • 程序、编译器与 C++。为机器写的算法就是程序,而 C++ 程序并不是按写下的样子执行的:预处理器、编译器和链接器把它转换成可执行文件。这节课依次讲解这三个阶段,区分编译时就能发现的问题和只有运行时才会显现的问题,比较编译型和解释型两种模式,并按时间梳理语言的各版标准,直到本课程使用的 C++23,以及编译器正在实现中的 C++26。
  • 在 Windows、macOS 和 Linux 上安装环境。三份完整的指南,每个系统一份,用来安装一个支持 C++23 的编译器和编辑器 Visual Studio Code 及其 C++ 扩展,用一个检验程序确认一切正常,并解决常见问题:终端找不到编译器、版本过旧,以及没有指定所需的标准。
  • 编译、运行与配置。编辑、保存、编译、运行、阅读这一工作循环,既在终端中进行,也在编辑器中进行,使用本课程的编译命令。这节课区分阻止生成可执行文件的错误和指出可疑代码的警告;启用检测器(sanitizer),即内存错误检测器 AddressSanitizer 和未定义行为检测器 UndefinedBehaviorSanitizer,它们在程序运行时对它进行监视;并解释为什么输出会显示奇怪的符号而不是汉字。
  • 程序的解剖。把最小的程序一件一件拆开:包含指令和头文件,函数 main 和退出码,命名空间 std,语句、块和注释。这节课教人从第一条消息读起,借助编译器的消息改正结构错误,并确定本课程的风格指南。
  • 输入、输出与错误。程序与外界交流:用 std::print 和 std::println 输出,借助格式说明规定宽度和小数位数,并用 std::cin 读取。这节课展示输入不符合预期格式时会发生什么,以及如何检查读取,最后按照错误出现的时刻对错误分类,以此结束本单元:编译错误、链接错误、运行时错误和逻辑错误。

把它们串起来的线索就是程序本身。第一节课构思它,第二节课说明它被转换成什么,第三节课安装转换它的工具,第四节课教人使用这些工具并倾听它们说什么,第五节课解剖它,第六节课让它与使用它的人对话。如果是这样,那么到单元结束时,学生对 C++ 所知仍然很少,但知道怎样用它工作,而这正是课程其余部分所假定的。

假定已有的知识

本课程不假定任何编程经验。不过,它假定了三种技能,如果入门测评显示出某些疑问,现在就值得复习一下。

第一种是学校里的算术:整数和小数的运算,遵守运算顺序(除非有括号,先乘除后加减),尤其是求整数除法的商和余数。最后这一点比看上去更重要。把 47 张纸分给 6 个人,每人 7 张,剩下 5 张,写成 47=6⋅7+547 = 6 \cdot 7 + 5,其中余数 55 小于除数 66。C++ 有一个求商的运算符和一个求余数的运算符,而大量整数计算(把秒换算成小时、把一个数拆成各位数字、判断一个数是否为偶数)都归结为它们。想要仔细研究整数除法的人,可以在“代数与几何 I:数、函数与平面几何”课程中找到它及其证明;这里只要会算就够了。

第二种是严格按照书面指示办事的习惯,以及识别一条指示何时不足以让人知道该做什么的能力。这不是一种知识,而是一种态度:读到“加盐适量”,就能注意到这条指示取决于执行它的人。本单元的第一节课把这种态度变成方法。

第三种是计算机的基本操作:新建一个文件夹,用自己选定的名字把文件保存在其中,认出名字中的扩展名(.cpp、.txt、.exe),并知道每个文件在哪个文件夹里。最好从现在起就把系统设置为显示扩展名,因为有些系统(例如 Windows)默认会隐藏它们:源代码 suma.cpp 和可执行文件 suma.exe 是截然不同的文件,把它们弄混是一些荒唐错误的根源。本单元也会教人使用终端,所以不必事先就会;只要愿意用输入命令代替点击按钮就够了。最后,安装程序需要能够在自己的计算机上安装;对做不到的人,讲安装的那节课给出了一个在线的替代办法。

本课程不假定懂英语。编译器的消息是英文的,而本课程教人逐个字段地阅读它们,因此其中要紧的内容(文件、行、缺陷的类别)不必精通英语也能看懂。本课程也不假定超出学校水平的数学:例子用到的是除法、平均数、乘法表、日期和时间。

本单元要解决的问题

一个会用手算的计算,还不是程序。两者之间的距离,正是本单元教人走过的,而一个简单的问题可以让我们量出这段距离。

例题用小时、分钟和秒表示的时长

一个秒表把每项活动的时长记录为秒数,例如 7384,而我们想把它写成小时、分钟和秒的常见形式,其中分钟和秒总是两位数:2:03:04。用手算,这是一连串的除法:7384=3600⋅2+1847384 = 3600 \cdot 2 + 184,由此得到 2 小时和 184 秒;184=60⋅3+4184 = 60 \cdot 3 + 4,由此得到 3 分钟和 4 秒。对一个时长,一分钟就能算完。可是对一份记录中的一千个时长,手算既漫长又容易出错,这项工作正适合交给机器来执行。

然而,机器无法接收上面的解释。它接收的是用一种编程语言写成的文本,而要写出这段文本,必须先解决手算时没有明说的三个问题。第一个是规格说明:输入什么(一个非负的整数秒数),输出什么(三个整数,即小时、分钟和秒),以及什么关系把它们联系起来:若 tt 为总秒数,则 t=3600h+60m+st = 3600 h + 60 m + s,其中 0≤m<600 \leq m < 60 且 0≤s<600 \leq s < 60。第二个是算法:对于任何合法的输入,都能产生这一输出的精确步骤。

  1. hh 取值为 tt 除以 36003600 的商。
  2. mm 取值为除以 6060 的商(被除数是 tt 除以 36003600 的余数)。
  3. ss 取值为 tt 除以 6060 的余数。
  4. 写出 hh、冒号、两位数字的 mm、冒号和两位数字的 ss。

第三个是用 C++ 写出算法,采用语言所要求的确切形式,并用合适的工具把它翻译成可执行文件。下面的程序做的就是这件事。其中有许多东西还没有讲解:变量(第 2.1 课),给出整数除法的商和余数的运算符 / 和 %(第 3.1 课),用 std::cin 读取以及格式说明(第 1.6 课);这个程序中的格式说明 {:02} 把一个数写成两位、在左边补零,它是那节课所讲的宽度的一种变体,第 3.1 课也会用到它。目前只要能在其中认出算法的四个步骤就够了。

C++ 程序
#include <iostream>#include <print>​int main(){    int total{};  // 变量将在第 2.1 课讲解    std::print("时长(秒):");    std::cin >> total;    const int hours{total / 3600};    const int minutes{total % 3600 / 60};    const int seconds{total % 60};    std::println("{} s = {}:{:02}:{:02}", total, hours, minutes, seconds);}
程序输入
7384
输出
时长(秒):7384 s = 2:03:04

第 9 至 11 行是步骤 1 至 3,第 12 行是步骤 4。结果与手算的结果一致并非巧合,而是每个程序都应当经受的检验:先用一个答案已知的数据来试它。

程序解决了问题,但本单元也教人不要轻信它。如果用户输入 abc 而不是一个数,读取就会失败,total 保持为零,而程序毫无报警信号地写出时长为 0:00:00:这是一个看起来像真的假结论。如果输入一个负的时长,规格说明没有规定应当发生什么,结果也就毫无意义。如果写成 total / 360,而不是 total / 3600,程序照样编译、运行,给出错误的结果,而没有任何工具发出警告。这些缺陷各属不同类别的错误,在不同的时刻被发现,要用不同的方法寻找;本单元的最后一节课对它们进行分类。由此可知,本单元要解决的问题不仅是写出这个程序,而且是知道它为什么能工作,在什么数据下会失效,以及哪种工具会(或不会)对每个缺陷发出警告。

因此,学完本单元时,学生将能够编写、编译和运行这一类程序:少量的输入数据,按顺序进行的若干计算,以及带格式写出的若干结果。他们还不能在 C++ 中做判断或重复执行语句(虽然在用中文写出的算法中可以),也还不能有根据地为每个数据选择类型;这些是后面各单元要带来的。

通向何处

本单元是整个课程的门槛。在这里未加解释就使用的东西,以后会按以下顺序加以解释。第 2 单元“数据与类型”讲解变量以及变量如何保存在内存中,整数的界限(以及为什么一个正确的计算也可能溢出),带小数的数的舍入,以及类型之间的转换。第 3 单元“表达式与运算符”讲解 C++ 的算术,包括负数的商和余数、条件的逻辑,以及标准在表达式中未作定义的地方。第 4 单元“流程控制”把第一节课用中文写出的判断和重复带到 C++ 中,并教人论证一个循环是正确的、会终止的。第 5 单元把程序分解为函数;第 6 单元引入复合数据(数组、向量、表格和记录),以及查找和排序的算法及其代价;第 7 单元讨论文本、稳健的输入读取和文件;第 8 单元讨论多文件程序、系统的测试和调试器。

学完本课程之后,“C++ 程序设计 II”接着讲本课程有意留在外面的内容:指针和动态内存,带方法的类,自己编写的模板,匿名函数和异常。解决本课程的问题不需要这些主题中的任何一个,而把它们推后,可以让人专注于在任何语言中都适用的东西:规约问题,设计算法,清楚地写出算法,并检验它是正确的。本单元的总结课正会归纳这一区分,即什么是 C++ 特有的,什么可以带到任何其他语言中去。