瘋狂程設 Hello World

C++ 语言为了保持与 C 语言的兼容性,在语法层面上曾经尽力保持 C-like。但随着越来越多内容被加入到 C++ 中,这一要求也越来越难以实现。最终,C++11 标准的发布彻底改变了 C++ 语言的面貌,其改变之大甚至让 C++11 看起来像一门全新的语言。因此,多把 C++11 后的 C++ 称为“Modern C++”。

在之前的 OI 之路上,由于 NOIP 不支持 C++11,因此学的基本是 C++98 的语法。在大一上的程设课里,出于惯性,我仍然基本上是在写 C++98。

开始学 C++20,一方面是因为 C++98 确实是过于老旧了(已经 20 多年前了!),另一方面是在翻阅下学期 OOP 课程的课程信息时,发现教材是《Beginning C++17》(I. Horton, P. van Weert),搜索发现这本书去年更新到了 C++20 版本。盘算了一下,上课教的是 C++17,而 C++20 与 C++17 版本也较相近,直接学 C++20 不会出现太多兼容性问题,遂打算直接从 C++20 开始学。

你好,世界!

// Hello from C++20

import <iostream>;
using std::cout, std::endl;

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

    int answer {42};
    
    cout << "The answer to life, the universe, and everything is "
         << answer << endl;
}

如何编译?

这里只介绍 macOS 环境。由于 macOS 预装的 clang 版本尚不支持模块机制,我们需要使用更新的开发版本。如果有 Homebrew,直接 brew install llvm 即可安装开发版本的 llvm。接下来使用下列语句即可编译:

/opt/homebrew/opt/llvm/bin/clang++ <filename> -std=c++20 -fmodules

指定 -fmodules 是由于模块机制的实现尚不完善,需要手动开启。

一些新东西

作为第一个 C++20 程序,它看起来还是相当地不一样的。

首先注意到的就是 import 语句。C++20 引入了模块 (Module) 机制,彻底(至少是在理论上)宣告了使用头文件+实现文件组织多文件代码历史的终结。

然而,目前各大编译器对模块机制的支持都不甚完善。为了让代码至少看上去在使用这个新特性,我们使用一种称为模块映射 (Module Mapping)的机制。这种机制可以将头文件映射为模块。目前我还没有深究模块机制的具体内容,因此这里只是这样用来让代码能跑起来而已。Clang为 libc++ 标准库提供了模块映射,因此只需 import <header>,其中 header 为标准库的头文件名,即可导入对应的头文件映射成的模块。

Clang 也定义了一个模块 std,其包含了整个 C++ 标准库。为了方便兼容更早的版本,我在学习过程中将不会直接导入 std 模块,而是导入各头文件映射的模块。

接下来,注意到一个长得很奇怪的变量定义语句。这是 C++11 引入的通用初始化 (Universal Initialization,或称 Brace-init) 机制,旨在提供一种统一、安全的初始化方式。具体来说,在 C++11 前,有许多方式都可以初始化一个变量:

int n = 0;
Point p = {0, 1};
double f(3.14);
char s1[] = {'H', 'e', 'l' ,'l', 'o', '\0'};

而在 C++11 后,建议使用的统一初始化方式为在变量名后的大括号内写上变量的初始内容。比如:

using std::string, std::vector;
int a {0};
string s {"Hello!"};
string s2 {s}; // 复制构造
int b[] {3, 4, 5};
// 直接指定 vector 内容;这在以前是无法实现的
vector<string> v {"Alpha", "Beta", "Gamma"};

同时,通用初始化还加强了安全检查。例如:

int f = 1145141919810;

这个语句最多只会造成编译器给出警告(字面值超出了 int 的范围导致存储的值改变了)。

int f {1145141919810};

但这个语句一定会造成编译错误,因为通用初始化要求初始化提供的参数能不经改变地被指定类型存储。

你可能会觉得这种方式不如以前直接用等号来得自然,尤其是对于基础类型。但是只要你将“初始化”和“赋值”看成两个不同的过程,你就能更好地理解为什么要这么做了。

比如,考虑这样的代码:

void f() {
    static int x = 5;
    cout << ++x << endl;
}

这里的等号似乎给你一种暗示,它和下面的写法等价:

void f() {
    static int x;
    x = 5;
    cout << ++x << endl;
}

然而,第一种写法中,每次调用时 x 的值都在改变,而第二种写法中,输出的永远是 6。这是因为第一种写法的等号实际上是“初始化”的语义,它只会在 x 定义时执行一次,而非第二种写法的“赋值”语义。因此,将等号的“初始化”语义拆分出来提供一个单独的语法,实际上是更好的做法。

更新:最近发现了使用通用初始化的另一个理由,那就是其可以避免恼人分析 (Most Vexing Parse),下面的例子都来源于这个维基百科链接:

void f(double my_dbl) {
    int i(int(my_dbl));
}

这之中 i 所在的那一行实际上被编译器解释为声明一个名为 i 的函数,其返回类型为 int,而有一个名叫 my_dbl 的参数。这是 C++ 允许在参数名前后加额外的括号导致的,但总归来说这仍是一个十分反直觉的事情。