波恩大学 CS414 现代 C++ 笔记(一)
001:基础
概述
在本节课中,我们将要学习现代C++课程的组织结构、学习C++的动机、C++语言的历史与设计哲学,以及开始编程前必须掌握的Linux操作系统基础知识。我们将从最基础的“Hello World”程序开始,了解C++程序的编译和运行过程。
课程组织与期望
我们已经准备好开始今年的现代C++课程。由于新冠疫情的影响,我们无法在教室授课,但我认为这对大家和我来说都是一个好消息。在教室里教授编程颇具挑战性,就像试图在教室里教10个人如何骑自行车一样。当然,我们可以解释一些理论和概念,但最重要的是你需要亲手在键盘上开始编码。
课程每周三下午4点(中欧时间)进行一次线上讲座,每次讲座时长不超过一小时。我的期望是,你每周投入一小时,并从中学习到知识。
除了讲座,我们每周五还有辅导课。我不会在辅导课上进行直播,但我会在Discord频道上在线,随时解答问题。我还可以通过屏幕共享来演示如何解决问题。
请务必注册并加入Discord频道。这是最快的讨论渠道。如果你有问题,并且认为其他人也可能从中受益,请在相应的频道提问。例如,关于讲座的问题可以发在讲座频道,关于作业的问题可以发在作业频道。这样我可以一次性为大家解答。
根据学习计划,本学期你需要投入大约60小时。这意味着你每周至少需要花费一整天(约8小时)在C++课程的学习、作业和辅导上。因此,仅仅每月投入两小时是不可能完成这门课程的。
课程内容与目标
在本课程中,你将学习以下核心内容:
-
如何在Linux环境下工作。
-
如何使用现代C++编写软件。
-
一些核心的软件开发技术。
本课程不是纯粹的编程入门课或设计模式课程。我们还将使用OpenCV(一个开源的图像处理库),最终的课程项目是实现一个基于逆向图像搜索的地点识别管道。
今年的课程内容经过了精心梳理,分为四个部分,从易到难:
-
工具:如何构建项目、使用Linux等。这部分内容将在辅导课中讲解,不会占用讲座时间。
-
C++核心语言:为期四周。完成这部分后,你将能够用C++编写其他语言(如Python)中常见的程序。
-
现代C++:这是课程的核心部分,包含了C++特有的一些高级和复杂特性。
-
期末项目:你将有一节关于项目概念的讲座,然后需要独立完成项目。
此外,今年的九次作业中有一些与期末项目直接相关。如果你认真完成了这些作业,那么你就已经完成了期末项目的一部分工作。我不建议你只完成作业拿到分数后就搁置,直到期末项目才开始,因为作业中的任务正是项目的基础。
课程哲学与期望
本课程的哲学是“Talk is cheap. Show me the code.”(空谈无益,代码为证)。在编程中,人们有时会花数小时讨论一个概念,但代码本身可能非常简单。直接看代码并讨论通常更高效。
因此,我期望你在本课程中亲自动手实践,花大量时间在键盘上尝试,让代码运行起来。
这里有一个免责声明:如果你不理解任何内容,请随时打断我。但请不要惊讶于我有时操作得很快。这不是因为我聪明,而是因为我使用Linux超过10年,已经非常习惯。有时我很难放慢速度。如果你看到我做了你不理解的操作,请在聊天区告诉我:“等一下,我不知道你在做什么。”我会尝试放慢速度。
另外,我目前使用的是一台五年前的双核机器,无法运行过于复杂的桌面环境,所以我使用了一个叫i3的平铺式窗口管理器。它可能看起来有些“极客”,我不推荐初学者使用。你的电脑完全可以看起来比我的更美观。
为什么学习C++?
我知道你们中的一些人学习C++只是因为它是课程要求。但请相信,当你正确学习它时,C++是一门非常有趣的语言。
根据Stack Overflow 2018年的调查,近一半的专业开发人员将Linux作为主要工作系统。在2015年,C++是使用最广泛的系统编程语言,拥有近500万用户。C++至今仍然非常流行。
当然,如今Python等语言可能更流行,但这部分是因为Python的应用领域更广。在机器人等领域,你很难找到用Python编写的主流框架。因此,在比较语言时,请确保你了解比较的背景。在我们这个领域,所有公司都希望你掌握C++。
即使你计划从事研究而非工业界,掌握C++也非常重要。C++可能不是原型开发的最佳语言,但一旦你有了成熟的想法,用它实现的程序运行速度极快。
C++的一个核心理念是高效。十年前,当人们谈论节省几兆字节内存时,可能会被嘲笑,因为个人电脑的内存正变得越来越大。但游戏规则已经改变,计算机不再仅仅是桌面设备,还存在于智能手机、手表和机器人中。这些嵌入式设备上的处理器功耗和尺寸变得至关重要,内存效率等问题重新受到重视。这就是C和C++至今仍是最流行语言的原因之一。即使你不在乎成本,可以购买更强大的处理器,你也需要考虑能耗和电池续航。这就是编译型语言(如C/C++)至关重要的地方。
许多知名公司都在使用C++,例如Google、Microsoft。这并不意味着他们用C++做所有事情,但他们确实在使用,并且投入资源让C++变得更好。许多浏览器(如Firefox、Chrome)的源代码也是用C++编写的。此外,像Java虚拟机、部分Windows操作系统、Office、Photoshop等软件,以及游戏产业的大部分,都主要使用C++。
C++简史
计算机发展初期,我们有一种美妙的语言——汇编语言。它的好处是指令简单,编写得当则运行极快,并且能完全控制硬件。既然如此,为什么我们不一直使用它呢?让我们看看用汇编语言编写“Hello World”程序是什么样子。这段代码难以阅读,并且需要大量代码来完成简单的任务。此外,汇编语言严重依赖于目标处理器,这是它最大的缺点之一。
于是人们提出了更高级语言的想法。计算机只理解二进制代码,但汇编语言很容易翻译成二进制。为什么不使用更直观的语言编写代码,然后通过一个叫做“编译器”的程序,将高级语言源代码转换成汇编语言呢?这就是C语言的诞生。
C语言由肯·汤普森和丹尼斯·里奇于1972年创建。它快速、简单且跨平台,你不再需要了解底层架构。C语言至今仍是最流行的编程语言之一,但它也有弱点:没有对象和类,难以编写通用代码,编写大型程序非常繁琐。
大约40年前,丹麦人比雅尼·斯特劳斯特鲁普发明了C++语言。他希望保留C语言快速、跨平台、易用的优点,同时加入更多高级语义特性,例如类、对象和泛型编程。这就是C++至今的发展历程。
现代C++与设计哲学
我们通常将C++11标准(2011年发布)之后的特性称为“现代C++”。在本课程中,我们将使用C++17标准(2017年发布)。C++20标准即将发布,但由于编译器支持尚不完全,我们不会使用它。
C++的设计哲学是多范式编程。它不仅是面向对象或面向过程的语言,而是支持多种范式,让你能够直接在代码中表达想法和意图。这一点对我来说非常重要。
在C++11之前,人们习惯于到处添加注释。后来人们开始思考,如果代码本身易于阅读,就不需要那么多注释。如果你的某行代码晦涩难懂,需要在其上方添加50行注释来解释,那很可能是一个糟糕的设计或想法。
因此,在本课程中,尝试在代码中表达想法和意图,而不是在注释中,这一点非常重要。C++为你提供了实现这一目标的工具,这也是现代C++更优秀的原因之一。
C++在明智使用时非常安全,并且极其高效。它提供了“零开销抽象”层,这意味着你可以使用语言的高级特性,而无需在运行时付出性能或内存的代价。
关于效率,至今仍有很多人认为C语言比C++更快。这些人大多是因为不了解如何正确使用C++,或者没有使用合适的工具和编译器。当你明智地使用C++时,在效率上很难被击败。
Linux操作系统简介
我们将在本课程中使用GNU/Linux发行版。我在这里做一个简要介绍,如果你有任何问题,现在是时候在聊天区提出了。当然,我建议你去YouTube或Google搜索“为什么应该使用Linux”。
Linux是一个免费、自由、类Unix的操作系统。如果你使用macOS并习惯使用终端,你会发现一些相似之处。如今Linux极其流行,大多数服务器都在运行Linux。
这是Linux的目录树结构。它非常简单,没有隐藏的盘符(如C:, D:)。一切从根目录(/)开始,所有其他目录或文件夹都在根目录之下。
你需要知道的是,用户空间文件夹位于 /home/ 下,然后是用户名。例如,我的主目录是 /home/ibi/。这基本上是你处理文件的地方。
在Linux中,文件夹路径通常以斜杠(/)结尾。任何以斜杠结尾的路径都是一个文件夹。绝对路径以斜杠开头,而相对路径则不是。路径是区分大小写的。文件扩展名是文件名的一部分,只是一个字符串。如果你想更改文件扩展名,只需重命名文件即可。
在Linux中,你的最佳朋友是终端。许多操作在终端中完成比在图形界面中更快。我强烈推荐使用终端。如果你是Linux新手,我的建议是:不要试图用你习惯的方式(图形界面)操作,而是去Google搜索如何在终端中完成,这是学习使用终端的最佳方式。
以下是一些基本的终端导航命令:
-
pwd:打印当前工作目录。 -
cd:更改目录。 -
ls:列出当前目录下的文件和文件夹。
命令本质上就是一个程序。当你输入一个命令时,shell(终端)会在一系列目录中搜索这个程序,这些目录由 PATH 环境变量定义。命令通常带有选项和参数。如果你想了解一个命令的用法,可以使用 man 命令或 --help 选项。
我强烈推荐使用Tab键自动补全功能。这可以确保你输入的命令路径是正确的。
这里列出了一些处理文件和文件夹的基本命令,例如创建目录(mkdir)、删除目录(rmdir)、复制文件(cp)、移动文件(mv)。我建议你在课后查看幻灯片上的占位符练习,学习如何使用通配符(如 *)来轻松处理文件。
最后,我想快速解释一下标准输入/输出/错误,因为我知道很多人对此感到困惑,但其实非常简单。
当一个程序在Linux中运行时,内核会为其分配三个文件描述符:标准输入(stdin, 文件描述符0)、标准输出(stdout, 文件描述符1)和标准错误(stderr, 文件描述符2)。默认情况下,标准输入来自键盘,标准输出和标准错误都打印到显示器。
你可以重定向这些流。例如,./program > output.txt 将标准输出重定向到 output.txt 文件(屏幕上不再显示)。./program 2> error.txt 将标准错误重定向到 error.txt 文件。./program > output.txt 2>&1 将标准输出和标准错误都重定向到同一个文件。
管道(|)可以将一个命令的标准输出连接到另一个命令的标准输入。例如,command1 | command2。
我强烈推荐你观看幻灯片上链接的关于管道和重定向的10分钟视频,以及关于Bash终端的一小时教程视频。这是你本周五的任务:观看Bash教程视频,并跟着练习。
第一个C++程序
C++是一门庞大的语言,要真正学会一切可能需要两年。但学习基础知识和核心技术,我认为六个月是足够的。当然,我无法在一门课中教会你C++的一切,也没有人知道C++的一切。
我们将编写C++代码。你有两个主要选择:使用集成开发环境(IDE)或文本编辑器。对于学习阶段,我完全不推荐使用IDE。虽然IDE更舒适,但你会错过了解背后原理的机会。学习时需要了解这些幕后机制。在本课程中,我们将探究这些并不算难的幕后原理,然后你将学会如何脱离IDE工作。
我们将使用Visual Studio Code作为文本编辑器。它是一个现代、开源、由微软支持的编辑器,具有许多强大功能。使用文本编辑器可以让你学到更多,更具灵活性。如果你去大公司工作,很可能不会使用Eclipse这类重型IDE。
另一个工具是Vim,它非常难用,但如果你计划在机器人领域建立职业生涯,我建议你有时间(比如现在疫情期间)开始学习它。Vim只是一个文本编辑器,但使用起来极快。当你需要远程连接服务器或机器人工作时,你会庆幸自己会使用Vim。
现在,让我们看看最简单的“Hello World”程序。与之前看到的汇编代码相比,这个程序简单明了。
让我们来构建这个例子。我将打开一个终端,创建一个新目录,然后进入该目录。我的建议是:从终端完成所有操作,设置好工作空间,然后再打开文本编辑器。
在Visual Studio Code中,你可以通过 code . 命令在当前目录打开编辑器。然后创建一个新文件,命名为 hello.cpp。
在文件中,我们写入以下代码:
#include <iostream>
int main() {
std::cout << "Hello Modern C++" << std::endl;
return 0;
}
保存文件。这只是一个文本文件。要运行它,我们需要编译。在终端中,使用 c++ hello.cpp -o hello 命令进行编译。这将生成一个可执行文件 hello。运行 ./hello,你将在屏幕上看到输出。
C++是一门编译型语言。这意味着我们编写高级语言代码,然后使用编译器(如Clang、GCC)将其转换为机器码。这与Python或MATLAB等解释型语言有重大区别。一个主要好处是,在运行程序之前,你就知道程序中是否有错误。
注释用于在源代码中添加说明。以 // 开头的行是单行注释。/* 和 */ 之间的内容是多行注释。现代哲学是编写能够自我说明的代码,而不是依赖注释。
代码格式非常重要。今年我将使用一个叫做 clang-format 的工具来自动格式化代码。遵循统一的代码风格可以使代码更易于阅读。如果你按照我的设置指南配置了开发环境,你已经拥有了这个工具。
每个C++程序都从 main 函数开始。这是语言定义的,你不能更改这个名字。main 函数返回一个整数,通常0表示成功,非0表示错误。
#include <iostream> 是一个预处理指令,它告诉编译器将 iostream 头文件的内容“复制粘贴”到当前文件中。这样我们就可以使用其中定义的输入输出功能。
如果你想尝试与标准输入输出交互,可以编写一个简单的程序来演示。我建议你亲手编写这个程序,并尝试幻灯片上的重定向例子。
编译过程
编译过程是将人类可读的文本(源代码)翻译成机器可执行的二进制代码(机器码)的过程。执行这个翻译工作的程序叫做编译器。在Linux中,你可以使用多种编译器,今年我转向使用Clang。
最后,我想感谢Igor,他是这门课程的创始人,也是一位出色的老师。本课程80%的幻灯片都基于他的工作。如果你对我的讲解有任何不理解的地方,可以去查看Igor往年的讲座视频。
总结
本节课中我们一起学习了现代C++课程的整体框架、学习C++的重要意义、C++语言的发展历史与核心设计理念,以及开始C++编程所必需的Linux基础操作。我们从最简单的“Hello World”程序入手,了解了C++代码从编写、编译到运行的完整流程,并强调了动手实践和代码可读性的重要性。
课后任务与资源
-
任务:安装Linux或配置WSL(Windows用户),完成幻灯片上的示例练习。
-
周五任务:观看提供的Bash终端教程视频(约1小时),并跟随练习。
-
讨论:任何问题请在Discord频道讨论。
-
下节课预告:下周我们将讨论构建系统和C++核心语言,并发布第一次作业。
-
推荐资源:
-
书籍:《C++程序设计语言》(Bjarne Stroustrup著),《C程序设计语言》(K&R著)。
-
在线参考:cppreference.com
-
扩展观看:一个关于“为什么应该学习编程”的TEDx演讲(可选)。
-
002:构建系统与工具 🛠️
在本节课中,我们将深入学习构建系统。这是关于工具主题的最后一讲。从下周开始,我们将讨论C++语言本身。理解如何构建程序以及如何处理构建系统,对于掌握C++至关重要。本节课我们不会看到任何C++代码,而是退一步,在开始编码之前先学习如何使用工具。
软件开发生态系统概览
当我们想到编程时,通常会专注于源代码。但实际上,软件开发涉及许多其他工具。学习如何使用这些工具,能让你在处理C++乃至其他语言时获得“超能力”。
在本课程中,我们将讨论其中一些工具。今天的重点是构建系统,它将教你如何构建C++项目并避免一些错误。我们还会在教程中简要涉及其他工具,如静态代码分析、测试、代码格式化以及持续集成/持续部署(CI/CD)。版本控制系统(如Git)和调试工具也是现代开发不可或缺的部分。
这里我想强调一个重要观点:在软件开发中,概念和工具是两件不同的事。概念(如算法、设计模式)不太可能随时间改变,而工具(如编译器、构建系统)则可能。因此,明智的做法是将更多时间投资于学习概念。然而,C++是一门复杂的语言,我们需要工具来高效地应用这些概念。如果你的工具不好用,你的工作效率也会大打折扣。
编译过程详解
现在,让我们深入探讨构建系统以及编译过程的工作原理。
什么是编译器?
编译器是一个特殊的程序,它的职责是将人类可读的源代码转换为计算机可以理解的二进制代码。
这个过程可以用一个简单的图示来理解:你编写源代码,编译器执行“魔法”,最终输出二进制代码。
最简单的编译方式
编译一个C++程序最简单的方式就是直接调用编译器。在本课程中,我们将使用 clang++。
clang++ main.cpp
这条命令会生成一个名为 a.out 的可执行文件。如果一切正常,就说明编译器安装正确且系统库齐全。我们在上一讲的“Hello World”示例中已经测试过这一点。
当然,现实中的项目不会总是这么简单。理解一个项目的最佳途径之一,就是通过它的构建系统。很多人在学术界和工业界遇到困难,往往是因为不熟悉构建工具。了解项目的构建系统,能让你洞察项目的结构和运作方式。
编译的四个步骤
当你调用 clang++ main.cpp 时,编译器在幕后执行了四个步骤:
-
预处理
-
编译
-
汇编
-
链接
下图展示了这个过程:
源代码 (.cpp) -> 预处理 -> 编译 -> 汇编 -> 链接 -> 可执行文件
了解这些步骤的细节非常有益,它能让你明白在构建程序时到底发生了什么。掌握构建程序的原理是一种“超能力”,它能让你有目的地操作,而不是盲目尝试。一旦你理解了如何构建,你就真正掌握了你的项目。
许多人因为不知道如何使用 #include 等语句,而将所有代码写在一个文件里。学会使用工具,会让你的开发工作轻松很多。
手动执行编译步骤
让我们通过一个小例子,手动执行这些步骤,以便更深入地理解。我建议你在自己的机器上尝试。
首先,创建一个干净的工作目录和一个简单的程序:
// main.cpp
#include <iostream>
// 这是一个注释
int main() {
std::cout << "Hello Build Systems\n";
return 0;
}
1. 预处理
预处理步骤负责处理 #include 指令(将头文件内容复制到源文件中)、展开宏定义以及删除注释。
clang++ -E main.cpp -o main.i
现在你可以查看 main.i 文件。你会发现文件变得非常长(例如,可能超过28000行),因为 #include <iostream> 的内容被包含了进来。同时,你写的注释也消失了。
2. 编译
这是真正的“编译”步骤,编译器将预处理后的代码(main.i)转换为汇编代码。
clang++ -S main.i -o main.s
生成的 main.s 文件包含汇编指令,这对人类来说难以阅读,但机器可以理解。
3. 汇编
汇编器将汇编代码(main.s)转换为目标文件(二进制代码)。
clang++ -c main.s -o main.o
main.o 是目标文件,包含机器码。直接打开它看到的将是乱码。
4. 链接
链接器将一个或多个目标文件(以及库)组合起来,生成最终的可执行文件。
clang++ main.o -o main
现在,运行 ./main 就可以执行程序了。
总结一下,直接运行 clang++ main.cpp 相当于自动执行了以上所有步骤。理解这个流程有助于你诊断构建问题。
编译标志
编译器支持许多标志来优化和控制编译过程。例如:
-
指定标准:
-std=c++17 -
显示警告:
-Wall -Wextra -
将警告视为错误:
-Werror -
优化级别:
-O1,-O2,-O3
优化器是编译器的核心组件之一,它负责将源代码转换为高效快速的机器码。例如,它可以在编译时计算 2 + 2 这样的常量表达式,而不是在运行时计算。我们将在课程最后讨论泛型编程和静态代码生成时再深入这个话题。
库的概念与创建
现在我们已经了解了如何编译一个简单程序。接下来,让我们利用这些知识来构建更复杂的项目,这就引入了库的概念。
什么是库?
库是一组预编译的符号(如函数、类)的集合。使用库的好处包括:易于维护、便于分发,并使项目结构更清晰、更易协作。
库主要分为两种类型:
-
静态库:在链接时被完整地复制到最终的可执行文件中。优点是执行速度快,缺点是会增加可执行文件的大小。
-
动态库(共享库):在程序运行时才被加载。多个程序可以共享同一个动态库的实例,节省磁盘和内存空间。常见的扩展名是
.so(Linux)或.dll(Windows)。
如何创建库?
在C++中创建库的关键在于分离函数的声明和定义。
-
函数声明(接口/原型):这就像一个“承诺”,告诉编译器这个函数存在,其具体实现稍后提供。声明通常放在头文件(
.hpp或.h)中。// some_file.hpp void functionName(int param); // 注意分号 -
函数定义(实现):这是函数的具体代码。定义放在源文件(
.cpp)中。// some_file.cpp #include "some_file.hpp" void functionName(int param) { // 实现细节... }
为什么要把实现放在 .cpp 文件而不是 .hpp 文件?
如果你把实现也放在头文件中,那么每次在其他 .cpp 文件中 #include 这个头文件时,都会复制一份完整的实现代码。这会导致编译时间急剧增加,尤其是在大型项目中,甚至可能导致编译机器内存不足。更重要的是,这完全绕过了链接器,没有利用到工具链的完整能力。
手动构建库示例
让我们通过一个例子手动构建一个库。假设我们有一个名为 tools 的库,它包含两个函数:makeItSunny 和 makeItRain。
1. 创建文件
-
tools.hpp(声明)#pragma once void makeItSunny(); void makeItRain(); -
tools.cpp(实现)#include "tools.hpp" #include <iostream> void makeItSunny() { std::cout << "Making it sunny!\n"; } void makeItRain() { std::cout << "Making it rain!\n"; } -
main.cpp(使用库)#include "tools.hpp" int main() { makeItSunny(); makeItRain(); return 0; }
2. 手动构建步骤
如果直接编译 main.cpp,链接器会报错,因为它找不到函数的实现。
clang++ main.cpp # 链接错误:undefined reference to `makeItSunny()`...
我们需要分步构建:
# 1. 将库的实现编译成目标文件
clang++ -std=c++17 -c tools.cpp -o tools.o
# 2. 将目标文件打包成静态库(归档文件)
ar rcs libtools.a tools.o
# 3. 编译主程序,并链接我们创建的库
# -L. 告诉链接器在当前目录(.)搜索库
# -ltools 告诉链接器链接名为 `libtools.a` 的库(去掉`lib`前缀和`.a`后缀)
clang++ -std=c++17 main.cpp -L. -ltools -o main
现在运行 ./main 即可。
可以看到,即使只有三个文件,手动构建也需要多条命令,非常繁琐。对于大型项目,这根本不可行。因此,我们需要自动化构建工具。
构建系统简介
构建系统的出现是为了自动化项目的构建过程。其发展历程大致如下:
-
Shell脚本:将命令写在脚本里,但难以维护(例如,重命名文件需要修改多处)。
-
Makefile:早期且至今仍广泛使用的构建系统,通过定义规则和依赖关系来构建。
-
CMake等元构建系统:由于Makefile编写和维护仍然复杂,出现了像CMake这样的工具。CMake本身不是一个构建系统,而是一个构建系统生成器。它读取一个高级的、相对易读的脚本(
CMakeLists.txt),然后为你生成对应平台(如Make或Ninja)的底层构建文件(如Makefile)。
使用CMake
CMake是跨平台的,功能强大。使用CMake的基本用户流程是:
-
创建一个
CMakeLists.txt文件,描述你的项目。 -
创建一个构建目录(例如
build/)。 -
在该目录中运行
cmake ..来生成构建系统文件。 -
运行
make(或其他生成器命令,如ninja)来实际编译项目。
让我们用CMake重写上面的库示例。
1. 创建 CMakeLists.txt
cmake_minimum_required(VERSION 3.10)
project(FirstProject)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# 添加一个名为 `tools` 的库,其源文件是 tools.cpp
add_library(tools tools.cpp)
# 添加一个名为 `main` 的可执行文件,其源文件是 main.cpp
add_executable(main main.cpp)
# 将可执行文件 `main` 与库 `tools` 链接
target_link_libraries(main tools)
这个脚本非常清晰:添加库、添加可执行文件、链接它们。
2. 构建项目
mkdir build && cd build
cmake ..
make
CMake会检测编译器,生成Makefile,然后 make 命令会执行实际的编译和链接步骤。完成后,你会在 build 目录下找到 main 可执行文件。
CMake的一个优点是,如果你把构建目录弄乱了,可以直接删除它,然后从头开始 (rm -rf build && mkdir build && cd build && cmake .. && make)。
CMake的其他功能
CMake还有很多强大功能,例如查找系统上已安装的软件包:
find_package(OpenCV REQUIRED)
target_link_libraries(my_target ${OpenCV_LIBS})
这比手动指定头文件路径和库文件路径要方便得多。随着课程深入,当你使用像OpenCV这样的外部库时,就会用到这些功能。
学习CMake和其他工具的最佳方式就是实践。你将在第一次作业以及后续的项目中使用它。
总结
本节课我们一起学习了以下内容:
-
编译过程:深入了解了从源代码到可执行文件的四个步骤(预处理、编译、汇编、链接),并手动执行了这些步骤。
-
库:学习了库的概念、静态库与动态库的区别,以及如何通过分离函数声明(在头文件)和定义(在源文件)来创建库。
-
手动构建的挑战:我们手动构建了一个简单的库,这个过程命令繁多且容易出错,凸显了自动化构建的必要性。
-
构建系统:引入了CMake作为元构建系统生成器。我们看到了如何用一个简洁的
CMakeLists.txt脚本来自动化整个构建流程,这比手动编写命令要高效、可维护得多。
记住,理解这些底层概念能赋予你强大的掌控力,而熟练使用像CMake这样的现代工具则能极大提升你的开发效率。请务必完成相关练习来巩固这些知识。
003:核心C++概念与基础语法 🚀
在本节课中,我们将学习C++程序的核心组成部分和基础语法。我们将从C++程序的基本定义开始,逐步了解关键字、实体、声明、定义、作用域、类型、变量以及基本的控制结构。课程最后,我们会通过一个字符串处理的例子,对比C风格与C++风格的代码,展示C++在表达意图和类型安全方面的优势。
C++程序的基本定义 📄
C++程序本质上是一系列文本文件(.cpp 和 .hpp 文件)的集合,这些文件包含了声明。这些文件经过翻译后成为可执行程序,当C++实现调用其 main 函数时,程序开始执行。
核心概念与术语 📖
上一节我们介绍了C++程序的基本构成,本节中我们来看看构成程序的具体元素及其术语。
关键字与注释
以下是C++中的一些基本术语:
-
关键字:C++中具有特殊含义的保留字,不能用作标识符。例如:
const,auto,friend,false。 -
注释:在翻译过程中被忽略的文本。C++支持三种注释:
-
单行注释:
// 这是一个注释 -
多行注释:
/* 这也是一个注释 */ -
自C++14起,还有基于
///或/**的文档注释。
-
-
转义序列:用于表示特殊字符的序列。例如,换行符
\n。
实体
C++程序中的实体包括值、对象、引用、函数、枚举、类型、类成员、模板等。预处理器宏不属于C++实体,这是从C语言继承的特性。
声明与定义
声明用于引入实体并将其与名称关联,同时定义其属性。那些提供了使用实体所需全部属性的声明,称为定义。
例如:
int foo; // 声明并定义了一个名为 foo 的 int 类型变量(实体)
void my_function(); // 声明了一个名为 my_function 的函数实体
void my_function() { // 这是 my_function 的定义,提供了实现细节
// ... 函数体
}
函数定义通常包含一系列语句,其中一些语句包含表达式,表达式用于指定程序要执行的计算。每个C++语句以分号 ; 结尾。
名称与作用域
程序中遇到的名称与引入它们的声明相关联。每个名称只在程序的某个部分有效,这个部分称为其作用域。花括号 {} 通常会开启一个新的作用域。
{
int my_variable = 42; // my_variable 在此作用域内有效
} // 作用域结束,my_variable 不再可访问
// int x = my_variable; // 错误!my_variable 在此作用域不可见
理解作用域对于管理变量和对象的生命周期至关重要。
类型
类型是C++中最重要的概念之一。每个对象、引用、函数和表达式都必须与一个类型关联,这使得C++成为一种强类型语言。
类型可以是:
-
基础类型:语言内置的类型,如
int,float,bool。 -
复合类型:如指针、数组、引用。
-
用户定义类型:如类、结构体、枚举。
类型还可以是完整的或不完整的。不完整类型只有声明没有定义。
变量
变量是已声明的对象和引用(非静态数据成员除外)。创建变量需要指定类型、名称(标识符),并可选择进行初始化。始终初始化变量是一个好习惯。
标识符与命名规则 🏷️
上一节我们了解了变量,本节我们来看看如何为程序中的实体命名。
标识符是由数字、下划线、大小写拉丁字母以及大多数Unicode字符组成的任意长序列。有效的标识符必须以非数字字符开头,并且区分大小写。
以下是命名规则:
-
名称必须以字母开头。
-
应使用有意义的名称。
-
对于变量名,推荐使用蛇形命名法(例如:
my_variable_name)。 -
对于常量,Google风格指南推荐使用以‘k’开头的驼峰命名法(例如:
kDaysInWeek),这有助于在代码中快速识别常量。 -
关键点:不能使用C++关键字作为标识符。
变量存在于其被声明的作用域内,并在离开该作用域时“消亡”。使用 const 关键字可以声明常量,防止其值被意外修改。一个良好的习惯是:将所有变量声明为常量,除非它们确实需要被改变。
运算符与表达式 ➕
在定义了变量之后,我们需要对它们进行操作。本节中我们来看看C++中用于计算的运算符和表达式。
表达式是由运算符和操作数组成的序列,用于指定计算。C++提供了丰富的运算符,包括:
-
算术运算符:
+,-,*,/,% -
比较运算符:
==,!=,<,>,<=,>= -
逻辑运算符:
&&,||,! -
赋值运算符:
=,+=,-=等 -
自增/自减运算符:
++,--
例如,a + b 是一个表达式。++ 运算符(C++名字的由来)将其操作数增加1。在后续课程中,我们将学习如何以“C++风格”重载这些运算符。
控制结构 🔄
掌握了基础计算后,我们需要控制程序的执行流程。本节介绍C++中的基本控制结构。
条件语句 (if, switch)
-
if语句:根据布尔条件执行代码。if (condition) { // 条件为真时执行 } else if (other_condition) { // 其他条件为真时执行 } else { // 上述条件都不为真时执行 } -
switch语句:基于整型或枚举值进行多路分支。注意:每个case后通常需要break语句,否则会“贯穿”执行后续case。switch (value) { case 1: // 处理 value == 1 break; case 2: // 处理 value == 2 break; default: // 处理其他情况 }C++鼓励使用枚举类 (
enum class) 代替纯整型,以增强类型安全和代码可读性。
循环语句 (while, for, range-for)
-
while循环:当条件为真时重复执行。while (condition) { // 循环体 } -
for循环:包含初始化、条件和递增部分的循环。for (int i = 0; i < 10; ++i) { // 循环体,执行10次 } -
range-for循环 (C++11):遍历容器(如数组、向量)中每个元素的简洁语法,也称为for-each循环。std::vector<int> vec = {1, 2, 3}; for (int value : vec) { std::cout << value << std::endl; }从C++17开始,甚至可以像Python一样方便地遍历映射(字典):
std::map<std::string, int> my_dict{{"A", 27}, {"B", 42}}; for (const auto& [key, value] : my_dict) { std::cout << key << " has value " << value << std::endl; }这种语法在保持高性能的同时,极大地提升了代码的可读性。
break语句:用于立即退出当前循环或switch语句。应谨慎使用,因为它可能使程序流程难以跟踪。
基础类型与操作 🔢
了解了控制流程后,我们回到数据本身。本节详细介绍C++内置的基础类型及其基本操作。
C++提供了多种基础类型,主要包括:
-
算术类型:用于表示整数、浮点数和单个字符。
-
整型:
int,short,long,long long(及其unsigned版本) -
浮点型:
float,double,long double -
字符型:
char,wchar_t,char16_t,char32_t
-
-
布尔类型:
bool,值为true或false。 -
void类型:表示“无类型”。
可以使用 auto 关键字进行自动类型推导,让编译器根据初始值推断变量类型。
auto my_float = 3.14f; // 编译器推导出 my_float 是 float 类型
重要提示:
-
可以对算术类型执行算术、比较、递增等操作。
-
避免使用
==或!=直接比较浮点数,由于浮点数的内部表示方式,这可能导致意想不到的结果。应使用检查两者差值是否小于某个极小值(epsilon)的方法。 -
布尔类型支持逻辑运算
&&(与)、||(或)、!(非)。
实践示例:字符串流处理 📊
在学习了所有基础语法之后,让我们通过一个实际的例子来看看C++如何更优雅地处理问题。本节我们将对比C风格和C++风格解析字符串的方法。
任务:解析一个文件名字符串(如 "img00205.txt"),将其拆分为数字部分(205)和扩展名部分(.txt),并且数字部分应以整数类型存储。
C风格实现:需要手动遍历字符数组,寻找分隔符(如 .),并进行复杂的指针操作和类型转换。代码意图不直观,且容易出错。
C++风格实现(使用 std::stringstream):
#include <iostream>
#include <sstream>
#include <string>
int main() {
std::string filename = "img00205.txt";
std::stringstream ss(filename);
int number;
std::string extension;
// 利用字符串流自动“解析”,忽略前导的“img”
// 并按照类型提取到相应变量
ss >> number >> extension; // number = 205, extension = ".txt"
std::cout << "Number is: " << number << std::endl;
std::cout << "Extension is: " << extension << std::endl;
// 现在可以对 number 进行算术运算
number += 100;
std::cout << "Modified number: " << number << std::endl;
return 0;
}
优势:
-
意图清晰:代码直接表达了“从流中提取一个整数和一个字符串”。
-
类型安全:
number直接就是int类型,无需后续转换。 -
易于维护:如果输入格式变化,修改起来比C风格代码简单得多。
-
性能无损:在开启优化后,其性能与精心编写的C代码相当,甚至更优。
这个例子体现了C++哲学:通过类型系统和丰富的库,让代码尽可能直接地表达程序员的意图,从而编写出更安全、更清晰、更易于维护的代码。
总结 🎯
本节课中我们一起学习了现代C++的核心基础。我们从C++程序的定义出发,系统性地了解了构成程序的关键概念:关键字、实体、声明与定义、作用域、类型系统以及变量的命名与生命周期。我们探讨了用于控制程序流程的基本结构(条件、循环)和用于数据操作的基础类型与运算符。最后,通过一个字符串处理的对比示例,我们直观感受到了C++在提升代码表达力、类型安全性和可维护性方面的强大能力。这些基础知识是后续学习更高级的C++特性(如类、模板、标准库容器等)的坚实基石。记住,良好的C++实践始于清晰地表达意图并充分利用类型系统。
004:函数
概述
在本节课中,我们将要学习C++中函数的核心概念。函数是组织代码、实现特定任务的基本单元。我们将从函数的基本语法开始,逐步深入到参数传递、返回值、作用域以及一些现代C++中关于函数的高级特性。通过学习,你将能够编写更安全、更高效、更易于维护的C++代码。
从C风格字符串操作说起
在深入函数之前,我们先看一个C风格字符串操作的例子,以理解为什么现代C++提倡使用std::string。
以下是C语言中复制字符串的一种方式:
#include <cstring>
#include <iostream>
int main() {
const char* source = "A string to copy";
char destination[50];
std::cout << source << ‘\n‘;
std::strcpy(destination, source);
std::cout << destination << ‘\n‘;
return 0;
}
这段代码使用strcpy函数进行字符串复制,但它存在缓冲区溢出的风险,行为可能因编译器或优化级别不同而变得不可预测,非常不安全。
在C++中,我们可以使用std::string来安全地完成同样的操作:
#include <string>
#include <iostream>
int main() {
std::string source = "A string to copy";
std::string destination = source; // 安全且易读的复制
std::cout << source << ‘\n‘;
std::cout << destination << ‘\n‘;
return 0;
}
使用std::string不仅更安全,避免了手动管理内存的麻烦,而且代码也更清晰易读。在本课程中,请务必使用C++风格的字符串。
函数的基本概念
上一节我们看到了代码组织的必要性,本节中我们来看看实现代码复用的核心工具——函数。
在编程中,函数是一段具有名称的代码,用于执行特定任务。C++不区分函数(返回值)和过程(不返回值),即使不返回任何值,也统称为函数。
函数的主要作用包括:
-
组织代码:将复杂程序分解为逻辑清晰的模块。
-
实现复用:同一段代码可以在不同位置多次调用。
-
创建作用域:函数内部定义的变量有其生命周期和可见性范围。
一个函数声明必须包含其名称、返回类型(或void)以及参数列表。类型在C++中至关重要,它帮助编译器检查错误并生成高效代码。
函数解剖
了解了函数的基本概念后,我们来详细拆解一个函数的各个组成部分。
一个标准的C++函数声明格式如下:
[可选属性] 返回类型 函数名(参数列表) {
// 函数体
}
-
返回类型:指定函数返回值的类型,例如
int、std::string或void(表示不返回值)。 -
函数名:应清晰表明函数的功能。常见的命名风格是驼峰式(camelCase),例如
calculateSum。 -
参数列表:定义了函数接收的输入变量及其类型,例如
(int a, double b)。 -
函数体:由花括号
{}包围,包含了实现函数功能的所有语句。花括号也定义了一个新的作用域。
关键点:
-
函数内部(函数体)是一个独立的作用域。
-
函数通过参数列表与外部世界交换信息。
-
函数体内实现细节对外部是隐藏的。
返回值
函数通过返回值将结果传递到外部。关于返回值,有几个重要的规则和现代特性。
基本规则
-
如果函数声明的返回类型不是
void(例如int),则必须在函数体中通过return语句返回一个该类型的值。 -
如果函数声明的返回类型是
void,则不能使用return语句返回任何值(或只能使用return;提前结束函数)。
int mustReturnValue() {
return 42; // 正确:返回int
// 如果没有return语句,编译错误
}
void mustNotReturnValue() {
// 执行操作...
return; // 正确:仅用于提前退出,不返回值
// return 42; // 错误:void函数不能返回值
}
自动类型推导 (C++14)
对于复杂的返回类型,可以使用auto关键字让编译器自动推导返回类型,这能提升代码可读性。
// 传统方式,类型冗长
std::map<std::string, std::vector<int>> getOldDictionary() {
return {{"key", {1, 2, 3}}};
}
// 使用auto (C++14)
auto getDictionary() {
return std::map<std::string, std::vector<int>>{{"key", {1, 2, 3}}};
}
返回多个值 (C++17)
传统上,C++函数只能返回一个值。但从C++17开始,可以使用结构化绑定(Structured Binding)方便地处理多个返回值,类似于Python中的元组解包。
// 返回一个包含两个值的std::pair
auto getField() {
std::string name = "answer";
int value = 42;
return std::pair{name, value}; // C++17可省略模板参数
}
int main() {
auto [name, val] = getField(); // 结构化绑定
std::cout << name << " is " << val << std::endl; // 输出:answer is 42
return 0;
}
关键警告:不要返回局部变量的引用
这是一个至关重要且常见的错误。函数内局部变量的生命周期仅限于该函数的作用域。当函数执行完毕时,其局部变量会被销毁。
绝对不要返回一个指向局部变量的引用或指针。因为返回后,所引用的内存区域可能已被释放或重用,访问它会导致未定义行为(程序崩溃或输出垃圾值)。
// 错误示例:返回局部变量的引用
int& multiplyByTen(int number) {
int result = number * 10; // `result`是局部变量
return result; // 危险!返回了即将被销毁的变量的引用
}
int main() {
int& ref = multiplyByTen(10);
std::cout << ref << std::endl; // 未定义行为!
return 0;
}
未定义行为的表现可能很“诡异”:在调试模式或添加了某些打印语句时程序可能“看似”正常工作,但开启编译器优化或改变代码后就会出错。永远不要依赖这种偶然的正确性。
如果需要返回大型对象,担心拷贝开销怎么办? 编译器通常会进行返回值优化(RVO),避免不必要的拷贝。信任编译器,优先编写安全、清晰的代码。
局部变量与静态变量
在函数内部定义的变量称为局部变量。它们有以下特点:
-
初始化时机:在程序执行到其定义语句时才被初始化。
-
独立性:函数的每次调用都会创建一套全新的局部变量。
-
生命周期:局部变量在函数作用域结束时被销毁。
有时,我们需要一个在函数多次调用间保持其值的变量。这时可以使用static局部变量。
-
static变量在程序生命周期内只初始化一次(在第一次执行到其声明时)。 -
其生命周期贯穿整个程序运行期间,但作用域仍限制在函数内部。
void counter() {
static int count = 0; // 只初始化一次
++count;
std::cout << "Called " << count << " times.\n";
}
int main() {
counter(); // 输出:Called 1 times.
counter(); // 输出:Called 2 times.
return 0;
}
谨慎使用static变量。过度使用可能表明程序设计存在缺陷,并且可能引发难以调试的“静态初始化顺序问题”。
参数传递
参数是函数与外部交互的接口。C++提供了几种不同的参数传递方式。
传值 vs 传引用
-
传值:默认方式。函数获得参数的一个副本。修改形参不影响实参。
void byValue(int x) { x = 100; } int a = 10; byValue(a); // a 仍然是 10 -
传引用:在参数类型后加
&。函数获得实参的一个别名,修改形参就是修改实参本身。void byReference(int& x) { x = 100; } int a = 10; byReference(a); // a 现在是 100
常量引用:兼顾效率与安全
对于大型对象(如std::string、std::vector),传值会产生昂贵的拷贝开销。传引用是高效的替代方案。
但直接传引用存在风险:调用者无法确定函数是否会修改其数据。
解决方案是使用常量引用 (const T&):
-
高效:只传递引用,无拷贝开销。
-
安全:函数承诺不会修改传入的对象。尝试修改会导致编译错误。
void processBigData(const std::string& hugeData) {
// 可以读取 hugeData...
// hugeData[0] = ‘A‘; // 错误!不能修改常量引用
std::cout << hugeData.size();
}
默认参数
可以为函数参数指定默认值。调用时若省略该参数,则使用默认值。
void greet(std::string name, std::string prefix = "Hello") {
std::cout << prefix << ", " << name << "!\n";
}
greet("Alice"); // 输出:Hello, Alice!
greet("Bob", "Hi"); // 输出:Hi, Bob!
慎用默认参数。它们可能隐藏逻辑,使代码行为不清晰,尤其是在大型项目中,调试由默认参数引发的间接错误会非常困难。
内联函数
函数调用涉及一些额外开销(如跳转和栈帧操作)。对于非常短小的函数,这种开销可能得不偿失。
使用inline关键字可以建议编译器将函数调用处用函数体本身替换,从而消除调用开销。这称为内联展开。
inline int square(int x) {
return x * x;
}
int main() {
int result = square(5); // 编译器可能会将此处替换为 `int result = 5 * 5;`
return 0;
}
注意:
-
inline只是一个对编译器的提示,编译器有权忽略它。 -
是否内联最终由编译器根据函数复杂度、调用频率等因素决定。
-
内联适用于短小、频繁调用的函数(如简单的getter/setter)。
函数重载
C语言中,如果需要对不同类型进行相同操作,必须为每个类型编写不同名称的函数(如cosf, cos, cosl)。
C++提供了函数重载功能:允许在同一作用域内定义多个同名函数,只要它们的参数列表(参数类型、数量或顺序)不同即可。编译器会根据调用时提供的实参来选择最匹配的函数版本。
// 重载的 cosine 函数
float cos(float x) { /* 实现 */ }
double cos(double x) { /* 实现 */ }
long double cos(long double x) { /* 实现 */ }
int main() {
float f;
double d;
auto a = cos(f); // 调用 float 版本
auto b = cos(d); // 调用 double 版本
return 0;
}
函数重载提高了代码的可读性和一致性。在后续课程中,我们还将看到如何使用模板(泛型编程)进一步简化这类代码。
良好的函数编写实践
编写优秀的函数是写出高质量代码的关键。以下是一些核心准则:
-
单一职责:一个函数只做好一件事。如果函数名需要用“和”来连接(如
createAndPrint),就该考虑拆分它。 -
简短精炼:函数体不宜过长。一个经验法则是,函数代码最好能在一屏内完整显示。
-
意图清晰的命名:函数名应直接反映其功能。好的名字胜过一堆注释。
calculateAverage比doStuff好得多。 -
避免不必要的注释:代码本身应该清晰表达意图。注释应解释“为什么这么做”,而不是重复“做了什么”。
-
谨慎控制副作用:函数应通过参数和返回值与外界通信,避免修改全局变量等隐蔽的副作用。
-
使用C++特性,避免C风格:优先使用
std::string、std::vector等,避免C风格的字符串、数组和宏。
好函数的例子:
std::vector<int> createVectorOfZeros(std::size_t size) {
std::vector<int> result(size, 0);
return result;
}
// 名字清晰,功能单一,返回值明确。
坏函数的例子:
int foo(int a, int b) {
static int c = 0;
if (b) {
for (int i=0; i<a; ++i) c+=i;
std::cout << "debug: " << c;
callYourAunt(); // !?
}
return c * b;
}
// 名字无意义,功能混杂,有隐藏的副作用和奇怪的操作。
命名空间简介
最后,我们简要介绍命名空间,它是管理代码名称、避免冲突的重要工具。
命名空间用于将代码封装在逻辑分组内,防止不同库中的同名标识符(如函数、类)发生冲突。
namespace my_lib {
class vector { /* 我的vector实现 */ };
}
namespace std {
// 标准库的vector
template<typename T> class vector { /* ... */ };
}
int main() {
my_lib::vector v1; // 使用我自己的vector
std::vector<int> v2; // 使用标准库的vector
return 0;
}
重要建议:
-
避免
using namespace std;:特别是在头文件中。这会将该命名空间的所有名称引入当前作用域,极易引发名称冲突,且污染全局命名空间。 -
推荐使用限定名或特定引入:
// 方式一:始终使用限定名(最安全) std::cout << "Hello" << std::endl; // 方式二:只引入需要的名称 using std::cout; using std::endl; cout << "Hello" << endl; -
匿名命名空间:用于使文件内的标识符具有内部链接性(仅在本文件内可见),替代C风格的
static。namespace { // 匿名命名空间 const int MAX_SIZE = 100; void helper() { /* 只在本文件内可用 */ } }
总结
本节课中我们一起深入学习了C++函数。
我们从为什么应该使用C++的std::string而不是C风格字符串开始,强调了安全性和现代性。
然后,我们系统地剖析了函数的组成部分:返回类型、函数名、参数列表和函数体,并重点讲解了返回值的规则、自动类型推导以及C++17的多返回值特性。
我们严肃地讨论了不要返回局部变量引用这一关键陷阱。
接着,我们探讨了参数传递的几种方式(传值、传引用、常量引用),并指出了默认参数需要慎用。
我们还介绍了用于性能优化的inline函数、提升代码表达力的函数重载。
最后,我们总结了编写良好函数的实践准则,并简要介绍了用于组织代码、避免名称冲突的命名空间。
掌握这些知识,是编写出清晰、高效、健壮的C++程序的基础。请务必通过练习来巩固理解。
005:STL库
在本节课中,我们将深入学习C++标准模板库(STL)中的核心工具。我们将探讨容器、迭代器以及算法,了解如何利用这些强大的组件来编写更安全、更高效、更易读的代码。
容器
在上一节中,我们学习了如何编写自己的函数。从本节开始,我们将学习如何使用STL库中预定义的函数和容器。
容器是用于存储数据的对象。在C++中,世界通常分为静态和动态两部分。静态意味着在编译时确定,动态意味着在运行时确定。容器也分为静态容器和动态容器。
静态容器:std::array
std::array 是一个静态容器,其大小在编译时定义,并且在程序执行期间不能改变。
公式/代码:
#include <array>
std::array<int, 3> data = {10, 20, 1000};
以下是使用 std::array 的关键点:
-
要使用
std::array,需要包含<array>头文件。 -
声明时需要两个模板参数:元素类型和数组大小。大小必须是编译时常量。
-
可以使用方括号运算符
[]访问元素,索引从0开始。 -
可以使用成员函数,如
empty()检查是否为空,size()获取大小。
动态容器:std::vector
std::vector 是一个动态容器,其大小在运行时可以改变,是更常用的容器。
公式/代码:
#include <vector>
std::vector<std::string> names = {"Alice", "Bob", "Charlie"};
names.emplace_back("Roberto"); // 动态添加元素
以下是使用 std::vector 的关键点:
-
要使用
std::vector,需要包含<vector>头文件。 -
声明时只需指定元素类型。
-
可以使用
emplace_back(推荐)或push_back添加新元素。 -
可以使用
clear()函数清空所有元素。 -
vector应该是存储同类型元素集合时的默认选择。
优化:reserve 函数
vector 的一个潜在缺点是动态调整容量可能耗时。如果预先知道大致要存储的元素数量,可以使用 reserve 函数预先分配内存,以提高性能。
公式/代码:
std::vector<int> optimized_vector;
optimized_vector.reserve(100); // 预先分配100个元素的内存
for (int i = 0; i < 100; ++i) {
optimized_vector.emplace_back(i);
}
关联容器:std::map 与 std::unordered_map
关联容器用于存储键值对。std::map 中的键是排序且唯一的,而 std::unordered_map 中的键不排序但访问通常更快。
公式/代码 (std::map):
#include <map>
std::map<int, std::string> student_map;
student_map[101] = "Alice";
student_map[102] = "Bob";
公式/代码 (std::unordered_map):
#include <unordered_map>
std::unordered_map<int, std::string> student_unordered_map;
student_unordered_map[102] = "Bob";
student_unordered_map[101] = "Alice";
以下是关联容器的关键点:
-
map的键必须可比较(定义<运算符),unordered_map的键必须可哈希。 -
可以使用
count(key)或 C++20 的contains(key)检查键是否存在。 -
使用
using语句为长类型名创建别名可以提高代码可读性。 -
遍历时,C++17 支持结构化绑定
for (const auto& [key, value] : my_map),使代码更清晰。
为什么使用STL容器?
与C风格数组相比,STL容器具有显著优势:
-
安全性更高:边界检查更严格(尽管
vector::operator[]不检查,但有at()函数)。 -
可读性更好:成员函数如
size(),empty(),back(),clear()能清晰表达意图。 -
功能更丰富:内置了大量成员函数和支持STL算法。
-
性能相当:经过高度优化,通常与手写代码性能相当。
核心建议:避免使用C风格数组,在不确定时优先使用 std::vector。
迭代器
迭代器是连接STL算法与其操作数据的“胶水”。它是一种泛型编程机制,允许算法独立于底层数据结构。
核心概念:算法通过迭代器操作数据,而不是直接操作容器本身。这样,一个排序算法可以用于 vector、list 或任何提供了适当迭代器的容器。
迭代器类似于指针,可以解引用(*it)来访问元素,并使用递增(++it)来移动到下一个元素。
所有STL容器都提供了一系列范围访问迭代器:
-
begin(),cbegin():指向容器首元素的(常量)迭代器。 -
end(),cend():指向容器尾后位置的(常量)迭代器。 -
rbegin(),crbegin():指向容器逆序首元素的(常量)迭代器。
STL算法
STL在 <algorithm> 头文件中提供了约80多种标准算法。它们操作由迭代器定义的序列。
关键建议:不要重复造轮子。在编写自己的算法(如排序、查找)前,先查看STL是否已提供。这些算法通常与手写代码一样快甚至更快。
以下是部分常用算法的示例:
排序 (std::sort)
#include <algorithm>
#include <vector>
std::vector<int> v = {5, 3, 1, 4, 2};
std::sort(v.begin(), v.end()); // 排序整个vector
std::sort(v.begin(), v.begin() + 3); // 只排序前三个元素
查找 (std::find)
auto it = std::find(v.begin(), v.end(), 3);
if (it != v.end()) {
// 找到了元素3
}
填充 (std::fill)
std::fill(v.begin(), v.end(), -1); // 将所有元素填充为-1
计数 (std::count, std::count_if)
int num_threes = std::count(v.begin(), v.end(), 3);
int num_even = std::count_if(v.begin(), v.end(), [](int i){ return i % 2 == 0; });
遍历操作 (std::for_each, std::transform)
std::for_each(v.begin(), v.end(), [](int n){ std::cout << n << " "; });
std::string s = "hello";
std::transform(s.begin(), s.end(), s.begin(), ::toupper); // 转为大写
数值算法 (std::accumulate)
#include <numeric>
int sum = std::accumulate(v.begin(), v.end(), 0); // 求和
int product = std::accumulate(v.begin(), v.end(), 1, std::multiplies<int>()); // 求积
最值 (std::min_element, std::max_element)
auto min_it = std::min_element(v.begin(), v.end());
auto max_it = std::max_element(v.begin(), v.end());
其他实用算法 (std::clamp, std::sample)
int clamped_value = std::clamp(5, 0, 10); // 将值限制在0到10之间,结果为5
std::string in = "C++ is cool";
std::string out;
std::sample(in.begin(), in.end(), std::back_inserter(out), 5, std::random_device{}); // 随机采样5个字符
使用STL算法可以消除许多手写的循环,使代码意图更清晰,并减少错误。
总结
本节课我们一起深入学习了C++标准模板库(STL)的核心部分。
我们首先介绍了容器,包括静态的 std::array 和动态的 std::vector,强调了 vector 的通用性和使用 reserve 进行优化的重要性。接着,我们探讨了关联容器 std::map 和 std::unordered_map,了解了键值对的存储和访问方式,并阐述了为何STL容器在安全性和可读性上优于C风格数组。
然后,我们学习了迭代器的概念,理解了它作为算法与数据之间“胶水”的关键作用,以及它如何实现泛型编程。
最后,我们快速浏览了大量强大的STL算法,如排序、查找、计数、变换等。这些算法与迭代器结合,能让我们以声明式、高效且安全的方式处理数据,避免重复编写底层循环逻辑。
记住,熟练运用STL是编写现代、高效、易维护C++代码的关键。请务必多实践,并查阅 cppreference.com 来探索更多容器和算法的细节。
006:输入输出与实用工具库
在本节课中,我们将学习C++标准库中的实用工具,并重点掌握文件的读写操作。这是C++核心语言部分的最后一讲,之后我们将进入面向对象编程等更高级的主题。
课程概述与进度提醒
上一节我们介绍了C++的一些高级特性。本节中,我们来看看标准库中提供的实用工具,特别是与输入输出相关的功能。
首先,提醒一下课程进度。目前课程已进行到一半。今天的讲座可能是关于C++核心语言的最后一讲。我将课程分为两部分,并有意将面向对象编程、类等内容安排在后半部分,目的是让大家理解C++远不止是“带类的C”。到目前为止,我们已经学习了许多工具和功能,今天将看到我认为你需要掌握的最后一部分,并且至今未涉及任何面向对象编程。
与以往课程版本相比,我正尝试加快概念讲解的节奏,以便大家有更多时间分块学习。请务必跟上作业进度。作业4已发布,本周内将发布作业5。作业5和作业3一样,与最终项目相关。最好现在就开始为最终项目做准备,如果等到最后一个月再做,过程会很痛苦。
C++实用工具库简介
C++标准附带了一个实用工具库,其中包含多种功能,从位计数到部分函数应用等。这些库大致可分为两类:语言支持库和通用工具库。
语言支持库与语言特性紧密交互。例如:
-
type_traits: 提供类型相关的元函数。 -
size_t: 一种用于表示大小的类型。 -
动态内存管理库: 包含
shared_ptr、unique_ptr等。 -
错误处理库。
-
initializer_list: 当你使用vector并传递初始化列表时,就在使用这个库。
通用工具库则在语言之上增加了一层功能,并非与语言本身内在相关。例如:
-
chrono: 日期和时间库。 -
optional、variant、any(C++17引入)。 -
pair和tuple。 -
swap、forward、move(移动语义相关)。 -
hash。
许多工具随语言标准提供。例如,如果你安装了支持C++17的编译器,就可以免费使用这些工具。使用这些工具通常需要包含<utility>等头文件。
实用工具示例
以下是几个核心实用工具的简单示例。
swap函数
交换两个变量的值,无需自己编写交换函数。
#include <utility>
int a = 5, b = 10;
std::swap(a, b); // 现在 a=10, b=5
variant类型 (C++17)
variant可以持有多种指定类型中的一种。
#include <variant>
std::variant<int, float> v;
v = 42; // v 现在持有 int
int i = std::get<int>(v); // i = 42
// float f = std::get<float>(v); // 错误!v当前不持有float
v = 3.14f; // v 现在持有 float
float f = std::get<1>(v); // f = 3.14, 使用索引访问
any类型
any可以持有任意类型的值。
#include <any>
std::any a = 1;
int i = std::any_cast<int>(a); // i = 1
a = false;
bool b = std::any_cast<bool>(a); // b = false
optional类型
optional表示一个可能存在的值,用于处理可能返回空值的函数。
#include <optional>
std::optional<std::string> create_string(bool create) {
if (create) {
return "C++ is awesome";
} else {
return {}; // 返回空 optional
}
}
auto str = create_string(true);
if (str) { // 检查是否有值
std::cout << str.value() << std::endl; // 输出: C++ is awesome
}
auto empty_str = create_string(false);
std::cout << empty_str.value_or("default") << std::endl; // 输出: default
tuple类型
tuple用于将多个值组合成单一对象。
#include <tuple>
// 定义和创建tuple
std::tuple<double, char, std::string> student1(3.8, 'A', "John Doe");
// 使用 make_tuple
auto student2 = std::make_tuple(2.9, 'B', "Jane Smith");
// 使用结构化绑定访问 (C++17)
auto [gpa, grade, name] = student1;
chrono库
用于简单的时间测量和基准测试。
#include <chrono>
#include <iostream>
auto start = std::chrono::high_resolution_clock::now();
// ... 执行一些操作 ...
auto end = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
std::cout << "操作耗时: " << duration.count() << " 毫秒" << std::endl;
注意: 工业界迁移编译器版本可能较慢,因此你未来的工作环境可能不总是支持最新的C++标准(如C++17)。许多优秀工具在C++14和C++17中引入,而C++11已是十年前的标准。在课程中我们有选择标准的自由,但需了解这一现实。
输入输出库与文件操作
现在,让我们进入本节课的核心部分:输入输出库和文件操作。我们之前使用过std::cout、std::cerr和std::cin,它们是与标准输出、错误流和标准输入交互的。文件操作的语法与之类似,但数据流指向的是文件而非控制台。
例如,在作业3中,我们生成HTML代码并输出到标准输出,然后重定向到文件。更理想的方式是直接写入名为index.html的文件。为此,我们需要包含<fstream>库。
文件流类型
主要有三种文件流对象:
-
std::ifstream: 输入文件流,用于读取文件。 -
std::ofstream: 输出文件流,用于写入文件。 -
std::fstream: 输入/输出文件流,可读可写(较少使用)。
打开文件时可以指定模式,例如:
-
std::ios::in: 读模式。 -
std::ios::out: 写模式(默认,会覆盖原有内容)。 -
std::ios::app: 追加模式。 -
std::ios::binary: 二进制模式。
读写文本文件
写入文本文件
写入文本文件与使用std::cout语法相同。
#include <fstream>
#include <string>
int main() {
std::ofstream out_file("output.txt"); // 默认模式为 std::ios::out
if (out_file.is_open()) {
int i = 10;
double d = 3.14159;
std::string s = "Hello";
out_file << "Integer: " << i << "\n";
out_file << "Double: " << d << "\n"; // 注意精度损失
out_file << "String: " << s << std::endl;
out_file.close();
}
return 0;
}
读取结构化文本文件
如果文件格式规整,可以像使用std::cin一样读取。
#include <fstream>
#include <string>
int main() {
std::ifstream in_file("data.txt", std::ios::in);
if (in_file.is_open()) {
int i;
double a, b;
std::string s;
// 假设文件每行格式为: int double string double
while (in_file >> i >> a >> s >> b) {
// 处理读取的数据 i, a, s, b
}
in_file.close();
}
return 0;
}
读取非结构化文本文件(逐行读取)
对于格式不固定的文件,可以使用std::getline。
#include <fstream>
#include <string>
int main() {
std::ifstream in_file("unstructured.txt");
std::string line;
if (in_file.is_open()) {
while (std::getline(in_file, line)) {
// 处理每一行 line
}
in_file.close();
}
return 0;
}
读写二进制文件
二进制文件以原始字节序列存储数据,而非人类可读的文本。这种方法有多个优点:速度更快、文件尺寸更小(无需字符表示)、对于浮点数没有精度损失。缺点是文件不可读,且读写时需要确切知道数据格式。
写入二进制文件
写入二进制文件使用write成员函数,并需要reinterpret_cast进行类型转换。
#include <fstream>
#include <vector>
int main() {
// 假设有一个2x2的灰度图像,数据全为0
int rows = 2, cols = 2;
std::vector<float> image_data = {0.0f, 0.0f, 0.0f, 0.0f}; // 2x2 = 4个像素
std::ofstream out_file("image.dat", std::ios::out | std::ios::binary);
if (out_file.is_open()) {
// 先写入图像的行数和列数(文件结构的一部分)
out_file.write(reinterpret_cast<const char*>(&rows), sizeof(rows));
out_file.write(reinterpret_cast<const char*>(&cols), sizeof(cols));
// 再写入图像数据
out_file.write(reinterpret_cast<const char*>(image_data.data()),
image_data.size() * sizeof(float));
out_file.close();
}
return 0;
}
// 生成的文件大小为: 4 (int) + 4 (int) + 4*4 (4个float) = 24 字节
读取二进制文件
读取是写入的逆过程,使用read成员函数。
#include <fstream>
#include <vector>
int main() {
int rows, cols;
std::vector<float> image_data;
std::ifstream in_file("image.dat", std::ios::in | std::ios::binary);
if (in_file.is_open()) {
// 按写入顺序读取
in_file.read(reinterpret_cast<char*>(&rows), sizeof(rows));
in_file.read(reinterpret_cast<char*>(&cols), sizeof(cols));
image_data.resize(rows * cols);
in_file.read(reinterpret_cast<char*>(image_data.data()),
image_data.size() * sizeof(float));
in_file.close();
}
return 0;
}
二进制文件操作总结
-
优点: 速度快,文件小,无精度损失。
-
缺点: 语法稍显复杂,文件不可读,必须预先知道数据格式。
-
注意: 作业5将涉及二进制文件的读写,用于保存特征描述符。
文件系统库 (C++17)
<filesystem>库(在C++17中从实验状态转为标准)提供了操作路径、常规文件和目录的功能。它极大简化了文件系统操作。
基本示例
#include <iostream>
#include <filesystem>
namespace fs = std::filesystem;
int main() {
// 创建目录
fs::create_directories("sandbox/a/b");
// 创建文件
std::ofstream("sandbox/file1.txt");
std::ofstream("sandbox/file2.txt");
// 遍历目录
std::cout << "sandbox/ 目录内容:\n";
for (auto& p : fs::directory_iterator("sandbox")) {
std::cout << p.path() << '\n';
}
// 操作路径
fs::path p = "/usr/local/bin/foo.txt";
std::cout << "文件名: " << p.filename() << '\n'; // "foo.txt"
std::cout << "主干名: " << p.stem() << '\n'; // "foo"
std::cout << "扩展名: " << p.extension() << '\n'; // ".txt"
// 检查文件是否存在
if (fs::exists("sandbox/file1.txt")) {
std::cout << "文件存在\n";
}
return 0;
}
设置提示: 在一些较旧的系统或默认编译器上,
<filesystem>可能位于<experimental/filesystem>命名空间中,并且需要链接额外的库(如-lstdc++fs)。如果编译失败,请检查你的开发环境设置。
类型与类:动机与简介
现在,我们进入C++中一个非常重要的概念:类型和类。作为程序员,我们总是希望有更多的类型来表达我们的想法。
为什么类型重要?
类型安全是C++的核心思想之一。1999年,NASA的火星气候轨道器因一个简单的单位错误而丢失:一个软件模块使用公制单位(牛顿秒),而另一个模块预期的是英制单位(磅力秒)。两者都使用double类型传递数据,但double本身不携带单位信息,导致导航错误。如果使用了带有单位信息的强类型(如Meters、Feet),编译器就能在发射前捕获这个错误。
反面例子
void blink_led(int time); // time的单位是什么?毫秒?秒?
blink_led(1000); // 是1000毫秒还是1000秒?
正面例子
// 定义强类型(假设已有,或自己定义)
void blink_led(std::chrono::milliseconds time);
blink_led(std::chrono::milliseconds(1000)); // 明确是1000毫秒
// blink_led(1000); // 编译错误!需要指定单位
// blink_led(std::chrono::seconds(2)); // 编译错误!类型不匹配
ROS 1到ROS 2的演进也体现了这一点:ROS 1的API常使用原始int表示频率,而ROS 2使用了std::chrono::milliseconds等类型,使代码意图更清晰、更安全。
类与类型
在C++中,“类”和“类型”的概念在很大程度上是相通的。类就是用户定义的类型。创建新类型(类)允许我们编写更匹配问题领域概念的程序,这样的程序更容易理解、推理和修改。
一个类是一个用户定义的类型,由一组成员组成,最常见的是数据成员(变量)和成员函数(方法)。成员函数可以定义初始化(构造函数)、复制、移动和清理(析构函数)的含义。成员通过点运算符(.)访问。
一个关键特性是,可以为类定义运算符。例如,可以为PointCloud类定义+运算符,表示合并两个点云,从而使代码cloud3 = cloud1 + cloud2;非常直观。
类也是一个命名空间。public成员构成类的对外接口,private成员则包含不应对外公开的实现细节。struct是一个所有成员默认为public的类。
一个具体示例:图像类
在计算机视觉领域,一个自然的用户定义类型就是Image。
现实世界实体: 一张512x512的RGB图像。
抽象表示: 一个Image类,包含行数、列数、通道数和存储像素数据的向量。
程序中使用:
Image linux_pic("linux.png"); // 从文件创建图像对象
linux_pic.draw_to_screen(); // 绘制到屏幕
Image gray_version = linux_pic.to_grayscale(); // 转换为灰度图
可能的类定义框架
class Image {
private:
int width_;
int height_;
int num_channels_;
std::vector<uint8_t> buffer_; // 存储像素数据
public:
// 构造函数
Image(const std::string& filename);
// 成员函数
bool is_empty() const;
Image flip_horizontal() const;
Image filter(const FilterType& type) const;
void clear();
// ...
};
使用示例
Image linux_pic("linux.png");
auto flipped = linux_pic.flip_horizontal();
auto filtered = linux_pic.filter(FilterType::GAUSSIAN_BLUR);
if (filtered.is_empty()) {
std::cerr << "Could not filter your image\n";
}
通过创建Image类型,我们的代码几乎像英语句子一样清晰地表达了意图,这是使用原始数组(如vector<uint8_t>)无法轻易实现的。
总结与作业
本节课中我们一起学习了:
-
C++实用工具库: 包括
swap、variant、any、optional、tuple、chrono等,它们能简化代码并提升安全性。 -
文件输入输出: 如何使用
ifstream、ofstream读写文本和二进制文件,并比较了它们的优缺点。 -
文件系统库: C++17引入的
<filesystem>库,用于便捷地操作路径和目录。 -
类与类型的动机: 通过实际案例了解了创建强类型用户定义类型(类)的重要性,它能提高代码的安全性、可读性和可维护性,并介绍了类的基本概念。
本周任务:
-
观看推荐的5分钟“视觉词袋”互动教程。
-
完成作业3和作业4的提交。
-
准备迎接即将发布的作业5,它将涉及OpenCV、特征描述符以及二进制文件读写。
-
如有需要,观看关于特征描述符的背景视频。
从下节课开始,我们将深入探讨类的细节,这是后续课程的核心主题。如有任何问题,可以通过Discord等渠道讨论。
007:类
在本节课中,我们将要学习C++中类的核心概念。类是一种将数据和操作数据的方法封装在一起的强大工具,它允许我们创建自定义类型,从而编写出更安全、更易读、更模块化的代码。
为什么需要类?
上一节我们介绍了课程安排,本节中我们来看看为什么需要类。让我们从一个简单的例子开始,这个例子展示了不使用类时可能遇到的问题。
假设我们想处理一个RGB图像。我们可能会这样写代码:
std::vector<std::uint8_t> data;
int rows;
int cols;
void load_image(const std::string& filename, std::vector<std::uint8_t>& data, int& rows, int& cols);
bool is_image_empty(const std::vector<std::uint8_t>& data, int rows, int cols);
void save_image(const std::vector<std::uint8_t>& data, int rows, int cols, const std::string& filename);
这段代码存在几个主要问题:
-
C风格代码:虽然使用了
std::vector,但整体结构像C语言,没有充分利用C++的特性。 -
数据未封装:
data、rows、cols这三个本应属于同一实体的变量是分散的,没有逻辑关联。 -
易出错:每次调用函数都需要手动传递所有相关参数,容易因疏忽而传错(例如,将
rows和cols传反)。 -
缺乏类型安全:
data只是一个字节向量,它可以代表图像数据,也可以代表程序中的任何其他字节数据。没有机制防止你将一个非图像数据传递给save_image函数。
使用类可以解决所有这些问题。
类的定义与基本结构
现在,让我们看看如何使用类来重构上面的图像处理示例。
class Image {
public:
// 构造函数:从文件加载图像
Image(const std::string& filename);
// 默认构造函数:创建空图像
Image();
// 析构函数:销毁图像时自动保存(示例用途)
~Image();
// 成员函数:检查图像是否为空
bool empty() const;
// 成员函数:保存图像到文件
void save(const std::string& filename) const;
private:
// 数据成员
std::vector<std::uint8_t> data_;
int rows_;
int cols_;
};
重构后的使用方式如下:
Image lena("lena.png"); // 从文件创建图像
if (lena.empty()) {
std::cerr << "Error loading image\n";
return 1;
}
// ... 进行一些图像处理 ...
lena.save("lena_new.png"); // 保存图像
通过对比,我们可以看到使用类的好处:
-
封装性:数据(
data_,rows_,cols_)和操作它们的方法被捆绑在一起。 -
安全性:创建对象时通过构造函数初始化,避免了无效状态。调用方法时无需再传递分散的参数。
-
类型安全:
Image是一个明确的类型,编译器能防止你将其他类型的数据当作图像处理。 -
易于使用:代码更简洁,更符合直觉。
类的核心组成部分
访问控制:public, private, protected
类通过访问说明符来控制其成员的可见性:
-
public(公有):任何代码都可以访问。
-
private(私有):只有类自身的成员函数可以访问。这是实现封装的关键。
-
protected(保护):允许类自身和派生类访问,用于继承(下节课内容)。
默认情况下,class的成员是private的,而struct的成员是public的。
class MyClass {
int private_member; // 默认是 private
public:
int public_member;
};
MyClass obj;
obj.public_member = 10; // 正确
// obj.private_member = 5; // 错误!无法访问私有成员
构造函数与析构函数
构造函数和析构函数是特殊的成员函数,用于管理对象的生命周期。
-
构造函数:在创建对象时自动调用,用于初始化对象。函数名与类名相同,没有返回类型。
-
析构函数:在对象销毁时自动调用,用于清理资源(如释放内存、关闭文件)。函数名是在类名前加
~。
一个类可以有多个构造函数(重载),但只能有一个析构函数。
class Student {
public:
Student(); // 默认构造函数
Student(const std::string& name, int id); // 带参数的构造函数
~Student(); // 析构函数
private:
std::string name_;
int id_;
};
成员函数与const正确性
成员函数是定义在类内部的函数,用于操作类的数据成员。
const正确性是C++中的一个重要概念。如果一个成员函数不会修改对象的状态(即不修改任何数据成员),就应该将其声明为const成员函数。这允许你在const对象或通过const引用传递的对象上调用这些函数。
class Student {
public:
// const成员函数:承诺不修改对象
std::string name() const { return name_; }
// 非const成员函数:可能会修改对象
void set_name(const std::string& name) { name_ = name; }
private:
std::string name_;
};
void print_student_name(const Student& s) {
// 正确:name()是const成员函数,可以在const对象上调用
std::cout << s.name() << '\n';
// 错误:set_name不是const成员函数,不能在const对象上调用
// s.set_name("Alice");
}
数据成员与初始化
数据成员可以是任何类型,包括内置类型、其他类的对象、容器等。
在C++11及以后,推荐使用类内初始化或在构造函数的初始化列表中初始化数据成员,而不是在构造函数体内赋值。
class Point {
public:
// 使用初始化列表
Point(int x, int y) : x_(x), y_(y) {}
private:
int x_ = 0; // 类内初始化
int y_ = 0;
};
深入特性
移动语义(Move Semantics)
移动语义是C++11引入的重要特性,旨在避免不必要的深层拷贝,提升性能。要理解移动语义,需要先了解左值(Lvalue)和右值(Rvalue)。
-
左值:可以取地址、有持久状态的表达式,通常出现在赋值号左边。
-
右值:临时对象、字面量(除了字符串字面量)等,通常出现在赋值号右边。
std::move 是一个标准库函数,它将其参数转换为一个右值引用,表示资源的所有权可以被转移。它本身并不移动任何数据,只是做了一个类型转换。
std::string str1 = "Hello";
std::string str2 = std::move(str1); // 移动构造,str1的资源被“移动”到str2
// 此时str1状态是有效但未指定的(通常为空),不应再使用其值
移动语义的核心思想是转移所有权,而非复制数据。对于管理大量资源的类(如动态数组、字符串),实现移动构造函数和移动赋值运算符可以大幅提升效率。
运算符重载(Operator Overloading)
C++允许我们为自定义类型重载运算符,使其用法像内置类型一样直观。
运算符重载的本质是定义一个特殊名称的成员函数或全局函数。
class Human {
public:
Human(int kindness) : kindness_(kindness) {}
// 重载小于运算符,用于排序
bool operator<(const Human& other) const {
return kindness_ < other.kindness_;
}
// 重载输出流运算符,方便打印
friend std::ostream& operator<<(std::ostream& os, const Human& h);
private:
int kindness_;
};
std::ostream& operator<<(std::ostream& os, const Human& h) {
os << "Human with kindness " << h.kindness_;
return os;
}
// 使用
Human alice(10), bob(5);
if (alice < bob) { ... } // 调用 operator<
std::cout << alice << std::endl; // 调用 operator<<
std::vector<Human> people = {alice, bob};
std::sort(people.begin(), people.end()); // 使用 operator< 进行排序
特殊的成员函数与Rule of Five/Zero
C++类有6个特殊的成员函数,编译器会在需要时自动生成它们:
-
默认构造函数
-
析构函数
-
拷贝构造函数
-
拷贝赋值运算符
-
移动构造函数(C++11)
-
移动赋值运算符(C++11)
Rule of Zero:如果类不需要管理资源(即数据成员都是能自己管理内存的类型,如std::vector, std::string),那么你不应该声明任何特殊的成员函数,让编译器为你生成一切。这是最简单、最安全的方式。
Rule of Five:如果一个类需要显式定义析构函数、拷贝构造函数或拷贝赋值运算符中的任何一个(通常是因为它需要手动管理资源,如原始指针),那么它很可能也需要定义其他四个特殊成员函数(或者明确禁用它们)。
你可以使用 = default 让编译器生成默认实现,或使用 = delete 来禁用某个函数。
class NonCopyable {
public:
NonCopyable() = default;
~NonCopyable() = default;
// 禁用拷贝语义
NonCopyable(const NonCopyable&) = delete;
NonCopyable& operator=(const NonCopyable&) = delete;
// 允许移动语义
NonCopyable(NonCopyable&&) = default;
NonCopyable& operator=(NonCopyable&&) = default;
};
静态成员(Static Members)
静态成员属于类本身,而不是类的某个对象。所有对象共享同一份静态成员。
-
静态数据成员:在类的所有对象中只有一个实例。必须在类外定义(C++17后可在类内初始化)。
-
静态成员函数:不依赖于任何对象,可以直接通过类名调用。不能访问非静态成员(因为没有
this指针)。
class ObjectCounter {
public:
ObjectCounter() { ++count_; }
~ObjectCounter() { --count_; }
// 静态成员函数
static int get_count() { return count_; }
private:
static int count_; // 静态数据成员声明
};
int ObjectCounter::count_ = 0; // 静态数据成员定义
// 使用
ObjectCounter obj1, obj2;
std::cout << ObjectCounter::get_count() << std::endl; // 输出 2
类型别名(Type Aliases)与 using
using 关键字可以创建类型别名,提高代码可读性,特别是在模板编程中。它比C语言的 typedef 更清晰、更强大。
// 为复杂的类型创建别名
using ImageData = std::vector<std::uint8_t>;
using Callback = std::function<void(int)>;
// 在类模板中常用
template<typename T>
class Container {
public:
using value_type = T; // 标准库常用模式
using iterator = typename std::vector<T>::iterator;
};
枚举类(Enumeration Classes)
枚举类(有作用域的枚举)是C++11引入的类型安全的枚举。与传统的C风格枚举相比,它们不会隐式转换为整数,并且其枚举值位于枚举类的作用域内,避免了名称污染。
enum class FileMode { Read, Write, Append };
enum class StatusCode { OK = 200, NotFound = 404, Error = 500 };
FileMode mode = FileMode::Read;
// int i = mode; // 错误!不能隐式转换
int i = static_cast<int>(mode); // 正确,需要显式转换
<https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/bonn-cs414-mdn-cpp/img/4fc819c93120c64eeeeae988b963ac19_17.png>
if (mode == FileMode::Write) { ... } // 清晰,不会与其他枚举冲突
总结
本节课中我们一起学习了C++类的核心知识。我们了解到类是将数据和对数据的操作封装在一起、从而创建新类型的强大工具。我们探讨了:
-
使用类如何改善代码的组织性、安全性和可读性。
-
类的基本结构:访问控制、构造函数/析构函数、成员函数和数据成员。
-
const正确性的重要性,它保证了代码的健壮性。
-
C++11引入的移动语义,它通过转移所有权而非复制来提升性能。
-
运算符重载,它让我们自定义类型的使用像内置类型一样自然。
-
特殊的成员函数和 Rule of Five/Zero,这是管理资源生命周期的关键规则。
-
静态成员、类型别名和枚举类等实用特性。
掌握类是深入理解现代C++面向对象编程和泛型编程的基石。请务必通过实践练习来巩固这些概念。
008:面向对象设计 🧩
在本节课中,我们将要学习面向对象设计(OOD)的核心概念。这是C++编程的基石,理解这些思想对于构建结构良好、可复用和可维护的代码至关重要。我们将从继承和多态等基本概念开始,然后探讨如何利用这些概念实现一些实用的设计模式。
面向对象设计的思想是通用的,适用于多种编程语言。其核心在于通过类层次结构来组织代码、复用功能,并表达对象之间的关系。虽然现代C++支持多种编程范式,但面向对象设计仍然是其重要组成部分。
继承:建立“是一个”的关系
上一节我们概述了面向对象设计的重要性,本节中我们来看看其核心机制之一:继承。继承允许一个类(派生类)从另一个类(基类)获取数据成员和成员函数,从而建立一种“是一个”(is-a)的关系,并促进代码复用。
在C语言中,我们无法在代码中明确表达类型之间的关系,只能通过结构体组合和函数来模拟,这会导致代码冗长且难以维护。而在C++中,我们可以使用继承语法清晰地表达这种关系。
语法示例:
class Vehicle { // 基类
public:
virtual int getFuelAmount() = 0; // 纯虚函数
virtual int getCapacity() = 0;
virtual void applyBrakes() = 0;
};
class Bus : public Vehicle { // 派生类,Bus “是一个” Vehicle
public:
int getFuelAmount() override { /* 实现 */ }
int getCapacity() override { /* 实现 */ }
void applyBrakes() override { /* 实现 */ }
};
在上面的例子中,Bus 类公开继承自 Vehicle 类。这意味着 Bus 对象可以访问 Vehicle 的所有公有和保护成员。public 继承是最常用的类型,它会保持基类成员的访问权限不变。
以下是继承的三种主要访问控制类型:
-
public继承:基类的
public成员在派生类中仍是public,protected成员仍是protected。 -
protected继承:基类的
public和protected成员在派生类中都变为protected。 -
private继承:基类的
public和protected成员在派生类中都变为private。
对于初学者,我们主要使用 public 继承来建立清晰的“是一个”关系。
虚函数与重写:实现多态行为
理解了如何建立类之间的关系后,我们需要一种机制让派生类能够提供与基类不同但相关的行为。这就是虚函数和函数重写的作用。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐
所有评论(0)