跳转至

Bootstrap

你应当已经对 C 语言有相当的了解,并且知道如何正确编译和运行 C 代码。作为 C++ 的第一课,我们会首先弥补一些 C 到 C++ 的差异,以及 C++ 的一些基本概念。

什么是 C++

短短的一句话中出现了五个定语,不过我们最关注的是其中两个:

  1. 静态类型statically typed:类型是在编译时确定的,而不是在运行时。类型系统是 C++ 的基础,赋予了 C++ 强大的表达能力与媲美 C 的性能。
  2. 多范式multi-paradigm:支持多种编程范式,比如面向过程、面向对象、函数式、泛型等。多范式是 C++ 的灵魂,也是其与 C 最大的不同。

许多地方认为 C++ 是一种面向对象的语言,这个说法不完全准确,面向对象只是多种范式之一。这个说法十分流行的原因在于面向对象确实是几种范式中最容易掌握的一种——而且掌握了面向对象编程后,经常会给人一种“我已经完全掌握了 C++”的错觉。

另外,“C++ 是 C 的超集”的说法完全不准确——甚至有些地方说任何合法的 C 代码都是合法的 C++ 代码。也许 40 年前是这样,但时代变了,尽管 C++ 从 C 继承了很多东西,但它们早已是两种不同的语言了,它们的关系用“相互借鉴”来形容更为合适。举个例子,下面 C 代码在 C++ 中是非法的,C++ 代码在 C 中是非法的。必须承认这确实是两个比较刻意的例子,但它们足以说明 C++ 与 C 并非完全兼容。

C
int class;
C++
auto answer = 42;

再比如,非常经典的 Hello world 对比:

C
1
2
3
4
5
#include <stdio.h>

int main(void) {
  printf("Hello, world!\n");
}
C++
1
2
3
4
5
#include <iostream>

int main() {
  std::cout << "Hello, world!" << std::endl;
}

C 语言的 Hello world 非常简单,寥寥数语就能讲清楚;但 C++ 的 Hello world 能展开的地方就非常多了:命名空间、输出流、运算符重载…所以我喜欢这样来比喻:如果说 C 的复杂度是 1,那 C++ 的复杂度是发散的。所幸你并不需要完全掌握 C++。

像 C 一样,C++ 也是有不断更新的标准的,ISO/IEC 14882 是完整定义了 C++ 语法与语义的国际化标准文档。第一个标准是 1998 年发布的 C++98,接下来是 C++03,然后是 C++11、C++14、C++17、C++20、C++23——保持着大约每 3 年发布一个新标准的速度。目前正在制定的标准是 C++26。尽管版本迭代如此之快,但我们这里仍然以 C++11 为主。

编程范式

面向过程

面向过程procedure-oriented编程的形式你已经非常熟悉了,C 就是一种面向过程的语言。程序可能含有一些函数,不同的语句与不同的函数调用组合,形成一个完整的程序。考虑在 C 中实现一个线性表,部分操作可能像这样:

struct vector {
  int *data;
  size_t size;
  size_t capacity;
};

struct vector *vector_create(size_t capacity);
void vector_destroy(struct vector *v);
void vector_append(struct vector *v, int value);
void vector_remove(struct vector *v, size_t index);

它可以运行得很好,但是有一些问题:

  1. 它只能处理 int 类型的数据。如果你想支持其他类型,有两种选择:
    1. 为每种类型都写一套函数。(这个过程可以用到 C 的宏,但写出好的宏要难得多,并且完全没有可读性和可维护性。)
    2. 献祭类型安全,使用 void * 类型操作所有的数据。(写不好的话会非常难用,要写好的话你需要有相当的泛型编程经验。)
  2. 你需要手动管理 struct vector 的创建和销毁。
  3. 数据和操作是分离的,你需要手动传递 struct vector 的指针。而众所周知的,一涉及到指针就会有一堆问题。
    • 因为数据和操作是分离的,所以没办法阻止你直接修改 struct vector 的内部状态。(有的人说这可以通过将声明与定义分离来实现,确实是一种方法。)
  4. 一个最大的问题是,如果你现在需要一个链表:

    1
    2
    3
    4
    struct list {
      int value;
      struct list *next;
    };
    

    然后你就会有两套函数:一套以 vector_ 开头,一套以 list_ 开头。也许在实现上没有什么问题,但是想象一下之后使用这两个数据结构的代码…

    1
    2
    3
    struct vector *v = vector_create(10);
    vector_append(v, 42);
    vector_destroy(v);
    

    现在想象一下你需要将 struct vector 替换为 struct list,然后你还需要将所有的 vector_ 替换为 list_

面向对象

面向对象object-oriented编程可以解决上面 4 个问题中的后 3 个。面向对象编程有三个核心概念(会考):

  1. 封装encapsulation:将数据和操作封装在一起(称作),只暴露必要的接口。
    • 这可以解决问题 2 和 3。
  2. 继承inheritance:可以从现有的类派生出新的类,以增强或修改现有的功能。
    • 这可以解决问题 4。
  3. 多态polymorphism:同一个操作作用于不同的对象,可以有不同的行为。(目前可以简单将“对象”理解为不同类型的变量。)
    • 这同样可以解决问题 4。

例如,如果面向对象的实现正当,那么使用起来应该像这样:

vector v;
v.append(42);

如果你想要将其替换为链表,只需将 vector 替换为 list 即可:

list v;
v.append(42);

一个更好的封装的例子(不过是 Java 而非 C++)

Java 是一门非常传统的面向对象语言——不像 C++ 各个范式并存,所以实际上 OOP 这门课如果用 Java 来教授的话会更好。Java 定义了一个接口 List(可以认为“接口”就是定义了一系列“子类必须实现的方法”的一种基类),比如一个列表应该能够向其中添加元素(add)、删除元素(remove)、获取元素(get)、获取大小(size)等等。那么,基于这个接口,Java 提供了一系列实现,比如 ArrayList 是基于数组实现的,LinkedList 是基于链表实现的。用法类似于:

1
2
3
4
5
6
7
List<Integer> l = new ArrayList<>();
for (int i = 0; i < 10; i++) {
  l.add(i);
}
for (var i : l) {
  System.out.println(i);
}

l 是一个 ArrayList 对象,因为 ArrayListListArrayList 实现了 List 接口),所以 l 同样是 List 对象。而所有的 List 对象都可以 addremovegetsize 等等。这就是多态的体现。如果你现在需要改用链表来实现,只需将 ArrayList 替换为 LinkedList 即可——因为 LinkedList 同样是 List 的实现,它也有自己的 addremovegetsize 等等方法。我们只需要知道 lList 就可以调用 List 的方法,而不需要关心具体是哪个实现——也可以很方便地切换到其他实现。

如果暂时不理解也没关系,可以看完下面关于类与对象的部分再回头看这段。

泛型

泛型generic编程能让我们写出更加通用的代码,不需要为每种类型都写一套函数。在 C++ 中这通过模板template来实现。例如,如果实现得当,那么你可以这样写:

1
2
3
4
5
vector<int> vi;
vi.append(42);

vector<double> vd;
vd.append(2.71828);

搭配上面向对象编程,我们可以完全解决上面的 4 个问题以及更多没有在此列出的问题。

函数式

函数式functional编程是一种更加小众的编程范式,但在 C++ 中也有一些支持,尤其是 C++11 与 C++20 之后。顾名思义,函数式编程强调函数是一等公民,强调不可变性。一等公民是什么意思?例如我们都知道 C 有函数指针:

1
2
3
4
5
6
7
int compare_ints(const void *a, const void *b) {
  const int *pa = a;
  const int *pb = b;
  return (*pa > *pb) - (*pa < *pb);
}

qsort(array, n, sizeof(int), compare_ints);

通过函数指针的方式,我们能“将函数作为参数传递”,但归根结底我们还是在操作指针,其注定无法实现一些更复杂的操作。函数式编程更强调“做什么”而不是“怎么做”,例如在 C++ 中我们可以这样写,只需一行就可以完成 array 的排序操作,而不需关心排序的具体实现:

std::sort(array.begin(), array.end(), std::less<int> {});

这得益于标准模板库standard template library以及 C++11 引入的仿函数functor概念以及 std::function 类型。再比如我们有一个更复杂的需求:将每个元素平方,筛选出大于 42 的元素,然后输出最大的 4 个元素。利用 C++20 加入的范围库,可以这样写:

1
2
3
4
5
6
std::ranges::sort(array, std::greater {});
std::ranges::for_each(array
    | std::views::transform([](int n) { return n * n; })
    | std::views::filter([](int n) { return n >= 42; })
    | std::views::take(4),
    [](auto n) { std::cout << n << ' '; });

稍微思考一下对应的面向过程的写法。说实话,C++ 能发展到这样是不容易的,上面这种写法在 C++11 之前根本无法想象。然而由于其复杂性以及 C++ 的历史原因(C++ 的函数式完全建立在面向对象和泛型上),函数式在 C++ 社区中并不是很流行。很多其他语言都要比 C++ 更适合函数式(甚至 Java 的函数式都比 C++ 强大)。函数式的一些思想有很高的含金量,但我们不会去过多关注它。

类与对象

从上面的介绍中也可以看到,在 C++ 中面向对象是一种相对更为基础的编程范式,泛型和函数式都是基于面向对象的语义实现的。而面向对象第一座大山就是class对象object的关系。我们可以理解为不同的类是不同的数据类型,而对象则是这个数据类型的变量。例如:

std::string s = "Hello, world!";

按照 C 的说法,s 是一个 std::string 类型的变量,而按照 C++ / OOP 的说法,std::string 是一个类,s 是这个类的一个对象。类定义了某一族对象应该有的属性和行为,而每个对象都是这个类的一个实例instance,都有与其他对象独立的属性。这些属性我们一般称之为成员变量member variable,行为我们一般称之为成员函数member function方法method。例如之前的 vector 类:

vector v;
v.append(42);

vvector 类的一个对象,appendvector 类定义的一个方法。通过在对象 v 上调用方法 append,我们可以向 v 中添加一个元素。比如,vector 的实现可能类似于这样:

1
2
3
4
5
6
7
8
9
class vector {
private:
  int *_data;
  size_t _size;
  size_t _capacity;
public:
  void append(int value) { /* ... */ }
  // ...
};

这里的 privatepublic访问说明符access specifier,表示哪些域和方法是对外可见(公开)的,哪些不是对外可见(私有)的。例如 _size 不是公开的,所以下面的代码无法通过编译:

v._size = 42;

上面这些都是封装的体现,将数据与操作结合在一起,这使得“对象”这一概念非常适合建模现实世界中的事物,这也是面向对象的一个主要卖点(尽管现在有更多的人开始批判这个观点)。面向对象在刚刚被提出时被寄予厚望,然而今天我们知道事情并非如此简单。

Many students of the art hold out more hope for object-oriented programming than for any of the other technical fads of the day. I am among them. Mark Sherman of Dartmouth notes that we must be careful to distinguish two separate ideas that go under that name: abstract data types and hierarchical types, also called classes. [...] Each removes one more accidental difficulty from the process, [...] Nevertheless, such advances can do no more than to remove all the accidental difficulties from the expression of the design. The complexity of the design itself is essential; and such attacks make no change whatever in that. [...]

— F. Brooks. No Silver Bullet—Essence and Accident in Software Engineering (1986)

另外两个概念,继承多态之间的联系要更紧密一些。一个极其经典以至于已经烂大街的例子是,我们要建模一些二维图形,比如矩形、圆形、三角形等。图形可以求面积、周长等。在 C++ 中,我们可能会写成这样:

struct shape {
  virtual double area() const = 0;
  virtual double perimeter() const = 0;
};

struct rectangle : shape {
  const double width, height;
  double area() const override { return width * height; }
  double perimeter() const override { return 2 * (width + height); }
};

struct circle : shape {
  const double radius;
  double area() const override { return M_PI * radius * radius; }
  double perimeter() const override { return 2 * M_PI * radius; }
};

即使暂时不理解这段代码也没关系。我们可以这样使用:

1
2
3
4
5
shape get_shape(); // 假设这个函数返回一个随机的图形

shape s = get_shape();
std::cout << "Area: " << s.area() << std::endl;
std::cout << "Perimeter: " << s.perimeter() << std::endl;

这里的重点在于我们不必知道具体的图形是什么,只需要知道它是一个 shape 类型的对象,就可以调用 areaperimeter 方法。这就是多态的体现,而多态是建立在继承的基础上的。现在考虑一下如果要用面向过程的方式实现这个需求,要写成什么样子。

需要区分的是 C++ 仍然保留了一系列“基础类型”,这些类型并不是类,包括但不限于整型与浮点数(intdoublechar…)、指针等与 C 语言表示相同语义的类型。只有通过 struct / class 声明的类型才算是“类”,这些类型与面向对象相关。

内存模型

尽管 C++ 力求与 C 做出区别,利用面向对象的特性来尽可能让程序员不用去关心内存相关的细节——实际上它也做到了,你不需要了解内存模型就能写出大部分程序——但是 C++ 的内存模型仍然与 C 有很多相似之处,并且是值得了解的。你可以阅读地址、指针、数组对 C 的内存模型有一个完整的了解。在 C++ 中程序员需要手动管理内存的情况大大减少,而且用到的也是 C++ 提倡的 new / delete,而不是 C 的 malloc / free,但这些仍然属于程序员去手动管理内存分配与释放的工具。C++ 进一步用其面向对象与泛型的特性对指针和内存管理进行了封装,比如 std::unique_ptrstd::shared_ptr 等被称作“智能指针”的东西,这些能让程序员更加方便地管理内存(并且确实减少了内存泄漏的发生)。总而言之,尽管 C++ 用语言特性刻意淡化了内存模型,使程序员大部分时间不用像 C 一样去关心某个变量在内存中的大小、位置与字节排布等等,但如果想要成为 C++ 高手,要学习的东西相比 C 只多不少。

最后

C++ 真的很复杂。C11 标准文档 ISO/IEC 9899:2011 只有 702 页,C++98 标准文档 ISO/IEC 14882:1998 就有 776 页,而 C++23 标准文档 ISO/IEC 14882:2024 有 2120 页——这个数还在不断增加。C++ 标准草案的实时更新位于 GitHub,你也可以编译属于自己的最新最热的 C++ 标准草案。

我们以最后一个简单的例子结束:

#include <iostream>

int main() {
  std::string name;
  std::cout << "hi there give me your name\n";
  getline(std::cin, name); // ADL

  std::cout << "hello " << name << ", give me your age\n";
  int age;
  std::cin >> age;
  std::cout << age << "yo ain't it cool, bye" << std::endl;
}

避免全局使用 using namespace std;

课程中出现的所有代码都会在最开头有一行全局的 using namespace std;,写上这一行后就不需要加上 std:: 的前缀,但请记住这是一种编程陋习,它完全破坏了命名空间(namespace)的设计本意。我们的所有示例中都不会这样做,你自己的代码中也不该这样做。我们在后面会看到大部分情况下 using namespace std; 完全可以被单独几个 using 语句替代。若实在需要使用,请将其限制在尽可能小的作用域内。

上面这个程序的行为应该是不言自明的:

  1. iostream 是输入输出需要的头文件。
  2. 定义一个 std::string 类型的变量(或者说定义一个 std::string 对象)name
  3. 使用 std::cout << "..." 的语法输出一个字符串。这是 C++ 的流输出语法,左移运算符 << 在这里有一个新的名字:流插入运算符,这是通过运算符重载实现的。
  4. 调用 getline(std::cin, name) 函数从键盘获取一行输入。注意到我们不需要取地址,这里涉及到引用。这里也不需要 std:: 前缀,原因是实参依赖查找argument-dependent lookup,这是相当小众的话题。
  5. 然后再次用 std::cout 输出一系列字符串,包括我们刚刚输入的 name 变量。我们看到流插入运算符可以像这样“连续”使用,这其中涉及到函数重载,我们之后会看到如何实现。
  6. 定义一个 int 类型的变量 age
  7. 使用 std::cin >> age 从键盘输入一个数字。右移运算符 >> 在这里同样有新的名字:流提取运算符,同样由运算符重载实现。
  8. 最后使用 std::cout 输出值和字符串。std::endl 是一个特殊的东西,输出它相当于输出一个换行,目前我们可以认为它和 "\n" 作用是一样的,但出于性能和代码可读性的考虑,直接写 \n 更好——除非你需要代码能够最大限度地跨平台。

cincout 怎么念?

cincout 前面的“c”代表的是 character,即 character input stream 与 character output stream,所以正确的读法应该是“see-in”与“see-out”而不是“sin”和“kout”。C++ 中还有cerrcharacter error streamwcinwide character input streamwcoutwide character output stream 等等,“w”意味着它们处理的是宽字符,这是与国际化i18n本地化l10n相关的概念。

我们可以通过两行 using 语句消除掉上面的大部分 std:: 前缀。

#include <iostream>

using std::cin;
using std::cout;

int main() {
  std::string name;
  cout << "hi there give me your name\n";
  getline(cin, name);

  cout << "hello " << name << ", give me your age\n";
  int age;
  cin >> age;
  cout << age << "yo ain't it cool, bye\n";
}

这样我们就得到了一个更简洁的程序。应该做的是只 using 必要的符号,而不是整个 namespace动态链接、系统调用与 C 的 inline 这篇 blog 同时也是一篇非常好的关于“命名空间污染”的例子——假设 C 语言也有命名空间,并且我们没有破坏命名空间的意义,那么就不会有文中提到的问题了。

评论