将某个库组件命名为“实用工具”或“实用程序”并不能提供太多信息。毕竟任何库组件都能在某个时间、某个地点,对某些人提供实用功能。这里所说的实用工具,是指那些在很多场合起到关键性作用,但又不适合被规划到特定范畴中的东西。通常它们被当做构建更强大的库(包括标准库)的基础构件。
标准库通过<chrono>头文件提供了一组用于处理时间的工具。例如:
时钟、time_point、duration等:用于测量某些操作的执行耗时,并作为与时间有关的任何事情的基础
year、month、day、weekdays等:用于将time_point形式的日期和时间,映射到人们的日常生活中
time_zone、zoned_time等:处理世界各地的时间差异
几乎所有的主流操作系统,都或多或少地提供了一些与时间处理有关的机制。
下面的程序演示了最基本的计时功能:
xxxxxxxxxx
31void doWork() {
2 for (int i = 0; i < 1000000000; ++i);
3}
xxxxxxxxxx
101time_point t1 = system_clock::now();
2doWork();
3time_point t2 = system_clock::now();
4duration d = t2 - t1;
5
6cout << d << endl; // 18732335[1/10000000]s
7cout << duration_cast<nanoseconds>(d).count() << "ns" << endl; // 1873233500ns
8cout << duration_cast<microseconds>(d).count() << "us" << endl; // 1873233us
9cout << duration_cast<milliseconds>(d).count() << "ms" << endl; // 1873ms
10cout << duration_cast<seconds>(d).count() << "s" << endl; // 1s
system_clock::now函数返回的当前系统时间,精确到百纳秒(
时钟对于性能测试非常有用。如果想对代码的执行效率发表意见,请在进行时间测量后再开口。对性能的任何盲目猜测都是不靠谱的。再简单的测量也好过完全没有测量。当然,现代计算机的性能是一个有争议的话题,单次简单测量的结果并不重要,多次重复测量有助于避免因偶然事件或缓存效应而妄下结论。
命名空间std::chrono_literals定义了一组表示时间单位的后缀。例如:
xxxxxxxxxx
11sleep_for(1s + 500ms); // 等待1秒500毫秒
符合常识的单位后缀,极大地提高了代码的可读性,使代码更容易维护。
C++11:duration和time_point等时间工具
在处理日常事件时,很少用到毫秒、微秒、纳秒这样的时间尺度,更多用到的是年、月、日和星期,这些时间度量。标准库为此提供了很好的支持。例如:
xxxxxxxxxx
41year_month_day d1{ December / 17 / 2024 };
2cout << d1 << endl; // 2024-12-17
3cout << weekday(d1) << endl; // Tue
4cout << format("{:%A}", weekday(d1)) << endl; // Tuesday
“Tue”是这台计算机上星期二的默认字符表示。如果希望得到星期的全拼形式,如“Tuesday”,可以借助format函数,并指定格式化标记“%A”。这里的“December”是一个表示月的对象,其类型为std::chrono::month。也可以等价地写成下面这样:
xxxxxxxxxx
21year_month_day d2{ 2024y / 12 / 17 };
2cout << d2 << endl; // 2024-12-17
这里的“y”是一个表示“年”的后缀,“2024y”是一个表示年的对象,其类型为std::chrono::year。后面的12和17都是普通整数,分别表示月和日。
日期类型也可以表示无效日期,其名为ok的成员函数用于检测日期的合法性。例如:
xxxxxxxxxx
31year_month_day d3{ 2024y / 12 / 32 };
2cout << d3 << endl; // 2024-12-32 is not a valid date
3cout << boolalpha << d3.ok() << endl; // false
显然,对于用户输入的日期,借助ok函数检测,还是有必要的。
日期的组成是通过重载year和month类对int类型的“/”操作符实现的。
另一种表示日期的类型sys_days可以与year_month_day类型相互转换,前者还支持常见的日期计算。例如:
xxxxxxxxxx
61sys_days d4{ 2024y / 2 / 28 };
2d4 += days{ 1 };
3cout << d4 << endl; // 2024-02-29
4year_month_day d5 = d4;
5cout << format("{:%B}/{}/{}",
6 d5.month(), d5.day(), d5.year()) << endl; // February/29/2024
日期计算并非简单的整数加减法,其中涉及到跨年、跨月和润年(二月有29天)问题。上述代码中的格式化标记“%B”,表示将月以全拼形式格式化为字符串,如“February”。
有关日期的处理甚至可以在编译时完成,以获得更高的效率。例如:
xxxxxxxxxx
11static_assert(weekday(April / 7 / 2018) == Saturday);
日期系统复杂而微妙。这是几个世纪以来,为普通人设计的系统的典型特征,它们更偏向于普通人,而从不考虑程序员编写代码是否容易。标准库的日期系统还将进一步扩展,以支持儒略历、伊斯兰历、泰历等更多历法。
C++20:日期
与时间相关的最棘手的问题之一就是时区。时区的规律是如此任性以至于让人难以记住,并且有时还会出于各种原因而发生变化,其变化方式在全球范围内尚无统一标准。例如:
xxxxxxxxxx
81time_point t1{ system_clock::now() };
2cout << t1 << endl; // 2024-12-18 02:48:01.7155469
3
4zoned_time t2{ current_zone(), t1 };
5cout << t2 << endl; // 2024-12-18 10:48:01.7155469 GMT+8
6
7zoned_time t3{ "America/New_York", t1 };
8cout << t3 << endl; // 2024-12-17 21:48:01.7155469 GMT-5
用time_point对象表示的时间是世界标准时间,也叫格林尼治时间(Greenwich Mean Time,GMT),即贯穿英国格林尼治天文台的本初子午线(
象日期一样,有关时区的一切问题已交由标准库解决,无需手动编写代码。想一想:2024年2月的最后一天,纽约的什么时间新德里会进入3月的第一天?2020年美国科罗拉多州丹佛市的夏令时何时结束?下一个润秒会在何时出现?标准库“知道”所有这类问题的答案。
C++20:时区
将函数作为参数传递给另一个函数时,作为实参的函数,其类型(返回值、参数表)必须与被调用函数相应形参的声明完全匹配。如果不能做到完全匹配,而只是大致符合某种约定,则可通过以下方法予以调整:
使用匿名函数
借助std::mem_fn将成员函数转化为函数对象
以std::function类型的对象作为参数
解决这类问题,还有许多其它方法,但效果最好的一定是上述三种方法中的一种。
C++11:改进的函数适配器
回顾之前编写的,用于绘制一组形状的drawShapes函数:
xxxxxxxxxx
41void drawShapes(vector<Shape*> shapes) {
2 for (auto shape : shapes)
3 shape->draw();
4}
该函数还有另一种等价的写法:
xxxxxxxxxx
31void drawShapes(vector<Shape*> shapes) {
2 for_each(shapes.begin(), shapes.end(), [](Shape* shape) { shape->draw(); });
3}
所有标准库算法一样,for_each函数遵循传统函数调用语法“f(x)”处理其第三个参数,而draw是一个成员函数,其调用形式为“x->f()”。匿名函数很好地协调了二者之间的矛盾,外“f(x)”,而内“x->f()”。
函数适配器mem_fn是一个用于生成函数对象的函数模板,可以传统函数调用语法调用其所生成的函数对象,并将调用对象作为参数传入其中。在该函数对象内部,再通过传入的调用对象调用指定的成员函数。例如:
xxxxxxxxxx
31void drawShapes(vector<Shape*> shapes) {
2 for_each(shapes.begin(), shapes.end(), mem_fn(&Shape::draw));
3}
在C++11引入匿名函数之前,mem_fn及类似的函数,是将传统函数调用语法“f(x)”,映射到成员函数调用语法“x->f()”的主要方式。
标准库的function是一个类模板,实例化该模板的类型参数是一个通过返回值和参数表描述的函数类型。function模板的实例化类通过其构造函数,接收并保存一个可调用对象(包括函数指针)。该类的实例化对象,因其重载了函数操作符,故可被当做函数调用,谓之函数对象。在其函数操作符函数的执行过程中,会调用构造时获得的可调用对象,传递所传递的参数,返回所返回的值。
function的本质就是一个封装了可调用对象的可调用对象,其内部重载的函数操作符函数,负责调用被封装的可调用对象,传入其所接收的参数,返回来自该可调用对象的返回值。例如:
xxxxxxxxxx
31int add(int x, int y) {
2 return x + y;
3}
xxxxxxxxxx
21function<int(int,int)> fun1{ add }; // 用函数类型实例化funciton模板
2cout << fun1(123, 456) << endl; // 579
又如:
xxxxxxxxxx
31string cat(string x, string y) {
2 return x + y;
3}
xxxxxxxxxx
21function fun2{ cat }; // 隐式推导function模板的类型参数
2cout << fun2("Hello, ", "World!") << endl; // Hello, World!
匿名函数也是可调用对象,也可以被function封装。例如:
xxxxxxxxxx
11function fun3{ [](Shape* shape) { shape->draw(); } }; // 封装匿名函数的function对象
这种做法相当于给匿名函数命名,被命名的匿名函数可以象任何具名函数一样被调用。例如:
xxxxxxxxxx
31void drawShapes(vector<Shape*> shapes) {
2 for_each(shapes.begin(), shapes.end(), fun3);
3}
显然,function对于回调、将操作作为参数传递、传递函数对象等,非常有用。但是,与直接调用相比,调用function可能会增加少量的运行时开销。特别地,对于在编译时未计算其大小的function对象,还会因自由存储分配,增加更多的运行时开销。对于性能关键型应用而言,这些额外的性能损失,往往会造成非常严重的不良影响。C++23有望推出一个新的解决方案——move_only_function,有助于性能的改善。
另一个问题是,function中的函数操作符函数只能有一个,无法提供参数表不同的多个重载版本。如果需要封装多个具有重载关系的可调用对象(包括匿名函数),可以考虑使用overloaded。
类型函数是以类型作为输入参数或返回值的函数,它们在编译时被执行。标准库通过多种类型函数帮助库的实现者编写代码,以更好地利用语言、标准库和普通代码的各种优势。
对于数值类型,<limits>头文件中的numeric_limits函数提供了各种有用的信息。例如:
xxxxxxxxxx
21constexpr float min = numeric_limits<float>::min(); // 最小正浮点数
2cout << min << endl; // 1.17549e-38
类似地,可以借助内置的sizeof操作符获取类型的大小。例如:
xxxxxxxxxx
21constexpr int szi = sizeof(int); // int的字节数
2cout << szi << endl; // 4
在<type_traits>头文件中,标准库提供了很多用于查询类型属性的函数。例如:
xxxxxxxxxx
31cout << boolalpha << is_arithmetic_v<short> << endl; // true
2cout << typeid(decltype(main)).name() << endl; // int __cdecl(void)
3cout << typeid(invoke_result_t<decltype(main)>).name() << endl; // int
其中,is_arithmetic_v用于判断一个类型是否是数值类型,decltype函数返回其函数型参数的类型,invoke_result_t用于从一个函数的类型中提取其返回值的类型,typeid函数返回一个type_info类型的对象,其中包含传递该函数的参数的类型信息,该对象的name成员函数返回类型信息中的类型名字符串。
某些类型函数还可以根据其输入创建出新的类型。例如:
xxxxxxxxxx
91template<typename T>
2class Stack {
3 ... 分配器在栈中分配内存 ...
4};
5
6template<typename T>
7class Heap {
8 ... 分配器在堆中分配内存 ...
9};
xxxxxxxxxx
71template<typename T>
2void foo() {
3 using Store = conditional_t<sizeof(T) < 4, Stack<T>, Heap<T>>;
4 cout << typeid(Store).name() << endl;
5
6 ... 使用Store存储T类型的数据 ...
7}
如果conditional_t的第一个参数为true,则取第二个参数所表示的类型,否则取第三个参数所表示的类型。例如:
xxxxxxxxxx
41foo<char>(); // class Stack<char>
2foo<short>(); // class Stack<short>
3foo<int>(); // class Heap<int>
4foo<long long>(); // class Heap<__int64>
Store容器所使用的分配器可以根据其中元素的字节数自适应调整。基于这种选择性的分配器性能调优,有时候会非常重要。
概念也属于类型函数。当在表达式中使用概念时,它们是特定的类型谓词。例如:
xxxxxxxxxx
71template<typename Func, typename... Args>
2auto call(Func func, Args... args, Allocator allocator) {
3 if constexpr (invocable<Func, Allocator, Args...>) // 若需要分配器
4 return func(allocator, args...);
5 else // 若不需要分配器
6 return func(args...);
7}
在许多情况下,概念是最好的类型函数,但大多数标准库都诞生自概念出现之前,且要支持那个时代的代码库。
类型函数的命名规则有时会令人感到困惑。一般而言,以“_v”结尾的类型函数会返回一个值,如is_arithmetic_v返回true或者false,而以“_t”结尾的类型函数会返回一个类型,如invoke_result_t返回函数类型中的返回值类型。这其实是在概念出现之前,弱类型时代的遗留物。截止目前,标准库中还没有任何仅靠后缀区分的类型函数,因此这些后缀其实是多余的。标准库和其它地方的概念,不需要也确实没有使用任何形式的后缀。
类型函数是C++编译期计算机制的一部分,与没有它们相比,类型函数提供了更严格的类型检查和更优越的运行性能。基于类型函数和概念的编程,通常被称为元编程或模板元编程(当涉及模板时)。
在<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。
这些谓词的传统用法是给模板的类型参数附带约束条件。例如:
xxxxxxxxxx
101template<typename T>
2class complex {
3 static_assert(is_arithmetic_v<T>, "复数的实部和虚部必须是整数或浮点数");
4
5public:
6 ...
7
8private:
9 T m_real, m_imag;
10};
然而,和其它传统用法一样,使用概念会更简洁,也更优雅。例如:
xxxxxxxxxx
81template<Arithmetic T>
2class complex {
3public:
4 ...
5
6private:
7 T m_real, m_imag;
8};
在许多情况下,类似is_arithmetic_v这样的类型谓词,更适于定义概念,而非直接使用。例如:
xxxxxxxxxx
21template<typename T>
2concept Arithmetic = is_arithmetic_v<T>;
甚至可以抛却标准库的类型谓词,定义更通用的概念。因为许多标准库的类型谓词仅适用于内置类型,所以有时需要根据所需操作自行定义一些概念,如Number概念:
xxxxxxxxxx
61template<typename U, typename V = U>
2concept Number = requires(U u, V v) {
3 u + v; u - v; u * v; u / v;
4 u += v; u -= v; u *= v; u /= v;
5 u = u; u = 0;
6};
并在此基础上定义Arithmetic概念:
xxxxxxxxxx
21template<typename U, typename V = U>
2concept Arithmetic = Number<U, V> && Number<V, U>;
在大多数情况下,基础设施的实现中深度使用了标准库的类型谓词,通常用于针对特定案例进行区别优化。比如下面的copy函数用于复制连续的对象序列:
xxxxxxxxxx
51template<typename T>
2void copy(T* first, T* last, T* target) {
3 while (first != last)
4 *target++ = *first++;
5}
但如果序列中的元素是类似整数这样的简单类型,完全可以采用更优化的复制策略。因此更好的copy函数应该类似下面这个样子:
xxxxxxxxxx
81template<typename T>
2void copy(T* first, T* last, T* target) {
3 if constexpr (is_trivially_copyable_v<T>)
4 memcpy(first, target, (last - first) * sizeof(T));
5 else
6 while (first != last)
7 *target++ = *first++;
8}
类似这样的优化,可以使程序的性能提升近50%,但不要沉迷于这种小聪明,除非有充分的证据证明标准库做得不够好。包含手工优化的代码通常比直接使用标准库的代码更难以维护。
C++11:类型特征,如is_integral_v、is_base_of等
假设有一个智能指针的实现:
xxxxxxxxxx
151template<typename T>
2class SmartPointer {
3public:
4 ...
5 T& operator*() const {
6 return *m_p;
7 }
8
9 T* operator->() const {
10 return m_p;
11 }
12 ...
13private:
14 T* m_p;
15};
其中解引用操作符(*)总是有意义的,但间接成员访问操作符(->)只有在T是一个类时才有意义。SmartPointer<Student>应该同时支持“*”和“->”操作符,而SmartPointer<int>则应仅支持“*”操作符。为此可以借助requires关键字结合标准库的类型谓词构造约束条件。例如:
xxxxxxxxxx
151template<typename T>
2class SmartPointer {
3public:
4 ...
5 T& operator*() const {
6 return *m_p;
7 }
8
9 T* operator->() const requires is_class_v<T> {
10 return m_p;
11 }
12 ...
13private:
14 T* m_p;
15};
即当且仅当T是一个类时才定义“->”操作符。也可以使用概念,达到同样的效果。例如:
xxxxxxxxxx
21template<typename T>
2concept Class = is_class_v<T> || is_union_v<T>; // union也是类
xxxxxxxxxx
151template<typename T>
2class SmartPointer {
3public:
4 ...
5 T& operator*() const {
6 return *m_p;
7 }
8
9 T* operator->() const requires Class<T> {
10 return m_p;
11 }
12 ...
13private:
14 T* m_p;
15};
一般而言,使用概念比直接使用标准库的类型谓词更通用,也更合适。
许多类型函数的返回值是一个类型,且此类型通常为该函数经计算得出的新类型,这种类型函数也叫类型生成器,以区别于类型谓词。下表列出了其中的一部分:
类型生成器 | 说明 |
---|---|
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。例如对智能指针“->”操作符的约束:
xxxxxxxxxx
151template<typename T>
2class SmartPointer {
3public:
4 ...
5 T& operator*() const {
6 return *m_p;
7 }
8
9 enable_if_t<is_class_v<T>, T*> operator->() const {
10 return m_p;
11 }
12 ...
13private:
14 T* m_p;
15};
即当且仅当T是一个类时才定义“->”操作符。
事实上,这样的代码可读性并不好。在更复杂的用法中,情况会变得愈发糟糕。enable_if_t的定义依赖于一种称为SFINAE(Substitution Failure Is Not An Error,替换失败不是错误)的语法机制。
C++11:更简单、更通用的SFINAE(替换失败不是错误)规则
所有的标准库容器及被设计为遵循标准库模式的容器,都有一些关联类型,例如它们的元素类型和迭代器类型。标准库在<iterator>和<ranges>头文件中提供了获取它们的类型函数。下表列出了其中的一部分:
获取关联类型的类型函数 | 说明 |
---|---|
range_value_t<R> | 容器R中的元素的类型 |
iterator_t<R> | 容器R的迭代器的类型 |
iter_value_t<I> | 迭代器I的目标元素的类型 |
其中的R和I分别为容器和迭代器的类型。
在程序中输出跟踪消息或错误日志时,常常希望将源代码的位置信息作为消息的一部分。标准库为此提供了source_location类。该类的静态成员函数current会返回一个该类的对象,其中包含了源代码的位置信息。例如:
xxxxxxxxxx
81void log(const string& msg = "", const source_location& loc =
2 source_location::current()) {
3 cout << loc.file_name() << '('
4 << loc.line() << ':'
5 << loc.column() << ")["
6 << loc.function_name() << "]: "
7 << msg << endl;
8}
xxxxxxxxxx
31void foo() {
2 log("some message");
3}
xxxxxxxxxx
31int main() {
2 foo();
3}
运行上面的程序,输出如下日志信息:
xxxxxxxxxx
11...\SourceLocation.cpp(19:5)[void __cdecl foo(void)]: some message
这里将对current函数的调用放在默认参数中,以保证获取的是log函数调用者的位置,而非log函数自身的位置。
在C++20之前,实现类似的功能,可能需要使用类似“__FILE__”、“__LINE__”、“__FUNCTION__”或“__func__”这样的预定于宏。
C++11:借助__func__宏获取当前函数名字符串
C++20:source_location
拷贝和转移之间的抉择,在多数情况下,都是隐式进行的。编译器更倾向于在对象即将被销毁时选择转移,比如从函数中返回对象,因为这样被认为是更简单、更有效的操作。但有时,人们可能需要显式指明,究竟是拷贝还是转移。比如unique_ptr是对象的唯一持有者,它不能被拷贝,如果希望得到另一个指向同一个对象的unique_ptr,只能通过转移获得。
假设p是一个指向整型变量的unique_ptr:
xxxxxxxxxx
11auto p = make_unique<int>(2);
为了获得p的副本q,将引发编译错误:
xxxxxxxxxx
11auto q = p; // 编译错误,unique_ptr不支持拷贝
但可以通过move函数,显式将p转移到q:
xxxxxxxxxx
11auto q = move(p); // 显式将p转移到q
move函数本身其实并没有转移任何东西,它唯一的作用就是将其参数转换为一个右值,这样做的结果就是:
从语义上,向编译器明确表示,其参数将不会再被使用,其所持有的资源可被转移到其它对象中
从语法上,匹配以右值引用为参数的,转移构造函数或转移赋值操作符函数,实现资源转移逻辑
从这个意义上讲,它似乎更适合被命名为类似rvalue_cast之类的东西。
除了象unique_ptr这种不得已而为之的情况外,move函数还可用于一些有意为之的场合。例如:
xxxxxxxxxx
61template<typename T>
2void swap(T& a, T& b) {
3 T c = a;
4 a = b;
5 b = c;
6}
这是一个典型的交换两个变量的值的函数。但从以上实现不难看出,其间至少发生了三次对象拷贝。对于大对象而言,对象拷贝可能会严重影响程序的运行性能。如能代之以对象转移,至少在性能上,将获得很大提升。例如:
xxxxxxxxxx
61template<typename T>
2void swap(T& a, T& b) {
3 T c = move(a);
4 a = move(b);
5 b = move(c);
6}
move函数非常诱人,但是也很危险。例如:
xxxxxxxxxx
61string s1 = "hello";
2string s2 = "world";
3vector<string> v;
4v.push_back(s1); // 调用string的拷贝构造函数string(const string&),将s1拷贝到v的尾端
5v.push_back(move(s2)); // 调用string的转移构造函数string(string&&),将s2转移到v的尾端
6v.emplace_back(s1); // 调用string的拷贝构造函数string(const string&),将s1拷贝到v的尾端
这里的s1是以拷贝方式进入动态数组v的,而s2却以转移方式进入,因此针对s2的push_back成本更低。但潜在的问题是留下了一个被转移走的对象。如果再次使用s2,将可能引发问题。例如:
xxxxxxxxxx
21cout << s1[2] << endl; // l
2cout << s2[2] << endl; // 可能崩溃
以这种方式使用move函数太容易出错了。除非能证明这样做确实有显著且必要的性能提升,否则最好还是敬而远之,毕竟在以后的维护过程中,很难保证不会再次启用已经被转移走的对象。
对于从函数中返回对象的情形,编译器自然知道被返回的对象不可能在函数中被继续使用,默认以转移方式使调用者获得该对象是合理且安全的。这时显式使用move函数不仅多余,而且有可能抑制编译器本应采取的优化。
被转移走的对象的状态通常是未定义的,但至少应该是可被销毁和可再次接受赋值的,采用其它做法并非明智的选择。对于容器(vector或string)而言,被转移走意味着容器为空。对于其它类型,最好以默认状态表示被转移走,有意义且成本最低。
考虑下面的函数:
xxxxxxxxxx
41template<typename T>
2void fun(T&& r) {
3 foo(r); // 无论r是左值引用还是右值引用,其实都是左值
4}
无论fun函数的形参r是一个左值引用还是右值引用,在将其作为实参传递给foo函数时,一律被视为左值。如果希望在不改变左右值属性的前提下,将形参作为实参传递给另一个函数,即所谓完美转发,就要用到标准库提供的forward函数。例如:
xxxxxxxxxx
41template<typename T>
2void fun(T&& r) {
3 foo(forward<T>(r)); // r是左值或右值引用,被forward处理为左值或右值
4}
如果r是一个左值引用,forward<T>(r)就是一个左值,而如果r是一个右值引用,forward<T>(r)就是一个右值。
forward函数能够很好地处理与左值和右值相关的一切微妙差别,确保被调函数的参数类型,与转发函数的参数类型高度一致,进而与调用者的实参类型相匹配。
例如:
xxxxxxxxxx
41template<typename T, typename... Args>
2unique_ptr<T> make_unique(Args&&... args) {
3 return unique_ptr<T>{ new T{ forward<Args>(args)... } };
4}
需要强调的是,forward函数专门用于转发,任何时候都不要forward两次,一个对象一旦被转发,它就已经不再是原来的它了。
标准库在<bit>头文件中提供了一系列用于底层位(比特)操作的函数。位操作是一项很专业,同时也是不可或缺的工作。尤其是在编写针对底层硬件的程序代码时,常常不得不直接查看位,以字节或字为单位更改位模式,并将原始内存转换为类型化的对象。
bit_cast函数可以将一个值从一种类型转换成另一种类型,前提是它们的大小必须相同。例如:
xxxxxxxxxx
171double d = 7.2;
2cout << d << endl; // 7.2
3cout << &d << endl; // 00000049C04FF528
4
5uint64_t x = bit_cast<uint64_t>(d); // 获取64位浮点数的比特表示
6cout << format("{:b}", x) << endl; // 100000000011100110011001100110011001100110011001100110011001101
7
8auto y = bit_cast<uint64_t>(&d); // 获取内存地址的比特表示
9cout << format("{:016X}", y) << endl; // 00000049C04FF528
10
11uint32_t u = 0x12345678;
12cout << format("0x{:08x}", u) << endl; // 0x12345678
13
14struct DWORD { uint8_t bytes[4]; };
15DWORD dw = bit_cast<DWORD>(u); // 获取32位整数的字节表示
16cout << format("|0x{:02x}|0x{:02x}|0x{:02x}|0x{:02x}|",
17 dw.bytes[0], dw.bytes[1], dw.bytes[2], dw.bytes[3]) << endl; // |0x78|0x56|0x34|0x12|
这里用uint8_t,即8字节整数表示一个字节。事实上,标准库专门为字节型数据提供了byte类型。与整型和字符型不同,byte类型仅支持位操作,而不支持算术运算。通常情况下,进行位操作的最佳类型是无符号整型或byte类型。例如:
xxxxxxxxxx
41cout << bit_width(u) << endl; // 有效位宽:29
2cout << format("{:b}", u) << endl; // 10010001101000101011001111000
3uint32_t v = rotr(u, 4); // 循环右移4位
4cout << format("{:b}", v) << endl; // 10000001001000110100010101100111
此处“最佳”的意思是最快且最安全。
C++17:std::byte类型
C++20:bit_cast
C++20:位操作
在一个函数中难免会遇到无法处理的问题:
如果这种问题很常见,并且期望函数的直接调用者能处理它,则返回特定的返回值
如果这种问题很罕见,或者不希望函数的直接调用者处理它,则抛出特定的异常
如果这种问题很严重,以至于程序的任何部分都无法处理它,则立刻终止程序
针对最后一种情况,标准库提供了用于快速退出程序的一系列函数:
exit(x):调用通过atexit函数注册的退出处理函数,然后以退出码x退出程序。atexit函数可以理解为是一个C语言时代的原始版本的析构机制
abort():立即无条件退出程序,程序以失败码终止。部分操作系统提供了用于修改abort函数行为的工具
quick_exit(x):调用通过at_quick_exit函数注册的退出处理函数,然后以退出码x退出程序
terminate():调用terminate_handler函数,该函数默认调用abort函数
这些函数用于处理非常严重的错误。它们不调用析构函数,也就是说,它们不做普通的,体面的清理工作。各种退出处理函数用于在临退出前执行一些操作,通常与资源清理有关。这些操作必须非常简单,因为这时程序的运行状态已经不正常,稍有不慎,极有可能引发更大的麻烦。一种合理且相当流行的做法是,在定义明确的状态下,重新启动应用程序,而不依赖于当前程序的任何状态。另一种不太体面但还算合理的做法是,记录错误日志并退出。记录日志的风险在于,I/O系统可能已经损坏,而这可能恰恰是退出处理函数被调用的原因。
错误处理可能是最棘手的编程问题之一,体面地退出程序并不容易。任何通用库都不应该因错误而终止程序运行。
C++11:通过quick_exit放弃进程
对于库而言,大而全不如小而精
在对效率下结论之前,一定要对程序进行计时测量
借助duration_cast函数,以适当的单位报告时间测量结果
在源代码中表示日期,可以使用类似“December/17/2024”的符号表示法
如果日期来自用户的输入或程序的计算,可以借助ok函数检查其有效性
当处理不同地点的时间时,可以使用zoned_time类型
借助匿名函数表示调用约定中的细微变化
需要以传统方式调用类的成员函数时,可以使用mem_fn函数或匿名函数创建函数对象
一个function类型的对象,可被当做函数调用,实际被调用的,是封装在该对象内部的,任何可被调用的东西
建议使用概念,而非直接使用类型谓词
代码中的某些部分,可能会严格依赖类型属性
尽可能地使用概念,而非特征和enable_if
借助source_location对象,在调试信息和日志消息中嵌入源代码位置
避免显式使用move函数
forward函数仅用于完美转发,而非其它场合
绝不要在move和forward之后读取对象
以byte类型表示(还)没有类型的数据
使用无符号整数或bitset进行位操作
如果函数的直接调用者可以处理函数中遇到的问题,则应返回错误代码
如果函数的直接调用者不一定能处理函数中遇到的问题,则应抛出异常
如果试图从问题中恢复是不合理的,则调用exit、abort、quick_exit或terminate函数,终止程序运行
任何通用库都不应该因错误而终止程序运行