跳转到内容

跨平台与跨语言开发

C++ 程序可以被编译后运行在多种不同的计算平台上,而这门语言本身也经过严格标准化,以确保“为某个平台写 C++”与“为另一个平台写 C++”在整体体验上尽可能相似。然而,即便语言已经被标准化,真正开始编写专业质量的 C++ 程序时,平台差异最终仍然一定会出现。哪怕你的开发工作只面向某一个特定平台,编译器之间的一些小差异,也足以制造巨大麻烦。本章讨论的,正是在一个拥有多平台、多编程语言的现实世界中进行编程时,必须面对的那部分复杂性。

本章前半部分,会总览 C++ 程序员在平台相关开发中经常遇到的问题。这里的 platform,指的是构成你的开发环境和/或运行环境的全部细节组合。例如,你的平台可能是:运行在 Windows 11 上、使用 Intel Core i7 处理器的 Microsoft Visual C++ 2022 编译器。又或者,你的平台可能是:运行在 Linux 上、使用 PowerPC 处理器的 GCC 13.2 编译器。这两个平台都能编译并运行 C++ 程序,但它们之间显然也存在大量重要差异。

本章后半部分,则会讨论 C++ 如何与其他编程语言协作。C++ 是一门通用语言,但它并不总是最适合所有任务。通过多种机制,你完全可以把 C++ 与其他更适合特定场景的语言结合起来使用。

C++ 之所以会面临平台问题,有几个原因。首先,C++ 是一门高层语言,而标准并不会规定所有底层细节。例如,对象在内存中的布局,标准就是未指定的,并交由编译器决定1。不同编译器因此可能会对同一个对象使用不同的内存布局。

C++ 面对的另一个挑战在于:语言本身和 Standard Library 虽然已有标准,但却并不存在唯一的“标准实现”。不同 C++ 编译器厂商和库实现厂商对标准的不同理解,都会在代码从一个系统迁移到另一个系统时带来麻烦。

最后,C++ 在“哪些能力属于标准的一部分”这件事上也相当克制。尽管已经有 Standard Library,但专业程序常常仍然需要语言或 Standard Library 并未提供的功能。这些功能通常来自第三方库或平台本身,而它们往往差异巨大。

Architecture 这个词通常指的是程序运行所在的 processor,或者某一整类 processor。运行 Windows 或 Linux 的标准 PC,一般属于 x86 或 x64 architecture;而较早版本的 macOS,则常见于 PowerPC architecture。作为 high-level language,C++ 会尽量把这些 architecture 差异屏蔽掉。例如,某个 Core i7 processor 可能有一条指令就能完成某项工作,而在 PowerPC 上同样功能却要用六条指令才能完成。作为 C++ 程序员,你既不需要知道这项差异是什么,也不一定需要知道它的存在。使用 high-level language 的一个巨大好处,本来就是 compiler 会负责把你的代码翻译成处理器自己的原生 assembly code。

不过,processor 差异有时仍然会“浮”到 C++ 代码这一层。下面首先讨论的是 integer size——如果你要写 cross-platform code,这一点尤其重要。后面几项你未必会经常遇到,除非你正在做非常底层的工作;但即便如此,你仍应知道它们确实存在。

C++ 标准并不会定义 integer type 的精确大小。标准在 [basic.fundamental] 小节中只说了如下内容:

标准有五种有符号整数类型: signed char short int int long int* 和 long long int在这个列表中,每一种类型所提供的存储能力,至少不小于它前面的那一种。

标准确实还会给出一些关于这些类型大小的额外提示,但从不会给出一个确定的、精确的字节数。实际大小完全取决于 compiler。因此,如果你真的想写“严格意义上的 cross-platform code”,就不能把希望寄托在这些基本 integer type 上。本章最后有一道练习,会让你亲自去调查这一点。

除了这些 core language integer type 之外,C++ Standard Library 还在 <cstdint>std 命名空间中提供了一组大小定义更清晰的类型(其中有些类型是 optional 的)。概览如下:

类型说明
int8_t, int16_t, int32_t, int64_t大小精确为 8、16、32 或 64 bit 的有符号整数。在某些 exotic platform 上,其中某些类型可能缺失。例如,如果你的 exotic 平台根本没有 8-bit type,那么 int8_t 就不会存在。
int_fast8_t, int_fast16_t, int_fast32_t, int_fast64_t大小至少为 8、16、32 或 64 bit 的有符号整数。对这些类型,compiler 应当选择“满足该要求的、自己所拥有的最快整数类型”。
int_least8_t, int_least16_t, int_least32_t, int_least64_t大小至少为 8、16、32 或 64 bit 的有符号整数——并且是可用的最小那一种。这些类型保证总是存在,即使在 exotic platform 上也是如此。例如,在一个假想的“byte 大小为 24 bit”的平台中,int_least8_tint_least16_t 都会别名到它的 24-bit char 类型。
intmax_tcompiler 所支持的“最大尺寸”整数类型。
intptr_t一个足够大、能够装下 pointer 的整数类型。它同样是 optional,但几乎所有 compiler 都支持它。

同时也存在对应的 unsigned 版本,例如 uint8_tuint_fast8_t 等等。

你大概早就知道:把一个为 Core i7 机器编译出来的程序,直接拿去 PowerPC-based Mac 上运行,是不可能工作的。这两个平台在 binary compatibility 上并不兼容,因为它们的 processor 并不支持同一套 instruction。当你编译一个 C++ 程序时,source code 会被翻译成计算机真正执行的 binary instruction;而这种 binary format,是由平台决定的,而不是由 C++ 语言决定的。

要支持那些 binary 不兼容的平台,一种办法是:在每个目标平台上,分别使用该平台上的 compiler 去构建各自版本。

另一种办法是 cross-compilation。如果你的开发环境是平台 X,但程序需要运行在平台 Y 和 Z 上,那么你可以在平台 X 上使用 cross-compiler,为 Y 和 Z 生成目标 binary code。

你还可以让程序变成 open source。当你把 source code 提供给最终用户时,他们就能直接在自己的机器上原生编译程序,并得到与自己平台 binary format 匹配的版本。正如第 4 章“设计专业级 C++ 程序”中所讨论的那样,open-source software 这些年越来越流行;其中一个重要原因就是:它使程序员能够协作开发软件,并不断扩大软件可运行的平台范围。

当有人说某个 architecture 是 64-bit 时,他通常指的是 address 的大小是 64 bit,也就是 8 byte。一般来说,address size 越大,系统能够处理的内存越多,并且在运行复杂程序时也可能更快。

由于 pointer 本质上就是 memory address,因此它们天然与 address size 绑定在一起。有些程序员被误导成以为 pointer 永远是 8 byte,但这是错误的。比如,看看下面这段代码,它会输出一个 pointer 的大小:

int *ptr { nullptr };
println("ptr size is {} bytes", sizeof(ptr));

如果这段程序被编译并运行在 32-bit x86 系统上,输出会是:

ptr size is 4 bytes

如果它运行在 x86-64 系统上,输出则会是:

ptr size is 8 bytes

从程序员角度看,这件事真正的含义是:你绝不能把 pointer 简单地等同于 4 byte 或 8 byte。更普遍地说,你必须时刻意识到:大多数“尺寸”并不是由 C++ 标准硬性规定的。标准只会说 long 至少不短于 int,而 int 至少不短于 short,诸如此类。

另外,pointer 的大小也并不一定与 integer 的大小相同。例如,在某个 64-bit 平台上,pointer 可能是 64 bit,而 integer 却只有 32 bit。把一个 64-bit pointer 强行 cast 成 32-bit integer,就会直接丢掉 32 个至关重要的 bit!标准确实在 <cstdint> 中定义了 std::uintptr_t 类型别名,它表示“至少足够装下一个 pointer 的整数类型”。虽然标准把它列为 optional,但几乎所有 compiler 都支持。

永远不要假设 pointer 一定是 32 bit 或 64 bit。也永远不要把 pointer cast 成整数,除非你使用的是 std::uintptr_t

现代计算机都用二进制来表示数字,但同一个数字在两个平台上的具体内存表示却未必完全一样。听起来似乎有点矛盾,但原因其实只是:对于数字在内存中的表示方式,存在两种都说得通的排列方案。

如今多数计算机都是 byte-addressable 的,也就是说,内存中的每一个 byte 都有自己唯一的地址。而 C++ 中的 numeric type 往往占据多个 byte。例如,一个 short 可能占 2 byte。设想程序中有下面这句代码:

short myShort { 513 };

在二进制中,513 是 0000 0010 0000 0001。这串数字一共包含 16 个 0 和 1,也就是 16 bit。由于一个 byte 等于 8 bit,因此计算机需要 2 个 byte 才能存下这个数。又因为每个 memory address 只能装下 1 个 byte,所以计算机必须把这个数拆成多个 byte。假设 short 是 2 byte,那么这个数就会被拆成两部分:高位部分写入 high-order byte,低位部分写入 low-order byte。在这个例子中,高位 byte 是 0000 0010,低位 byte 是 0000 0001

把数字拆成 memory-sized 的块之后,剩下唯一的问题就是:它们在内存中该按什么顺序摆放?既然需要两个 byte,那么这两个 byte 的顺序其实并没有被唯一决定,而是依赖于目标系统的 architecture。

一种方案,是把 high-order byte 先放进内存,再放 low-order byte。这种策略叫 big-endian ordering,因为数字“大的一端”先出现。PowerPC 和 SPARC processor 就使用 big-endian。另一些 processor(例如 x86)则会采取完全相反的顺序:先放 low-order byte。这种方式叫 little-endian ordering,因为数字“小的一端”先出现。某个 architecture 选择哪种方案,通常取决于 backward compatibility。顺带一提,big-endianlittle-endian 这两个词其实比现代计算机还早了好几个世纪。它们出自 Jonathan Swift 十八世纪的小说 Gulliver’s Travels,用来形容“鸡蛋究竟该从大头敲开还是从小头敲开”的两派人。

无论某个 architecture 使用的是哪种 endianness,你的程序在处理普通数字时,通常都不需要关注这一点。真正需要考虑 byte ordering 的情况,往往发生在数据跨 architecture 传输时。例如,如果你要通过网络发送二进制数据,就可能需要关心对方系统的 endianness。一种常见方案,是统一使用标准网络字节序,也就是始终使用 big-endian。于是,在发送数据前,你先把它转换为 big-endian;而在接收数据后,再把它从 big-endian 转回本机的 native endianness。

类似地,如果你把二进制数据写入文件,那么一旦这个文件将来要在相反字节序的系统上被打开,你也必须考虑相应影响。

Standard Library 在 <bit> 中提供了 std::endian enumeration,可用于判断当前系统到底是 big-endian 还是 little-endian。下面这段代码会输出你当前系统的 native byte ordering:

switch (endian::native)
{
case endian::little:
println("Native ordering is little-endian.");
break;
case endian::big:
println("Native ordering is big-endian.");
break;
}

一个 C++ compiler 是由人编写出来的,而这些人必须尽量忠实地实现 C++ 标准。不幸的是,C++ 标准本身超过 2000 页,而且内容混杂着 prose、pseudocode 和 example。面对这样一份规范,两位不同的人在实现 compiler 时,不可能保证对每一段标准文本都得出完全一致的理解,也不可能保证自己不遗漏任何 edge case。结果就是:compiler 也会有 bug。

想发现或避免 compiler bug,并不存在什么简单公式。你所能做的最好事情,是及时跟进 compiler update,并订阅该 compiler 的 mailing list 或 newsgroup。一旦你怀疑自己遇到了 compiler bug,针对报错信息或异常现象做一次简单 web 搜索,往往就能找到现成 workaround 或 patch。

compiler 最容易出问题的区域之一,就是那些刚被新标准引入的语言特性。当然,近几年主流 compiler vendor 在支持最新标准特性方面,速度已经比过去快了很多。

另一个你应当意识到的问题,是:compiler 往往会暗中加入一些自己的语言扩展,却并不会太高调地提醒程序员。例如,variable-length stack-based array(VLA)并不是 C++ 标准的一部分,但它却是 C 语言的一部分。有些 compiler 同时支持 C 和 C++ 标准,因此会允许你在 C++ 代码中使用 VLA。g++ 就是这样一个例子。下面这段代码在 g++ 下会按预期编译并运行:

int i { 4 };
char myStackArray[i]; // Not a standard language feature!

有些 compiler extension 当然可能很好用,但如果你将来有切换 compiler 的可能,那么最好先看看当前 compiler 是否支持某种 strict mode,让它避免使用这些 extension。例如,如果在 g++ 下给刚才这段代码再加上 -pedantic,就会得到类似如下的 warning:

warning: ISO C++ forbids variable length array 'myStackArray' [-Wvla]

C++ 规范允许通过 #pragma 机制存在某种“由 compiler 自行定义”的语言扩展。#pragma 是一种 preprocessor directive,它的具体行为完全由实现决定。如果某个实现不理解这个 directive,它就会直接忽略它。例如,有些 compiler 就允许程序员通过 #pragma 暂时关闭 warning。

大概率来说,你的 compiler 自带了一份 C++ Standard Library 实现。不过,由于 Standard Library 本身就是用 C++ 写的,因此你并不一定非要使用 compiler 捆绑的那一份实现。你完全可以换一份 third-party Standard Library,例如某种更偏重性能优化的实现;甚至,从理论上说,你还可以自己写一份。

当然,Standard Library 的实现者也面临着和 compiler 作者一样的问题:标准文本本身需要被解释。此外,不同实现还会做出不同 tradeoff,而这些 tradeoff 并不一定符合你的需求。例如,某一份实现可能把重点放在速度上,而另一份实现则更偏向于在运行期更容易捕捉误用。

因此,当你在使用某个 Standard Library 实现,或任何 third-party library 时,都必须认真考虑设计者在实现过程中所做的 tradeoff。第 4 章已经更详细地讨论过使用 library 时会遇到的相关问题。

正如前面几节所说,不同 compiler 与 Standard Library 实现的行为并不完全一致。在做 cross-platform development 时,这一点你必须始终放在心上。更具体地说,作为开发者,你平时手头通常只会使用一条固定 toolchain,也就是单一 compiler 加单一 Standard Library 实现。你几乎不可能亲自用产品要支持的全部 toolchain,去逐一验证自己的每一处代码改动。这件事的答案是:continuous integrationautomated testing

你应建立一套 continuous integration 环境,让所有代码变更都能自动在你需要支持的全部 toolchain 上完成构建。一旦某条 toolchain 上构建失败,那个引入问题的开发者就应当立即自动收到通知。

另外,并非所有开发环境都使用相同的 project file 来描述 source file、compiler switch 等等。如果你需要支持多个环境,那么手动维护每套环境各自独立的 project file,几乎一定会演变成维护噩梦。更好的办法,是只维护一种 project file 或一套统一 build script,然后由它们自动转换成特定 toolchain 所需的实际 project file 或 build script。一个典型工具,就是 CMake。source file 集合、compiler switch、需要链接的 library 等信息,都可以写在 CMake 配置文件中,这些配置文件本身还支持脚本能力。随后,CMake 就能自动生成具体项目文件,例如在 Windows 上供 Visual C++ 使用的项目文件,或在 macOS 上供 Xcode 使用的项目文件。

当 continuous integration 环境产出一个 build 后,还应自动为这个 build 启动 automated testing。这一步会针对产出的 executable 跑一整套测试脚本,以验证程序行为是否正确。如果此时发现问题,也应自动通知对应开发者。

C++ 是一门出色的 general-purpose language。得益于 Standard Library,这门语言本身已经提供了非常多能力,以至于一个普通程序员完全可以只依靠其内建功能,愉快地用 C++ 写好多年程序。然而,专业级软件通常还会依赖一些“C++ 语言本身和 C++ Standard Library 都没有提供”的功能。下面列出一些典型例子,它们往往需要来自平台自身或 third-party library,而不是来自语言或 Standard Library:

  • Graphical user interfaces: 现在绝大多数商业软件都运行在带图形界面的操作系统上,里面有 clickable button、可移动窗口、层级菜单等等。和 C 一样,C++ 本身对这些元素一无所知。若想在 C++ 中编写图形应用,你可以使用平台专属 library 来绘制窗口、接收鼠标输入并执行各种图形任务。更好的选择,通常是采用一个 third-party library,例如 wxWidgets(wxwidgets.org)、Qt(qt.io)、Uno(platform.uno)等等,它们会为构建图形应用提供抽象层,并且通常还支持多个 target platform。
  • Networking: Internet 已经彻底改变了应用的写法。如今,多数应用都会通过网络检查更新,游戏也普遍支持联网多人模式。C++ 目前仍未在语言层面提供 networking 机制,不过已经有若干现成的 networking library 可用。最常见的 networking 抽象叫 socket。大多数平台上都能找到 socket library,它以一种相对简单的 procedural 风格提供网络数据传输能力。有些平台还提供基于 stream 的网络系统,让它看起来像 C++ I/O stream。除此之外,也有 third-party networking library 可用,它们提供一层更高的抽象,并往往支持多个 target platform。如果必须选 networking library,那么一个支持 IPv-independent 的方案,会比只支持 IPv4 的实现更有前途,因为 IPv6 早已在现实世界中部署开来。
  • OS events and application interaction: 在纯 C++ 代码中,你与外部操作系统和其他应用之间的交互其实非常有限。没有平台扩展时,标准 C++ 程序几乎只有命令行参数这一个入口。比如,像 copy / paste 这样依赖操作系统 clipboard 的功能,在 C++ 里并没有直接支持。对此,你既可以使用平台提供的 library,也可以使用那些支持多平台的 third-party library。wxWidgets 和 Qt 就是这类库的典型例子:它们都把 clipboard 操作抽象起来,并可运行于多个平台。
  • Low-level files: 第 13 章“揭开 C++ I/O 的面纱”已经介绍了标准 C++ I/O,包括文件读写。但许多操作系统还有自己的一套文件 API,而这些 API 通常与 C++ 标准文件类并不兼容。这些 library 往往还会额外提供某些 OS 特有的文件工具,例如获取当前用户 home directory 的机制。
  • Threads: 在 C++03 及之前版本中,单个程序中的并发执行线程并没有被标准直接支持。从 C++11 开始,Standard Library 已经包含线程支持库,详见第 27 章“使用 C++ 进行多线程编程”;而 C++17 又加入了并行算法,见第 20 章“掌握标准库算法”。如果你需要的 threading 功能比 Standard Library 提供的更多,那就需要使用 third-party library,例如 Intel 的 Threading Building Blocks(TBB),或 STE||AR Group 的 High Performance ParalleX(HPX)library。

对于某些类型的程序来说,C++ 也许并不是最合适的工具。举例来说,如果你的 Unix 程序需要与 shell environment 深度交互,那么写 shell script 也许比写 C++ 程序更合适;如果程序需要进行大量文本处理,你可能会觉得 Perl 才是更顺手的语言;如果程序高度依赖数据库交互,那么 C# 或 Java 也许会是更好的选择。再比如,C# 配合 WPF framework 或 Uno platform,往往会比 C++ 更适合编写现代 graphical user interface 应用,等等。即便如此,当你决定使用另一门语言时,你有时仍然会希望调用 C++ 代码,例如把某些 computation-intensive 的工作交给 C++ 完成;反过来,你也可能想从 C++ 调用非 C++ 代码。幸运的是,你可以利用一些技术把两边的优势结合起来——既利用另一门语言的专长,又保留 C++ 的能力与灵活性。

正如你已经知道的,C++ 几乎可以看作 C 的超集。大多数 C 代码都能比较容易地迁移到 C++,但仍有一些事情需要留意。少数 C 特性并不被 C++ 支持;例如,C 支持 variable-length array(VLA),而 C++ 不支持。另一类需要注意的点,是 reserved word 的使用。举例来说,在 C 里,class 这个词并没有特殊含义,因此它可以被用作变量名,如下所示:

int class = 1; // Compiles in C, not C++
printf("class is %d\n", class);

这段程序在 C 中可以正常编译运行,但若当作 C++ 代码来编译就会报错。当你把一个程序从 C 翻译、也就是 port 到 C++ 时,碰到的往往就是这种问题。好在修复通常都很简单。以这个例子来说,只要把 class 变量改名成例如 classID,代码就能重新编译通过。

另一方面,每个 C++ compiler 同时也都是一个 C compiler。并没有理由把“C 当成 C++ 来编译”;你完全可以直接把“C 当成 C 来编译”。如果你的项目同时包含 C 和 C++,那么只需把编译出来的 C object file 和 C++ object file 一起链接进最终 executable 即可。这种“把 C 代码并入 C++ 程序”的便利性,在你碰到一个用 C 编写的实用 library 或 legacy code 时,会非常有价值。正如本书已经反复展示过的那样,function 和 class 完全可以协同工作:class member function 可以调用 function,而 function 也可以操作 object。

把 C 和 C++ 混在一起时,一个危险是:程序可能会逐渐失去自己原本的 object-oriented 特征。举个例子,如果你的 object-oriented web browser 底层却依赖一套 procedural networking library,那么整个程序实际上就在混用这两种 paradigm。考虑到这种应用中 networking 相关任务的重要性和占比,你也许应该在那套 procedural library 外面包一层 object-oriented wrapper。一种很适合这种场景的典型 design pattern,叫做 façade

例如,假设你正在用 C++ 编写一个 web browser,但你所依赖的 networking library 提供的是一套 C 风格 API,且它暴露出的函数声明如下。为了简洁起见,这里略去了 HostHandleConnectionHandle 数据结构的具体定义。

// networklib.h
#include "HostHandle.h"
#include "ConnectionHandle.h"
// 根据给定 Internet 主机的主机名(即 www.host.com)
// 获取其主机记录。
HostHandle* lookupHostByName(const char* hostName);
// 释放给定的 HostHandle。
void freeHostHandle(HostHandle* host);
// 连接到给定主机。
ConnectionHandle* connectToHost(HostHandle* host);
// 关闭给定连接。
void closeConnection(ConnectionHandle* connection);
// 从已打开的连接中获取网页。
char* retrieveWebPage(ConnectionHandle* connection, const char* page);
// 释放 page 所指向的内存。
void freeWebPage(char* page);

networklib.h 这套接口足够简单直接,但它显然不是 object-oriented 的;对一个 C++ 程序员来说,直接用这样的 library,难免会觉得有点 icky——借用一个技术术语来说。它没有围绕某个 cohesive class 来组织。当然,library 作者本可以把接口设计得更好,但作为 library 的使用者,你只能接受自己拿到手的东西。写 wrapper,正是你重新塑造这套接口的机会。

在为这个 library 构建 object-oriented wrapper 之前,先看看它原样使用时会是什么样子,有助于理解它的真实使用方式。下面这段程序使用 networklib library 来获取 www.example.com/index.html 这个网页:

HostHandle* myHost { lookupHostByName("www.example.com") };
ConnectionHandle* myConnection { connectToHost(myHost) };
char* result { retrieveWebPage(myConnection, "/index.html") };
println("结果如下:\n{}", result);
freeWebPage(result); result = nullptr;
closeConnection(myConnection); myConnection = nullptr;
freeHostHandle(myHost); myHost = nullptr;

要让这套 library 更具 object-oriented 味道,一种可行做法是提供一个统一抽象,去捕捉“查找 host、连接 host、获取网页”这几个动作背后的共性。一个优秀的 object-oriented wrapper,会把 HostHandleConnectionHandle 这些不必要暴露的复杂细节隐藏起来。

这个例子遵循了第 5 章“使用类进行设计”和第 6 章“面向复用设计”中介绍的设计原则:新类应当捕捉这套 library 最常见的使用场景。前面的例子已经展示了最常见模式:先查 host,再建立连接,最后取回页面。并且,后续通常还会继续从同一个 host 获取更多页面,因此一个好的设计也应当照顾这种使用方式。

首先,HostRecord 类对“查找 host”这件事做了封装。它是一个 RAII class。它的 constructor 通过 lookupHostByName() 完成查找;而其中的 unique_ptr data member 使用 custom deleter,在对象销毁时自动调用 freeHostHandle() 来释放获取到的 HostHandle。关于 unique_ptr 搭配 custom deleter 的用法,可参考第 7 章“内存管理”。代码如下:

export class HostRecord final
{
public:
// 查找给定主机的主机记录。
explicit HostRecord(const std::string& host)
: m_hostHandle { lookupHostByName(host.c_str()), freeHostHandle }
{ }
// 返回底层句柄。
HostHandle* get() const noexcept { return m_hostHandle.get(); }
private:
std::unique_ptr<HostHandle, decltype(&freeHostHandle)> m_hostHandle;
};

接下来,实现一个使用 HostRecordWebHost 类。WebHost 会建立到指定 host 的连接,并提供获取网页的能力。它同样是一个 RAII class:当 WebHost object 被销毁时,与 host 的连接会自动关闭。getPage() member function 会调用 retrieveWebPage(),并立刻把返回结果放进一个带 custom deleter freeWebPage()unique_ptr 中。代码如下:

export class WebHost final
{
public:
// 连接到给定主机。
explicit WebHost(const std::string& host);
// 从该主机获取指定页面。
std::string getPage(const std::string& page);
private:
std::unique_ptr<ConnectionHandle, decltype(&closeConnection)> m_connection
{ nullptr, closeConnection };
};
WebHost::WebHost(const std::string& host)
{
HostRecord hostRecord { host };
if (hostRecord.get()) {
m_connection = { connectToHost(hostRecord.get()), closeConnection };
}
}
std::string WebHost::getPage(const std::string& page)
{
std::string resultAsString;
if (m_connection) {
std::unique_ptr<char[], decltype(&freeWebPage)> result {
retrieveWebPage(m_connection.get(), page.c_str()),
freeWebPage };
resultAsString = result.get();
}
return resultAsString;
}

WebHost 类有效封装了 host 的行为,在不暴露无谓调用和底层数据结构的前提下,提供了真正有用的功能。WebHost 的实现大量使用了 networklib library,但这些实现细节对使用者完全不可见。WebHost 的 constructor 会为指定 host 创建一个 HostRecord RAII object,再用它建立连接,并把连接保存到 m_connection data member 中,供之后复用。constructor 结束时,HostRecord RAII object 会自动销毁;而 WebHost 的 destructor 则会销毁 m_connection,从而关闭连接。getPage() member function 使用 retrieveWebPage() 获取网页,把结果转成 std::string,调用 freeWebPage() 释放内存,并最终以 std::string 的形式返回页面内容。

对于客户端程序员来说,WebHost 让最常见的使用场景变得非常简单。示例如下:

WebHost myHost { "www.example.com" };
string result { myHost.getPage("/index.html") };
println("结果如下:\n{}", result);

如你所见,WebHost 类为这套 C 风格 library 提供了一层 object-oriented wrapper。通过引入抽象,你既可以在不影响 client code 的前提下替换底层实现,也可以额外加入更多能力,例如连接引用计数、按时间自动关闭连接以遵守 HTTP specification、在下一次 getPage() 调用时自动重新打开连接,等等。

你会在本章最后的一道练习里,再进一步练习编写 wrapper。

前面的例子默认你能拿到原始 C source code。之所以能这么做,是因为大多数 C 代码都能被 C++ compiler 成功编译。但如果你手上只有已经编译好的 C 代码,例如一个 library,你仍然可以在 C++ 程序中使用它,只不过需要多做几步。

在开始把已编译的 C 代码接入 C++ 程序之前,你首先需要理解一个概念:name mangling。为了实现 function overloading,复杂的 C++ namespace 最终会被“展平”。例如,在一个 C++ 程序中,下面这样的写法完全合法:

void myFunc(double);
void myFunc(int);
void myFunc(int, int);

但如果直接交给 linker,它只会看到几个都叫 myFunc 的不同函数,根本无法知道你到底要链接哪一个。因此,所有 C++ compiler 都会执行一种叫做 name mangling 的处理;从逻辑上说,它等价于把函数名变成类似下面这样的形式:

myFunc_double
myFunc_int
myFunc_int_int

为了避免和你自己定义的其他名字发生冲突,compiler 实际生成的名字往往还会使用被保留的 identifier 形式,例如以双下划线开头,或以下划线加大写字母开头。也有些 compiler 会生成一些对 linker 来说合法、但对 C++ source code 来说并不合法的字符序列。比如,Microsoft VC++ 会生成类似下面这样的名字:

?myFunc@@YAXN@Z
?myFunc@@YAXH@Z
?myFunc@@YAXHH@Z

这种编码规则相当复杂,而且通常是 vendor-specific 的。C++ 标准并没有规定“某个平台上 function overloading 必须如何实现”,因此也不存在统一的 name mangling 标准。

而在 C 中,function overloading 并不被支持(compiler 会直接抱怨重复定义),所以 C compiler 生成的名字要简单得多,例如 _myFunc

现在,如果你用 C++ compiler 去编译一个简单程序,即使这个程序只出现了一次 myFunc,compiler 依然会去请求链接一个 mangled name。可当你真正拿它去链接一个 C library 时,library 里根本找不到那个 mangled name,linker 就会报错。因此,必须明确告诉 C++ compiler:不要对这个名字做 mangling。这件事要通过 extern "C" 来完成:在 header file 里使用它,是为了让 client code 生成与 C 兼容的名字;如果你的 library source 本身是用 C++ 写的,那么在定义处也需要使用它,以便让 library 真的导出与 C 兼容的名字。

extern "C" 的语法如下:

extern "C" declaration1();
extern "C" declaration2();

或者:

extern "C" {
declaration1();
declaration2();
}

C++ 标准规定,原则上可以使用任意语言说明符,因此理论上编译器甚至可以支持下面这种写法:

extern "C" void myFunc(int i);
extern "Fortran" Matrix* matrixInvert(Matrix* M);
extern "Pascal" void someLegacySubroutine(int n);
extern "Ada" bool aimMissileDefense(double angle);

但在实践中,很多 compiler 只支持 "C"。具体支持哪些 language designator,需要看各家 compiler vendor 的文档说明。

例如,下面这段代码把 cFunction() 的函数原型声明为一个外部 C function:

extern "C" {
void cFunction(int i);
}
int main()
{
cFunction(8); // 调用这个 C 函数。
}

cFunction() 的真正定义存在于链接阶段附加进来的某个已编译 binary file 中。这里的 extern 关键字,是在告诉 compiler:被链接进来的这段代码是按 C 的方式编译的。

在实际开发中,更常见的 extern 用法出现在 header 层。例如,如果你正在使用一个用 C 编写的 graphics library,它通常会随附一个供你 #include.h 文件。这个 header 的作者应该根据“当前是按 C 还是按 C++ 编译”来做条件处理。C++ compiler 在以 C++ 模式编译时会预定义符号 __cplusplus;而在 C 编译中,这个符号不会被定义。于是,就可以用它把 header 写成下面这样:

#ifdef __cplusplus
extern "C" {
#endif
drawCircle();
drawSquare();
#ifdef __cplusplus
} // matches extern "C"
#endif

这表示 drawCircle()drawSquare() 这两个函数,来自一个由 C compiler 编译出来的 library。使用这种技巧,同一个 header file 就可以同时供 C client 和 C++ client 使用。

无论你是在 C++ 程序中直接包含 C code,还是链接一个已经编译好的 C library,都要记住:尽管 C++ 几乎是 C 的超集,但两者仍是不同语言,拥有不同的设计目标。把 C 代码适配进 C++ 非常常见,但相比“直接拿来用”,为 procedural C code 提供一层 object-oriented 的 C++ wrapper,往往会是更好的选择。

尽管这是一本 C++ 书,但我当然不会假装世界上只有 C++。C# 就是一个例子。借助 C# 的 Interop services,你可以相当容易地在 C# application 中调用 C++ 代码。一个典型场景是:应用的一部分,例如 graphical user interface,用 C# 开发;而某些 performance-critical 或 computation-intensive 的组件,则交给 C++ 实现。要让 Interop 生效,你需要先写一个可供 C# 调用的 C++ library。在 Windows 上,这通常会是一个 .dll 文件。下面这个 C++ 例子定义了一个编译进 library 的 functionInDLL() 函数。它接收一个 Unicode string,返回一个整数。实现中会把收到的字符串输出到 console,并把数值 42 返回给调用者:

import std;
using namespace std;
extern "C"
{
__declspec(dllexport) int functionInDLL(const wchar_t* p)
{
wcout << format(L"C++ 收到了以下字符串:'{}'", p)
<< endl;
return 42; // 返回某个值……
}
}

请记住,这里你实现的是库中的一个函数,而不是在写一个完整程序,因此并不需要 main()。至于如何编译这段代码,则取决于你的开发环境。如果你使用 Microsoft Visual C++,需要在项目属性里把 configuration type 设为 Dynamic Library (.dll)。这个例子使用 __declspec(dllexport) 告诉链接器:这个函数应当对库的客户端可见。对 Microsoft Visual C++ 来说,这是导出函数的标准方式;而其他链接器也许会采用不同机制。

有了这个库之后,就可以通过 Interop services 从 C# 调用它。首先,你需要引入 Interop 命名空间:

using System.Runtime.InteropServices;

接着,定义函数原型,并告诉 C# 去哪里寻找该函数的实现。假设你把库编译成了 HelloCpp.dll,写法如下:

[DllImport("HelloCpp.dll", CharSet = CharSet.Unicode)]
public static extern int functionInDLL(String s);

第一行表示:C# 应从名为 HelloCpp.dll 的库中导入该函数,并按 Unicode string 处理字符串。第二行则声明了函数原型:它接收一个字符串参数,并返回一个整数。下面给出一个完整示例,展示如何从 C# 使用这个 C++ 库:

using System;
using System.Runtime.InteropServices;
namespace HelloCSharp
{
class Program
{
[DllImport("HelloCpp.dll", CharSet = CharSet.Unicode)]
public static extern int functionInDLL(String s);
static void Main(string[] args)
{
Console.WriteLine("由 C# 输出。");
int result = functionInDLL("来自 C# 的一段字符串。");
Console.WriteLine("C++ 返回的值为 " + result);
}
}
}

输出如下:

由 C# 输出。
C++ 收到了以下字符串:'来自 C# 的一段字符串。'
C++ 返回的值为 42

这个例子之外的 C# 细节超出了本书的范围,但从整体思路上看,你应该已经能理解它是如何工作的。

本节只讨论了“如何从 C# 调用 C++ function”,并没有涉及“如何从 C# 使用 C++ class”。这个缺口会在下一节通过 C++/CLI 来补上。

使用 C++/CLI 从 C++ 调用 C#,并从 C# 调用 C++

Section titled “使用 C++/CLI 从 C++ 调用 C#,并从 C# 调用 C++”

如果你想从 C++ 使用 C# 代码,可以借助 C++/CLI。CLI 是 Common Language Infrastructure 的缩写,它是所有 .NET 语言(例如 C#、Visual Basic .NET 等)的共同基础。C++/CLI 由 Microsoft 于 2005 年提出,用来提供一套支持 CLI 的 C++ 变体。到了 2005 年 12 月,C++/CLI 也被标准化为 ECMA-372 标准。你可以用 C++/CLI 来编写 C++ 程序,并访问任何其他支持 CLI 的语言所实现的功能,例如 C#。不过要注意,C++/CLI 对最新 C++ 标准的支持可能会有滞后,因此它未必支持所有最新 C++ 特性。对 C++/CLI 语言本身做深入讨论,超出了这本“纯 C++”书籍的范围;这里只给出几个小例子。

假设你有如下 C# class,它定义在一个 C# library 中:

namespace MyLibrary
{
public class MyClass
{
public double DoubleIt(double value) { return value * 2.0; }
}
}

你可以像下面这样在 C++/CLI 代码中使用这个 C# library。关键部分已经在示例里体现出来。CLI object 由 garbage collector 管理,当内存不再需要时会自动清理。因此,你不能像标准 C++ 那样直接使用 new 来创建 managed object,而是必须使用 gcnew——也就是 “garbage collect new” 的缩写。得到的结果也不能存放在普通的 C++ pointer 变量(如 MyClass*)中,也不能存进 std::unique_ptr<MyClass> 这样的 smart pointer,而必须存放在一个 handle 里,也就是 MyClass^,它通常被念作 “MyClass hat”。

#include <iostream>
using namespace System;
using namespace MyLibrary;
int main(array<System::String^>^ args)
{
MyClass^ instance { gcnew MyClass() };
auto result { instance->DoubleIt(1.2) };
std::cout << result << std::endl;
}

C++/CLI 也可以反过来使用:你可以编写 managed C++ ref class,然后让任何其他 CLI language 去访问它。下面是一个简单的 managed C++ ref class 示例:

#pragma once
using namespace System;
namespace MyCppLibrary
{
public ref class MyCppRefClass
{
public:
double TripleIt(double value) { return value * 3.0; }
};
}

这个 C++/CLI ref class 之后就可以被 C# 这样使用:

using MyCppLibrary;
namespace MyLibrary
{
public class MyClass
{
public double TripleIt(double value)
{
// 让 C++ 把它变成三倍。
MyCppRefClass cppRefClass = new MyCppRefClass();
return cppRefClass.TripleIt(value);
}
}
}

正如你看到的,最基础的部分并不复杂,但这些示例使用的都还是 double 这样的 primitive datatype。一旦你需要处理字符串、vector 等非 primitive datatype,事情就会迅速复杂起来,因为你必须开始在 C# 与 C++/CLI 之间进行 object marshaling,反之亦然。不过,这已经超出了本节这段简短导论的范围。

Java Native Interface(JNI)是 Java 语言的一部分,它允许程序员访问那些不是用 Java 编写的功能。由于 Java 本身是一门 cross-platform language,JNI 最初的目的之一,就是让 Java 程序能够与操作系统进行交互。JNI 也允许程序员使用由其他语言(例如 C++)编写的 library。对于 Java 程序员来说,如果某段代码性能要求极高、计算量很大,或者需要复用 legacy code,那么访问 C++ library 就会非常有价值。

JNI 也可以反过来让 C++ 程序执行 Java 代码,不过这种用法要少见得多。由于这是一本 C++ 书,因此我不会在这里加入 Java 语言的入门介绍。如果你已经会 Java,并希望在 Java 代码中接入 C++,那么这一节会很适合你。

要开始你的 Java 跨语言之旅,先从 Java 程序写起。这个例子里,一个最简单的 Java 程序就够用了:

public class HelloCpp
{
public static void main(String[] args)
{
System.out.println("来自 Java 的问候!");
}
}

接下来,你需要声明一个“由其他语言实现”的 Java method。做法是使用 native 关键字,并且不提供实现体:

// 该方法将在 C++ 中实现。
public static native void callCpp();

C++ 代码最终会被编译成一个 shared library,并在运行时动态加载到 Java 程序中。你可以把这个 library 的加载动作写进 Java 的 static block,让它在程序启动时自动执行。library 的名字可以自定,例如在 Linux 上叫 hellocpp.so,在 Windows 上叫 hellocpp.dll

static { System.loadLibrary("hellocpp"); }

最后,还需要在 Java 程序中真正调用 C++ 代码。callCpp() 这个 Java method 目前只是一个占位符,真正实现稍后由 C++ 提供。完整 Java 程序如下:

public class HelloCpp
{
static { System.loadLibrary("hellocpp"); }
// 该方法将在 C++ 中实现。
public static native void callCpp();
public static void main(String[] args)
{
System.out.println("来自 Java 的问候!");
callCpp();
}
}

Java 这一侧到这里就完成了。接下来,像平常一样编译这个 Java 程序:

Terminal window
javac HelloCpp.java

然后使用 javah 程序(我习惯把它念作 jav-AHH!)为这个 native function 生成 header file:

Terminal window
javah HelloCpp

运行 javah 后,你会得到一个名为 HelloCpp.h 的文件,它是一个完整可用(虽然不怎么好看)的 C/C++ header file。这个 header file 里会包含一个 C function 的声明,名字叫 Java_HelloCpp_callCpp()。你的 C++ 程序需要实现的就是这个函数。它的完整原型如下:

JNIEXPORT void JNICALL Java_HelloCpp_callCpp(JNIEnv*, jclass);

这个函数的 C++ 实现可以充分使用 C++ 语言本身。下面这个例子只是简单地从 C++ 输出一些文字。首先,需要包含 jni.hjavah 生成的 HelloCpp.h,以及你打算使用的其他 C++ header:

#include <jni.h>
#include "HelloCpp.h"
#include <iostream>

接着正常编写这个 C++ function 即可。函数参数允许你与 Java environment 以及调用 native code 的 object 进行交互,但这些细节超出了本例范围。

JNIEXPORT void JNICALL Java_HelloCpp_callCpp(JNIEnv*, jclass)
{
std::cout << "来自 C++ 的问候!" << std::endl;
}

至于怎样把这段代码编译成 library,则取决于你的环境,但大概率需要调整 compiler 设置,把 JNI 的 header 搜索路径加进去。若使用 Linux 上的 GCC,编译命令可能类似这样:

Terminal window
g++ -shared -I/usr/java/jdk/include/ -I/usr/java/jdk/include/linux \
HelloCpp.cpp -o hellocpp.so

compiler 的输出结果,就是供 Java 程序使用的那个 library。只要 shared library 位于 Java class path 的某处,你就可以像往常一样运行 Java 程序:

java HelloCpp

你会看到如下输出:

来自 Java 的问候!
来自 C++ 的问候!

当然,这个例子只是轻轻碰了一下 JNI 的表面。你完全可以借助 JNI 去访问 OS-specific feature,甚至硬件驱动。如果想系统掌握 JNI,还是应该去读一本 Java 专门教材。

最初的 Unix OS 附带的 C library 功能相当有限,很多常见操作都不在其能力范围内。于是,Unix 程序员逐渐养成了一种习惯:从应用程序中去启动脚本,让脚本代替 API 或库完成本应由系统提供的工作。脚本可以写成 Perl、Python,也可以写成在 Bash 这类 shell 中执行的 shell script。

直到今天,许多 Unix 程序员仍坚持把脚本当作一种“子程序调用方式”来使用。为了支持这种互操作,C++ 提供了定义在 <cstdlib> 中的 std::system() function。它只需要一个参数:一个表示你要执行之命令的字符串。示例如下:

system("python my_python_script.py"); // 启动一个 Python 脚本。
system("perl my_perl_script.pl"); // 启动一个 Perl 脚本。
system("my_shell_script.sh"); // 启动一个 Shell 脚本。

不过,这种做法存在明显风险。例如,如果脚本本身出错,调用方未必能得到足够详细的错误信息。system() 也属于相当“重型”的调用,因为它必须创建一个全新的 process 去执行脚本。这在应用中最终很可能变成严重的 performance bottleneck。

本书不再继续深入讨论用 system() 启动 script。通常来说,你应先看看现有 C++ library 是否已经能以更好的方式完成相同任务。现实中,很多平台专属 library 都已经有了平台无关的 wrapper,例如 Boost Asio,它提供了可移植的 networking 和其他 low-level I/O 能力,包括 socket、timer、serial port 等等。如果你需要操作 filesystem,自 C++17 起,Standard Library 已经提供了平台无关的 <filesystem> API,可参考第 13 章。像“先用 system() 启一个 Perl script 去处理文本”这样的思路,很多时候都不是最佳选择。对字符串处理而言,使用 C++ 的 regular expressions library(见第 21 章“字符串本地化与正则表达式”)往往会是更好的方案。

C++ 内建了一种通用机制,用于与其他语言或环境交互。你其实早就已经用了很多次,只是可能没太留意——它就是 main() function 的参数与返回值。

C 和 C++ 从设计上就充分考虑了 command-line interface。main() function 从命令行接收参数,并返回一个可由调用方解释的 status code。在 scripting environment 中,程序的输入参数与退出状态码,往往就是与外部环境交互的一种非常强大的机制。

假设你有一个系统,会把用户看到的每一行内容、以及用户键入的每一项输入,都写入一个文件以备审计使用。这个文件只有系统管理员能够读取,以便在出现问题时查出责任归属。文件的一小段内容可能长这样:

Login: bucky-bo
Password: feldspar
bucky-bo > mail
bucky-bo has no mail
bucky-bo > exit

系统管理员可能希望保留全部用户活动日志,但她也可能希望把每个人的 password 隐去,以防这个文件被 hacker 获取。因此,她决定写一个 script 来解析这些 log file,并让 C++ 负责执行真正的加密逻辑。于是,这个 script 会调用一个 C++ 程序来完成加密。

下面的 script 使用 Perl 编写,不过几乎任何 scripting language 都能完成这个任务。顺便一提,如今 Perl 其实也已经有现成的 encryption library 可用;但为了这个例子,我们假设加密工作必须由 C++ 完成。即便你不会 Perl,也完全能跟上本例。对这个例子来说,Perl 语法里最重要的元素是 ` 字符。这个字符会告诉 Perl script:去 shell out 到一个外部命令。在这里,script 会调用一个名为 encryptString 的 C++ 程序。

这个 script 的策略是:逐行遍历文件 userlog.txt,寻找包含 password prompt 的那些行。它会生成一个新文件 userlog.out,其中内容与原文件相同,只不过所有 password 都会被加密。第一步是以读取模式打开输入文件、以写入模式打开输出文件;然后脚本开始遍历文件中的每一行,每次读到的一行都会放入 $line 变量中。

open (INPUT, "userlog.txt") or die "Couldn't open input file!";
open (OUTPUT, ">userlog.out") or die "Couldn't open output file!";
while ($line = <INPUT>) {

接下来,脚本会用一个 regular expression 检查当前行是否包含 Password: prompt。如果匹配成功,Perl 会把 password 部分存入变量 $1

if ($line =~ m/^Password: (.*)/) {

找到匹配后,脚本会把检测到的 password 传给 encryptString 程序,以拿到它的加密版本。程序输出会被存入 $result,而程序的 result status code 会存入变量 $?。脚本会检查 $?,一旦有问题就立刻退出。如果一切正常,它就把 Password: 那一行写到输出文件中,不过写入的是加密后的 password,而不是原始值。

$result = `./encryptString $1`;
if ($? != 0) { exit(-1); }
print OUTPUT "Password: $result\n";
} else {

如果当前行不是 password prompt,脚本就原样把这一行写入输出文件。循环结束后,它会关闭两个文件并退出。

print OUTPUT "$line";
}
}
close (INPUT);
close (OUTPUT);

这就完成了。剩下唯一需要的,就是那个真正执行加密的 C++ 程序。至于 cryptographic algorithm 的实现,超出了本书范围。这里真正重要的是 main() function,因为它负责把待加密字符串作为参数接收进来。

参数保存在 argv 这个 C 风格字符串数组里。在访问 argv 中的元素之前,你应始终先检查 argc。如果 argc 为 1,说明参数列表中只有一个元素,也就是 argv[0]。真正的命令行参数从 argv[1] 开始。argv 数组的第 0 项通常是程序名,但由于它完全由启动当前进程的一方控制——例如 Linux 的 execve() system call——因此从技术上说它可以保存任何数据。你唯一能确信的是:argv[0]argv[argc-1] 中的每个元素都是一个 null-terminated string,而 argv[argc] 本身则是一个 null pointer。

下面给出一个对输入字符串进行加密的 C++ 程序的 main() function。注意,按照 Linux 的惯例,程序成功时返回 0,失败时返回非 0。

int main(int argc, char** argv)
{
if (argc < 2) {
println(cerr, "Usage: {} string-to-be-encrypted", argv[0]);
return 1;
}
print("{}", encrypt(argv[1]));
}

现在你已经看到,把 C++ 程序接入 scripting language 有多么直接了。你完全可以在自己的项目里结合两种语言的优势:让 scripting language 负责和操作系统交互、控制脚本流程,再让 C++ 这样的传统编程语言承担真正的重活。

C++ 被认为是一门速度很快的语言,尤其是与很多其他语言相比更是如此。但在极少数情况下,如果性能真的重要到极致,你也许会希望直接使用原始 assembly code。compiler 本来就会从 source file 生成 assembly code,而这些自动生成的 assembly code 对几乎所有用途来说都已经足够快。无论是 compiler 还是 linker(在支持 link-time code generation 时),都会利用优化算法尽量让生成出的 assembly code 更高效。随着 MMX、SSE、AVX 等特殊 processor instruction set 的利用日益成熟,这些 optimizer 也变得越来越强大。如今,除非你对这些增强指令集的各种细节都了如指掌,否则自己手写 assembly,往往很难超过 compiler 自动生成代码的性能。

不过,如果你真的需要,C++ compiler 往往会通过 asm 关键字,让程序员能够插入原始 assembly code。这个关键字是 C++ 标准的一部分,但它的具体实现由 compiler 自行定义。在某些 compiler 中,你可以直接在程序中途从 C++ “掉下去”写 assembly。某些 compiler 对 asm 的支持还取决于目标 architecture,也有些 compiler 并不用标准的 asm,而是使用自己的非标准关键字。比如,Microsoft Visual C++ 2022 并不支持 asm;它在 32-bit 模式下支持 __asm,而在 64-bit 模式下则完全不支持 inline assembly。

assembly code 在某些应用中当然有价值,但对绝大多数程序而言,我并不推荐使用它。避免 assembly 的理由有很多:

  • 一旦你在代码中写入针对某个平台的原始 assembly,这段代码就不再能移植到其他 processor。
  • 大多数程序员并不了解 assembly language,因此他们无法有效修改或维护你的代码。
  • assembly code 的可读性通常很差,它会破坏程序整体的风格与可维护性。
  • 大多数时候根本没必要这么做。如果程序很慢,先去找 algorithm 层面的原因,或者参考第 29 章“编写高效 C++”中的其他性能优化建议。

当你的应用出现性能问题时,先用 profiler 找出真正的 hotspot,并优先考虑 algorithm 层面的提速!只有在其他所有办法都已经用尽之后,才开始考虑使用 assembly code;即便到了那一步,也别忘了认真权衡 assembly 的缺点。

在实践中,如果你有一段 computation-intensive 的代码块,首先应该把它提炼进一个独立的 C++ function。然后,借助性能剖析(见第 29 章)确认这个 function 确实是 performance bottleneck;并且,如果已经没有办法再把它写得更小、更快了,你才可以考虑尝试用原始 assembly 去继续提升性能。

在这种情况下,你首先会想把这个 function 声明成 extern "C",以屏蔽 C++ 的 name mangling。随后,你就可以编写一个独立的 assembly module,让它以更高效率完成同样的工作。把它写成独立 module 的好处是:你一方面保留了一份平台无关的 C++ “reference implementation”,另一方面又拥有一份平台专属的高性能原始 assembly 实现。使用 extern "C" 之后,assembly code 也可以采用简单的命名约定(否则你就得去逆向你家 compiler 的 name mangling 算法)。之后,你就可以选择链接 C++ 版本,或者链接 assembly 版本。

这种 module 应当写成单独的 assembly source,并交给 assembler 处理,而不是在 C++ 里直接写 inline asm。对许多流行的 x86-compatible 64-bit compiler 来说,这一点尤其重要,因为它们根本不支持 inline asm 关键字。

即便从技术上可行,使用原始 assembly code 也只应该出现在“性能收益非常可观”的情况下。此外,别忘了 Amdahl’s law。例如,把你的 encryption routine 提速 10 倍,听起来很惊人;但如果整个程序有 90% 的时间根本不在做加密,那这个“10 倍提速”其实只作用于整程序的 10%,最终整体收益也不过只有 9% 左右!

如果你读完整章只记住一件事,那应该是:C++ 是一门非常灵活的语言。它恰好位于一个理想区间——既不像某些语言那样被特定平台绑得太死,也不像另一些语言那样过于 high-level、过于泛化。你完全可以放心:当你使用 C++ 开发代码时,并不等于把自己永远锁死在这门语言里。C++ 可以与其他技术协同工作,而且它拥有深厚的历史积累和庞大的现有代码基础,这些都足以帮助它在未来继续保持相关性。

在本书的第 V 部分中,我讨论了 software engineering 方法、如何编写高效 C++、测试与调试技巧、设计技术与 design pattern,以及跨平台与跨语言应用开发。把这些内容放在 professional C++ 学习旅程的尾声,是非常合适的,因为这些主题能帮助“不错的 C++ 程序员”成长为“优秀的 C++ 程序员”。只要你持续思考设计、尝试不同的 object-oriented programming 方法、选择性地把新技术纳入自己的编码工具箱,并认真练习测试与调试技巧,你就能把自己的 C++ 能力推进到真正的专业级别。

通过完成下面这些练习,你可以动手巩固本章讨论过的内容。所有练习的参考解答都包含在本书网站 www.wiley.com/go/proc++6e 提供的代码下载包中。不过,如果你在某道题上卡住了,建议先重新阅读本章相关部分,尽量自己推导出答案,再去查看网站上的解法。

  1. 练习 34-1: 编写一个程序,输出所有标准 C++ integer type 的大小。如果条件允许,尝试在不同平台上用不同 compiler 编译并运行它。

  2. 练习 34-2: 本章介绍了 integer value 的 big-endian 与 little-endian 编码方式,也解释了在网络传输中通常建议统一使用 big-endian,并在需要时做转换。编写一个程序,能够在两个方向上把 16-bit unsigned integer 在 little-endian 与 big-endian 编码之间互相转换。请特别留意你选用的数据类型。再写一个 main() function 来测试它。

  3. 附加练习: 你能把同样的事情扩展到 32-bit integer 吗?

  4. 练习 34-3: “编程范式的切换”一节中的 networking 示例展示了如何在 C++ 中使用 C 风格 API,但它略显抽象。由于完整实现会涉及 C 或 C++ Standard Library 都不提供的 networking code,因此书中没有把它展开成完整实现。在这道练习里,我们来看一个规模小得多、但同样可能想在 C++ 代码中使用的 C 风格 library。这个“library”本质上只有两个函数:第一个函数 reverseString() 会分配一段新字符串,并将其初始化为给定源字符串的逆序结果;第二个函数 freeString() 会释放 reverseString() 分配出来的内存。它们的声明及说明如下:

    /// <summary>
    /// Allocates a new string and initializes it with the reverse of a given string.
    /// </summary>
    /// <param name="string">The source string to reverse.</param>
    /// <returns>A newly allocated buffer filled with the reverse of the
    /// given string.
    /// The returned memory needs to be freed with freeString().</returns>
    char* reverseString(const char* string);
    /// <summary>Frees the memory allocated for the given string.</summary>
    /// <param name="string">The string to deallocate.</param>
    void freeString(char* string);

    你会如何在自己的 C++ 代码中使用这套“library”?

  5. 练习 34-4: 本章中关于混用 C 与 C++ 的示例,都是“从 C++ 调用 C 代码”。当然,只要把自己限制在 C 能理解的数据类型范围内,反过来做也完全可以。在这道练习中,你需要把两个方向都串起来:编写一个名为 writeTextFromC(const char*) 的 C function,它内部会调用一个名为 writeTextFromCpp(const char*) 的 C++ function,而后者使用 std::println() 把给定字符串输出到 standard output。为了测试它,再编写一个 C++ 的 main() function,去调用这个 C function writeTextFromC()

  1. 从 C++23 开始,member 的顺序已由标准明确定义,必须与它们在 class definition 中的声明顺序一致——而在更早的标准里,compiler 可以重排 member。不过,对象布局的其他方面,例如 padding 与 alignment,仍然是 platform-specific 的。