威斯康星-CS368c-系统编程笔记-全-

威斯康星 CS368c 系统编程笔记(全)

001:第1讲 - 课程概述

概述

在本节课中,我们将学习CS368C++课程的整体介绍,包括课程目标、教学方式、评分标准以及如何成功完成这门课程。我们还将编写并运行第一个C++程序“Hello World”,并将其与Java版本进行比较,以直观地展示两种语言的基本差异。

课程介绍

大家好,这里是威斯康星大学麦迪逊分校2020年秋季学期面向Java程序员的CS368C++课程。

如果你是从其他地方观看此视频,欢迎加入我们。对于选课的同学,请注意我们的Canvas网站上提供了大量资源,我今天将介绍其中一部分。我们将从课程介绍开始。

今日大纲

今天的课程大纲如下:首先是总体课程概述,然后浏览教学大纲,讨论如何通过这门课程。接着,我们将查看一个示例程序“Hello World”——这是大多数人学习编程时编写的第一个程序。最后,我们将这个程序与Java版本进行比较。

这是本周课程内容的第一部分。

关于讲师

我的名字是Mike Douchher,请叫我Mike。如果你打开了这些幻灯片的PowerPoint版本,可以点击链接给我发邮件。

我最初是一名化学家,成为计算机科学家大约只有10年时间。我在南卡罗来纳大学获得了化学学位。之后,我在海军研究实验室担任了三年的独立承包商,这是一个支持海军和海军陆战队的军事实验室。合同结束后,我在本尼迪克特学院教了八年化学。后来,出于对计算机科学的好奇,我开始在线学习课程,积累了丰富的学生经验。当我学到足够的知识进入研究生项目后,我来到麦迪逊大学获得了硕士学位,并在S Art软件公司担任了三年的软件工程师。期间,我还在晚上教授C++和Matlab课程。最近,我接受了麦迪逊大学的全职教职工作。

课程详情

如果你访问Canvas网站并打开教学大纲,最重要的信息将是课程安排表,那里会发布所有内容的链接。

我们将使用Piazza进行课程交流,这是一个供学生提问和获取答案的平台。你还需要激活你的CS账户以访问CSL机器。

所有视频讲座将发布在YouTube上,链接会放在课程安排表中。作业将通过Canvas发布,并附有说明视频。

关于办公时间,我们将使用预约队列系统。在办公时间开始前,你可以通过填写调查问卷来预约,我会按顺序与学生见面。

教学大纲要点

这门课程是关于C++的,特别是针对已经了解Java的人。我们将涵盖一系列主题。课程没有指定教材,所有阅读材料将在线提供。

课程评分基于几次作业,没有考试。你需要获得70%或以上的总分才能通过。迟交政策相对宽松:在我们开始评分之前提交都不算迟交,一周内迟交可获得最高50%的分数。

严禁作弊。我们将使用名为Moss的软件相似性检测工具来识别抄袭行为,任何作弊行为都将被严肃处理。

课程目标与受众

本课程专为至少上过一门编程课(最好是Java或类似C的语言)的学生设计。课程内容将与CS200(可能还有CS300)中学习的结构相对应,并涵盖C++特有的功能。

根据注册信息,班级中有不同年级的学生。本课程主要针对大二水平的学生,即已经上过一门编程课的同学。请高级学生在评价课程时,考虑课程是否有效地达到了针对初学者的教学目标。

如何通过课程

这是一门“通过/不通过”的课程,没有字母等级。我们将有大约7次作业。要通过课程,你必须在所有作业中获得70%或以上的总分,并且必须尝试完成所有作业。

开发环境与工具

我将提供关于如何使用CSL机器、命令行和Vim文本编辑器进行编译的支持。虽然你可以使用任何喜欢的IDE(如Eclipse CDT、Visual Studio、Qt等),但本课程将重点讲解在命令行中使用Vim和G++编译器。掌握这些工具对于提高编程效率非常有帮助。

上一节我们介绍了课程的基本信息和规则,本节中我们来看看如何开始编写第一个C++程序。

第一个C++程序:Hello World

现在,我将跳转到CSL机器上,演示如何编写一个“Hello World”程序,并逐行讲解其含义以及与Java版本的比较。

以下是编写和运行C++ “Hello World”程序的步骤:

  1. 使用Vim创建一个新文件,例如 hw.cpp
  2. 输入以下代码:
    #include <iostream>
    using namespace std;
    
    int main(int argc, char* argv[]) {
        cout << "Hello, world!" << endl;
        return 0;
    }
    
  3. 保存并退出Vim。
  4. 在终端中使用 g++ 编译器进行编译:
    g++ hw.cpp -o hw
    
  5. 运行编译后的程序:
    ./hw
    

程序将输出 Hello, world!

与Java的Hello World比较

让我们将C++版本与Java的“Hello World”进行逐行比较。

public class HelloWorld {
    public static void main(String[] args) {
        System.out.println("Hello, world!");
    }
}

以下是核心概念的对比分析:

  • 主函数:两者都有一个 main 函数作为程序入口。在C++中,main 位于全局作用域,且每个程序只能有一个。其返回类型是 int,返回0表示程序成功执行。在Java中,main 必须包含在一个类中。
  • 命令行参数:C++中使用 int argc(参数计数)和 char* argv[](参数字符串数组)。Java中使用 String[] args。C++的 char* argv[] 是一个指向字符指针的指针,代表字符串数组。
  • 包含头文件:C++使用 #include <iostream> 预处理指令将输入输出库的内容包含进来。尖括号 < > 表示系统库文件。这类似于Java中的 import 语句,但机制不同。
  • 命名空间using namespace std; 类似于Java的 import,它允许我们直接使用 std 命名空间中的名称(如 cout),而无需前缀。
  • 输出语句:C++中使用 cout << "Hello, world!" << endl; 进行控制台输出。cout 代表控制台输出流,<< 是流插入运算符,endl 表示换行并刷新缓冲区。这相当于Java中的 System.out.println("Hello, world!");

C++与Java的语法共性

许多在Java中使用的语法结构在C++中同样有效,这为Java程序员提供了便利。以下是一些例子:

  • 控制结构for 循环、if 语句、while 循环的语法基本相同。
  • 变量声明:基本数据类型(如 int, double, bool)的声明方式类似。
  • 布尔表达式:C++中的布尔表达式可以计算为数值(0表示false,非0表示true),这提供了额外的灵活性。

为了演示用户输入和一些通用语法,让我们看一个简单的“磅转换为千克”的程序示例:

#include <iostream>
using namespace std;

int main() {
    cout << "Mike, how much do you weigh? ";
    double pounds;
    cin >> pounds; // 从键盘获取输入

    double kilograms = pounds * 0.454;
    cout << "You weigh " << kilograms << " kilograms." << endl;

    bool greatShape = (pounds < 1000); // 布尔表达式赋值
    if (greatShape) {
        cout << "Wow, you're in great shape!" << endl;
    }

    // for循环示例
    for (int i = 0; i < 3; i++) {
        cout << "Have a great day!" << endl;
    }

    // while循环示例
    int j = 0;
    while (j < 3) {
        cout << "Loop with while: " << j << endl;
        j++;
    }

    return 0;
}

这个程序演示了:

  1. 使用 cin 进行用户输入。
  2. 基本的算术运算和变量赋值。
  3. 布尔表达式的使用和 if 条件判断。
  4. for 循环和 while 循环,其语法与Java一致。

总结

本节课中我们一起学习了CS368C++课程的概述。我们了解了课程的目标、评分方式以及重要的学术诚信规定。我们动手编写并运行了第一个C++程序“Hello World”,并通过与Java版本的详细对比,理解了C++在程序结构、输入输出和基本语法上的特点。我们还看到,许多Java的控制流和变量操作语法在C++中可以直接使用,这为学习过渡提供了便利。

请记得填写Canvas上的调查问卷,并关注即将发布的第一份作业。如果有任何问题,请在Piazza上提问。祝大家学习愉快!

002:文件I/O与字符串 📚

在本节课中,我们将学习C++中的输入/输出(I/O)操作和字符串处理。我们将从课程安排和作业提醒开始,然后深入探讨C++与Java的历史背景和设计哲学差异,最后通过实际代码示例学习如何使用流(streams)进行I/O操作以及如何操作字符串。


课程安排与作业提醒 📅

上一节我们介绍了课程的基本信息,本节中我们来看看本周的具体安排和作业。

  • 办公时间:根据调查结果,我的办公时间定在周一到周五的下午2点。助教R.T. Kane Rodriguez的办公时间是周一至周四的早上8点和下午4:30。请通过Canvas课程页面上的“票务队列”链接预约。
  • Piazza:请尚未加入的同学尽快注册。大约90%的同学已经加入,这是一个很好的交流平台。
  • 作业1:第一部分(Homework 1A)将于明天(周四)晚上截止。第二部分(Homework 1B)将于明天发布,并于9月17日(下周四)截止。Homework 1B旨在评估你是否适合本课程,涉及基本的编程技能,如循环和字符串处理。

C++与Java:历史与设计哲学对比 ⚖️

在深入学习具体语法之前,了解C++和Java的设计目标差异至关重要。这有助于理解为什么C++的某些特性看起来更复杂或更底层。

C++脱胎于C语言,其核心目标是性能对硬件的直接控制。它被设计为C语言的面向对象扩展,旨在生成与C语言一样高效的机器码。因此,C++将许多控制权(和相应的责任)交给了程序员,例如手动内存管理、缺乏数组边界检查等。这带来了极高的运行效率,但也增加了编写正确代码的难度。

Java的设计哲学则侧重于安全易用性。它从C++中选取了最常用、最不易出错的特性,并加入了自动垃圾回收、严格的运行时检查等机制,以防止程序员犯常见错误。其代价是牺牲了一些底层控制能力和绝对的性能峰值。

以下是两种语言的一些关键区别:

  • 目标:C++追求极致的运行速度;Java追求代码的安全性与可移植性。
  • 编译与运行:C++编译为本地机器码;Java编译为字节码,在虚拟机(JVM)上运行。
  • 内存管理:C++需要手动管理(new/delete);Java有自动垃圾回收。
  • 错误检查:C++在编译和运行时检查较少;Java在编译和运行时都有严格的检查。
  • 指针与引用:C++支持指针和引用;Java只有引用(且不支持指针运算)。
  • 多重继承:C++支持;Java通过接口实现类似功能。
  • 运算符重载:C++支持;Java不支持。

理解了这些根本差异,我们就能更好地接受C++中那些看似“繁琐”或“危险”的特性,它们正是其高性能的源泉。


变量命名规范 📝

在开始编码前,我们先快速回顾一下C++的变量命名规则,这对于写出清晰可读的代码非常重要。

变量名可以由字母、数字和下划线(_)组成,且不能以数字开头。长度没有限制。虽然编译器没有强制要求,但遵循一致的命名约定是良好的编程习惯。例如:

  • 变量名使用小写字母,单词间用下划线分隔(如 student_name)。
  • 函数名使用“驼峰式”(如 getStudentName)或“帕斯卡式”(每个单词首字母大写)。
  • 类名使用大写字母开头(如 StudentRecord)。

选择有意义的变量名至关重要。好的变量名可以让代码自文档化,让他人(包括未来的你)更容易理解。避免使用像 ttttt 这样含义模糊的缩写。


流(Streams):C++的I/O工具箱 🛠️

现在,让我们进入今天的核心内容:如何使用C++进行输入和输出。C++使用的概念来处理I/O操作。你可以将流想象成一个数据的管道或序列,数据从源头(如键盘、文件)流向目的地(如屏幕、文件)。

要使用C++的I/O功能,需要包含相应的头文件:

  • #include <iostream>:用于标准输入输出(cin, cout)。
  • #include <fstream>:用于文件操作。
  • #include <iomanip>:用于格式化输出(如设置精度、宽度)。

为了简化代码,我们通常还会加上 using namespace std;,这样就不必在每个流对象前写 std:: 前缀。

标准输入与输出

  • 输入流 cin:连接到键盘。使用流提取运算符 >>cin 读取数据到变量。
    int x;
    cin >> x; // 从键盘读取一个整数到变量x
    
  • 输出流 cout:连接到屏幕。使用流插入运算符 << 将数据发送到 cout
    cout << "You entered: " << x << endl;
    

运算符链<<>> 运算符可以连续使用,因为它们每次操作后都返回流对象本身。

int x, y;
cin >> x >> y; // 连续读取两个整数
cout << "x=" << x << ", y=" << y << endl; // 连续输出

注意cout 的输出是缓冲的,意味着数据可能不会立即显示在屏幕上。使用 endl(换行并刷新缓冲区)或 flush(仅刷新缓冲区)可以强制立即输出。这在调试时非常有用。


字符串操作 📄

C++标准库提供了 string 类,使得字符串处理比C风格的字符数组方便得多。需要包含头文件 #include <string>

以下是字符串的一些基本操作:

  • 声明与初始化
    string first_name = "Indigo";
    string last_name = "Montoya";
    
  • 拼接:使用 + 运算符或 append() 方法。
    string full_name = first_name + " " + last_name;
    first_name.append(" Mike"); // 将" Mike"附加到first_name末尾
    
  • 获取长度:使用 length()size() 方法。
    int name_length = full_name.length();
    
  • 访问与修改字符:使用下标运算符 [](从0开始)。
    char first_char = full_name[0]; // 获取第一个字符
    full_name[0] = 'E'; // 将第一个字符改为'E'
    
  • 添加字符:使用 push_back() 方法。
    full_name.push_back('!'); // 在字符串末尾添加'!'
    
  • 插入字符串:使用 insert() 方法。
    // 在位置6(第7个字符前)插入字符串
    full_name.insert(6, " the Great");
    
  • 读取整行:使用 getline() 函数读取包含空格的整行输入。
    string user_input;
    getline(cin, user_input); // 读取一整行,包括空格
    

总结 🎯

本节课中我们一起学习了:

  1. C++与Java的核心差异:理解了C++追求性能与控制、Java追求安全与易用的设计哲学。
  2. C++的I/O系统:学会了使用 cincout 流对象以及 <<>> 运算符进行基本的输入输出,并了解了运算符链和缓冲区刷新的概念。
  3. 字符串处理:掌握了C++ string 类的基本操作,包括声明、拼接、获取长度、访问字符以及使用 getline() 读取整行输入。

这些知识是进行后续C++编程的基础,特别是对于完成即将到来的作业至关重要。请务必动手实践示例代码,加深理解。

003:迭代、条件句与IO操作 🚀

在本节课中,我们将学习C++编程的基础知识,重点探讨变量声明、布尔逻辑、循环控制以及输入输出格式化操作。这些内容是理解C++与Java差异并编写高效代码的关键。

变量声明与初始化

上一节我们介绍了课程概述,本节中我们来看看C++中变量声明的基础。与Java不同,C++中的变量在声明时不会自动初始化。

int x; // 声明变量x,其值未定义
cout << "No default initialization. X is equal to " << x << endl;

上述代码中,变量x的值是未定义的,可能是0,也可能是内存中的任意值。为了安全,我们应始终初始化变量。

以下是初始化变量的几种方式:

  • 声明后赋值int x; x = 5;
  • 声明时初始化int y = 4;
  • 同一行声明多个变量int a, b, c;int d = 1, e = 2, f, g = 6;

布尔值与条件语句

理解了变量声明后,我们来看看C++如何处理布尔逻辑。C++将布尔值truefalse内部存储为整数1和0。

bool b = true;
cout << "Boolean statements. True is equal to " << true << " and false is equal to " << false << endl;

在条件语句(如ifwhilefor)中,C++会将表达式简化为布尔值。任何非零值被视为true,零值被视为false

以下是C++中可被视为布尔条件的示例:

  • 布尔变量if (b)
  • 布尔字面量if (true)if (false)
  • 整数if (1)(真), if (0)(假), if (42)(真), if (-1)(真)
  • 字符if ('a')(真,因为‘a’的ASCII码97非零)
  • 关系运算符if (3 < 4)if (3 == 4)if (3 != 4)
  • 逻辑运算符if (true && true)if (true || false)if (!true)

注意:在条件中使用赋值运算符(=)是允许的,但容易造成混淆,通常是不良实践。赋值表达式会返回所赋的值,该值随后被用作条件。

if ((x = 5)) { // 将5赋值给x,然后判断5(非零,为真)
    cout << "true" << endl;
}

逻辑运算符&&(与)和||(或)支持短路求值。对于&&,如果左侧为假,则右侧不再计算;对于||,如果左侧为真,则右侧不再计算。

循环控制

掌握了条件判断,接下来我们深入探讨C++中的循环结构。for循环非常灵活,其三个部分(初始化、条件、递增)都可以包含多个语句或留空。

for (int i = 0, square = 0; i < 10; i++, square = i * i) {
    cout << setw(3) << i << setw(6) << square << endl;
}

以下是循环控制的关键字:

  • break:立即终止整个循环,跳出循环体。
  • continue:跳过当前循环迭代的剩余代码,直接进入下一次循环的条件判断。
  • goto:跳转到程序中指定的标签处。虽然可用,但应谨慎使用,以免破坏代码结构。

C++11引入了基于范围的for循环,非常适合遍历容器(如字符串、向量)。

string quote = "It's hardware that makes a machine fast. "
               "It's software that makes a fast machine slow.";
// 遍历副本,不修改原字符串
for (char c : quote) {
    cout << c << endl;
}
// 使用引用遍历并修改原字符串
for (char &c : quote) {
    if (c == 'I') c = 'X';
}
// 使用auto关键字自动推断类型
for (auto c : quote) {
    cout << c;
}

输入输出格式化(I/O Manipulators)

最后,我们学习如何精确控制输出的格式。I/O操纵器是发送给cout的特殊指令,用于修改数字或文本的显示方式。

以下是一些常用的I/O操纵器:

  • showpoint / noshowpoint:显示或隐藏浮点数末尾的零和小数点。
  • showpos / noshowpos:为非负数显示+号。
  • hex / dec / oct:以十六进制、十进制或八进制格式输出整数。
  • showbase / noshowbase:显示(如0x表示十六进制)或隐藏数字进制的前缀。
  • fixed / scientific:以定点计数法或科学计数法显示浮点数。
  • setprecision(n):设置浮点数输出的小数位数(在fixed模式下)或总有效数字位数(在默认模式下)。
  • setw(n):设置下一个输出字段的宽度。
  • left / right:在字段内左对齐或右对齐文本。
  • setfill(ch):用指定字符ch填充字段的空白部分。
  • boolalpha / noboolalpha:将布尔值true/false输出为字符串形式,而非1/0。
cout << showpos << 1.0 << endl; // 输出: +1
cout << hex << showbase << 4567 << endl; // 输出: 0x11d7
cout << fixed << setprecision(2) << 123.456789 << endl; // 输出: 123.46
cout << setw(8) << setfill('*') << "ABCD" << endl; // 输出: ****ABCD
cout << boolalpha << true << endl; // 输出: true

重要提示:大多数操纵器(如showposhex)的状态是持久的,一旦设置,会影响后续所有相关输出,直到被显式更改(如使用noshowposdec)。


本节课中我们一起学习了C++编程的核心基础:变量声明的规则、布尔逻辑的求值方式、各种循环结构及其控制语句,以及强大的I/O格式化工具。理解这些概念是编写正确、高效且输出美观的C++程序的第一步。

004:基本类型、枚举、开关语句、函数基础 🧱

在本节课中,我们将要学习C++中的基本数据类型、枚举类型、switch语句以及函数的基础知识。这些是构建C++程序的核心组件,理解它们对于从Java过渡到C++至关重要。

概述

上一节我们介绍了C++的基本结构和输入输出。本节中,我们来看看C++中更基础的元素:原始数据类型、如何定义和使用枚举、控制流中的switch语句,以及如何声明和定义函数。

基本数据类型

C++提供了多种基本数据类型,它们的大小通常是平台相关的,这与Java中固定大小的类型不同。

整数类型

C++的整数类型包括 intshortlonglong long。它们可以通过 signedunsigned 修饰符来改变表示范围。

  • short:至少2字节。
  • int:通常4字节,但大小系统相关。
  • long:至少和 int 一样大。
  • long long:至少8字节,提供更大的整数范围。

使用 unsigned 修饰符可以表示非负数,从而扩大正数范围。例如,unsigned int 的范围是 0 到约 40 亿。

获取类型大小

可以使用 sizeof 操作符来获取特定类型在您系统上的字节大小。它返回一个 size_t 类型的值。

#include <iostream>
int main() {
    std::cout << "Size of int: " << sizeof(int) << " bytes\n";
    std::cout << "Size of long long: " << sizeof(long long) << " bytes\n";
    return 0;
}

字符类型

字符类型用于存储单个字符,在内部以数字形式存储(如ASCII码)。

  • char:默认1字节,可表示ASCII字符。
  • wchar_t:宽字符,用于表示更大字符集(如Unicode)。
  • char16_t / char32_t:用于明确表示UTF-16和UTF-32编码的字符。

浮点类型

浮点类型用于表示小数。

  • float:通常4字节,提供约6-7位十进制精度。
  • double:通常8字节,提供约15位十进制精度。
  • long double:扩展精度浮点数,大小和精度因系统而异。

字面量

字面量是直接在代码中硬编码的值。

整数字面量

可以以不同进制表示整数:

  • 十进制:42
  • 八进制(以0开头):052 (注意:09 是无效的,因为八进制没有数字9)
  • 十六进制(以0x开头):0x2A
  • 二进制(C++14起,以0b开头):0b101010

可以使用后缀指定类型:

  • Uu:无符号 (unsigned),例如 42U
  • Ll:长整型 (long),建议使用大写 L,因为小写 l 容易与数字 1 混淆,例如 42L
  • LLll:长长整型 (long long),例如 42LL

浮点数字面量

默认是 double 类型。可以使用后缀:

  • Ff:单精度 (float),例如 3.14f
  • Ll:扩展精度 (long double),例如 3.14L

类型转换与运算

隐式类型转换

C++会在许多情况下自动进行类型转换,例如在赋值或算术运算中。

bool b = 5; // 非零值转换为 true,b 存储为 1
int i = 3.14; // 浮点数转换为整数,小数部分被截断,i 为 3
double d = i; // 整数转换为浮点数,d 为 3.0,但丢失了原来的 .14
unsigned int u = -1; // 负数转换为无符号数,u 变为一个很大的正数

整数除法

当两个整数相除时,C++执行整数除法,结果会被截断为整数。

int result = 23 / 4; // 结果是 5,不是 5.75
int remainder = 23 % 4; // 取余运算,结果是 3

要让除法产生浮点数结果,至少需要一个操作数是浮点类型。

double result = 23.0 / 4; // 结果是 5.75
// 或者使用类型转换
double result2 = static_cast<double>(23) / 4; // 结果也是 5.75

显式类型转换

C++提供了几种显式类型转换的方法:

  1. C风格转换(目标类型)表达式,例如 (unsigned int) x
  2. 函数风格转换目标类型(表达式),例如 unsigned int(x)
  3. static_caststatic_cast<目标类型>(表达式),这是推荐使用的方式,更安全且易于在代码中搜索。
int x = 42;
unsigned int u1 = (unsigned int)x; // C风格
unsigned int u2 = unsigned int(x); // 函数风格
unsigned int u3 = static_cast<unsigned int>(x); // 推荐:static_cast

static_cast 在编译时进行类型检查,适用于大多数明确的类型转换场景。

枚举类型

枚举(enum)允许我们创建一种新的数据类型,其值被限制为一组命名的整数常量。

定义和使用枚举

// 定义一个名为 Color 的枚举类型
enum Color { RED, ORANGE, YELLOW, GREEN, BLUE, INDIGO, VIOLET };

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs368c-sysprog/img/b485c624220a8c34268915d344f57863_7.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs368c-sysprog/img/b485c624220a8c34268915d344f57863_9.png)

int main() {
    Color myColor = RED; // 声明一个 Color 类型的变量并赋值
    // myColor = 5; // 错误:不能直接用整数赋值
    // myColor = PURPLE; // 错误:PURPLE 不在枚举列表中

    std::cout << "myColor value: " << myColor << std::endl; // 输出 0 (RED)
    return 0;
}

默认情况下,枚举值从0开始依次递增(RED=0, ORANGE=1, ...)。也可以显式指定值:

enum Transportation { CAR = 0, PLANE = 13, BOAT = 3, BIKE }; // BIKE 会从 BOAT 递增,值为 4

枚举常量(如 RED)在定义后即进入作用域,不能在其他地方重定义。

Switch 语句

switch 语句提供了一种基于整数或枚举表达式值进行多路分支的清晰方式。它通常与枚举类型一起使用。

基本语法

switch (表达式) { // 表达式必须能求值为整型或枚举类型
    case 常量表达式1:
        // 语句...
        break; // 跳出 switch
    case 常量表达式2:
        // 语句...
        // 注意:如果没有 break,会“贯穿”到下一个 case
        break;
    // ... 更多 case
    default:
        // 如果所有 case 都不匹配,则执行此处的语句
        break;
}

示例:与枚举结合

Transportation t = PLANE;
switch (t) {
    case CAR:
        std::cout << "You selected car.\n";
        break;
    case PLANE:
        std::cout << "You selected plane.\n";
        // 这里故意没有 break,会贯穿到下一个 case
    case BOAT:
        std::cout << "You might have selected plane or boat (no break after plane).\n";
        break;
    default:
        std::cout << "You selected something else.\n";
        break;
}
// 当 t 为 PLANE 时,输出:
// You selected plane.
// You might have selected plane or boat (no break after plane).

重要case 标签后的 break 语句用于防止“贯穿”执行下一个 case。如果省略 break,程序会继续执行后续 case 中的语句,直到遇到 breakswitch 结束。

函数基础

在C++中,函数可以定义在全局作用域,而不必像Java那样必须属于某个类。

函数声明与定义

  • 函数声明(原型):告诉编译器函数的存在、返回值类型、名称和参数类型。参数名可选,但建议加上以提高可读性。
  • 函数定义:提供函数的具体实现,包括函数体。
// 函数声明(通常在头文件或文件顶部)
int toInches(int feet, int inches = 0); // inches 有默认值 0

int main() {
    int heightInInches = toInches(6, 1); // 调用函数
    std::cout << "Height: " << heightInInches << " inches\n";
    return 0;
}

// 函数定义
int toInches(int feet, int inches) {
    return feet * 12 + inches;
}

关键点

  1. 函数需要在使用前被声明(或定义)。
  2. 声明中的默认参数值(如 inches = 0)应放在函数声明中,而不是定义中(除非声明和定义是同一个)。
  3. 默认参数必须从参数列表的最右边开始连续设置。

参数传递与作用域

在上面的例子中,参数 feetinches 通过值传递。这意味着调用函数时,实参(如 61)的值被复制到形参(函数内部的 feetinches)中。函数内部对形参的修改不会影响外部的实参。

函数内部定义的变量(包括形参)具有局部作用域,它们只在函数执行期间存在,函数返回后即被销毁。

总结

本节课我们一起学习了C++编程的几项基础内容。我们了解了C++丰富且平台相关的基本数据类型,学会了如何使用枚举来创建有意义的常量集合,掌握了用switch语句处理多分支选择,并初步认识了如何声明和定义全局函数。这些知识是编写结构化C++程序的基石。下一节,我们将探讨数组和指针,它们是C++中管理内存和数据的强大工具。

005: 数组与指针 🧠

在本节课中,我们将要学习C++中的数组和指针。数组是存储相同类型数据的集合,而指针则是存储内存地址的变量。理解这两者及其关系是掌握C++系统级编程的关键。我们将从基础概念开始,逐步深入到它们的使用方法和注意事项。


数组基础

上一节我们介绍了基本数据类型,本节中我们来看看如何创建和使用数组。

数组的声明与初始化

以下是声明和初始化数组的几种方式:

  • 指定大小声明int data[100]; 声明一个包含100个整数的数组。
  • 自动推断大小char message[] = "Hello"; 编译器会根据字符串长度自动确定数组大小。
  • 列表初始化int limits[] = {10, 12, 14, 17, 20}; 编译器会计算元素数量并分配空间。

数组的重要特性

以下是关于数组的几个核心特性:

  1. 语法差异:在C++中,方括号 [] 放在变量名之后,而不是像Java那样放在类型之后。
  2. 非对象:数组不是对象,没有关联的方法,本质上只是一块连续的内存区域。
  3. 无大小信息:数组自身不存储其大小,程序员必须自己记住元素数量。
  4. 无边界检查:访问数组边界之外的元素是合法的(但危险),编译器不会报错。

数组访问示例

我们可以通过索引来访问和修改数组元素。索引从0开始。

int limits[] = {10, 12, 14, 17, 20};
cout << limits[0] << endl; // 输出 10
limits[4] = 25; // 修改最后一个元素
// 危险:访问越界,行为未定义
cout << limits[-1] << endl;
cout << limits[10] << endl;

数组的局限性

数组有一些重要的限制需要注意:

  • 不能直接复制或赋值:不能用一个数组直接初始化另一个数组,也不能将一个数组赋值给另一个数组。
    int a[5] = {1,2,3,4,5};
    int b[5] = a; // 错误:必须使用大括号初始化器
    int c[5];
    c = a; // 错误:无效的数组赋值
    
  • 关系运算符比较的是地址:对数组使用 ==, < 等运算符,比较的是它们在内存中的起始地址,而非内容。
    int arr1[5] = {1,2,3,4,5};
    int arr2[5] = {1,2,3,4,5};
    cout << (arr1 == arr2) << endl; // 输出 0 (false),因为地址不同
    

指针入门 🎯

上一节我们了解了数组,本节中我们来看看指针。指针是C++中强大但也容易令人困惑的特性,它存储的是另一个变量的内存地址。

指针的声明

声明指针变量需要在类型或变量名旁加上星号 *

int* x1; // 方式一:星号靠近类型(常见)
int *x2; // 方式二:星号靠近变量名
int *x6, x7; // x6是指针,x7是普通整数。星号不作用于逗号后的变量。

指针的初始化

创建指针后,最好立即将其初始化为 nullptr(C++11推荐)或 NULL0,表示它不指向任何有效内存。

int* ptr1 = nullptr;
int* ptr2 = NULL;
int* ptr3 = 0;
// 判断指针是否有效
if (ptr1) {
    // ptr1 非空,可以安全使用
}

为什么使用指针?

以下是使用指针的几个主要原因:

  • 固定大小:指针的大小是固定的(例如,64位系统上是8字节),便于在栈上分配。
  • 动态内存管理:通过 new 运算符在堆上分配内存,指针用于访问这块内存。
  • 实现多态:通过基类指针指向派生类对象,实现运行时多态。
  • 操作数组:数组名在很多情况下可以退化为指向其首元素的指针。

指针的基本操作

让我们通过一个例子来理解指针的赋值和解引用。

int x = 3;
int* x1 = &x; // & 是取地址运算符,将x的地址赋给指针x1

cout << “x = ” << x << endl; // 输出:x = 3
cout << “x1 = ” << x1 << endl; // 输出:x1 = 0x7ffe... (一个内存地址)
cout << “*x1 = ” << *x1 << endl; // * 是解引用运算符,输出:*x1 = 3

x = 4; // 直接修改变量x
cout << “*x1 = ” << *x1 << endl; // 输出:*x1 = 4,因为x1指向x

*x1 = 5; // 通过指针间接修改变量x
cout << “x = ” << x << endl; // 输出:x = 5

int y = 11;
x1 = &y; // 指针可以重新指向另一个变量
cout << “*x1 = ” << *x1 << endl; // 输出:*x1 = 11

指针与动态内存

使用 new 在堆上分配内存,并使用 delete 释放。

// 分配一个字符串
string* p = new string(“Hello World”);
cout << *p << endl; // 解引用以获取字符串内容
delete p; // 释放内存

// 分配一个整数数组
int* px = new int[6]; // 分配6个整数的空间
px[0] = 5; // 使用数组下标语法访问
*(px + 1) = 4; // 使用指针算术访问
delete[] px; // 释放数组内存,注意使用 delete[]

数组与指针的紧密联系 🔗

数组名在大多数表达式中会退化为指向其第一个元素的指针。这使得指针和数组的用法可以互通。

int arr[] = {0, 1, 2, 3, 4, 5};
int* pArr = arr; // arr 退化为 &arr[0]

cout << arr[2] << endl; // 输出 2
cout << pArr[2] << endl; // 输出 2,指针可以像数组一样使用下标
cout << *(arr + 3) << endl; // 输出 3,数组名可以像指针一样进行算术运算

// 指针算术:移动指针
pArr = pArr + 1; // 现在 pArr 指向 arr[1]
cout << *pArr << endl; // 输出 1
cout << pArr[0] << endl; // 输出 1,pArr[0] 现在等价于原来的 arr[1]

关键区别:数组名是一个常量指针,其值(指向的地址)不能改变。而普通指针变量可以被重新赋值。


指针的注意事项与陷阱 ⚠️

理解了基本操作后,我们来看看一些常见的陷阱。

1. 运算符优先级

解引用运算符 * 的优先级非常低。*p++ 意味着 *(p++),即先移动指针,再解引用移动前的地址。如果想增加指针指向的值,需要使用 (*p)++

int arr[] = {0, 1, 2};
int* p = arr;
(*p)++; // 将 arr[0] 从 0 增加到 1
// *p++; // 这会将指针p移动到arr[1],可能不是你想要的效果

2. 双重释放错误

如果两个指针指向同一块堆内存,对其中一个使用 delete 后,该内存已被释放。再对另一个指针使用 delete 会导致未定义行为(通常程序崩溃)。

int* x = new int(5);
int* y = x; // y 和 x 指向同一地址

delete x; // 正确:释放内存
// delete y; // 危险!双重释放错误,y现在是一个“悬空指针”
x = nullptr; // 良好实践:delete后置为空指针
y = nullptr;

3. 指向指针的指针

指针可以指向另一个指针。一个常见的应用是处理命令行参数。

int main(int argc, char** argv) {
    // argc: 参数计数,包括程序名本身
    // argv: 指针数组,每个元素是一个C风格字符串(char*)
    for (int i = 0; i < argc; ++i) {
        cout << “Argument ” << i << “: ” << argv[i] << endl;
    }
    return 0;
}

引用:指针的“安全马甲” 📝

引用是另一个变量的别名。它使用 & 符号声明,但此处的 & 是引用声明符,而非取地址符。

int x = 4;
int& y = x; // y 是 x 的引用(别名)

y = 3; // 通过引用修改 x 的值
cout << x << endl; // 输出 3

// int& z; // 错误!引用必须在声明时初始化。
// y = &someOtherVar; // 错误!引用一旦绑定,不能更改其指向。

引用 vs 指针

  • 必须初始化:引用在创建时必须绑定到一个变量。
  • 不能为空:不存在“空引用”。
  • 不能重绑定:引用在其生命周期内始终指向初始化的变量。
  • 无需解引用:使用引用就像使用原始变量一样。

引用通常用于函数参数传递,以避免拷贝大型对象,我们将在下一讲中详细讨论。


本节课中我们一起学习了C++中数组和指针的核心概念。数组是固定大小的同类型元素集合,而指针则是存储地址的变量,它们之间有着紧密的联系。我们还探讨了指针算术、动态内存管理以及常见的陷阱。最后,我们介绍了引用作为指针的一种更安全、更直观的替代品。理解这些概念是进行高效C++编程和内存管理的基础。下一讲,我们将学习如何将这些知识应用于函数参数传递。

006:C++ for Java Programmers: Lecture 6: 内存模型、多维数组与参数传递

在本节课中,我们将深入探讨C++的内存模型,理解指针和数组在内存中的布局,并学习如何向函数传递各种类型的参数,包括数组和多维数组。掌握这些概念对于编写高效、正确的C++程序至关重要。

内存模型与编译过程

上一节我们回顾了指针和引用的基本用法。本节中,我们来看看代码在计算机内部是如何被组织和执行的。

当我们编译代码时,编译器会经历多个阶段:

  1. 预处理器:处理#include等指令,展开头文件,移除注释。
  2. 编译器:将代码转换为汇编语言。
  3. 汇编器:将汇编代码转换为机器码(0和1)。

编译器会维护一个符号表,用于记录变量名与其内存地址的对应关系。编译完成后,机器码中只使用内存地址,变量名不再存在。

程序运行时,操作系统会为它分配一个虚拟内存空间。这个空间主要分为以下几个区域:

  • 代码区:存放编译后的机器指令。
  • 数据区:存放全局变量和静态局部变量。
  • :存放函数的局部变量。函数调用时,其局部变量被压入栈;函数返回时,这些变量被弹出。栈从高地址向低地址增长。
  • :用于动态内存分配(如使用newmalloc)。堆从低地址向高地址增长。程序员需要手动管理堆内存的分配和释放。

指针、引用与内存模型

让我们通过代码示例,将变量、指针和引用映射到内存模型中。

int x = 3;
int y = 5;
int* p = &x; // p 是一个指向 x 的指针

在内存中,xyp作为局部变量,会被分配在栈上。假设它们的地址如下(为简化,使用短地址表示):

  • x 存储在地址 0xF130,值为 3
  • y 存储在地址 0xF134,值为 5
  • p 存储在地址 0xF138,其存储的值x 的地址 0xF130

因此,指针 p 本身有一个存储位置(0xF138),其内容指向另一个内存位置(0xF130)。通过解引用操作 *p,我们可以访问或修改 x 的值。

引用则可以看作是一个变量的别名。它必须在创建时初始化,且之后不能更改其绑定的对象。

int& r = x; // r 是 x 的引用(别名)

在编译器内部,引用可能通过指针机制实现,也可能直接在符号表中处理为别名。对于程序员而言,rx 访问的是同一块内存。

数组与内存模型

数组在内存中是连续存储的。声明一个数组时,数组名本质上是一个指向数组首元素的常量指针

int a[5] = {0, 1, 2, 3, 4};
int* pa = a; // pa 是一个指向数组 a 的指针

在内存中,数组 a 的五个元素 0, 1, 2, 3, 4 会连续存放。数组名 a 的值就是第一个元素 a[0] 的地址。下标操作 a[i] 在内部被转换为 *(a + i),即“从首地址向后移动 i 个元素大小的距离,然后取该地址的值”。

动态分配的数组则存储在堆上:

int* da = new int[3] {5, 6, 7}; // da 指向堆上的数组
// ... 使用 da
delete[] da; // 必须手动释放内存

这里,指针变量 da 本身在栈上,但它存储的值是堆上数组的起始地址。

字符数组(C风格字符串)

C风格字符串是以空字符 \0 结尾的字符数组。许多字符串处理函数都依赖这个终止符来判断字符串的结束。

char c1[] = {'C', 'S', '3', '6', '8'}; // 错误:缺少 \0,打印可能导致越界
char c2[] = {'C', 'S', '3', '6', '8', '\0'}; // 正确
char c3[] = "CS368"; // 正确:编译器自动添加 \0

忘记空终止符是常见的错误来源。在现代C++中,建议使用 std::string 类型来避免这些问题。

多维数组

多维数组在内存中也是线性连续存储的。例如,一个 3行 x 4列 的二维数组:

int md[3][4] = {
    {11, 12, 13, 14},
    {21, 22, 23, 24},
    {31, 32, 33, 34}
};

在内存中的布局顺序是:11, 12, 13, 14, 21, 22, 23, 24, 31, 32, 33, 34md[1] 表示第二行的首地址,其类型是 int*

可以使用嵌套的范围for循环来遍历多维数组(C++11):

for (auto& row : md) { // row 是对一个“包含4个int的数组”的引用
    for (int& elem : row) {
        cout << elem << ' ';
    }
    cout << endl;
}

参数传递

向函数传递参数主要有以下几种方式:

1. 按值传递
这是默认方式。函数获得实参的一个副本,对副本的修改不影响原始数据。

void dontChangeX(int x) {
    x = 5; // 只修改了局部副本
}
int main() {
    int x = 3;
    dontChangeX(x); // x 仍然是 3
}

2. 按引用传递
函数参数是实参的引用(别名)。对形参的修改直接影响实参。常用于避免复制大型对象,或需要函数修改外部变量时。

void callByReference(int& x) {
    x = 5; // 直接修改了 main 中的 x
}
int main() {
    int x = 3;
    callByReference(x); // x 变为 5
}

如果函数不会修改参数,应使用 const 引用,如 void func(const BigObject& obj)

3. 按指针传递
与按引用传递类似,但语法上需要显式使用地址操作符 & 和解引用操作符 *。这种方式明确地向调用者暗示参数可能被修改。

void callByPointer(int* xPtr) {
    *xPtr = 7; // 解引用并修改
}
int main() {
    int x = 3;
    callByPointer(&x); // 传递 x 的地址,x 变为 7
}

4. 传递数组
当数组作为函数参数时,实际上传递的是指向其首元素的指针。因此,函数内部无法直接获知数组的大小,通常需要额外传递一个大小参数。

void printArray(int arr[], int size) { // `int arr[]` 等价于 `int* arr`
    for (int i = 0; i < size; ++i) {
        cout << arr[i] << ' ';
    }
}

由于传递的是指针,函数内对数组元素的修改会影响原始数组。

5. 传递多维数组
传递多维数组时,必须指定除第一维之外的所有维度大小。

void printMatrix(int matrix[][4], int rows) { // 必须指定列数 4
    for (int i = 0; i < rows; ++i) {
        for (int j = 0; j < 4; ++j) {
            cout << matrix[i][j] << ' ';
        }
        cout << endl;
    }
}
// 也可以写成:void printMatrix(int (*matrix)[4], int rows)

返回指针或引用

函数可以返回指针或引用,但必须格外小心,绝不能返回指向局部变量(位于栈上)的指针或引用,因为函数返回后局部变量就被销毁了。

// 错误示例:返回局部变量的引用
int& badFunction() {
    int localVar = 42;
    return localVar; // 危险!localVar 即将被销毁
}

// 正确示例:返回动态分配内存的指针(调用者负责 delete)
int* createArray(int size) {
    int* arr = new int[size];
    // ... 初始化 arr
    return arr; // 返回堆上内存的地址
}
// 调用者必须:int* myArr = createArray(10); ... delete[] myArr;

// 正确示例:返回传入参数的引用
const int& findMax(const std::vector<int>& vec) {
    int maxIndex = 0;
    // ... 查找最大值的索引
    return vec[maxIndex]; // 返回 vec 中某个元素的引用,安全
}

总结

本节课中我们一起学习了C++程序的内存布局,理解了栈、堆、代码区、数据区的概念。我们深入探讨了指针和数组在内存中的表示,以及它们之间的紧密联系。我们还学习了如何向函数传递各种类型的参数,包括基本类型、数组、多维数组,并比较了按值传递、按引用传递和按指针传递的区别与适用场景。最后,我们了解了在函数中返回指针或引用时需要避免的陷阱。掌握这些底层知识,将帮助你写出更高效、更健壮的C++代码。

007:C++ for Java Programmers: Lecture 7: Vector 🧮

在本节课中,我们将要学习C++标准模板库中的容器,并重点介绍vectorvector是一个动态数组,类似于Java中的ArrayList,它极大地简化了数组操作。我们将学习如何创建、初始化、访问和修改vector,并了解其基本操作和特性。


容器与算法库概述

上一节我们介绍了课程安排,本节中我们来看看C++标准模板库的两个核心组成部分:容器和算法。

  • 容器:是预定义的数据结构,用于存储和管理数据集合。它们像容器一样持有对象,这些对象可以是基本类型、字符串或自定义类。C++中的容器是模板化的,这意味着在创建时需要指定存储的数据类型,例如vector<int>
  • 算法库:包含约50-60个通用函数,用于对数据范围(如vector的一部分或全部)执行操作,例如排序(sort)。这些算法通过迭代器(类似于指针)来访问和操作容器中的数据。

容器主要分为两类:

  • 序列容器:数据按顺序存储在相邻的内存位置,可通过索引快速访问。vector就属于此类。
  • 关联容器:通过键来访问数据,例如mapset

Vector 简介

上一节我们了解了容器的分类,本节中我们聚焦于序列容器vector

vector是一个动态数组,其大小可以在运行时增长或缩小,无需手动管理内存。数据存储在连续的内存块中,因此通过索引访问速度很快。vector不自动对元素排序,也允许存在重复值。

创建一个vector需要包含头文件:

#include <vector>

初始化 Vector

以下是创建和初始化vector的几种常见方法。

列表初始化

如果已知所有初始值,可以使用列表初始化。

std::vector<std::string> colors = {"red", "green", "blue"};
// 或省略等号(C++11及以上)
std::vector<std::string> colors2 {"red", "green", "blue"};

注意:使用圆括号()进行列表初始化是常见错误,编译器会将其解释为函数声明。

创建空Vector

要创建一个空的vector,只需声明其类型和变量名。

std::vector<int> numbers; // 正确
std::vector<int> numbers2(); // 错误!这声明了一个函数

带参数的构造

可以指定vector的初始大小和所有元素的默认值。

std::vector<int> numbers3(10, -2); // 创建大小为10,每个元素都是-2的vector
std::vector<int> numbers4(4); // 创建大小为4,每个元素默认为0的vector

重要区别vector<int> v(10, -2); 创建10个值为-2的元素。vector<int> v{10, -2}; 则创建两个元素:10和-2。

拷贝构造与赋值

可以从另一个vector复制内容。

std::vector<int> v1 {1, 2, 3, 4, 5};
std::vector<int> v2(v1); // 拷贝构造函数
std::vector<int> v3 = v1; // 另一种拷贝形式(本质上也是拷贝构造)

std::vector<int> v4;
v4 = v1; // 赋值操作

这些操作创建的是元素的独立副本(深拷贝)。


访问与修改元素

上一节我们学习了如何创建vector,本节来看看如何访问和修改其中的数据。

使用下标运算符

像数组一样使用[]通过索引访问元素。

colors[0] = "orange"; // 修改第一个元素
std::cout << colors[0]; // 访问第一个元素

注意[]不进行边界检查,访问越界会导致未定义行为。

使用 at() 函数

at()函数提供带边界检查的访问,越界时会抛出std::out_of_range异常。

std::cout << colors.at(0); // 安全访问
// colors.at(100); // 会抛出异常

使用 front()back() 函数

快速访问首尾元素。

std::cout << colors.front(); // 第一个元素
std::cout << colors.back();  // 最后一个元素

front()back()返回的是引用,因此可以用于修改元素:

colors.front() = "cyan"; // 修改第一个元素

获取大小

使用size()成员函数获取vector中元素的数量。

int count = colors.size();

遍历 Vector

以下是遍历vector元素的两种常用方法。

基于范围的for循环 (C++11)

这是最简单、最推荐的遍历方式。

for (std::string color : colors) {
    std::cout << color << " ";
}

若要修改元素,需使用引用:

for (auto& color : colors) { // auto 自动推导类型,& 表示引用
    color = "dark_" + color; // 可以修改原元素
}

使用下标的标准for循环

当需要索引时可以使用。

for (size_t i = 0; i < colors.size(); ++i) {
    std::cout << colors[i] << " ";
}

添加与删除元素

vector是动态的,可以方便地添加或删除元素。

添加元素

使用push_back()vector末尾添加新元素。

colors.push_back("black");

删除元素

  • pop_back():删除最后一个元素(不返回该元素)。
    colors.pop_back();
    
  • clear():清空所有元素。
    colors.clear();
    
  • erase():删除指定位置或范围的元素(需要迭代器,见下文)。
    // 删除第三个元素(迭代器版本)
    colors.erase(colors.begin() + 2);
    

迭代器简介

上一节我们使用了基于范围的for循环,其底层依赖于迭代器。本节简要介绍迭代器的概念。

迭代器是指向容器内元素的类指针对象。对于vector,迭代器行为非常接近原生指针。

获取迭代器

  • begin():返回指向第一个元素的迭代器。
  • end():返回指向最后一个元素之后位置的迭代器(尾后迭代器)。

使用迭代器遍历

std::vector<int> vec {4, 3, 6, 2, 7, 1, 5};
for (auto it = vec.begin(); it != vec.end(); ++it) {
    std::cout << *it << " "; // 解引用迭代器以获取值
}

这里auto自动推导出it的类型为std::vector<int>::iterator

迭代器的应用

许多STL算法和vector自身的成员函数(如sort, insert)接受迭代器作为参数来指定操作范围。

#include <algorithm>
std::sort(vec.begin(), vec.end()); // 排序整个vector

// 将另一个vector的部分内容插入到指定位置
std::vector<int> vec2 {10, 20};
vec.insert(vec.begin() + 2, vec2.begin(), vec2.end());

inserterase等函数使用迭代器来精确定位。


其他实用技巧与注意事项

Vector 的 Vector(多维数组)

可以创建vectorvector来模拟矩阵。

std::vector<std::vector<int>> matrix; // 注意 >> 间的空格

重要std::vector<std::vector<int>>中两个>之间必须有空格,否则编译器会将其解析为流提取运算符>>

关系运算符

vector支持==, !=, <, <=, >, >=等比较操作。比较是逐元素进行的,类似于字典序比较。

std::vector<int> a {1, 2, 3};
std::vector<int> b {1, 2, 3};
std::vector<int> c {1, 2, 4};

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs368c-sysprog/img/e521902e74075f33fefe24747695d0db_3.png)

bool eq1 = (a == b); // true
bool eq2 = (a == c); // false
bool lt = (a < c);   // true (比较到3<4)

容量管理

  • resize(n):改变vector的大小。
  • reserve(n):预分配内存空间,避免多次动态扩容,以提高性能。
  • capacity():返回当前已分配的内存空间能容纳的元素数。
  • empty():检查vector是否为空。

总结与资源

本节课中我们一起学习了C++标准模板库中的vector容器。我们涵盖了如何初始化、访问、修改、遍历vector,以及如何添加和删除元素。我们还简要介绍了迭代器的概念及其在算法和容器操作中的应用。

vector是C++中最常用、最通用的序列容器。要深入了解其所有成员函数(如emplace_back, shrink_to_fit, data等),建议查阅官方文档,例如 cppreference.com。在接下来的课程中,我们将继续探索其他容器类型和更强大的STL算法。

008:关联容器 🗂️

在本节课中,我们将要学习C++标准模板库(STL)中的关联容器,特别是setmap。关联容器允许我们根据键(key)来高效地存储和访问数据,这与我们之前学习的序列容器(如vector)有很大不同。我们将从set开始深入探讨,然后了解其变体,最后学习功能更强大的map

关联容器概述

上一节我们介绍了序列容器,它们适用于需要按顺序索引元素的情况。然而,并非所有数据都适合用数字索引来组织。

本节中我们来看看关联容器。关联容器根据键来存储数据,这使得检查成员资格(例如,某个物品是否在库存中)或建立映射关系(例如,将商品名称映射到库存数量)变得非常高效。C++提供了几种关联容器,本节课我们将重点学习setmap

深入理解 Set 🧮

set是STL的一部分,它是一个表示无序(就插入顺序而言)元素集合的容器,类似于数学中的集合,不允许重复元素。它的核心功能是支持高效的插入、删除和查找(检查成员资格)。

在内部,set通常实现为平衡二叉搜索树(例如红黑树),以实现这些操作的效率。这意味着存入set的元素必须能够进行比较排序,因此类型需要定义小于运算符(<

创建与初始化 Set

要使用set,首先需要包含头文件。创建set时,必须通过模板参数指定其存储元素的类型。

#include <iostream>
#include <set>
#include <vector>
using namespace std;

int main() {
    // 1. 创建一个空的整数set
    set<int> s1;

    // 2. 使用初始化列表创建并初始化一个字符串set
    set<string> avengers {"Thor", "Hulk", "Captain America", "Iron Man", "Black Widow"};

    // 打印set中的元素(将按字母顺序输出)
    for (auto hero : avengers) {
        cout << hero << " ";
    }
    cout << endl;
    // 输出: Black Widow Captain America Hulk Iron Man Thor
    return 0;
}

向 Set 中插入元素

可以使用insert成员函数向set中添加新元素。insert操作会维护集合的唯一性。

avengers.insert("Vision");
avengers.insert("Spider-Man"); // 插入后,遍历时会按字母顺序出现

// 也可以使用迭代器范围进行插入
vector<string> more_heroes {"Wasp", "Ant-Man", "Hawkeye", "Scarlet Witch"};
avengers.insert(more_heroes.begin(), more_heroes.end());

insert操作会返回一个pair<iterator, bool>,其中first是指向被插入元素(或已存在元素)的迭代器,second是一个布尔值,表示插入是否成功(即元素是否为新加入的)。

auto result = avengers.insert("Black Panther");
cout << "Inserted " << *(result.first) << "? " << boolalpha << result.second << endl;
// 输出: Inserted Black Panther? true

result = avengers.insert("Black Panther"); // 再次尝试插入相同元素
cout << "Inserted again? " << result.second << endl;
// 输出: Inserted again? false

查找与遍历元素

set中查找元素使用find函数,它返回一个指向该元素的迭代器,如果未找到则返回end()迭代器。

// 查找元素
auto it_cap = avengers.find("Captain America");
auto it_spidey = avengers.find("Spider-Man");

// 使用迭代器遍历一个范围(从Captain America到Spider-Man之前)
for (auto it = it_cap; it != it_spidey; ++it) {
    cout << *it << " ";
}
cout << endl;

对于有序容器(如set),还可以使用lower_boundupper_bound进行范围查询。

  • lower_bound(key): 返回指向第一个不小于 key 的元素的迭代器。
  • upper_bound(key): 返回指向第一个大于 key 的元素的迭代器。
// 打印所有首字母在 ‘C‘ 和 ‘S‘ (不包括S) 之间的英雄
auto low = avengers.lower_bound("C"); // 第一个 >= "C" 的元素,如 "Captain America"
auto up = avengers.upper_bound("S");  // 第一个 > "S" 的元素,如 "Scarlet Witch"
for (auto it = low; it != up; ++it) {
    cout << *it << " ";
}

删除元素与容器信息

使用erase函数可以删除元素,可以按键值删除,也可以按迭代器范围删除。

// 按键值删除,返回删除的元素数量(0或1)
size_t num_erased = avengers.erase("Captain America");
cout << "Erased " << num_erased << " element." << endl;

// 按迭代器范围删除 [low, up)
avengers.erase(low, up);

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs368c-sysprog/img/54736c00e2f1dcae069df288dee18dd8_4.png)

// 其他通用操作
cout << "Size: " << avengers.size() << endl;
cout << "Empty? " << avengers.empty() << endl;

avengers.clear(); // 清空所有元素
cout << "Size after clear: " << avengers.size() << endl;

Set 的变体 🔄

set有几个重要的变体,它们修改了set的某些特性以适应不同需求。

无序集合 (unordered_set)

unordered_set同样存储唯一元素,但其内部使用哈希表实现,因此不保证元素的任何顺序。它的优点是查找、插入和删除的平均时间复杂度接近O(1),但代价是失去了基于顺序的范围查询能力(如lower_bound)。使用它要求元素类型必须有可用的哈希函数

#include <unordered_set>

unordered_set<string> u_avengers(avengers.begin(), avengers.end());
for (const auto& hero : u_avengers) {
    cout << hero << " "; // 输出顺序不确定,且可能随容器扩容而改变
}

多重集合 (multiset)

multiset允许存储重复的元素。其接口与set类似,但insert总是成功,且count(key)erase(key)的行为会针对重复元素进行调整。

#include <set> // multiset也在<set>头文件中

multiset<string> m_avengers;
m_avengers.insert("Hawkeye");
m_avengers.insert("Hawkeye"); // 允许重复

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/wscs-cs368c-sysprog/img/54736c00e2f1dcae069df288dee18dd8_8.png)

cout << "Count of Hawkeye: " << m_avengers.count("Hawkeye") << endl; // 输出: 2

// 删除所有"Hawkeye"
m_avengers.erase("Hawkeye");

对应的还有unordered_multiset,它结合了哈希表和允许重复的特性。

深入理解 Map 🗺️

map是关联容器的核心,它存储的是键值对(key-value pairs)。每个键在map中是唯一的,并关联一个值。你可以将其理解为一种将键映射到值的字典。

map的模板参数有两个:map<KeyType, ValueType>。内部通常也实现为平衡二叉搜索树,因此键类型必须支持小于比较。

键值对 (pair)

map中存储的元素类型是std::pair<const KeyType, ValueType>pair是一个模板类,用于将两个值组合成一个单元。

#include <utility> // 包含pair,但通常由其他容器头文件间接引入

// 创建pair的几种方式
pair<string, int> p1("C++", 3); // 构造函数
auto p2 = make_pair("Data Programming", 220); // 使用make_pair辅助函数,自动推导类型
pair<string, int> p3 = {"Systems Programming", 368};

// 访问pair的成员
cout << p1.first << ": " << p1.second << endl; // 输出: C++: 3

创建与使用 Map

以下是map的基本操作:

#include <iostream>
#include <map>
using namespace std;

int main() {
    // 1. 创建一个空的map,将字符串映射到整数
    map<string, int> course_to_number;

    // 2. 使用初始化列表创建map
    map<string, int> numbers {
        {"one", 1},
        make_pair("two", 2), // 也可以混用make_pair
        pair<string, int>("three", 3)
    };

    // 3. 插入元素:使用下标运算符(如果键不存在则插入,存在则修改值)
    course_to_number["C++"] = 368;
    course_to_number["Data Programming"] = 220;

    // 使用insert插入(如果键已存在,则不会修改值)
    auto ret = course_to_number.insert(make_pair("C++", 999));
    // ret.second 为 false,因为"C++"已存在,值保持为368

    // 4. 访问元素:使用下标运算符(注意:如果键不存在,会插入一个具有默认值的键值对!)
    int val = course_to_number["C++"]; // val = 368
    // int val2 = course_to_number["Java"]; // 危险!会插入 {"Java", 0}

    // 安全的查找:使用find或count
    auto it = course_to_number.find("Java");
    if (it != course_to_number.end()) {
        cout << "Found: " << it->first << " -> " << it->second << endl;
    } else {
        cout << "Key not found." << endl;
    }
    // 或者使用count(对于map,结果为0或1)
    if (course_to_number.count("C++")) {
        cout << "C++ exists in the map." << endl;
    }

    // 5. 删除元素
    course_to_number.erase("Data Programming");

    // 6. 遍历map
    // 方法1:基于范围的for循环(推荐)
    for (const auto& entry : course_to_number) {
        cout << entry.first << ": " << entry.second << endl;
    }
    // 方法2:使用迭代器
    for (auto it = course_to_number.begin(); it != course_to_number.end(); ++it) {
        // it->first 是键, it->second 是值
        cout << it->first << " -> " << it->second << endl;
    }

    return 0;
}

Map 的变体

set类似,map也有对应的变体:

  • multimap: 允许重复的键。不能使用下标运算符[]进行访问,因为同一个键可能对应多个值。需要使用equal_range(key)lower_bound(key)/upper_bound(key)find来获取特定键对应的所有值。
  • unordered_map: 基于哈希表实现,提供平均O(1)的查找性能,但不保证元素顺序

总结 📚

本节课中我们一起学习了C++中强大的关联容器。

我们首先深入探讨了set,它是一个存储唯一元素的有序集合,支持高效的成员检查、插入和删除。我们学习了其创建、初始化、插入(insert)、查找(findlower_boundupper_bound)、删除(erase)以及遍历操作。

接着,我们了解了set的变体:允许重复元素的multiset和基于哈希表实现、提供更快访问但无序的unordered_set及其多重版本。

然后,我们将重点转向map,它存储键值对,是将一个键唯一映射到一个值的理想数据结构。我们学习了键值对pair的用法,以及map的插入(使用[]insert)、访问(使用[]或安全的find/count)、删除和遍历。最后,我们简要提及了允许重复键的multimap和基于哈希的unordered_map

理解这些关联容器及其适用场景,对于在C++中高效地组织和管理数据至关重要。在接下来的实践中,请尝试使用它们来解决具体问题,例如统计词频或管理库存。

009:类与面向对象编程入门 🏗️

在本节课中,我们将学习C++中类的基础知识,这是面向对象编程的核心。我们将了解如何定义类、创建对象、管理数据成员的访问权限,以及编写成员函数。通过本课,你将能够创建自己的自定义数据类型。


类的定义与对象创建

在C++中,使用 class 关键字来定义一个类。类是一种自定义的数据结构,它封装了数据(成员变量)和操作这些数据的方法(成员函数)。

代码示例:定义一个简单的 Point

class Point {
public:
    int x;
    int y;
};

定义好类之后,我们可以像使用内置类型一样创建该类的对象(也称为实例)。

代码示例:创建 Point 对象并访问其成员

Point p; // 创建一个Point对象
p.x = 3; // 使用点运算符访问公共成员变量
p.y = 6;

上一节我们介绍了类的基本定义,本节中我们来看看如何控制类成员的访问权限。


访问控制:Public 与 Private

C++使用访问说明符来控制类成员的可见性。这有助于封装数据,保护其不被意外修改。

  • public:在 public 部分声明的成员,可以在类的外部被直接访问。
  • private:在 private 部分声明的成员,只能在类的成员函数内部访问。

默认情况下,类(class)的成员是 private,而结构体(struct)的成员是 public。结构体在C++中基本与类相同,通常用于表示纯数据容器。

代码示例:使用 Public 和 Private

class Point {
private:
    int x; // 私有成员,外部无法直接访问
    int y;
public:
    // 公共成员函数,可以访问私有成员
    void setX(int newX) { x = newX; }
    int getX() const { return x; } // const 表示此函数不修改对象
};

成员函数

成员函数是定义在类内部的函数,用于操作类的数据成员。它们可以直接访问类的所有成员(包括 private 成员)。

代码示例:为 Point 类添加打印功能

class Point {
public:
    int x;
    int y;
    void printPoint() {
        std::cout << "(" << x << ", " << y << ")" << std::endl;
    }
};

每个类的实例都拥有自己独立的一套数据成员,但它们共享相同的成员函数代码。


类的嵌套与组合

一个类的数据成员可以是另一个类的对象。这允许我们构建更复杂的数据结构。

代码示例:使用 Point 类构建 Box

class Box {
public:
    Point minCorner;
    Point maxCorner;
    void printBox() {
        minCorner.printPoint();
        maxCorner.printPoint();
    }
};

通过点运算符可以链式访问嵌套对象的成员:myBox.minCorner.x = 10;


成员函数的声明与定义分离

为了提高代码的可读性和可维护性,通常将成员函数的声明放在类定义内部,而将具体的函数定义(实现)放在类的外部。这时需要使用作用域解析运算符 :: 来指明该函数属于哪个类。

代码示例:在类外定义成员函数

// 在类内部声明
class Box {
public:
    std::pair<int, int> edgeLengths() const; // 声明
};

// 在类外部定义
std::pair<int, int> Box::edgeLengths() const {
    return {maxCorner.x - minCorner.x, maxCorner.y - minCorner.y};
}

在类内部直接定义的短小函数,编译器可能会将其视为内联函数,这可以避免函数调用的开销。


Getter 与 Setter(访问器与修改器)

为了保持数据的封装性,最佳实践是将数据成员设为 private,然后提供公共的成员函数来读取(Getter)和修改(Setter)它们。这允许你在修改数据时加入验证逻辑。

以下是实现 Getter 和 Setter 的方法:

  • Getter:通常以 get 开头,返回数据成员的值。对于不修改对象的 Getter,应使用 const 关键字修饰。
  • Setter:通常以 set 开头,接受一个参数来设置数据成员的值。

代码示例:为私有成员提供 Getter 和 Setter

class PrivatePoint {
private:
    int x;
    int y;
public:
    // Setter
    void setX(int xIn) { x = xIn; }
    void setY(int yIn) { y = yIn; }
    // Const Getter
    int getX() const { return x; }
    int getY() const { return y; }
};

const 成员函数的重要性:当你在一个承诺不修改对象的上下文(例如,函数接受 const 对象引用)中调用成员函数时,只能调用被标记为 const 的成员函数。这确保了数据的完整性。


构造函数

构造函数是一种特殊的成员函数,在创建类的新对象时自动调用。它的名称与类名相同,没有返回类型。

代码示例:定义构造函数

class Point {
public:
    int x;
    int y;
    // 默认构造函数
    Point() {
        std::cout << "默认构造函数被调用" << std::endl;
    }
    // 带参数的构造函数
    Point(int xIn, int yIn) {
        x = xIn;
        y = yIn;
    }
};

C++会为类提供一个什么都不做的默认构造函数。但一旦你定义了任何构造函数,编译器就不再提供默认版本。你可以通过参数默认值来创建灵活的构造函数。


初始化列表

在构造函数中,更高效的方式是使用初始化列表来初始化成员变量,而不是在构造函数体内进行赋值。这对于类类型的成员尤其重要,因为它直接调用拷贝构造函数,而不是先调用默认构造函数再赋值。

代码示例:使用初始化列表的构造函数

class Point {
public:
    int x;
    int y;
    // 使用初始化列表
    Point(int xIn, int yIn = 77) : x(xIn), y(yIn) {
        // 构造函数体可以为空,或用于其他初始化逻辑
    }
};

公式/概念:初始化列表语法

ClassName(parameters) : member1(value1), member2(value2), ... {
    // 构造函数体
}

this 指针

在类的成员函数内部,this 是一个指向当前对象实例的指针。当函数参数名与成员变量名相同时,可以使用 this-> 来明确指代成员变量。

代码示例:使用 this 指针

class Point {
public:
    int x;
    Point(int x) {
        this->x = x; // 使用this区分参数x和成员变量x
    }
};

本节课中我们一起学习了C++中类的基础知识,包括如何定义类、控制访问权限、编写成员函数、使用构造函数初始化对象,以及通过 Getter/Setter 实现封装。理解这些概念是掌握C++面向对象编程的第一步。下一讲我们将深入探讨构造函数的更多细节,以及析构函数、拷贝构造函数和赋值运算符。

010:终端与CSL机器入门指南 🖥️

在本节课中,我们将学习如何使用终端,并连接到威斯康星大学的CSL(计算机系统实验室)Linux机器来完成编程作业。课程内容涵盖终端的基本概念、常用命令、文件操作以及如何在本地计算机和远程服务器之间传输文件。

概述

终端是程序员与计算机系统交互的重要工具。本节将介绍终端的历史、基本操作,并演示如何连接到远程的CSL Linux服务器进行开发工作。

终端简介

很久以前,人们并不拥有个人计算机。部门会购买昂贵的大型机供所有人使用。为了共享资源,他们会购买“哑终端”——这些只是带有屏幕和键盘的文本界面盒子,用于连接到大型机。

如今,我们不再使用那种终端和大型机。我们拥有更强大的本地个人计算机。因此,我们使用的是“终端模拟器”。

为何使用终端模拟器

使用终端模拟器可能比使用鼠标操作更快。它在浏览文件系统时非常高效。例如,通过键入目录名称来切换路径,通常比在文件列表中寻找并点击鼠标要快得多。

以下是两条建议:

  1. 寻找优秀的工具,并投入时间精通它们。这项投资非常重要,能避免你因使用笨拙或不熟悉的工具而付出额外努力。
  2. 掌握能让你在任何地方工作的工具。这一点尤为重要。例如,当需要在家工作(如全球疫情期间),或者想在更舒适的地方(如海滩)工作时。

Shell 的作用

当我们在计算机上运行终端模拟器(如Windows的PowerShell或Mac的Terminal)时,该程序会运行另一个名为“Shell”的程序。

Shell非常简单,主要做两件事:

  • 允许用户在系统的文件夹(目录)中移动并查看所有文件。
  • 允许用户运行程序。

它的工作流程是:询问“我应该做什么?”,用户输入一个命令;然后再次询问“现在我应该做什么?”,用户输入另一个命令。

常见的 Shell 类型

在终端模拟器中,我们可以运行多种不同的Shell。以下是一些例子:

  • CMD:Windows的默认Shell。
  • PowerShell:我将使用的Shell。Windows版本的CMD通常不接受很多Linux风格的命令,而PowerShell则接受这些命令,方便熟悉Linux/Mac的用户。
  • Bash (Bourne Again Shell):这是你登录CSL机器时默认看到的Shell。它由Stephen Bourne在1979年开发,后经改进成为Bash。

连接到 CSL 机器

现在,我将演示如何连接到CSL机器。这是我们完成作业和提交作业的环境。

由于我使用的是Windows计算机,需要下载一个SSH客户端。我将使用 PuTTY。你可以在描述中找到链接,或者直接访问 putty.org。

如果你使用Mac或Linux机器,可以直接使用终端,因为它内置了SSH客户端。

Windows 用户步骤 (使用 PuTTY)

  1. 下载PuTTY安装程序(32位或64位)。如果不确定,选择32位版本。
  2. 运行安装程序并完成安装。
  3. 打开PuTTY。
  4. 在“主机名”字段中输入你的CSL用户名,格式为:你的用户名@bestlinux.cs.wisc.edu
  5. 点击“打开”进行连接。
  6. 输入你的CSL登录密码。

Mac/Linux 用户步骤

  1. 打开终端(Terminal)。
  2. 使用 ssh 命令连接,格式为:ssh 你的用户名@bestlinux.cs.wisc.edu
  3. 输入你的CSL登录密码。

连接成功后,你将看到命令行提示符,例如 doher@royal13:~$。其中 doher 是用户名,royal13 是计算机名,~ 表示当前在用户的主目录,$ 是提示符结束标志。

基本终端命令

成功登录后,让我们学习一些基本的终端命令来导航和操作文件。

清理屏幕与查看当前位置

屏幕可能有很多信息。使用 clear 命令可以清屏。

要查看当前所在目录的完整路径,使用 pwd (Print Working Directory) 命令。

pwd

在CSL机器上,你的主目录路径可能类似于 /u/d/o/doher,这是系统按用户名首字母组织用户的方式。

列出文件与切换目录

要查看当前目录下的文件和文件夹,使用 ls (List) 命令。

ls

要切换目录,使用 cd (Change Directory) 命令。

  • cd 目录名:进入指定目录。
  • cd ..:返回上一级目录。
  • cd ~cd:直接返回用户主目录。

小技巧:输入目录或文件名时,可以按 Tab 键自动补全,提高效率。

创建目录与文件

要创建一个新目录,使用 mkdir (Make Directory) 命令。

mkdir demo

要创建一个简单的文本文件,可以使用 echo 命令配合输出重定向 >

echo "Hello" > demo.txt

这条命令将“Hello”写入(或覆盖)到 demo.txt 文件中。如果要将内容追加到文件末尾,使用 >>

echo "World" >> demo.txt

查看、复制、移动与重命名文件

要查看文件内容,使用 cat (Concatenate) 命令。

cat demo.txt

要复制文件,使用 cp (Copy) 命令。

cp demo.txt ../demo_copy.txt # 复制到上一级目录并重命名

要移动或重命名文件,使用 mv (Move) 命令。在同一个目录下使用 mv 就是重命名。

mv demo.txt howdy.txt # 重命名
mv howdy.txt .. # 移动到上一级目录

获取命令帮助

如果不熟悉某个命令的用法,可以使用 man (Manual) 命令查看手册。

man pwd

在手册页面中,可以使用方向键、Page Up/Page Down 浏览,按 q 键退出。

提高效率的技巧

  • 上箭头键:快速调出之前执行过的命令。
  • history 命令:列出所有最近使用过的命令。
  • Ctrl + R:进入反向搜索模式,输入关键词可以查找历史命令。

文件传输

完成作业后,你需要将文件在本地计算机和CSL机器之间相互传输。

从 Windows 上传文件到 CSL (使用 PSCP)

PuTTY 提供了 pscp 命令用于安全拷贝。在 Windows 的 PowerShell 或命令提示符中使用。

pscp compilerTest.cpp doher@bestlinux.cs.wisc.edu:/u/d/o/doher/private/teaching/cs368/homework1/
  • compilerTest.cpp:本地要上传的文件。
  • doher@...:你的CSL登录信息。
  • :/path/to/destination/:CSL机器上的目标路径。

从 CSL 下载文件到 Windows (使用 PSCP)

pscp doher@bestlinux.cs.wisc.edu:/u/d/o/doher/private/teaching/cs368/homework1/compilerTest.cpp .\CT.cpp
  • 第一部分指定了远程服务器上的文件路径。
  • .\CT.cpp 是下载到本地后保存的文件名和路径(. 代表当前目录)。

Mac/Linux 用户文件传输

Mac 和 Linux 用户使用内置的 scp (Secure Copy) 命令,语法与 pscp 类似,但命令名不同。

上传:

scp compilerTest.cpp doher@bestlinux.cs.wisc.edu:/path/to/destination/

下载:

scp doher@bestlinux.cs.wisc.edu:/path/to/source/file.cpp ./

编译与运行 C++ 程序

连接到CSL并准备好源代码后,你需要编译和运行它。CSL机器通常使用 g++ 编译器。

以下是一个编译示例:

g++ -Wall -o compTest compilerTest.cpp
  • -Wall:启用所有警告,有助于发现代码问题。
  • -o compTest:指定生成的可执行文件名为 compTest
  • compilerTest.cpp:要编译的源代码文件。

如果编译成功(没有错误和警告),不会有输出。使用 ls 命令可以看到新生成的绿色可执行文件 compTest

运行程序:

./compTest

程序会提示你输入学号,然后进行计算并输出一个结果代码。你需要将这个代码填写到作业文件的指定位置。

使用文本编辑器 (Vim)

在CSL机器上编辑文件,你需要使用命令行文本编辑器,如 VimEmacs。本节简要提及,会有单独视频详细介绍 Vim。

打开文件进行编辑:

vim compilerTest.cpp

在 Vim 中,你需要进入插入模式(按 i)才能输入文字。编辑完成后,按 Esc 退出插入模式,然后输入 :wq 保存并退出。

总结

本节课我们一起学习了终端和CSL机器的基本使用。我们了解了终端的历史与作用,掌握了如何通过 PuTTY (Windows) 或 SSH (Mac/Linux) 连接到远程的 CSL Linux 服务器。我们学习了一系列基本的终端命令,包括导航目录 (cd, pwd, ls)、操作文件 (mkdir, cp, mv, cat)、以及获取帮助 (man)。我们还演示了如何在本地和远程服务器之间传输文件 (pscp/scp),并完成了在CSL上编译和运行一个简单C++程序的完整流程 (g++, ./program)。掌握这些技能是进行后续系统级编程作业的基础。

011:Vim 基础入门 🚀

在本节课中,我们将学习 Vim 文本编辑器的基本使用方法。Vim 是一个基于模式的编辑器,掌握其核心模式与操作能极大提升文本编辑效率。我们将从进入 Vim、切换模式、基础导航开始,逐步介绍编辑、搜索、复制粘贴以及保存退出等关键操作。


Vim 教程:1.1:Vim 的三种核心模式

Vim 的核心在于其不同的操作模式。上一节我们概述了课程内容,本节中我们来看看 Vim 的三种基本模式。

Vim 主要包含三种模式:

  1. 命令模式:也称为导航模式。在此模式下,键盘输入被视为命令,用于移动光标、删除、复制等操作。
  2. 插入模式:在此模式下,键盘输入会作为文本内容插入到文件中,就像普通的文本编辑器一样。
  3. 可视模式:在此模式下,可以选择文本块进行操作。它又分为字符可视模式、行可视模式和块可视模式。

要进入插入模式,请在命令模式下按下 i 键。
要退出插入模式并返回命令模式,请按下 Esc 键。


Vim 教程:1.2:基础导航

在命令模式下,你可以高效地移动光标而无需使用鼠标。以下是基础的导航键位。

将手指放在键盘的“Home Row”上,使用 HJKL 这四个键进行移动:

  • H:向左移动光标。
  • J:向下移动光标。
  • K:向上移动光标。
  • L:向右移动光标。

你可以在这些命令前加上数字,以实现多单位移动。例如:

  • 5j:向下移动5行。
  • 10l:向右移动10个字符。

Vim 教程:1.3:进入插入模式的不同方式

除了使用 i 键在光标前进入插入模式,Vim 还提供了其他几种方式,以适应不同的编辑场景。

以下是进入插入模式的几种方法:

  • i:在光标当前位置之前进入插入模式。
  • a:在光标当前位置之后进入插入模式。
  • o:在当前行的下方新建一行,并进入插入模式。
  • O:在当前行的上方新建一行,并进入插入模式。

Vim 教程:1.4:高级导航技巧

掌握了基础移动后,我们来看看如何更快地在文件内和行内跳转。

文件内跳转

  • gg:跳转到文件第一行。
  • G:跳转到文件最后一行。
  • 5G:跳转到第5行。

行内跳转

  • 0:跳转到当前行的行首。
  • $:跳转到当前行的行尾。

按单词跳转

  • w:跳转到下一个单词的词首。
  • b:跳转到上一个单词的词首。
  • e:跳转到当前或下一个单词的词尾。

同样,可以结合数字使用,例如 3w 表示向后移动3个单词。


Vim 教程:1.5:搜索文本

在 Vim 中搜索文本非常高效。上一节我们学习了如何跳转,本节中我们来看看如何查找特定内容。

  1. 在命令模式下,按下 / 键。
  2. 输入要搜索的词汇,例如 mode
  3. 按下 Enter 键。所有匹配项会被高亮,光标跳至第一个匹配处。
  4. 使用 n 键跳转到下一个匹配项。
  5. 使用 N 键跳转到上一个匹配项。

若光标已在一个单词上,可以使用 # 键向上搜索该单词,使用 * 键向下搜索该单词。

要清除搜索高亮,可以搜索一个不存在的字符串,例如 /asdfghjkl


Vim 教程:1.6:编辑与修改文本

现在我们已经知道如何移动和查找,接下来学习如何编辑文本。

删除字符

  • x:删除光标下的字符。

删除更多内容
d 命令结合移动命令可以删除文本。

  • dw:删除一个单词(从光标处到词尾)。
  • d$D:删除从光标到行尾的内容。
  • dd:删除整行。
  • d5j:删除光标及向下的5行。

替换字符

  • r:然后输入一个新字符,将替换光标下的字符。例如 r8 会将当前字符替换为 8

撤销与重做

  • u:撤销上一次操作。
  • Ctrl + r:重做被撤销的操作。

Vim 教程:1.7:复制、剪切与粘贴

Vim 中的复制操作称为“yank”,剪切操作即“delete”,因为它们会将内容存入寄存器。

复制

  • yw:复制一个单词。
  • yyY:复制整行。

剪切
d 命令在删除的同时也执行了剪切。例如 dd 剪切整行。

粘贴

  • p:在光标后粘贴内容。
  • P:在光标前粘贴内容。

Vim 教程:1.8:可视模式操作

可视模式允许你选择文本块,然后对其进行操作。这是进行批量编辑的强大工具。

有三种进入可视模式的方式:

  1. v:进入字符可视模式,按字符选择文本。
  2. V:进入行可视模式,按整行选择文本。
  3. Ctrl + v:进入块可视模式,按矩形块选择文本,非常适合对齐的代码列。

进入可视模式并选择文本后,你可以使用 y 复制、d 删除或 x 剪切所选内容。

在块可视模式下,选择一列后,按 Shift + i 进入插入模式,输入文本后按 Esc,该文本会被插入到所选块的每一行前,非常适用于同时为多行代码添加注释或类型声明。


Vim 教程:1.9:保存与退出

编辑完成后,你需要保存文件并退出 Vim。这是最关键的操作之一。

所有保存和退出操作都需要在命令模式下输入(以冒号 : 开头):

  • :w:保存文件。
  • :q:退出 Vim。如果文件有未保存的修改,Vim 会阻止退出。
  • :q!:强制退出,不保存任何修改。
  • :wq:x:保存文件并退出。

Vim 教程:1.10:总结与配置建议

本节课中我们一起学习了 Vim 编辑器的核心概念和基本操作。

我们涵盖了:

  1. Vim 的三种核心模式:命令模式、插入模式和可视模式。
  2. 使用 hjklwbggG 等进行高效导航。
  3. 使用 iaoO 进入插入模式进行编辑。
  4. 使用 /n 进行文本搜索。
  5. 使用 dxr 进行删除与替换,以及 uCtrl + r 进行撤销重做。
  6. 使用 ydp 进行复制、剪切和粘贴。
  7. 利用 vVCtrl + v 进入可视模式进行块操作。
  8. 使用 :w:q:wq 保存和退出。

个性化配置:Vim 可以通过 ~/.vimrc 配置文件进行高度定制,例如开启语法高亮、显示行号、设置缩进等。建议初学者在熟悉基本操作后,逐步探索和配置自己的 .vimrc 文件。

熟练掌握 Vim 需要练习,但一旦习惯,其键盘驱动的编辑方式将带来极高的效率。请选择适合你的工具并坚持练习。

012:文件读写实践 📄

在本节课中,我们将学习如何在C++中进行文件的写入和读取操作。我们将通过一个简单的示例程序,逐步演示如何创建文件、向文件写入数据,以及如何从文件中读取数据。

概述

文件输入输出是编程中常见的任务,它允许程序将数据持久化存储到磁盘,或从磁盘读取数据。C++标准库提供了fstream库来简化文件操作。本节将介绍使用ofstream写入文件和ifstream读取文件的基本方法。

写入文件

上一节我们介绍了文件操作的基本概念,本节中我们来看看如何具体实现文件的写入。

首先,进行文件写入需要包含<fstream>头文件,并使用std::ofstream类。以下步骤详细说明了写入文件的过程:

  1. 包含必要的头文件:首先需要包含<fstream>库。
    #include <fstream>
    

  1. 创建输出文件流对象:声明一个ofstream对象,并指定要打开的文件名。

    std::ofstream outFile("example.txt");
    
  2. 检查文件是否成功打开:在写入之前,应检查文件流是否成功打开。

    if (outFile.is_open()) {
        // 文件打开成功,可以写入
    } else {
        // 文件打开失败,处理错误
    }
    

  1. 向文件写入数据:使用插入运算符<<将数据写入文件,就像使用cout向屏幕输出一样。

    outFile << "这是要写入文件的第一行文本。\n";
    outFile << "这是第二行。";
    
  2. 关闭文件:完成写入操作后,应关闭文件流以释放资源。

    outFile.close();
    

核心代码示例:以下是一个完整的写入文件的函数:

void writeToFile(const std::string& filename) {
    std::ofstream outFile(filename);
    if (outFile.is_open()) {
        outFile << "第一行内容\n";
        outFile << "第二行内容\n";
        outFile << "第三行内容\n";
        outFile.close();
        std::cout << "文件写入成功。\n";
    } else {
        std::cout << "无法打开文件进行写入。\n";
    }
}

运行此函数后,会在程序同级目录下创建一个名为example.txt的文件,并包含三行文本。

读取文件

在学会了如何写入文件之后,接下来我们学习如何从文件中读取数据。

读取文件需要使用std::ifstream类。基本流程与写入类似,但使用的是输入操作。以下是读取文件的步骤:

  1. 创建输入文件流对象:声明一个ifstream对象。

    std::ifstream inFile("example.txt");
    
  2. 检查文件是否成功打开

    if (!inFile.is_open()) {
        std::cout << "无法打开文件进行读取。\n";
        return;
    }
    
  3. 从文件中读取数据:常用的方法是使用std::getline函数逐行读取。

    std::string line;
    while (std::getline(inFile, line)) {
        std::cout << line << std::endl;
    }
    

    在这个while循环中,getline会一直读取,直到文件末尾。每次读取一行内容到line字符串中,然后将其打印到控制台。

  4. 关闭文件

    inFile.close();
    

核心代码示例:以下是一个完整的读取文件的函数:

void readFromFile(const std::string& filename) {
    std::ifstream inFile(filename);
    std::string line;

    if (inFile.is_open()) {
        while (std::getline(inFile, line)) {
            std::cout << line << std::endl;
        }
        inFile.close();
    } else {
        std::cout << "无法打开文件: " << filename << std::endl;
    }
}

调用此函数读取之前创建的example.txt文件,会将文件中的三行内容依次打印在控制台上。

完整程序演示

为了更清晰地展示整个流程,我们可以将写入和读取操作放在main函数中依次执行。

#include <iostream>
#include <fstream>
#include <string>

int main() {
    const std::string filename = "example.txt";

    // 1. 写入文件
    std::ofstream outFile(filename);
    if (outFile.is_open()) {
        outFile << "这是新写入的第一行。\n";
        outFile << "这是新写入的第二行。\n";
        outFile.close();
        std::cout << "--- 文件写入完成 ---\n";
    }

    // 2. 读取文件
    std::cout << "\n--- 开始读取文件内容 ---\n";
    std::ifstream inFile(filename);
    std::string line;
    if (inFile.is_open()) {
        while (std::getline(inFile, line)) {
            std::cout << line << std::endl;
        }
        inFile.close();
    }
    std::cout << "--- 文件读取结束 ---\n";

    return 0;
}

运行此程序,会先创建(或覆盖)example.txt文件并写入两行文本,随后立即读取该文件并将内容打印到控制台。

总结

本节课中我们一起学习了C++文件输入输出的基础操作。我们掌握了:

  • 使用std::ofstream<<操作符向文件写入数据。
  • 使用std::ifstreamstd::getline函数从文件逐行读取数据。
  • 在操作文件前后,使用is_open()检查状态以及使用close()关闭文件流的重要性。

这些是处理文本文件最基本且最常用的方法,是后续学习更复杂文件操作(如二进制文件、随机存取等)的坚实基础。

013:运行测试指南 🧪

在本教程中,我们将学习如何为作业4下载资源、上传至CSL机器,以及如何运行测试脚本以获取程序开发过程中的反馈。

概述 📋

上一节我们介绍了作业的基本要求。本节中,我们来看看如何获取测试资源并运行自动化测试。这个过程能帮助你在开发过程中验证代码的正确性。

下载资源 💾

首先,需要从Canvas下载作业4所需的所有资源文件。

以下是需要下载的文件列表:

  • 模板文件
  • 包含待分析客户元数据的文件
  • 格式化摘要文件(即程序应生成的最终输出格式)
  • 测试脚本
  • 输入文件及其对应的解决方案文件

下载完成后,所有文件将保存在本地机器的下载文件夹中。

上传至CSL机器 ⬆️

现在,我将把这些资源上传到CSL机器。

首先,在本地创建一个专用文件夹(例如 homework3),并将所有下载的文件移入其中。接着,通过SSH连接到CSL机器,并切换到目标目录。最后,使用文件传输工具(如 scp)将整个文件夹上传。

操作完成后,可以在CSL机器的相应目录下看到所有已上传的资源文件。

运行测试脚本 ▶️

成功上传文件后,就可以运行测试脚本了。

在运行脚本之前,需要先为其添加可执行权限。使用以下命令:

chmod +x run_tests.sh

权限设置完成后,即可执行测试脚本:

./run_tests.sh

脚本运行后,会输出一系列测试结果。

理解测试输出 📊

测试脚本会按顺序检查作业的各个部分。

最初的测试仅验证程序能否成功编译。随着你逐步实现更多功能,后续测试会检查更具体的内容。

例如,测试可能依次验证:

  • 数据是否正确读入
  • 客户姓名格式是否正确
  • 是否为每位客户计算了成本和百分比

测试输出会明确指示每个环节是通过还是失败,帮助你定位需要修改的代码部分。

总结 🎯

本节课中,我们一起学习了为C++作业配置测试环境的完整流程:从Canvas下载资源文件,将其上传到CSL机器,为测试脚本添加执行权限并运行它,最后解读测试输出以指导后续开发。定期运行测试是确保代码符合要求的高效方法。

014:向量操作与测试驱动开发 📚

在本教程中,我们将学习如何完成一个C++编程作业。该作业的核心是操作一个数字向量(std::vector<int>),并实践测试驱动开发(TDD)的工作流程。我们将编写多个函数来实现格式化输出、插入、删除、计算统计信息以及排序和去重等功能。

作业概述与流程 🔄

上一节我们介绍了本课程的目标,本节中我们来看看完成本次作业的具体步骤。

整个作业流程可以分为以下几个关键步骤:

  1. 编译程序:首先,你需要编译源代码文件以生成可执行文件。
  2. 运行测试:作业中已包含测试代码。编译后,直接运行生成的可执行文件即可执行所有测试。
  3. 调试与修改:当测试失败时,测试框架会提供清晰的错误信息,指出哪个函数测试失败以及期望的结果是什么。你需要根据这些信息修改你的代码。
  4. 迭代:修改代码后,重新编译并运行测试,直到所有测试通过。

以下是完成作业的详细步骤列表:

  • 编译:使用C++编译器(如g++)编译包含作业代码的源文件。
  • 测试:运行编译后的可执行程序,自动执行所有预设的单元测试。
  • 调试:阅读测试失败时输出的描述性错误信息,定位问题。
  • 修改:根据错误信息,修正对应函数的实现逻辑。
  • 验证:保存修改,重新编译并运行测试,确认问题已解决。

核心任务:向量操作函数 ✨

上一节我们了解了作业的工作流程,本节中我们来看看需要实现的具体函数。你的主要任务是实现一系列操作整数向量的函数。

需要实现的函数包括:

  1. 格式化输出formatNumbers

    • 功能:接收一个整数向量,生成一个格式化的字符串,显示每个元素及其在向量中的索引。
    • 示例:向量 [10, 20, 30] 可能被格式化为 “[0:10, 1:20, 2:30]”
  2. 插入与删除

    • insertNumberAtIndex:在向量的指定索引处插入一个数字。需要处理索引越界等边界情况。
    • deleteNumberAtIndex:删除向量中指定索引处的数字。同样需要处理边界情况。

  1. 统计计算

    • computeSum:计算向量中所有数字的总和。
      • 公式sum = numbers[0] + numbers[1] + ... + numbers[n-1]
    • computeAverage:计算向量中所有数字的平均值。
      • 公式average = sum(numbers) / count(numbers)
  2. 排序与去重

    • sortAscending:将向量中的数字按默认升序排列。
    • sortDescending:将向量中的数字按降序排列。
    • sortAscendingAndRemoveDuplicates:将向量按升序排序,并移除所有重复的值。
    • removeAllInstances:从向量中移除所有与指定数字相等的元素。

每个函数在代码中都附有详细的描述,说明了其预期的行为、参数和返回值,请仔细阅读。

代码结构与开发实践 💻

上一节我们列出了所有需要实现的函数,本节中我们来看看代码的组织形式和推荐的开发方法。

作业的源代码文件结构如下:

  • 测试函数:文件底部包含了大量的测试用例(例如 testInsertNumberAtIndexEmptyVector)。不建议修改这部分代码
  • 待实现函数:文件中部是等待你填充代码的函数框架(如 formatNumbers, insertNumberAtIndex 等)。
  • 主函数main 函数负责调用所有测试方法。

开发过程中,你将遵循测试驱动开发模式:

  1. 初始运行测试时,许多测试会因函数未实现而失败。
  2. 例如,测试 insertNumberAtIndex 时,失败信息会明确告知你,对于空向量的插入操作,实际返回结果与期望结果(如 “[]”)不符。
  3. 你根据此信息去实现 insertNumberAtIndex 函数,处理空向量的情况。
  4. 实现后,重新编译并运行测试,观察该测试是否通过,并继续解决下一个失败测试。

这就是完成作业五的整个过程,它结合了具体的向量操作编程任务和测试驱动开发的实践方法。

总结 📝

本节课中,我们一起学习了如何完成一个C++编程作业。我们详细介绍了作业的完整流程:从编译程序、运行测试,到根据测试反馈进行调试和代码修改。我们明确了需要实现的核心功能,包括对std::vector<int>的格式化、增删、统计计算、排序及去重操作。最后,我们分析了代码结构,并实践了测试驱动开发的基本迭代步骤:运行测试、查看失败信息、修改代码、重新验证,直至所有功能符合预期。

posted @ 2026-03-29 09:47  布客飞龙III  阅读(4)  评论(0)    收藏  举报