16 实用工具

16.1 引言

将某个库组件命名为“实用工具”或“实用程序”并不能提供太多信息。毕竟任何库组件都能在某个时间、某个地点,对某些人提供实用功能。这里所说的实用工具,是指那些在很多场合起到关键性作用,但又不适合被规划到特定范畴中的东西。通常它们被当做构建更强大的库(包括标准库)的基础构件。

16.2 时间

标准库通过<chrono>头文件提供了一组用于处理时间的工具。例如:

几乎所有的主流操作系统,都或多或少地提供了一些与时间处理有关的机制。

16.2.1 时钟

下面的程序演示了最基本的计时功能:

system_clock::now函数返回的当前系统时间,精确到百纳秒(107,千万分之一秒),以time_point(时间点)类型的对象表示。两个time_point对象相减得到一个duration(时间段)类型的对象,表示两个时间点的间隔。duration对象的默认输出以百纳秒为单位。可以借助duration_cast函数模板,将其转换为任何所期望的时间单位。

时钟对于性能测试非常有用。如果想对代码的执行效率发表意见,请在进行时间测量后再开口。对性能的任何盲目猜测都是不靠谱的。再简单的测量也好过完全没有测量。当然,现代计算机的性能是一个有争议的话题,单次简单测量的结果并不重要,多次重复测量有助于避免因偶然事件或缓存效应而妄下结论。

命名空间std::chrono_literals定义了一组表示时间单位的后缀。例如:

符合常识的单位后缀,极大地提高了代码的可读性,使代码更容易维护。

C++11:duration和time_point等时间工具

16.2.2 日期

在处理日常事件时,很少用到毫秒、微秒、纳秒这样的时间尺度,更多用到的是年、月、日和星期,这些时间度量。标准库为此提供了很好的支持。例如:

“Tue”是这台计算机上星期二的默认字符表示。如果希望得到星期的全拼形式,如“Tuesday”,可以借助format函数,并指定格式化标记“%A”。这里的“December”是一个表示月的对象,其类型为std::chrono::month。也可以等价地写成下面这样:

这里的“y”是一个表示“年”的后缀,“2024y”是一个表示年的对象,其类型为std::chrono::year。后面的12和17都是普通整数,分别表示月和日。

日期类型也可以表示无效日期,其名为ok的成员函数用于检测日期的合法性。例如:

显然,对于用户输入的日期,借助ok函数检测,还是有必要的。

日期的组成是通过重载year和month类对int类型的“/”操作符实现的。

另一种表示日期的类型sys_days可以与year_month_day类型相互转换,前者还支持常见的日期计算。例如:

日期计算并非简单的整数加减法,其中涉及到跨年、跨月和润年(二月有29天)问题。上述代码中的格式化标记“%B”,表示将月以全拼形式格式化为字符串,如“February”。

有关日期的处理甚至可以在编译时完成,以获得更高的效率。例如:

日期系统复杂而微妙。这是几个世纪以来,为普通人设计的系统的典型特征,它们更偏向于普通人,而从不考虑程序员编写代码是否容易。标准库的日期系统还将进一步扩展,以支持儒略历、伊斯兰历、泰历等更多历法。

C++20:日期

16.2.3 时区

与时间相关的最棘手的问题之一就是时区。时区的规律是如此任性以至于让人难以记住,并且有时还会出于各种原因而发生变化,其变化方式在全球范围内尚无统一标准。例如:

用time_point对象表示的时间是世界标准时间,也叫格林尼治时间(Greenwich Mean Time,GMT),即贯穿英国格林尼治天文台的本初子午线(0°​经线)时间或国际协调时间(Universal Time Coordinated,UTC),即国际原子钟时间。一个zoned_time对象可被视作一个表示时区的time_zone对象和一个表示标准时间的time_point对象的组合体,即带时区的时间,简称时区时间。有关时区时间的计算,由标准库与互联网号码分配局(Internet Assigned Numbers Authority,IANA)的时区数据库同步,以获得正确结果。时区名称遵循IANA标准,是形如“大陆/主要城市”的C风格字符串,如“Europe/Copenhagen”、“Asia/Tokyo”、“Africa/Nairobi”等。

象日期一样,有关时区的一切问题已交由标准库解决,无需手动编写代码。想一想:2024年2月的最后一天,纽约的什么时间新德里会进入3月的第一天?2020年美国科罗拉多州丹佛市的夏令时何时结束?下一个润秒会在何时出现?标准库“知道”所有这类问题的答案。

C++20:时区

16.3 函数适配

将函数作为参数传递给另一个函数时,作为实参的函数,其类型(返回值、参数表)必须与被调用函数相应形参的声明完全匹配。如果不能做到完全匹配,而只是大致符合某种约定,则可通过以下方法予以调整:

解决这类问题,还有许多其它方法,但效果最好的一定是上述三种方法中的一种。

C++11:改进的函数适配器

16.3.1 匿名函数作为适配器

回顾之前编写的,用于绘制一组形状的drawShapes函数:

该函数还有另一种等价的写法:

所有标准库算法一样,for_each函数遵循传统函数调用语法“f(x)”处理其第三个参数,而draw是一个成员函数,其调用形式为“x->f()”。匿名函数很好地协调了二者之间的矛盾,外“f(x)”,而内“x->f()”。

16.3.2 mem_fn

函数适配器mem_fn是一个用于生成函数对象的函数模板,可以传统函数调用语法调用其所生成的函数对象,并将调用对象作为参数传入其中。在该函数对象内部,再通过传入的调用对象调用指定的成员函数。例如:

在C++11引入匿名函数之前,mem_fn及类似的函数,是将传统函数调用语法“f(x)”,映射到成员函数调用语法“x->f()”的主要方式。

16.3.3 function

标准库的function是一个类模板,实例化该模板的类型参数是一个通过返回值和参数表描述的函数类型。function模板的实例化类通过其构造函数,接收并保存一个可调用对象(包括函数指针)。该类的实例化对象,因其重载了函数操作符,故可被当做函数调用,谓之函数对象。在其函数操作符函数的执行过程中,会调用构造时获得的可调用对象,传递所传递的参数,返回所返回的值。

function
初始化
参数/返回值
可调用对象
参数/返回值
构造函数
函数操作符
可调用对象
创建者
调用者

function的本质就是一个封装了可调用对象的可调用对象,其内部重载的函数操作符函数,负责调用被封装的可调用对象,传入其所接收的参数,返回来自该可调用对象的返回值。例如:

又如:

匿名函数也是可调用对象,也可以被function封装。例如:

这种做法相当于给匿名函数命名,被命名的匿名函数可以象任何具名函数一样被调用。例如:

显然,function对于回调、将操作作为参数传递、传递函数对象等,非常有用。但是,与直接调用相比,调用function可能会增加少量的运行时开销。特别地,对于在编译时未计算其大小的function对象,还会因自由存储分配,增加更多的运行时开销。对于性能关键型应用而言,这些额外的性能损失,往往会造成非常严重的不良影响。C++23有望推出一个新的解决方案——move_only_function,有助于性能的改善。

另一个问题是,function中的函数操作符函数只能有一个,无法提供参数表不同的多个重载版本。如果需要封装多个具有重载关系的可调用对象(包括匿名函数),可以考虑使用overloaded。

16.4 类型函数

类型函数是以类型作为输入参数或返回值的函数,它们在编译时被执行。标准库通过多种类型函数帮助库的实现者编写代码,以更好地利用语言、标准库和普通代码的各种优势。

对于数值类型,<limits>头文件中的numeric_limits函数提供了各种有用的信息。例如:

类似地,可以借助内置的sizeof操作符获取类型的大小。例如:

在<type_traits>头文件中,标准库提供了很多用于查询类型属性的函数。例如:

其中,is_arithmetic_v用于判断一个类型是否是数值类型,decltype函数返回其函数型参数的类型,invoke_result_t用于从一个函数的类型中提取其返回值的类型,typeid函数返回一个type_info类型的对象,其中包含传递该函数的参数的类型信息,该对象的name成员函数返回类型信息中的类型名字符串。

某些类型函数还可以根据其输入创建出新的类型。例如:

如果conditional_t的第一个参数为true,则取第二个参数所表示的类型,否则取第三个参数所表示的类型。例如:

Store容器所使用的分配器可以根据其中元素的字节数自适应调整。基于这种选择性的分配器性能调优,有时候会非常重要。

概念也属于类型函数。当在表达式中使用概念时,它们是特定的类型谓词。例如:

在许多情况下,概念是最好的类型函数,但大多数标准库都诞生自概念出现之前,且要支持那个时代的代码库。

类型函数的命名规则有时会令人感到困惑。一般而言,以“_v”结尾的类型函数会返回一个值,如is_arithmetic_v返回true或者false,而以“_t”结尾的类型函数会返回一个类型,如invoke_result_t返回函数类型中的返回值类型。这其实是在概念出现之前,弱类型时代的遗留物。截止目前,标准库中还没有任何仅靠后缀区分的类型函数,因此这些后缀其实是多余的。标准库和其它地方的概念,不需要也确实没有使用任何形式的后缀。

类型函数是C++编译期计算机制的一部分,与没有它们相比,类型函数提供了更严格的类型检查和更优越的运行性能。基于类型函数和概念的编程,通常被称为元编程或模板元编程(当涉及模板时)。

16.4.1 类型谓词

在<type_traits>头文件中,标准库提供了许多简单的类型函数,称为类型谓词,用于回答有关类型的基本问题。下表列出了其中的一部分:

类型谓词说明
is_void_v<T>T是void类型吗?
is_integral_v<T>T是整数类型吗?
is_floating_point_v<T>T是浮点数类型吗?
is_arithmetic_v<T>T是整数或浮点数类型吗?
is_class_v<T>T是类类型吗?
is_scalar_v<T>T是算术类型、枚举类型、指针或成员指针类型吗?
is_function_v<F>F是一个函数(而非函数对象或函数指针)类型吗?
is_invocable_v<F,A...>可以用参数表A...调用F类型的对象吗?
is_constructible_v<T,A...>T拥有可以参数表A...调用的构造函数吗?
is_default_constructible<T>T拥有可以无参方式调用的构造函数吗?
is_copy_constructible_v<T>T拥有拷贝构造函数(参数为const T&)吗?
is_move_constructible_v<T>T拥有转移构造函数(参数为T&&)吗?
has_virtual_destructor_v<T>T拥有虚析构函数吗?
is_assignable_v<U,V>V类型的对象可以赋值给U类型的对象吗?
is_trivially_copyable_v<U,V>V类型的对象可以在不借助赋值操作符函数的前提下赋值给U类型的对象吗?
is_same_v<U,V>U和V是相同类型吗?
is_base_of<U,V>U继承自V或U和V是相同类型吗?
is_convertible_v<U,V>U类型的对象可以隐式转换成V类型的对象吗?
is_iterator_v<I>I是一个迭代器类型吗?

其中的T、F、A、U、V、I都是类型,所有类型谓词均返回bool类型的值true或false。

这些谓词的传统用法是给模板的类型参数附带约束条件。例如:

然而,和其它传统用法一样,使用概念会更简洁,也更优雅。例如:

在许多情况下,类似is_arithmetic_v这样的类型谓词,更适于定义概念,而非直接使用。例如:

甚至可以抛却标准库的类型谓词,定义更通用的概念。因为许多标准库的类型谓词仅适用于内置类型,所以有时需要根据所需操作自行定义一些概念,如Number概念:

并在此基础上定义Arithmetic概念:

在大多数情况下,基础设施的实现中深度使用了标准库的类型谓词,通常用于针对特定案例进行区别优化。比如下面的copy函数用于复制连续的对象序列:

但如果序列中的元素是类似整数这样的简单类型,完全可以采用更优化的复制策略。因此更好的copy函数应该类似下面这个样子:

类似这样的优化,可以使程序的性能提升近50%,但不要沉迷于这种小聪明,除非有充分的证据证明标准库做得不够好。包含手工优化的代码通常比直接使用标准库的代码更难以维护。

C++11:类型特征,如is_integral_v、is_base_of等

16.4.2 条件属性

假设有一个智能指针的实现:

其中解引用操作符(*)总是有意义的,但间接成员访问操作符(->)只有在T是一个类时才有意义。SmartPointer<Student>应该同时支持“*”和“->”操作符,而SmartPointer<int>则应仅支持“*”操作符。为此可以借助requires关键字结合标准库的类型谓词构造约束条件。例如:

即当且仅当T是一个类时才定义“->”操作符。也可以使用概念,达到同样的效果。例如:

一般而言,使用概念比直接使用标准库的类型谓词更通用,也更合适。

16.4.3 类型生成器

许多类型函数的返回值是一个类型,且此类型通常为该函数经计算得出的新类型,这种类型函数也叫类型生成器,以区别于类型谓词。下表列出了其中的一部分:

类型生成器说明
R=remove_const_t<T>R是去除T最外层const(如果有的话)后的类型
R=add_const_t<T>R是为T添加const后的类型
R=remove_reference_t<T>如果T是U&形式的引用,R就是U,否则R就是T
R=add_lvalue_reference_t<T>如果T已经是左值引用,R就是T,否则R就是T&
R=add_rvalue_reference_t<T>如果T已经是右值引用,R就是T,否则R就是T&&
R=enable_if_t<b,T=void>如果b的值为true,R就是T,否则R无定义
R=conditional_t<b,U,V>如果b的值为true,R就是U,否则R就是V
R=common_type_t<T...>如果类型列表T...中的所有类型都能隐式转换为U,R就是U,否则R无定义
R=underlying_type_t<T>如果T是枚举类型,R就是该枚举的值类型,否则报错
R=invoke_result_t<F,A...>如果F类型的对象可用参数表A...调用,R就是其返回值的类型,否则报错

其中的R、T、U、V、F、A都是类型,b是一个bool类型的值true或false。

这些类型函数通常用于实用工具代码的实现,而非直接用于应用程序代码。其中,在概念诞生前,代码中最常见的可能是enable_if_t。例如对智能指针“->”操作符的约束:

即当且仅当T是一个类时才定义“->”操作符。

事实上,这样的代码可读性并不好。在更复杂的用法中,情况会变得愈发糟糕。enable_if_t的定义依赖于一种称为SFINAE(Substitution Failure Is Not An Error,替换失败不是错误)的语法机制。

C++11:更简单、更通用的SFINAE(替换失败不是错误)规则

16.4.4 关联类型

所有的标准库容器及被设计为遵循标准库模式的容器,都有一些关联类型,例如它们的元素类型和迭代器类型。标准库在<iterator>和<ranges>头文件中提供了获取它们的类型函数。下表列出了其中的一部分:

获取关联类型的类型函数说明
range_value_t<R>容器R中的元素的类型
iterator_t<R>容器R的迭代器的类型
iter_value_t<I>迭代器I的目标元素的类型

其中的R和I分别为容器和迭代器的类型。

16.5 source_location

在程序中输出跟踪消息或错误日志时,常常希望将源代码的位置信息作为消息的一部分。标准库为此提供了source_location类。该类的静态成员函数current会返回一个该类的对象,其中包含了源代码的位置信息。例如:

运行上面的程序,输出如下日志信息:

这里将对current函数的调用放在默认参数中,以保证获取的是log函数调用者的位置,而非log函数自身的位置。

在C++20之前,实现类似的功能,可能需要使用类似“__FILE__”、“__LINE__”、“__FUNCTION__”或“__func__”这样的预定于宏。

C++11:借助__func__宏获取当前函数名字符串

C++20:source_location

16.6 move和forward

拷贝和转移之间的抉择,在多数情况下,都是隐式进行的。编译器更倾向于在对象即将被销毁时选择转移,比如从函数中返回对象,因为这样被认为是更简单、更有效的操作。但有时,人们可能需要显式指明,究竟是拷贝还是转移。比如unique_ptr是对象的唯一持有者,它不能被拷贝,如果希望得到另一个指向同一个对象的unique_ptr,只能通过转移获得。

目标unique_ptr
源unique_ptr
不再指向
指向
转移
平凡指针
平凡指针
对象

假设p是一个指向整型变量的unique_ptr:

为了获得p的副本q,将引发编译错误:

但可以通过move函数,显式将p转移到q:

move函数本身其实并没有转移任何东西,它唯一的作用就是将其参数转换为一个右值,这样做的结果就是:

从这个意义上讲,它似乎更适合被命名为类似rvalue_cast之类的东西。

除了象unique_ptr这种不得已而为之的情况外,move函数还可用于一些有意为之的场合。例如:

这是一个典型的交换两个变量的值的函数。但从以上实现不难看出,其间至少发生了三次对象拷贝。对于大对象而言,对象拷贝可能会严重影响程序的运行性能。如能代之以对象转移,至少在性能上,将获得很大提升。例如:

move函数非常诱人,但是也很危险。例如:

这里的s1是以拷贝方式进入动态数组v的,而s2却以转移方式进入,因此针对s2的push_back成本更低。但潜在的问题是留下了一个被转移走的对象。如果再次使用s2,将可能引发问题。例如:

以这种方式使用move函数太容易出错了。除非能证明这样做确实有显著且必要的性能提升,否则最好还是敬而远之,毕竟在以后的维护过程中,很难保证不会再次启用已经被转移走的对象。

对于从函数中返回对象的情形,编译器自然知道被返回的对象不可能在函数中被继续使用,默认以转移方式使调用者获得该对象是合理且安全的。这时显式使用move函数不仅多余,而且有可能抑制编译器本应采取的优化。

函数
调用者
转移
作为返回值的对象
接收返回值的对象

被转移走的对象的状态通常是未定义的,但至少应该是可被销毁和可再次接受赋值的,采用其它做法并非明智的选择。对于容器(vector或string)而言,被转移走意味着容器为空。对于其它类型,最好以默认状态表示被转移走,有意义且成本最低。

考虑下面的函数:

无论fun函数的形参r是一个左值引用还是右值引用,在将其作为实参传递给foo函数时,一律被视为左值。如果希望在不改变左右值属性的前提下,将形参作为实参传递给另一个函数,即所谓完美转发,就要用到标准库提供的forward函数。例如:

如果r是一个左值引用,forward<T>(r)就是一个左值,而如果r是一个右值引用,forward<T>(r)就是一个右值。

forward函数能够很好地处理与左值和右值相关的一切微妙差别,确保被调函数的参数类型,与转发函数的参数类型高度一致,进而与调用者的实参类型相匹配。

被调函数
转发函数
调用者
左值引用
右值引用
左值引用
右值引用
forward
左值
右值
左值
右值

例如:

需要强调的是,forward函数专门用于转发,任何时候都不要forward两次,一个对象一旦被转发,它就已经不再是原来的它了。

16.7 位操作

标准库在<bit>头文件中提供了一系列用于底层位(比特)操作的函数。位操作是一项很专业,同时也是不可或缺的工作。尤其是在编写针对底层硬件的程序代码时,常常不得不直接查看位,以字节或字为单位更改位模式,并将原始内存转换为类型化的对象。

bit_cast函数可以将一个值从一种类型转换成另一种类型,前提是它们的大小必须相同。例如:

这里用uint8_t,即8字节整数表示一个字节。事实上,标准库专门为字节型数据提供了byte类型。与整型和字符型不同,byte类型仅支持位操作,而不支持算术运算。通常情况下,进行位操作的最佳类型是无符号整型或byte类型。例如:

此处“最佳”的意思是最快且最安全。

C++17:std::byte类型

C++20:bit_cast

C++20:位操作

16.8 退出程序

在一个函数中难免会遇到无法处理的问题:

针对最后一种情况,标准库提供了用于快速退出程序的一系列函数:

这些函数用于处理非常严重的错误。它们不调用析构函数,也就是说,它们不做普通的,体面的清理工作。各种退出处理函数用于在临退出前执行一些操作,通常与资源清理有关。这些操作必须非常简单,因为这时程序的运行状态已经不正常,稍有不慎,极有可能引发更大的麻烦。一种合理且相当流行的做法是,在定义明确的状态下,重新启动应用程序,而不依赖于当前程序的任何状态。另一种不太体面但还算合理的做法是,记录错误日志并退出。记录日志的风险在于,I/O系统可能已经损坏,而这可能恰恰是退出处理函数被调用的原因。

错误处理可能是最棘手的编程问题之一,体面地退出程序并不容易。任何通用库都不应该因错误而终止程序运行。

C++11:通过quick_exit放弃进程

16.9 建议