多线程
1998年的C++标准并未涉及多线程的概念。然而,从那时起到当前C++标准发布的这段时间里,计算机已经发展为多核设备,因此在软件开发时考虑使用多线程已成为一种现实的选择。
多线程是一个广泛而复杂的主题,关于这一主题有许多优秀的参考书籍。C++的多线程功能是基于pthread库提供的功能构建的(参见Nichols, B等人的《Pthreads Programming》, O’Reilly)。然而,按照C++当前的设计理念,语言提供的多线程实现为多线程操作提供了一个高级接口,因此几乎不需要直接使用底层的pthread构件(参见Williams, A.(2019):《C++ Concurrency in Action》)。
本章将介绍C++支持的多线程功能。虽然本章的内容旨在提供创建多线程程序所需的工具和示例,但由于多线程主题的广泛性,内容远未涵盖全部。提到的参考书籍是进一步学习多线程的良好起点。
一个执行线程(通常缩写为线程)是程序内的单一控制流。它不同于通过fork(1)
系统调用创建的单独执行的程序,因为线程是在一个程序内部运行的,而fork(1)
创建的是运行程序的独立副本。多线程意味着多个任务在一个程序内部并行执行,不能假设哪个线程先运行或后运行,也不能假设具体的时间点。特别是当线程数量不超过核心数量时,每个线程可能同时处于活动状态。如果线程数量超过了核心数量,操作系统将切换任务,为每个线程提供时间片以执行其任务。任务切换需要时间,并且这里也适用收益递减规律:如果线程数量远远超过可用核心数量(也称为过度并发),则产生的开销可能超过并行执行多个任务所带来的好处。
由于所有线程都在一个程序内运行,因此所有线程共享该程序的数据和代码。当多个线程访问相同的数据时,如果至少有一个线程正在修改这些数据,则必须同步访问,以避免线程在数据被其他线程修改时读取数据,并避免多个线程同时修改相同的数据。
那么,如何在C++中运行一个多线程程序呢?让我们来看一个多线程版的“Hello World”:
#include <iostream>
#include <thread>
void hello()
{
std::cout << "hello world!\n";
}
int main()
{
std::thread hi(hello);
hi.join();
}
- 在第2行,引入了
<thread>
头文件,告诉编译器存在std::thread
类(参见第20.1.2节); - 在第11行,创建了
std::thread hi
对象。它接受一个函数名(hello
),该函数将在一个单独的线程中被调用。实际上,当通过这种方式定义std::thread
时,运行hello
的第二个线程立即启动; main
函数本身也代表一个线程:程序的第一个线程。它应当等待第二个线程完成。这通过第12行的hi.join()
实现,它等待线程hi
完成其任务。由于main
中没有其他语句,因此程序在此之后立即结束;- 第4到7行定义的
hello
函数非常简单:它只是将“hello world”文本插入到cout
中,并终止,从而结束第二个线程。
多线程
在C++中,多线程可以在不同抽象层次上实现。通常应该使用可用的最高抽象层次来实现多线程问题。这不仅仅是因为使用较高抽象层次通常比使用较低层次更简单,还因为较高层次的抽象通常在语义上更接近原始问题描述,从而使代码更易于理解和维护。此外,高抽象层次的类还提供异常安全性,并防止内存泄漏的发生。
C++中创建多线程程序的主要工具是std::thread
类,在本章开头已经展示了一些使用它的示例。
可以通过std::this_thread
命名空间查询单个线程的特性。此外,std::this_thread
还提供了一些对单个线程行为的控制。
为了同步对共享数据的访问,C++提供了互斥量(由std::mutex
类实现)和条件变量(由std::condition_variable
类实现)。
当遇到低级错误条件时,这些类的成员函数可能会抛出system_error
对象(参见第10.9节)。
命名空间 std::this_thread
命名空间 std::this_thread
包含了一些与当前运行的线程唯一相关的函数。
在使用 this_thread
命名空间之前,必须包含 <thread>
头文件。
在 std::this_thread
命名空间中定义了几个自由函数,它们提供关于当前线程的信息或用于控制其行为:
-
thread::id this_thread::get_id() noexcept
:
返回一个thread::id
类型的对象,该对象标识当前活动的执行线程。对于一个活动线程,返回的id
是唯一的,1:1 映射到当前活动的线程,且不会被其他线程返回。如果线程当前未运行,则由std::thread
对象的get_id
成员函数返回默认的thread::id
对象。 -
void yield() noexcept
:
当一个线程调用this_thread::yield()
时,当前线程会被暂时挂起,允许其他(等待的)线程启动。 -
void sleep_for(chrono::duration<Rep, Period> const &relTime) noexcept
:
当一个线程调用this_thread::sleep_for(...)
时,它会根据参数指定的时间量被挂起。例如:std::this_thread::sleep_for(std::chrono::seconds(5));
-
void sleep_until(chrono::time_point<Clock, Duration> const &absTime) noexcept
:
当一个线程调用此成员函数时,它会被挂起,直到指定的absTime
时间点已经过去。例如,下面的代码与前一个示例效果相同:this_thread::sleep_until(chrono::system_clock().now() + chrono::seconds(5));
相反,在下面的示例中,
sleep_until
调用会立即返回:this_thread::sleep_until(chrono::system_clock().now() - chrono::seconds(5));
C++ 中的 std::thread 类
在 C++ 中,多线程编程是从 std::thread
类的对象开始的。每个 std::thread
类的对象都处理一个单独的线程。在使用 std::thread
对象之前,必须包含 <thread>
头文件。线程对象可以通过多种方式构造:
-
thread() noexcept:
默认构造函数会创建一个std::thread
对象。由于它没有接收任何要执行的函数,因此不会启动一个单独的执行线程。它通常用于类的成员变量,允许类对象在稍后的时间点启动一个单独的线程。 -
thread(thread &&tmp) noexcept:
移动构造函数会接管tmp
所控制的线程的所有权,而如果tmp
正在运行一个线程,则它会失去对其线程的控制。此后,tmp
将处于默认状态,而新创建的线程负责调用join
等操作。 -
explicit thread(Fun &&fun, Args &&…args):
此成员模板(参见 22.1.3 节)期望第一个参数为函数(或仿函数)。该函数会立即作为一个单独的线程启动。如果函数(或仿函数)需要参数,则可以在构造std::thread
对象时,将这些参数紧跟在第一个函数参数之后传递。额外的参数会以其适当的类型和值传递给fun
。在创建std::thread
对象后,会立即启动一个单独运行的线程。参数
Arg &&...args
的表示方式意味着额外的参数将按原样传递给函数。传递给线程构造函数的参数类型必须与被调用函数期望的参数类型匹配:值必须是值,引用必须是引用,右值引用必须是右值引用(或支持移动构造)。
以下示例展示了这种要求:
#include <iostream>
#include <thread>
using namespace std;
struct NoMove {
NoMove() = default;
NoMove(NoMove && tmp) = delete;
};
struct MoveOK {
int d_value = 10;
MoveOK() = default;
MoveOK(MoveOK const &) = default;
MoveOK(MoveOK && tmp) {
d_value = 0;
cout << "MoveOK move cons.\n";
}
};
void valueArg(int value) {}
void refArg(int &ref) {}
void r_refArg(int &&tmp) {
tmp = 100;
}
void r_refNoMove(NoMove &&tmp) {}
void r_refMoveOK(MoveOK &&tmp) {}
int main() {
int value = 0;
std::thread(valueArg, value).join();
std::thread(refArg, ref(value)).join();
std::thread(r_refArg, move(value)).join();
// std::thread(refArg, value); // 此行编译会失败
std::thread(r_refArg, value).join();
cout << "value after r_refArg: " << value << '\n';
// std::thread(r_refNoMove, NoMove()); // 此行编译会失败
NoMove noMove;
// std::thread(r_refNoMove, noMove).join(); // 此行编译会失败
MoveOK moveOK;
std::thread(r_refMoveOK, moveOK).join();
cout << moveOK.d_value << '\n';
}
- 在第 43 到 45 行,我们看到将值、引用和右值引用传递给
std::thread
,并且运行这些线程的函数期望匹配的参数类型。 - 第 47 行编译失败,因为值参数与
refArg
期望的引用不匹配。注意,这个问题在第 43 行使用std::ref
函数解决了。 - 另一方面,第 49 和 58 行编译成功,因为
int
值和支持移动操作的类类型可以作为值传递给期望右值引用的函数。在这种情况下,期望右值引用的函数不访问提供的参数(除了通过它们的移动构造函数执行的操作),而是使用移动构造创建临时值或对象,函数在这些对象上操作。 - 第 52 和 55 行无法编译,因为
NoMove
结构体没有提供移动构造函数。 - 类的成员函数也可以用作线程函数。在这种情况下,构造函数的第一个参数必须是成员函数的地址,第二个参数必须是一个指针(或引用,或对象),用于调用线程函数的成员函数,而后续的参数则传递给成员函数。
成员函数作为线程函数的示例:
struct Demo {
int d_value = 0;
void fun(int value) {
d_value = value;
cout << "fun sets value to " << value << "\n";
}
};
int main() {
Demo demo;
std::thread thr{&Demo::fun, ref(demo), 12 };
thr.join();
cout << "demo's value: " << demo.d_value << '\n'; // 输出 12
thr = std::thread{&Demo::fun, &demo, 42 };
thr.join();
cout << "demo's value: " << demo.d_value << '\n'; // 输出 42
thr = std::thread{&Demo::fun, demo, 77 };
thr.join();
cout << "demo's value: " << demo.d_value << '\n'; // 输出 42:线程复制了 demo
}
在将局部变量作为参数传递给 std::thread
对象时要小心:如果线程在使用局部变量的函数终止后仍继续运行,那么线程会突然使用野指针或野引用,因为局部变量不再存在。要防止这种情况发生,可以按如下方式操作:
- 将局部变量的匿名副本作为参数传递给线程构造函数,或
- 在局部变量的生命周期内调用线程对象的
join
以确保线程已完成。
示例代码:
#include <iostream>
#include <thread>
#include <string>
#include <chrono>
void threadFun(std::string const &text) {
for (size_t iter = 1; iter != 6; ++iter) {
std::cout << text << '\n';
std::this_thread::sleep_for(std::chrono::seconds(1));
}
}
std::thread safeLocal() {
std::string text = "hello world";
return std::thread(threadFun, std::string{ text });
}
int main() {
std::thread local(safeLocal());
local.join();
std::cout << "safeLocal has ended\n";
}
在第 18 行,确保不要调用 std::ref(text)
,而是使用 std::string{ text }
。
如果线程无法创建,则会抛出 std::system_error
异常。由于此构造函数不仅接收函数,还可以接收函数对象作为第一个参数,因此可以将局部上下文传递给函数对象的构造函数。以下是线程接收使用局部上下文的函数对象的示例:
#include <thread>
#include <array>
using namespace std;
class Functor {
array<int, 30> &d_data;
int d_value;
public:
Functor(array<int, 30> &data, int value) : d_data(data), d_value(value) {}
void operator()(ostream &out) {
for (auto &value : d_data) {
value = d_value++;
out << value << ' ';
}
out << '\n';
}
};
int main() {
array<int, 30> data;
Functor functor{ data, 5 };
thread funThread{ functor, ref(cout) };
funThread.join();
}
std::thread
类不提供复制构造函数。以下是可用的成员函数:
-
thread &operator=(thread &&tmp) noexcept:
如果运算符的左操作数(lhs)是一个可连接的线程,则调用terminate
。否则,tmp
被赋值给运算符的左操作数,并且tmp
的状态被更改为线程的默认状态(即thread()
)。 -
void detach():
要求joinable()
返回true
。调用detach
的线程继续运行,调用detach
的线程立即在detach
调用之后继续。调用object.detach()
后,‘object’ 不再表示(可能仍在继续但现在已分离的)执行线程。当其执行结束时,分离线程的实现有责任释放其资源。由于
detach
将线程与运行程序断开连接,例如,main
不再能够等待线程的完成。当main
结束时,正在运行的分离线程也会停止,程序可能无法正确完成其所有线程。
示例代码:
#include <iostream>
#include <chrono>
#include <thread>
void fun(size_t count, char const *txt) {
for (; count--; ) {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
std::cout << count << ": " << txt <<
std::endl;
}
}
int main() {
std::thread first(fun, 5, "hello world");
first.detach();
std::thread second(fun, 5, "a second thread");
second.detach();
std::this_thread::sleep_for(std::chrono::milliseconds(400));
std::cout << "leaving" << std::endl;
}
在这里,分离的线程可能在启动它的函数完成后继续运行。因此,应非常小心不要将局部变量传递给分离的线程,因为它们的引用或指针将在定义局部变量的函数终止后变为未定义的。
示例代码:
#include <iostream>
#include <thread>
#include <chrono>
using namespace std;
using namespace chrono;
void add(int const &p1, int const &p2) {
this_thread::sleep_for(milliseconds(200));
cerr << p1 << " + " << p2 << " = " << (p1 + p2) << '\n';
}
void run() {
int v1 = 10;
int v2 = 20;
thread(add, ref(v1), ref(v2)).detach(); // 不要这样做
thread(add, int(v1), int(v2)).detach(); // 这样做是可以的:拥有自己的副本
}
int main() {
run();
this_thread::sleep_for(seconds(1));
}
-
id get_id() const noexcept:
如果当前对象不代表正在运行的线程,则返回thread::id()
。否则,返回线程的唯一 ID(也可以从线程内部通过this_thread::get_id()
获取)。 -
unsigned thread::hardware_concurrency() noexcept:
此静态成员返回当前计算机上可以同时运行的线程数。在独立的多核计算机上,它可能返回核心数量。 -
void join():
要求joinable()
返回true
。如果调用join
的线程尚未完成,则调用join
的线程将被暂停(也称为阻塞),直到调用join
的线程完成。完成后,调用join
的对象不再代表正在运行的线程,其get_id
成员将返回std::thread::id()
。在
main
结束时,如果仍有可连接的线程在运行,则调用terminate
,终止程序。 -
bool joinable() const noexcept:
返回object.get_id() != id()
,其中object
是调用joinable
的线程对象。 -
native_handle_type native_handle():
返回线程的句柄(实现定义)。可以将此句柄传递给pthread_getschedparam
和pthread_setschedparam
等函数以获取/设置线程的调度策略和参数。 -
void swap(thread &other) noexcept:
交换调用swap
的线程对象和other
的状态。注意,线程总是可以交换,即使它们的线程函数当前正在执行。
注意事项:
- 当打算定义匿名线程时,除非立即调用
join
,否则线程可能不会启动。例如:
void doSomething();
int main() {
thread(doSomething); // 没有发生什么?
thread(doSomething).join(); // doSomething 被执行了?
}
这类似于我们在 7.5 节中遇到的情况:第一条语句根本没有定义匿名线程对象。它只是定义了线程对象 doSomething
。因此,第二条语句的编译失败,因为没有 thread(thread &)
构造函数。省略第一条语句时,第二条语句执行了 doSomething
函数。如果省略第二条语句,默认构造的线程对象 doSomething
被定义。
-
线程只在构造完成后才启动。这包括移动构造或移动赋值。例如,在类似
thread object(thread(doSomething));
的语句中,使用移动构造函数将控制权从执行doSomething
的匿名线程转移到线程对象。只有在对象构造完成后,doSomething
才会在单独的线程中启动。 -
从线程抛出的异常(例如,由定义线程操作的函数抛出)是线程的局部异常。它们必须由执行线程捕获(因为每个运行线程都有自己的执行栈),或者可以使用
packaged_task
和future
传递给启动线程(参见 20.11 和 20.8 节)。
线程在执行线程函数完成时结束。当线程对象在其线程函数仍在运行时销毁时,会调用 terminate
,终止程序。这是一个糟糕的情况:现有对象的析构函数不会被调用,抛出的异常也不会被捕获。
示例代码:
#include <iostream>
#include <thread>
void hello() {
while (true)
std::cout << "hello world!\n";
}
int main() {
std::thread hi(hello);
}
在上述代码中,线程在 main
结束时仍处于活动状态,因此调用了 terminate
。有几种方法可以解决此问题,其中一种在下一节中讨论。
在多线程程序中,传统的全局数据和局部数据之间的区分可能显得过于粗糙。对于单线程和多线程程序来说,全局数据对于程序的所有代码都是可用的,而局部数据仅对定义它们的函数(或复合语句)可用。但多线程程序可能需要一种中间类型的数据,这种数据仅对不同的线程可见。
thread_local
关键字提供了这种中间层次的数据。声明为 thread_local
的全局变量在每个单独的线程中都是全局的。每个线程拥有 thread_local
变量的一份副本,并且可以随意修改它们。一个线程中的 thread_local
变量与另一个线程中的同名变量完全隔离。以下是一个示例:
#include <iostream>
#include <thread>
using namespace std;
thread_local int t_value = 100;
void modify(char const *label, int newValue) {
cout << label << " before: " << t_value << ". Address: " << &t_value << '\n';
t_value = newValue;
cout << label << " after: " << t_value << '\n';
}
int main() {
thread(modify, "first", 50).join();
thread(modify, "second", 20).join();
modify("main", 0);
}
- 在第6行,定义了
thread_local
变量t_value
。它被初始化为100,这将成为每个单独运行的线程的初始值; - 在第8到第14行中,定义了
modify
函数。该函数为t_value
赋予一个新值; - 在第18和第19行,启动了两个线程,这两个线程立即与主线程汇合;
- 主线程本身也是一个线程,它直接调用了
modify
函数。
运行该程序可以看到,每个独立的线程从 t_value
为100开始,然后修改它而不影响其他线程中 t_value
的值。
需要注意的是,尽管 t_value
变量对每个线程都是唯一的,但它们可能显示相同的地址。由于每个线程使用自己的栈,这些变量可能占据各自栈中的相同相对位置,从而给人一种它们物理地址相同的错觉。
异常处理与join()
一旦线程启动并且没有被分离,它最终必须与启动它的(父)线程join,否则程序将中止。通常情况下,一旦线程启动,父线程会继续执行一些工作:
void childActions();
void doSomeWork();
void parent() {
std::thread child(childActions);
doSomeWork();
child.join();
}
然而,如果doSomeWork
无法完成它的工作,并抛出异常,异常会在parent
函数之外被捕获。这不幸地导致parent
函数结束,而child.join()
则没有被调用。因此,程序因为一个未join的线程而中止。
显然,必须捕获所有异常,调用join
,然后重新抛出异常。但parent
函数无法使用函数try-block
,因为一旦执行到对应的catch
语句时,线程对象已经超出了作用域。因此我们得到如下代码:
void childActions();
void doSomeWork();
void parent() {
std::thread child(childActions);
try {
doSomeWork();
child.join();
}
catch (...) {
child.join();
throw;
}
}
这段代码显得不优雅:函数的代码突然被try-catch
语句所覆盖,并且存在一些不必要的代码重复。
这种情况可以通过面向对象编程来避免。例如,像unique_ptr
一样,它通过析构函数来封装动态分配内存的销毁,我们可以使用类似的技术在对象的析构函数中封装线程的合并。
通过在类中定义线程对象,我们可以确保即使childActions
函数抛出异常,当我们的对象超出作用域时,线程的join
成员函数也会被调用。
以下是提供合并保证的JoinGuard
类的基本要素(为简洁起见,使用了内联成员实现):
#include <thread>
class JoinGuard {
std::thread d_thread;
public:
JoinGuard(std::thread &&threadObj)
: d_thread(std::move(threadObj)) {}
~JoinGuard() {
if (d_thread.joinable())
d_thread.join();
}
};
- 在第8行,它的唯一构造函数开始:它接收一个临时的线程对象,并在第10行将其移动到
JoinGuard
的d_thread
数据成员中。 - 当
JoinGuard
对象不再存在时,它的析构函数(第12行)确保如果线程仍然可以join,则将其join(第14和15行)。
以下是如何使用JoinGuard
的示例:
#include <iostream>
#include "joinguard.h"
void childActions();
void doSomeWork() {
throw std::runtime_error("doSomeWork throws");
}
void parent() {
JoinGuard{std::thread{childActions}};
doSomeWork();
}
int main() try {
parent();
} catch (std::exception const &exc) {
std::cout << exc.what() << '\n';
}
- 第4行声明了
childActions
。它的实现(这里未提供)定义了子线程的操作。 main
函数(第17到25行)提供了函数try-block
来捕获parent
函数抛出的异常;parent
函数在第13行定义了一个匿名的JoinGuard
,并接收一个匿名的线程对象。使用匿名对象是因为parent
函数不再需要访问它们。- 在第14行调用
doSomeWork
,该函数抛出异常。这结束了parent
函数,但就在那之前,JoinGuard
的析构函数确保子线程已被合并。
类 std::jthread
除了 std::thread
,还可以使用 std::jthread
类。在使用 jthread
对象之前,必须包含 <thread>
头文件。
jthread
类的对象的行为类似于线程对象,但是 jthread
线程会自动与激活 jthread
的线程join。此外,在某些情况下,jthread
线程可以直接结束。
一旦构造了一个接收定义线程操作的函数的 jthread
对象,该函数就会立即作为一个单独的线程启动。如果该函数通过返回一个值结束,则该值将被忽略。如果函数抛出异常,程序将通过调用 std::terminate
终止。或者,如果该函数需要与启动 jthread
的函数通信返回值或异常,可以使用 std::promise
(参见第 20.12 节),或者它可以修改与其他线程共享的变量(参见第 20.2 和 20.5 节)。
jthread
类提供以下构造函数:
jthread() noexcept
:默认构造函数创建一个不会启动线程的jthread
对象。它可以用作类的数据成员,允许类对象在稍后的某个时间点启动jthread
;explicit jthread(Function &&function, Args &&...args)
:该构造函数(它是一个成员模板,参见第 22.1.3 节)期望一个函数(或仿函数)作为第一个参数,启动由该函数定义的线程。该函数接收的第一个参数是jthread
成员get_stop_token
的返回值(见下文),随后是args
参数(如果存在)。如果函数的第一个参数不是std::stop_token
,那么该函数仅接收args
参数值作为其参数。参数会以它们适当的类型和值传递给函数(参见下方jthread
成员request_stop
的描述中的示例);jthread
类支持移动构造和移动赋值,但不提供复制构造和复制赋值。
以下成员可用,并像名称相同的 std::thread
成员一样操作。请参考第 20.1.2 节的描述:
void detach();
id get_id() const noexcept;
unsigned thread::hardware_concurrency() noexcept
void join();
bool joinable() const noexcept;
native_handle_type native_handle();
void swap(thread &other) noexcept;
以下成员是 jthread
特有的,允许其他线程结束由 jthread
启动的线程:
std::stop_source get_stop_source() noexcept
:返回jthread
的std::stop_source
。std::get_stop_token get_stop_token() const noexcept
:返回jthread
的std::stop_token
。bool request_stop() noexcept
:尝试结束由jthread
对象启动的线程。该函数以原子方式操作:可以从多个线程调用它而不会引起竞争条件。如果成功发出停止请求,则返回true
。如果已经发出停止请求,则返回false
,这也可能发生在不同线程发出了request_stop
请求时,而另一个线程仍在结束jthread
的线程。
发出 request_stop
请求时,线程的停止状态下注册的 std::stop_callback
函数(参见下一节)会同步调用。如果这些回调函数抛出异常,则调用 std::terminate
。此外,与 jthread
的停止状态相关的任何等待条件变量都会结束其等待状态。
以下是一个演示 request_stop
的简短程序:
#include <iostream>
#include <thread>
#include <chrono>
using namespace std;
void fun(std::stop_token stop) { //8
while (not stop.stop_requested()) {
cout << "next\n"; //10
this_thread::sleep_for(1s); //11
}
}
int main() {
jthread thr(fun); //17
this_thread::sleep_for(3s);
thr.request_stop();
// thr.join() not required.
}
- 在第 17 行,
jthread
线程启动,接收函数fun
作为其参数; - 由于
fun
定义了一个std::stop_token
参数,jthread
将启动该函数。该函数执行一个while
循环(第 8 行),直到stop
的stop_requested
返回true
。循环本身显示一个简短的输出行(第 10 行),然后进行一秒钟的休眠(第 11 行); - 主函数在启动线程后,休眠三秒钟(第 19 行),然后(第 21 行)发出停止请求,结束线程。
运行该程序时,会显示三行包含“next”的输出。
std::stop_callback
在使用 std::stop_callback
对象之前,必须包含 <stop_token>
头文件。除了通过 jthread
的 request_stop
成员函数来结束线程函数外,还可以将回调函数与 request_stop
关联,当调用 request_stop
时,这些回调函数会被执行。如果在线程函数已经停止的情况下注册回调函数,这些回调函数会在注册时立即被调用(回调函数的注册将在下文讨论)。
需要注意的是,可以注册多个回调函数。然而,一旦线程停止,这些回调函数的执行顺序并没有定义。此外,回调函数中不允许抛出异常,否则程序会通过调用 std::terminate
终止。
回调函数通过 std::stop_callback
类的对象来注册。
stop_callback
提供以下构造函数:
explicit stop_callback(std::stop_token const &st, Function &&cb) noexcept;
explicit stop_callback(std::stop_token &&st, Function &&cb) noexcept;
注意事项:
Function
可以是一个没有参数的(void)
函数的名称,或者可以是一个(匿名或已存在的)对象,提供一个无参数的(void)
函数调用操作符。函数不一定非要是void
类型,但它们的返回值会被忽略;- 只有当
Function
也被声明为noexcept
时,noexcept
才会被使用(如果Function
是仿函数类的名称,那么当其构造函数被声明为noexcept
时,noexcept
就会被使用); stop_callback
类不提供复制/移动构造和赋值。
下面是上一节使用的示例,这次定义了一个回调函数。运行该程序时,其输出为:
next
next
next
stopFun called via stop_callback
代码如下:
void fun(std::stop_token stop) {
while (not stop.stop_requested()) {
cout << "next\n";
this_thread::sleep_for(1s);
}
}
void stopFun() {
cout << "stopFun called via stop_callback\n";
}
int main() {
jthread thr(fun);
stop_callback sc{ thr.get_stop_token(), stopFun };
this_thread::sleep_for(3s);
thr.request_stop();
thr.join();
}
函数 fun
与上一节中显示的函数相同,但在 main
函数中定义了 stop_callback
对象 sc
,传递了 thr
的 get_stop_token
的返回值以及在第 10 到 13 行定义的函数 stopFun
的地址。在这种情况下,一旦调用 request_stop
(第 23 行),回调函数 stopFun
也会被调用。
互斥锁 (mutexes)
互斥锁类的对象用于保护共享数据。在使用互斥锁之前,必须包含 <mutex>
头文件。
多线程程序的一个关键特性是线程可能会共享数据。作为单独线程运行的函数可以访问所有全局数据,还可能共享其父线程的局部数据。然而,如果没有采取适当的措施,这可能会导致数据损坏。下面是一个模拟多线程程序中可能遇到的一些步骤的例子:
时间步 | 线程 1 | 线程 2 | 描述 |
---|---|---|---|
0 | var = 5 | 初始值 5 | |
1 | 开始 | T1 活跃 | |
2 | 写入 var | T1 开始写入 | |
3 | 停止 | 上下文切换 | |
4 | 开始 | T2 活跃 | |
5 | 写入 var | T2 开始写入 | |
6 | var = 10 | T2 写入 10 | |
7 | 停止 | 上下文切换 | |
8 | var = 12 | T1 写入 12 | |
9 | 结束 |
在此过程中,两个线程对共享变量 var
进行写操作,导致数据竞态问题,最终 var
的值可能是不一致的。
在这个例子中,线程 1 和线程 2 共享变量 var
,初始值为 5。在第 1 步,线程 1 开始运行,并开始写入 var
。然而,它被一个上下文切换中断,线程 2 开始运行(第 4 步)。线程 2 也想写入 var
,并在第 7 步前完成了写入。此时,var
的值为 10。然而,线程 1 也在写入 var
,并在第 8 步将其赋值为 12。当到达第 9 步时,线程 2 继续假设 var
应该是 10。显然,从线程 2 的角度来看,它的数据已被破坏。
在这种情况下,数据破坏是由于多个线程以不受控制的方式访问相同的数据引起的。为了防止这种情况的发生,应保护对共享数据的访问,以确保一次只有一个线程可以访问这些数据。
互斥锁用于防止上述问题,通过保证只有在请求互斥锁的线程能够访问共享数据,从而提供同步访问。独占的数据访问完全依赖于线程之间的合作。如果线程 1 使用互斥锁,但线程 2 不使用,那么线程 2 可以自由访问共享数据。这当然是不好的实践,应避免。
需要强调的是,虽然使用互斥锁是程序员的责任,但它们的实现不是:互斥锁提供了必要的原子操作。当请求互斥锁时,线程会被阻塞(即,互斥操作不会返回),直到请求的线程获得锁。
除了 std::mutex
类外,还有 std::recursive_mutex
类。当一个线程多次调用 recursive_mutex
时,它会增加其锁计数。在其他线程访问受保护数据之前,递归互斥锁必须被解锁相应次数。此外,还有 std::timed_mutex
和 std::recursive_timed_mutex
类。它们的锁在释放时会过期,但也会在一段时间后过期。
互斥锁类的成员执行原子操作:它们在活动期间不会发生上下文切换。因此,当两个线程尝试锁定互斥锁时,只有一个可以成功。在上面的例子中:
如果两个线程都使用互斥锁来控制对 var
的访问,那么线程 2 将无法将 var
赋值为 12,同时线程 1 假设其值为 10。我们甚至可以让两个线程完全并行运行(例如,在两个不同的核心上)。例如:
时间步 | 线程 1 | 线程 2 | 描述 |
---|---|---|---|
1 | 开始 | 开始 | T1 和 T2 活跃 |
2 | 锁定 | 锁定 | 两个线程尝试锁定互斥锁 |
3 | 阻塞中… | 获得锁 | T2 获得锁,T1 必须等待 |
4 | (阻塞中) | 处理 var | T2 处理 var ,T1 仍然阻塞 |
5 | 获得锁 | 释放锁 | T2 释放锁,T1 立即获得锁 |
6 | 处理 var | 现在 T1 处理 var | |
7 | 释放锁 | T1 也释放锁 |
在这个过程中,线程 1 和线程 2 使用互斥锁来同步对共享资源的访问,确保每次只有一个线程能够访问 var
。
虽然互斥锁可以直接在程序中使用,但这种情况很少见。更常见的是将互斥锁处理嵌入到锁定类中,这些类确保互斥锁在不再需要时自动解锁。因此,本节仅概述了互斥锁类的接口。它们的使用示例将在接下来的章节中提供(例如,第 20.3 节)。
所有互斥锁类提供以下构造函数和成员:
mutex() constexpr
:
默认的constexpr
构造函数是唯一可用的构造函数;~mutex()
:
析构函数不会解锁已锁定的互斥锁。如果互斥锁被锁定,则必须显式地使用互斥锁的unlock
成员进行解锁;void lock()
:
调用线程会阻塞,直到它拥有互斥锁。除非是递归互斥锁,否则如果线程已经拥有锁,将抛出system_error
。递归互斥锁会增加其内部锁计数;bool try_lock() noexcept
:
调用线程尝试获取互斥锁的所有权。如果获取到所有权,则返回true
,否则返回false
。如果调用线程已经拥有锁,则返回true
,在这种情况下,递归互斥锁也会增加其内部锁计数;void unlock() noexcept
:
调用线程释放互斥锁的所有权。如果线程不拥有锁,则会抛出system_error
。递归互斥锁会减少其内部锁计数,当锁计数降到零时释放互斥锁。
定时互斥锁类(timed_mutex
、recursive_timed_mutex
)还提供以下成员:
bool try_lock_for(chrono::duration<Rep, Period> const &relTime) noexcept
:
调用线程尝试在指定的时间间隔内获取互斥锁的所有权。如果获取到所有权,则返回true
,否则返回false
。如果调用线程已经拥有锁,则返回true
,在这种情况下,递归定时互斥锁也会增加其内部锁计数。Rep
和Duration
类型由实际的relTime
参数推断。例如:std::timed_mutex timedMutex; timedMutex.try_lock_for(chrono::seconds(5));
bool try_lock_until(chrono::time_point<Clock, Duration> const &absTime) noexcept
:
调用线程尝试在absTime
之前获取互斥锁的所有权。如果获取到所有权,则返回true
,否则返回false
。如果调用线程已经拥有锁,则返回true
,在这种情况下,递归定时互斥锁也会增加其内部锁计数。Clock
和Duration
类型由实际的absTime
参数推断。例如:std::timed_mutex timedMutex; timedMutex.try_lock_until(chrono::system_clock::now() + chrono::seconds(5));
在多线程程序中初始化
在使用 std::once_flag
和 std::call_once
函数之前,必须包含 <mutex>
头文件。
在单线程程序中,全局数据的初始化不一定发生在代码的一个点上。例如,单例类的对象初始化(参见 Gamma 等 (1995),《设计模式》,Addison-Wesley)。单例类可能定义一个静态指针数据成员 Singleton *s_object
,指向单例对象,并提供一个静态成员 instance
,实现可能如下:
Singleton &Singleton::instance()
{
return s_object ?
s_object
:
(s_object = new Singleton);
}
在多线程程序中,这种方法立即变得复杂。例如,如果两个线程同时调用 instance
,而 s_object
仍然为 0,那么两个线程都可能调用 new Singleton
,导致一个动态分配的 Singleton
对象变得不可达。其他线程在 s_object
第一次初始化之后调用时,可能会返回对该对象的引用,或者返回第二个线程初始化的对象的引用。这显然不是预期的单例行为。
虽然可以使用互斥锁(参见第20.2节)来解决这些问题,但它们会导致一些开销和低效,因为每次调用 Singleton::instance
时都必须检查互斥锁。当变量必须动态初始化时,并且初始化应仅进行一次时,应使用 std::once_flag
类型和 std::call_once
函数。
call_once
函数需要两个或三个参数:
- 第一个参数是一个
once_flag
变量,用于跟踪实际的初始化状态。如果once_flag
表示初始化已经完成,则call_once
函数将直接返回。 - 第二个参数是一个函数的地址,该函数必须只被调用一次。这个函数可以是自由函数,也可以是类成员函数的地址。
- 如果第二个参数是类成员函数的地址,则必须提供一个对象作为
call_once
的第三个参数,以调用该成员函数。
一个线程安全的单例 instance
函数的实现可以很容易地设计(为了简洁,使用类内实现):
class Singleton
{
static std::once_flag s_once;
static Singleton *s_singleton;
...
public:
static Singleton *instance()
{
std::call_once(s_once, []{ s_singleton = new Singleton; });
return s_singleton;
}
...
};
然而,即使对于多线程程序,还有其他方法来初始化数据:
-
首先,假设构造函数使用
constexpr
关键字(参见第8.1.4.1节),满足常量初始化的要求。在这种情况下,使用该构造函数初始化的静态对象保证在任何代码运行之前完成初始化。这种方式被std::mutex
使用,因为它消除了全局互斥锁初始化时的竞争条件的可能性。 -
其次,可以使用在复合语句内定义的静态变量(例如,在函数体内定义的静态局部变量)。复合语句内定义的静态变量在函数第一次调用时,在静态变量定义的代码点初始化。以下是一个示例:
#include <iostream>
struct Cons
{
Cons()
{
std::cout << "Cons called\n";
}
};
void called(char const *time)
{
std::cout << time << "time called() activated\n";
static Cons cons;
}
int main()
{
std::cout << "Pre-1\n";
called("first");
called("second");
std::cout << "Pre-2\n";
Cons cons;
}
这段代码显示:
Pre-1
firsttime called() activated
Cons called
secondtime called() activated
Pre-2
Cons called
此特性会导致线程自动等待,如果另一个线程仍在初始化静态数据(请注意,非静态数据不会引发问题,因为非静态局部变量仅存在于其自己的执行线程内)。
共享互斥锁
共享互斥锁(通过 std::shared_mutex
类型)在包含 <shared_mutex>
头文件后可用。共享互斥锁类型的行为类似于 timed_mutex
类型,并且可选地具有以下描述的特性。
std::shared_mutex
类提供了一个非递归互斥锁,具有共享所有权语义,可以与 shared_ptr
类型进行比较。使用共享互斥锁的程序是未定义的,如果:
- 销毁了由任何线程拥有的
std::shared_mutex
对象; - 线程递归地尝试获得
std::shared_mutex
的所有权; - 线程在拥有
std::shared_mutex
的情况下终止。
共享互斥锁类型提供了共享锁所有权模式。多个线程可以同时持有 std::shared_mutex
类型对象的共享锁所有权。但如果一个线程持有共享锁,另一个线程不能持有同一个 std::shared_mutex
对象的独占锁,反之亦然。
共享互斥锁在以下情况下非常有用:多个线程(消费者)想要读取信息,消费者不想改变数据,仅仅是检索数据。与此同时,另一个线程(生产者)想要修改数据。在这种情况下,生产者请求对数据的独占访问,并被迫等待直到所有消费者释放他们的锁。在生产者等待独占锁的过程中,新消费者请求共享锁的请求将保持挂起,直到生产者释放独占锁。因此,多个线程可以同时进行读取,但写入时的独占锁保证了没有其他线程可以访问数据。
std::shared_mutex
类型提供了以下成员函数来提供共享锁所有权。要获得独占所有权,请省略以下成员函数中的 _shared
:
-
void lock_shared()
:
阻塞调用线程,直到调用线程可以获得共享互斥锁的所有权。如果当前线程已经拥有锁、没有权限锁定互斥锁,或者互斥锁已经被锁定并且无法阻塞,将抛出异常。 -
void unlock_shared()
:
释放调用线程持有的互斥锁上的共享锁。如果当前线程没有持有锁,则不会发生任何操作。 -
bool try_lock_shared()
:
当前线程尝试在不阻塞的情况下获得互斥锁的共享所有权。如果没有获得共享所有权,则没有效果,try_lock_shared
立即返回。返回true
如果共享所有权锁被获取,否则返回false
。即使没有其他线程持有互斥锁,实现也可能无法获得锁。最初调用线程可能尚未拥有互斥锁。 -
bool try_lock_shared_for(rel_time)
:
尝试在由rel_time
指定的相对时间段内为调用线程获得共享锁所有权。如果rel_time
指定的时间小于或等于rel_time.zero()
,则成员函数尝试在不阻塞的情况下获得所有权(就像调用try_lock_shared()
一样)。只有在获得了互斥锁的共享所有权后,成员函数才会在rel_time
指定的时间间隔内返回。返回true
如果共享所有权锁被获取,返回false
否则。最初调用线程可能尚未拥有互斥锁。 -
bool try_lock_shared_until(abs_time)
:
尝试在指定的abs_time
时间之前为调用线程获得共享锁所有权。如果abs_time
指定的时间已经过去,则成员函数尝试在不阻塞的情况下获得所有权(就像调用try_lock_shared()
一样)。返回true
如果共享所有权锁被获取,返回false
否则。最初调用线程可能尚未拥有互斥锁。
锁和锁处理
锁用于简化互斥锁的使用。在使用锁之前,必须包含 <mutex>
头文件。
每当线程共享数据,并且至少有一个线程可能会更改共享数据时,应使用互斥锁来防止线程同步地使用相同的数据。通常,锁在动作(action)块的末尾被释放。这需要显式调用互斥锁的 unlock
函数,这引入了与线程的 join
成员相似的问题。
为了简化锁定和解锁,有两个互斥锁封装类可用:
-
std::lock_guard
:
这个类的对象提供了基本的解锁保证:它们的析构函数调用它们控制的互斥锁的unlock
方法。 -
std::unique_lock
:
这个类的对象提供了更全面的接口,允许显式地解锁和锁定它们控制的互斥锁,同时其析构函数也保留了由lock_guard
提供的解锁保证。
std::lock_guard
类
std::lock_guard
提供了一个有限但有用的接口:
-
lock_guard<Mutex>(Mutex &mutex)
:
当定义lock_guard
对象时,指定互斥锁类型(例如,std::mutex
、std::timed_mutex
、std::shared_mutex
),并将指定类型的互斥锁作为其参数提供。构造函数会阻塞,直到lock_guard
对象获得锁。lock_guard
的析构函数会自动释放互斥锁。 -
lock_guard<Mutex>(Mutex &mutex, std::adopt_lock_t)
:
这个构造函数用于将对互斥锁的控制从调用线程转移到lock_guard
。互斥锁的锁会被lock_guard
的析构函数释放。在构造时,互斥锁必须已经被调用线程拥有。以下是使用示例:void threadAction(std::mutex &mut, int &sharedInt) { std::lock_guard<std::mutex> lg{mut, std::adopt_lock_t()}; // 对 sharedInt 执行操作 }
- 第 1 行
threadAction
接收一个互斥锁的引用。假设互斥锁已经持有锁; - 第 3 行控制权转移给
lock_guard
。尽管我们没有显式使用lock_guard
对象,但应该定义一个对象,以防止编译器在函数结束之前销毁匿名对象; - 当函数结束时,在第 5 行,互斥锁的锁由
lock_guard
的析构函数释放。
- 第 1 行
-
mutex_type
:
除了构造函数和析构函数外,lock_guard<Mutex>
类型还定义了mutex_type
:它是传递给lock_guard
构造函数的互斥锁类型的同义词。
下面是一个简单的多线程程序示例,使用 lock_guard
来防止 cout
输出被混合:
bool oneLine(std::istream &in, std::mutex &mut, int nr)
{
std::lock_guard<std::mutex> lg(mut);
std::string line;
if (!std::getline(in, line))
return false;
std::cout << nr << ": " << line << std::endl;
return true;
}
void io(std::istream &in, std::mutex &mut, int nr) {
while (oneLine(in, mut, nr))
std::this_thread::yield();
}
int main(int argc, char **argv)
{
std::ifstream in(argv[1]);
std::mutex ioMutex;
std::thread t1(io, std::ref(in), std::ref(ioMutex), 1);
std::thread t2(io, std::ref(in), std::ref(ioMutex), 2);
std::thread t3(io, std::ref(in), std::ref(ioMutex), 3);
t1.join();
t2.join();
t3.join();
}
std::unique_lock
类
std::unique_lock
类比 lock_guard
更加复杂。它没有定义拷贝构造函数或重载赋值运算符,但定义了移动构造函数和移动赋值运算符。以下是 unique_lock
接口的概述,Mutex
指代在定义 unique_lock
时指定的互斥锁类型:
-
unique_lock() noexcept
:
默认构造函数尚未与互斥锁对象关联。在执行任何有用操作之前,必须分配一个互斥锁(例如,使用移动赋值)。 -
explicit unique_lock(Mutex &mutex)
:
使用现有的Mutex
对象初始化unique_lock
,并调用mutex.lock()
。 -
unique_lock(Mutex &mutex, defer_lock_t) noexcept
:
使用现有的Mutex
对象初始化unique_lock
,但不调用mutex.lock()
。通过将defer_lock_t
对象作为构造函数的第二个参数来调用,例如:unique_lock<std::mutex> ul(mutexObj, std::defer_lock);
-
unique_lock(Mutex &mutex, try_to_lock_t) noexcept
:
使用现有的Mutex
对象初始化unique_lock
,并调用mutex.try_lock()
:构造函数不会阻塞,如果互斥锁无法锁定。 -
unique_lock(Mutex &mutex, adopt_lock_t) noexcept
:
使用现有的Mutex
对象初始化unique_lock
,并假设当前线程已经锁定了互斥锁。 -
unique_lock(Mutex &mutex, std::chrono::duration<Rep, Period> const &relTime) noexcept
:
该构造函数尝试通过调用mutex.try_lock_for(relTime)
来获取互斥锁的所有权。指定的互斥锁类型必须支持此成员(例如,它是std::timed_mutex
)。例如:std::unique_lock<std::timed_mutex> ulock(timedMutex, std::chrono::seconds(5));
-
unique_lock(Mutex &mutex, std::chrono::time_point<Clock, Duration> const &absTime) noexcept
:
该构造函数尝试通过调用mutex.try_lock_until(absTime)
来获取互斥锁的所有权。指定的互斥锁类型必须支持此成员(例如,它是std::timed_mutex
)。例如:std::unique_lock<std::timed_mutex> ulock(timedMutex, std::chrono::system_clock::now() + std::chrono::seconds(5));
-
void lock()
:
阻塞当前线程,直到获取unique_lock
管理的互斥锁的所有权。如果当前没有管理的互斥锁,则抛出system_error
异常。 -
Mutex* mutex() const noexcept
:
返回指向unique_lock
内部存储的互斥锁对象的指针(如果当前没有关联互斥锁对象,则返回nullptr
)。 -
explicit operator bool() const noexcept
:
如果unique_lock
拥有一个锁定的互斥锁,则返回true
,否则返回false
。 -
unique_lock& operator=(unique_lock &&tmp) noexcept
:
如果左操作数拥有一个锁,则会调用其互斥锁的unlock
成员,然后将tmp
的状态转移到左操作数。 -
bool owns_lock() const noexcept
:
如果unique_lock
拥有互斥锁,则返回true
,否则返回false
。 -
Mutex* release() noexcept
:
返回与unique_lock
对象关联的互斥锁的指针,并丢弃该关联。 -
void swap(unique_lock& other) noexcept
:
交换当前unique_lock
和other
的状态。 -
bool try_lock()
:
尝试获取与unique_lock
关联的互斥锁的所有权,如果成功,则返回true
,否则返回false
。如果当前没有关联互斥锁,则抛出system_error
异常。 -
bool try_lock_for(std::chrono::duration<Rep, Period> const &relTime)
:
该成员函数尝试通过调用互斥锁的try_lock_for(relTime)
成员来获取unique_lock
对象管理的互斥锁的所有权。指定的互斥锁类型必须支持此成员(例如,它是std::timed_mutex
)。 -
bool try_lock_until(std::chrono::time_point<Clock, Duration> const &absTime)
:
该成员函数尝试通过调用互斥锁的try_lock_until(absTime)
成员来获取unique_lock
对象管理的互斥锁的所有权。指定
死锁
死锁发生在当两个锁都需要处理数据,但一个线程获得了第一个锁,而另一个线程获得了第二个锁时。在这种情况下,线程1持有第一个锁,线程2持有第二个锁。C++ 定义了通用的 std::lock
和 std::try_lock
函数,用于帮助防止这种情况。
在使用这些函数之前,必须包含 <mutex>
头文件。
以下概述了 std::lock
和 std::try_lock
的功能:
-
void std::lock(L1 &l1, ...)
:
当函数返回时,所有传入的l1
对象的锁都已被获取。如果无法获取至少一个对象的锁,那么已经获取的所有锁都会被释放,即使没有获取锁的对象抛出了异常。 -
int std::try_lock(L1 &l1, ...)
:
该函数调用可锁对象的try_lock
成员。如果所有锁都能被获取,则返回-1
。否则,返回第一个无法锁定的参数的索引(从 0 开始),并释放所有之前获取的锁。
以下是一个示例多线程程序:线程使用互斥锁来获取对 cout
和一个 int
值的唯一访问。然而,fun1
首先锁定 cout
(第 7 行),然后锁定 value
(第 10 行);fun2
首先锁定 value
(第 16 行),然后锁定 cout
(第 19 行)。显然,如果 fun1
锁定了 cout
,fun2
不能在 fun1
释放 cout
之前获取该锁。不幸的是,fun2
已经锁定了 value
,而函数只有在返回时才会释放它们的锁。但为了访问 value
,fun1
必须获得对 value
的锁,这时 fun2
已经锁定了 value
:线程相互等待,双方都不愿让步。
int value;
std::mutex valueMutex;
std::mutex coutMutex;
void fun1()
{
std::lock_guard<std::mutex> lg1(coutMutex); //7
std::cout << "fun 1 locks cout\n";
std::lock_guard<std::mutex> lg2(valueMutex); //10
std::cout << "fun 1 locks value\n";
}
void fun2()
{
std::lock_guard<std::mutex> lg1(valueMutex); //16
std::cerr << "fun 2 locks value\n";
std::lock_guard<std::mutex> lg2(coutMutex); //19
std::cout << "fun 2 locks cout\n";
}
int main()
{
std::thread t1(fun1);
fun2();
t1.join();
}
一个避免死锁的好方法是防止嵌套(或多个)互斥锁调用。但如果必须使用多个互斥锁,始终按照相同的顺序获取锁。为了避免手动处理,应该尽可能使用 std::lock
和 std::try_lock
来获取多个互斥锁。这些函数接受多个参数,这些参数必须是可锁定类型,例如 lock_guard
、unique_lock
或甚至是普通的互斥锁。以下是修改后的程序示例,使用 std::lock
锁定两个互斥锁。这个示例中,使用一个单一的互斥锁也可以正常工作,但修改后的程序尽可能保持了与之前的程序相似。请注意,在第 10 行和第 21 行,unique_lock
参数的顺序不同:在调用 std::lock
或 std::try_lock
时,不必使用相同的参数顺序。
int value;
std::mutex valueMutex;
std::mutex coutMutex;
void fun1()
{
std::scoped_lock sl{ coutMutex, valueMutex };//10
std::cout << "fun 1 locks cout\n";
std::this_thread::sleep_for(std::chrono::seconds(1));
std::cout << "fun 1 locks value\n";
}
void fun2()
{
std::scoped_lock sl{ valueMutex, coutMutex };//21
std::cout << "fun 2 locks value\n";
std::this_thread::sleep_for(std::chrono::seconds(1));
std::cout << "fun 2 locks cout\n";
}
int main()
{
std::thread t1(fun1);
fun2();
t1.join();
}
输出:
fun 2 locks value
fun 2 locks cout
fun 1 locks cout
fun 1 locks value
共享锁
共享锁可以通过 std::shared_lock
类型实现,需包含 <shared_mutex>
头文件。std::shared_lock
对象在作用域内控制对一个可锁对象的共享所有权。可以在构造时或之后获取共享所有权,一旦获取,可以将其转移到另一个 std::shared_lock
对象。std::shared_lock
对象不能被复制,但支持移动构造和赋值。
如果包含的互斥指针 (pm
) 的值非零,但 pm
指向的可锁对象在 std::shared_lock
对象的整个生命周期内不存在,则程序的行为是未定义的。提供的互斥类型必须是 std::shared_mutex
或具有相同特性的类型。
std::shared_lock
提供以下构造函数、析构函数和运算符:
-
shared_lock() noexcept
:
默认构造函数创建一个没有线程拥有的std::shared_lock
,且pm == 0
。 -
explicit shared_lock(mutex_type &mut)
:
该构造函数锁定互斥锁,调用mut.lock_shared()
。调用线程可能不已经拥有锁。构造后,pm == &mut
,锁由当前线程拥有。 -
shared_lock(mutex_type &mut, defer_lock_t) noexcept
:
该构造函数将pm
赋值为&mut
,但调用线程不拥有锁。 -
shared_lock(mutex_type &mut, try_to_lock_t)
:
该构造函数尝试锁定互斥锁,调用mut.try_lock_shared()
。调用线程可能不已经拥有锁。构造后,pm == &mut
,锁可能被当前线程拥有,具体取决于try_lock_shared
的返回值。 -
shared_lock(mutex_type &mut, adopt_lock_t)
:
该构造函数可以在调用线程已经共享拥有互斥锁的情况下调用。构造后,pm == &mut
,锁由当前线程拥有。 -
shared_lock(mutex_type &mut, chrono::time_point<Clock, Duration> const &abs_time)
:
该构造函数是一个成员模板,其中Clock
和Duration
是指定时钟和绝对时间的类型。它可以在调用线程不已经拥有互斥锁的情况下调用。它调用mut.try_lock_shared_until(abs_time)
。构造后,pm == &mut
,锁可能被当前线程拥有,具体取决于try_lock_shared_until
的返回值。 -
shared_lock(mutex_type &mut, chrono::duration<Rep, Period> const &rel_time)
:
该构造函数是一个成员模板,其中Clock
和Period
是指定时钟和相对时间的类型。它可以在调用线程不已经拥有互斥锁的情况下调用。它调用mut.try_lock_shared_for(rel_time)
。构造后,pm == &mut
,锁可能被当前线程拥有,具体取决于try_lock_shared_for
的返回值。 -
shared_lock(shared_lock &&tmp) noexcept
:
移动构造函数将tmp
的信息转移到新构造的shared_lock
。构造后,tmp.pm == 0
,tmp
不再拥有锁。 -
~shared_lock()
:
如果锁由当前线程拥有,调用pm->unlock_shared()
。 -
shared_lock &operator=(shared_lock &&tmp) noexcept
:
移动赋值运算符调用pm->unlock_shared
,然后将tmp
的信息转移到当前shared_lock
对象。tmp.pm == 0
,tmp
不再拥有锁。 -
explicit operator bool () const noexcept
:
返回shared_lock
对象是否拥有锁。
以下成员函数也被提供:
-
void lock()
:
调用pm->lock_shared()
,之后当前线程拥有共享锁。如果pm == 0
或当前线程已经拥有锁,则可能会抛出异常。 -
mutex_type *mutex() const noexcept
:
返回pm
。 -
mutex_type *release() noexcept
:
返回pm
的先前值,调用此成员后,pm
变为零。当前对象不再拥有锁。 -
void swap(shared_lock &other) noexcept
:
交换当前对象和另一个shared_lock
对象的数据成员。还有一个自由成员交换函数模板,交换两个shared_lock<Mutex>
对象,其中Mutex
代表互斥锁的类型:void swap(shared_lock<Mutex> &one, shared_lock<Mutex> &two) noexcept
。 -
bool try_lock()
:
调用pm->try_lock_shared()
,返回此调用的返回值。如果pm == 0
或当前线程已经拥有锁,则可能会抛出异常。 -
bool try_lock_for(const chrono::duration<Rep, Period>& rel_time)
:
一个成员模板,其中Clock
和Period
是指定时钟和相对时间的类型。调用mut.try_lock_shared_for(rel_time)
。调用后,锁可能被当前线程拥有,具体取决于try_lock_shared_for
的返回值。如果pm == 0
或当前线程已经拥有锁,则可能会抛出异常。 -
bool try_lock_until(const chrono::time_point<Clock, Duration>& abs_time)
:
一个成员模板,其中Clock
和Duration
是指定时钟和绝对时间的类型。调用mut.try_lock_shared_until(abs_time)
,返回其返回值。调用后,锁可能被当前线程拥有,具体取决于try_lock_shared_until
的返回值。如果pm == 0
或当前线程已经拥有锁,则可能会抛出异常。 -
void unlock()
:
解锁共享互斥锁,释放其所有权。如果共享互斥锁未被当前线程拥有,则会抛出异常。
范围锁 scoped_lock
使用前述的原则可以避免死锁。然而,除了将避免死锁的责任交给软件工程师之外,还可以采用另一种方法:使用 scoped_lock
来一次性锁定多个信号量,scoped_lock
确保避免死锁。
scoped_lock
也有一个默认构造函数,不执行任何操作,因此,工程师需要定义至少一个互斥锁的 scoped_lock
对象。在使用 scoped_lock
对象之前,必须包含 <mutex>
头文件。以下是适应于 20.3.1 节示例的代码:两个函数都定义了 scoped_lock
(注意,指定互斥锁的顺序不重要),并且不会发生死锁:
int value;
mutex valueMutex;
mutex coutMutex;
void fun1()
{
unique_lock<mutex> lg1(coutMutex, defer_lock);
unique_lock<mutex> lg2(valueMutex, defer_lock);
lock(lg1, lg2);
cout << "fun 1 locks cout\n";
cout << "fun 1 locks value\n";
}
void fun2()
{
unique_lock<mutex> lg1(coutMutex, defer_lock);
unique_lock<mutex> lg2(valueMutex, defer_lock);
lock(lg2, lg1);
cout << "fun 2 locks cout\n";
cout << "fun 2 locks value\n";
}
int main()
{
thread t1(fun1);
thread t2(fun2);
t1.join();
t2.join();
}
#include <iostream>
#include <mutex>
#include <thread>
using namespace std;
int value;
mutex valueMutex;
mutex coutMutex;
void fun1()
{
scoped_lock lock(coutMutex, valueMutex); // 使用 scoped_lock 同时锁定 coutMutex 和 valueMutex
cout << "fun 1 locks cout\n";
cout << "fun 1 locks value\n";
}
void fun2()
{
scoped_lock lock(valueMutex, coutMutex); // 使用 scoped_lock 同时锁定 valueMutex 和 coutMutex
cout << "fun 2 locks value\n";
cout << "fun 2 locks cout\n";
}
int main()
{
thread t1(fun1);
thread t2(fun2);
t1.join();
t2.join();
}
因此,与使用 lock_guard
对象相比,可以使用 scoped_lock
对象。是否偏好使用 lock_guard
还是 scoped_lock
在只使用一个互斥锁的情况下,取决于个人口味。也许在总是有效的情况下,scoped_lock
更值得推荐。
条件变量(Condition Variables)
本节介绍了条件变量。条件变量允许程序通过数据的状态来同步线程,而不仅仅是通过锁定数据(这通过互斥锁实现)。
在使用条件变量之前,必须包含 <condition_variable>
头文件。
让我们首先考虑一个经典的生产者-消费者场景:生产者生成项目,消费者消费这些项目。生产者在其存储容量满了之后,必须等待直到消费者消费一些项目,从而在生产者的存储中腾出空间。类似地,消费者在生产者至少生产了一些项目之前不能开始消费。
仅使用互斥锁(数据锁定)来实现这个场景不是一个吸引人的选项,因为仅使用互斥锁会强迫程序实现轮询:进程必须不断(重新)获取互斥锁,确定是否可以执行某些操作,然后释放锁。通常没有操作需要执行,进程在忙于获取和释放互斥锁。轮询迫使线程等待,直到它们能够锁定互斥锁,即使可能已经可以继续。尽管可以减少轮询间隔,但这也不是一个吸引人的选项,因为这增加了处理互斥锁的开销(也称为“忙等待”)。
条件变量可以用来避免轮询。线程可以使用条件变量通知等待的线程有事情可以做。这样,线程可以根据数据值(状态)来同步。
由于数据值可能被多个线程修改,因此线程仍然需要使用互斥锁,但仅用于控制对数据的访问。此外,条件变量允许线程释放互斥锁的所有权,直到获得某个值、经过预设的时间,或到达预设的时间点。
使用条件变量的线程的典型设置如下:
- 消费者线程:
- 锁定互斥锁
- 当所需条件尚未满足(即为假)时:
- 等待直到被通知(这会自动释放互斥锁的锁)。
- 一旦重新获得互斥锁,并且所需条件已满足:
- 处理数据
- 释放互斥锁的锁。
- 生产者线程:
- 锁定互斥锁
- 当所需条件尚未满足时:
- 做一些事情来满足所需条件
- 通知等待的线程(条件已满足)
- 释放互斥锁的锁。
无论哪个线程开始,持有互斥锁的线程在某些时候都会释放锁,从而允许其他进程(重新)获取锁。如果消费者线程开始,它会在进入等待状态时立即释放锁;如果生产者线程开始,它在条件为真时释放锁。
这种协议隐藏了一个微妙的初始同步要求。如果消费者线程还没有进入等待状态,它将错过生产者的通知。因此,等待(消费者)线程应在通知(生产者)线程之前启动。一旦线程启动,不能再对条件变量的成员(如 notify_one、notify_all、wait、wait_for 和 wait_until)的调用顺序做出假设。
条件变量有两种形式:std::condition_variable
类的对象用于与 unique_lock<mutex>
对象配合使用。由于针对这种特定组合的优化,使用 std::condition_variable
通常比使用更通用的 std::condition_variable_any
更高效,后者可以与任何(例如用户自定义的)锁类型一起使用。
条件变量类(在接下来的两节中详细介绍)提供了像 wait
、wait_for
、wait_until
、notify_one
和 notify_all
这样的成员,它们可以被并发调用。通知成员始终是原子执行的。wait
成员的执行包括三个原子部分:
- 释放互斥锁,线程被挂起直到接收到通知;
- 一旦收到通知,重新获取锁;
- 等待状态结束(处理继续进行)。
因此,返回 wait
成员时,之前等待的线程已经重新获得了互斥锁。
此外,条件变量类还提供了以下自由函数和枚举类型:
void std::notify_all_at_thread_exit(condition_variable &cond, unique_lock<mutex> lockObject)
:
一旦当前线程结束,所有其他等待cond
的线程将被通知。建议在调用notify_all_at_thread_exit
后尽快退出线程。等待线程必须验证它们等待的线程是否确实已结束。这通常通过首先获取lockObject
上的锁,然后验证它们等待的条件是否为真,以及在调用notify_all_at_thread_exit
之前锁是否未被重新获得来实现。std::cv_status
:
cv_status
枚举由条件变量类的几个成员函数使用(见第 20.4.1 和 20.4.2 节):namespace std { enum class cv_status { no_timeout, timeout }; }
类 std::condition_variable
类 std::condition_variable
仅提供一个默认构造函数。不提供复制构造函数或重载赋值运算符。
在使用 condition_variable
类之前,必须包含 <condition_variable>
头文件。
类的析构函数要求在销毁 condition_variable
时,没有线程被阻塞在这个条件变量上。因此,在 condition_variable
对象的生命周期结束之前,必须通知所有等待在该条件变量上的线程。在 condition_variable
的生命周期结束之前调用 notify_all
(见下文)可以解决这个问题,因为条件变量的线程会释放互斥锁,从而允许一个被通知的线程锁定互斥锁。
在以下成员描述中,Predicate
表示提供的 Predicate
参数可以作为一个无参数的函数调用,返回一个布尔值。此外,通常还会提到其他成员函数。假定所有提到的成员函数都是使用相同的条件变量对象调用的。
std::condition_variable
类支持多个等待成员,这些成员会阻塞线程直到被另一个线程通知(或在可配置的等待时间后)。然而,等待成员也可能会出现虚假解除阻塞的情况,即在没有重新获取锁的情况下解除阻塞。因此,从等待成员返回的线程应该验证所需的条件是否真正满足。如果没有,再次调用 wait
可能是合适的。下面的伪代码说明了这种方案:
while (conditionNotTrue())
condVariable.wait(&uniqueLock);
std::condition_variable
类的成员包括:
-
void notify_one() noexcept
:
通知一个等待成员返回。实际返回的是哪一个不能预测。 -
void notify_all() noexcept
:
所有等待的成员解除其等待状态。当然,只有一个线程会随后成功重新获取条件变量的锁对象。 -
void wait(unique_lock<mutex>& uniqueLock)
:
在调用wait
之前,当前线程必须已经获取了uniqueLock
的锁。调用wait
会释放锁,当前线程被阻塞直到接收到来自另一个线程的通知,并且重新获取锁。 -
void wait(unique_lock<mutex>& uniqueLock, Predicate pred)
:
这是一个成员模板,使用模板头template <typename Predicate>
。模板的类型会自动从函数的参数类型推导出来,无需显式指定。在调用wait
之前,当前线程必须已经获取了uniqueLock
的锁。只要pred
返回 false,就会调用wait(lock)
。 -
cv_status wait_for(unique_lock<mutex> &uniqueLock, std::chrono::duration<Rep, Period> const &relTime)
:
这个成员定义为成员模板,使用模板头template <typename Rep, typename Period>
。模板的类型会自动从函数的参数类型推导出来,无需显式指定。例如,要等待最多 5 秒,可以像这样调用wait_for
:cond.wait_for(&uniqueLock, std::chrono::seconds(5));
这个成员会在被通知时或者当
relTime
指定的时间间隔已过时返回。如果由于超时而返回,将返回std::cv_status::timeout
;否则返回std::cv_status::no_timeout
。线程应该在wait_for
返回后验证所需的数据条件是否满足。 -
bool wait_for(unique_lock<mutex> &uniqueLock, std::chrono::duration<Rep, Period> const &relTime, Predicate pred)
:
这个成员定义为成员模板,使用模板头template <typename Rep, typename Period, typename Predicate>
。模板的类型会自动从函数的参数类型推导出来,无需显式指定。只要pred
返回 false,就会调用前面的wait_for
成员。如果前一个成员返回cv_status::timeout
,则返回pred
的结果,否则返回 true。 -
cv_status wait_until(unique_lock<mutex>& uniqueLock, std::chrono::time_point<Clock, Duration> const &absTime)
:
这个成员定义为成员模板,使用模板头template <typename Clock, typename Duration>
。模板的类型会自动从函数的参数类型推导出来,无需显式指定。例如,要等待直到当前时间之后的 5 分钟,可以像这样调用wait_until
:cond.wait_until(&uniqueLock, std::chrono::system_clock::now() + std::chrono::minutes(5));
这个函数的作用与前面描述的
wait_for
成员相同,但使用的是绝对时间点,而不是相对时间。这个成员会在被通知时或者当relTime
指定的时间间隔已过时返回。如果由于超时而返回,将返回std::cv_status::timeout
;否则返回std::cv_status::no_timeout
。 -
bool wait_until(unique_lock<mutex> &lock, std::chrono::time_point<Clock, Duration> const &absTime, Predicate pred)
:
这个成员定义为成员模板,使用模板头template <typename Clock, typename Duration, typename Predicate>
。模板的类型会自动从函数的参数类型推导出来,无需显式指定。只要pred
返回 false,就会调用前面的wait_until
成员。如果前一个成员返回cv_status::timeout
,则返回pred
的结果,否则返回 true。
线程应该在条件变量的 wait
成员返回时验证所需的条件是否满足。
类 std::condition_variable_any
与 std::condition_variable
类不同,std::condition_variable_any
类可以与任何(例如用户自定义的)锁类型一起使用,而不仅仅是 STL 提供的 unique_lock<mutex>
。
在使用 std::condition_variable_any
类之前,必须包含 <condition_variable>
头文件。
condition_variable_any
提供的功能与 condition_variable
类的功能是相同的,只不过 condition_variable_any
使用的锁类型不是预定义的。因此,condition_variable_any
需要指定其对象必须使用的锁类型。在下面的接口中,这种锁类型被称为 Lock
。
condition_variable_any
的大部分成员函数被定义为成员模板,将锁类型 Lock
作为参数之一。这些锁类型的要求与 STL 提供的 unique_lock
相同,因此用户定义的锁类型实现应至少提供与 unique_lock
相同的接口和语义。
本节仅展示了 std::condition_variable_any
类的接口。由于其接口提供了与 condition_variable
相同的成员(允许在适用的情况下传递任何锁类型,而不仅仅是 unique_lock
),请参阅前一节以了解类成员的语义。
与 condition_variable
类似,std::condition_variable_any
类只提供默认构造函数。不提供复制构造函数或重载赋值运算符。
此外,与 condition_variable
类似,该类的析构函数要求当前线程没有被阻塞在条件变量上。这意味着所有其他(等待的)线程必须已经被通知;这些线程可能随后会在其等待调用中指定的锁上被阻塞。
注意,除了 Lock
,其他类型如 Clock
、Duration
、Period
、Predicate
和 Rep
是模板类型,定义方式与前一节中提到的相同。
假设 MyMutex
是用户定义的互斥锁类型,MyLock
是用户定义的锁类型(参见第 20.3 节有关锁类型的详细信息),则可以像下面这样定义和使用 condition_variable_any
对象:
MyMutex mut;
MyLock<MyMutex> ul(mut);
condition_variable_any cva;
cva.wait(ul);
std::condition_variable_any
类的成员包括:
-
void notify_one() noexcept;
通知一个等待成员返回。无法预测实际返回的是哪一个。 -
void notify_all() noexcept;
所有等待的成员解除其等待状态。当然,只有一个线程会随后成功重新获取条件变量的锁对象。 -
void wait(Lock& lock);
在调用wait
之前,当前线程必须已经获取了lock
的锁。调用wait
会释放锁,当前线程被阻塞直到接收到来自另一个线程的通知,并且重新获取锁。 -
void wait(Lock& lock, Predicate pred);
这是一个成员模板,使用模板头template <typename Predicate>
。模板的类型会自动从函数的参数类型推导出来,无需显式指定。在调用wait
之前,当前线程必须已经获取了lock
的锁。只要pred
返回 false,就会调用wait(lock)
。 -
cv_status wait_until(Lock& lock, const chrono::time_point<Clock, Duration>& absTime);
这个成员定义为成员模板,使用模板头template <typename Clock, typename Duration>
。模板的类型会自动从函数的参数类型推导出来,无需显式指定。例如,要等待直到当前时间之后的 5 分钟,可以像这样调用wait_until
:cond.wait_until(lock, chrono::system_clock::now() + std::chrono::minutes(5));
这个函数的作用与前面描述的
wait_for
成员相同,但使用的是绝对时间点,而不是相对时间。这个成员会在被通知时或者当absTime
指定的时间点已过时返回。如果由于超时而返回,将返回std::cv_status::timeout
;否则返回std::cv_status::no_timeout
。 -
bool wait_until(Lock& lock, const chrono::time_point<Clock, Duration>& absTime, Predicate pred);
这个成员定义为成员模板,使用模板头template <typename Clock, typename Duration, typename Predicate>
。模板的类型会自动从函数的参数类型推导出来,无需显式指定。只要pred
返回 false,就会调用前面的wait_until
成员。如果前一个成员返回cv_status::timeout
,则返回pred
的结果,否则返回 true。 -
cv_status wait_for(Lock& lock, const chrono::duration<Rep, Period>& relTime);
这个成员定义为成员模板,使用模板头template <typename Rep, typename Period>
。模板的类型会自动从函数的参数类型推导出来,无需显式指定。例如,要等待最多 5 秒,可以像这样调用wait_for
:cond.wait_for(lock, std::chrono::seconds(5));
这个成员会在被通知时或者当
relTime
指定的时间间隔已过时返回。如果由于超时而返回,将返回std::cv_status::timeout
;否则返回std::cv_status::no_timeout
。 -
bool wait_for(Lock& lock, const chrono::duration<Rep, Period>& relTime, Predicate pred);
这个成员定义为成员模板,使用模板头template <typename Rep, typename Period, typename Predicate>
。模板的类型会自动从函数的参数类型推导出来,无需显式指定。只要pred
返回 false,就会调用前面的wait_for
成员。如果前一个成员返回cv_status::timeout
,则返回pred
的结果,否则返回 true。
使用条件变量的示例
条件变量用于在线程之间同步数据值,而不是仅仅同步对数据的访问(对于数据访问,可以使用普通的互斥锁)。使用条件变量时,线程会在被另一个线程通知之前处于休眠状态。在生产者-消费者类型的程序中,这通常是这样实现的:
消费者循环:
- 等待直到商店中有项存在,然后减少存储项的数量
- 从商店中移除项
- 增加可用的存储位置数量
- 处理检索到的项
生产者循环:
- 生产下一个项
- 等待直到有足够的空间存储项,然后减少可用存储位置的数量
- 存储项
- 增加已存储项的数量
重要的是这两个存储管理任务(注册可用项的数量和可用存储位置)是由客户端还是生产者来完成。对于消费者来说,“等待”意味着:
- 获取包含实际计数的变量的锁
- 只要计数为零:等待,释放锁,直到另一个线程增加计数,然后重新获取锁
- 减少计数
- 释放锁
这种方案在 Semaphore
类中实现,提供了 wait
和 notify_all
成员函数。有关信号量的更详细讨论,请参见 Tanenbaum, A.S. (2016)《结构化计算机组织》,Pearson Prentice-Hall。
简要总结: 信号量限制了可以访问有限大小资源的线程数量。它确保添加项的线程(生产者)的数量不会超过资源的最大大小,或者确保检索项的线程(消费者)的数量不会超过资源的当前大小。因此,在生产者/消费者设计中使用两个信号量:一个控制生产者对资源的访问,另一个控制消费者对资源的访问。例如,假设我们有十个生产线程和十个消费者,以及一个不能超过 1000 项的可锁定队列。生产者尝试逐个推送项;消费者尝试逐个弹出项。
包含实际计数的数据成员称为 d_available
。它由互斥锁 d_mutex
保护。此外,还定义了一个条件变量 d_condition
:
mutable std::mutex d_mutex; // mutable 因为在 'size_t size() const' 中使用
std::condition_variable d_condition;
size_t d_available;
等待过程通过其成员函数 wait
实现:
void Semaphore::wait()
{
std::unique_lock<std::mutex> lk(d_mutex); // 获取锁
while (d_available == 0)
d_condition.wait(lk); // 内部释放锁并等待,退出时重新获取锁 //5
--d_available; // 减少可用数量
} // 锁被释放
在第 5 行中,d_condition.wait
释放了锁。它等待直到接收到通知,并在返回之前重新获取锁。因此,wait
的代码始终完全且唯一地控制 d_available
。
如何通知等待线程? 这在 notify_all
成员函数的第 4 和第 5 行中处理:
void Semaphore::notify_all()
{
std::lock_guard<std::mutex> lk(d_mutex); // 获取锁
if (d_available++ == 0)
d_condition.notify_all(); // 使用 notify_one 通知其他线程
} // 锁被释放
在第 4 行,d_available
始终递增;通过使用后缀递增操作符,它可以同时测试是否为零。如果最初为零,则 d_available
现在为一。一个等待直到 d_available
超过零的线程现在可以继续。通过调用 d_condition.notify_one
来通知一个等待的线程。在多个线程等待的情况下,也可以使用 notify_all
。
使用 Semaphore
类的构造函数,它期望其 d_available
数据成员的初始值,现在可以使用多线程实现经典的生产者-消费者范式:
Semaphore available(10);
Semaphore filled(0);
std::queue<size_t> itemQueue;
std::mutex qMutex;
void consumer()
{
while (true)
{
filled.wait(); // 等待填充信号
// 锁定队列
{
std::lock_guard<std::mutex> lg(qMutex);
size_t item = itemQueue.front();
itemQueue.pop();
}
available.notify_all(); // 通知生产者
process(item); // 处理项(未实现)
}
}
void producer()
{
size_t item = 0;
while (true)
{
++item;
available.wait(); // 等待可用空间信号
// 锁定队列
{
std::lock_guard<std::mutex> lg(qMutex);
itemQueue.push(item);
}
filled.notify_all(); // 通知消费者
}
}
int main()
{
std::thread consume(consumer);
std::thread produce(producer);
consume.join();
produce.join();
}
注意,为了避免多个线程同时访问队列,使用了互斥锁。考虑队列中包含 5 项的情况:在这种情况下,信号量允许消费者和生产者访问队列,但为了避免破坏队列,只有一个线程可以同时修改队列。这通过两个线程在修改队列之前获取 std::mutex qMutex
锁来实现。
原子操作:不需要互斥锁
在使用本节介绍的功能之前,需要包含 <atomic>
头文件。
当数据在多个线程之间共享时,通常使用互斥锁来防止数据损坏。例如,使用这种策略来递增一个简单的 int
的代码如下:
{
std::lock_guard<std::mutex> lk{ intVarMutex };
++intVar;
}
这个复合语句用于限制 lock_guard
的生命周期,以便 intVar
仅在短时间内被锁定。这种方案虽然不复杂,但在每次使用简单变量时都需要定义 lock_guard
,并且为每个简单变量定义匹配的互斥锁,这有点麻烦。
C++ 提供了一种解决方案:使用原子数据类型。原子数据类型适用于所有基本类型,也适用于(简单的)用户定义类型。简单类型是(参见 23.6.2 节)所有标量类型、简单类型元素的数组以及构造函数、拷贝构造函数和析构函数均为默认实现的类,并且它们的非静态数据成员本身也是简单类型。
类模板 std::atomic<Type>
适用于所有内置类型,包括指针类型。例如,std::atomic<bool>
定义了一个原子布尔类型。对于许多类型,还可以使用稍短的替代类型名称。例如,std::atomic<unsigned short>
可以使用 std::atomic_ushort
替代。有关所有替代名称的完整列表,请参见 <atomic>
头文件。
如果 Trivial
是一个用户定义的简单类型,则 std::atomic<Trivial>
定义了 Trivial
的原子变体:这种类型不需要单独的互斥锁来同步多个线程的访问。
类模板 std::atomic<Type>
的对象不能直接相互拷贝或赋值。然而,它们可以通过类型 Type
的值进行初始化,类型 Type
的值也可以直接赋值给 std::atomic<Type>
对象。此外,由于 atomic<Type>
类型提供了返回其 Type
值的转换操作符,因此 atomic<Type>
对象也可以通过 static_cast
进行赋值或初始化:
std::atomic<int> a1 = 5;
std::atomic<int> a2{ static_cast<int>(a1) };
std::atomic<Type>
类提供了几个公共成员,如下所示。非成员(自由)函数也可以对 atomic<Type>
对象进行操作。
std::memory_order
枚举定义了以下符号常量,用于指定原子操作的排序约束:
memory_order_acq_rel
:操作必须是读-修改-写操作,结合了memory_order_acquire
和memory_order_release
;memory_order_acquire
:操作是一个获取操作。它与写入相同内存位置的释放操作同步;memory_order_consume
:操作是在涉及的内存位置上的消费操作;memory_order_relaxed
:操作不提供排序约束;memory_order_release
:操作是一个释放操作。它与在相同位置上的获取操作同步;memory_order_seq_cst
:所有操作的默认内存顺序规格。
存储操作使用 memory_order_release
,加载操作使用 memory_order_acquire
,读-修改-写操作使用 memory_order_acq_rel
。对 atomic<Type>
提供的重载运算符无法指定内存顺序。大多数原子成员函数也可以接受最终的 memory_order
参数。在没有此选项的情况下,在函数的描述中会明确提到。
以下是标准的 std::atomic<Type>
成员函数:
-
bool compare_exchange_strong(Type ¤tValue, Type newValue) noexcept
:
对原子对象中的值与newValue
进行字节级比较。如果相等(返回true
),则将newValue
存储在原子对象中;如果不相等(返回false
),则将对象的当前值存储在currentValue
中; -
bool compare_exchange_weak(Type &oldValue, Type newValue) noexcept
:
对原子对象中的值与newValue
进行字节级比较。如果相等(返回true
),则将newValue
存储在原子对象中;如果不相等,或者newValue
无法原子地分配给当前对象,返回false
并将对象的当前值存储在oldValue
中; -
Type exchange(Type newValue) noexcept
:
返回对象的当前值,并将newValue
分配给当前对象; -
bool is_lock_free() const noexcept
:
如果当前对象上的操作可以无锁执行,则返回true
,否则返回false
。此成员函数没有memory_order
参数; -
Type load() const noexcept
:
返回对象的值; -
operator Type() const noexcept
:
返回对象的值; -
void store(Type newValue) noexcept
:
将newValue
分配给当前对象。注意,标准赋值运算符也可以使用。
除了上述成员函数,整型原子类型 Integral
(实际上是所有内置整型的原子变体)还提供了以下成员函数:
-
Integral fetch_add(Integral value) noexcept
:
将value
加到对象的值上,并返回调用时对象的值; -
Integral fetch_sub(Integral value) noexcept
:
从对象的值中减去value
,并返回调用时对象的值; -
Integral fetch_and(Integral mask) noexcept
:
将位与操作应用于对象的值和mask
,将结果值分配给当前对象。调用时返回对象的值; -
Integral fetch_or(Integral mask) noexcept
:
将位或操作应用于对象的值和mask
,将结果值分配给当前对象。调用时返回对象的值; -
Integral fetch_xor(Integral mask) noexcept
:
将位异或操作应用于对象的值和mask
,将结果值分配给当前对象。调用时返回对象的值; -
Integral operator++() noexcept
:
前缀递增运算符,返回对象的新值; -
Integral operator++(int) noexcept
:
后缀递增运算符,返回对象递增前的值; -
Integral operator--() noexcept
:
前缀递减运算符,返回对象的新值; -
Integral operator--(int) noexcept
:
后缀递减运算符,返回对象递减前的值; -
Integral operator+=(Integral value) noexcept
:
将value
加到对象的当前值上,并返回对象的新值; -
Integral operator-=(Integral value) noexcept
:
从对象的当前值中减去value
,并返回对象的新值; -
Integral operator&=(Integral mask) noexcept
:
将位与操作应用于对象的当前值和mask
,将结果值分配给当前对象。返回对象的新值; -
Integral operator|=(Integral mask) noexcept
:
将位或操作应用于对象的当前值和mask
,将结果值分配给当前对象。返回对象的新值; -
Integral operator^=(Integral mask) noexcept
:
将位异或操作应用于对象的当前值和mask
,将结果值分配给当前对象。返回对象的新值;
一些自由成员函数的名称以 _explicit
结尾。 _explicit
函数定义了一个附加的 memory_order order
参数,该参数对于非 _explicit
函数不可用(例如,atomic_load(atomic<Type> *ptr)
和 atomic_load_explicit(atomic<Type> *ptr, memory_order order)
)。
以下是适用于所有原子类型的自由函数:
-
bool std::atomic_compare_exchange_strong_explicit(std::atomic<Type> *ptr, Type *oldValue, Type newValue, std::memory_order order) noexcept
:
返回ptr->compare_exchange_strong(*oldValue, newValue)
; -
bool std::atomic_compare_exchange_weak_explicit(std::atomic<Type> *ptr, Type *oldValue, Type newValue, std::memory_order order) noexcept
:
返回ptr->compare_exchange_weak(*oldValue, newValue)
; -
Type std::atomic_exchange_explicit(std::atomic<Type> *ptr, Type newValue, std::memory_order order) noexcept
:
返回ptr->exchange(newValue)
; -
void std::atomic_init(std::atomic<Type> *ptr, Type init) noexcept
:
将init
非原子地存储在*ptr
中。指针ptr
指向的对象必须已经默认构造,并且尚未调用任何成员函数。此函数没有memory_order
参数; -
bool std::atomic_is_lock_free(std::atomic<Type> const *ptr) noexcept
:
返回ptr->is_lock_free()
。此函数没有memory_order
参数; -
`Type std::atomic_load_explicit(std::atomic *ptr
, std::memory_order order) noexcept: 返回
ptr->load()`;
void std::atomic_store_explicit(std::atomic<Type> *ptr, Type value, std::memory_order order) noexcept
:
调用ptr->store(value)
。
除了上述自由函数外,整型原子类型 Integral
还提供以下自由函数:
-
Integral std::atomic_fetch_add_explicit(std::atomic<Integral> *ptr, Integral value, std::memory_order order) noexcept
:
返回ptr->fetch_add(value)
; -
Integral std::atomic_fetch_sub_explicit(std::atomic<Integral> *ptr, Integral value, std::memory_order order) noexcept
:
返回ptr->fetch_sub(value)
; -
Integral std::atomic_fetch_and_explicit(std::atomic<Integral> *ptr, Integral mask, std::memory_order order) noexcept
:
返回ptr->fetch_and(mask)
; -
Integral std::atomic_fetch_or_explicit(std::atomic<Integral> *ptr, Integral mask, std::memory_order order) noexcept
:
返回ptr->fetch_or(mask)
; -
Integral std::atomic_fetch_xor_explicit(std::atomic<Integral> *ptr, Integral mask, std::memory_order order) noexcept
:
返回ptr->fetch_xor(mask)
。
多线程快速排序示例
快速排序算法(Hoare,1962)是一种著名的排序算法。给定一个包含 n
个元素的数组,它的工作流程如下:
- 从数组中选择一个元素,将数组按该元素(称为基准元素)进行分区(假设有一个
partition
函数可用)。这会将数组分成两个(可能为空的)子数组:一个在基准元素左边,另一个在基准元素右边; - 对左边的子数组递归执行快速排序;
- 对右边的子数组递归执行快速排序。
将此算法转换为多线程算法似乎很简单:
void quicksort(Iterator begin, Iterator end) {
if (end - begin < 2)
return; // 小于 2 个元素,排序完成
Iterator pivot = partition(begin, end); // 确定指向基准元素的迭代器
std::thread lhs(quicksort, begin, pivot); // 在左边子数组上启动线程
std::thread rhs(quicksort, pivot + 1, end); // 在右边子数组上启动线程
lhs.join();
rhs.join();
// 排序完成
}
不幸的是,这种转换为多线程的方式在处理较大的数组时效果不好,因为会出现一种称为过度分配的现象:启动的线程数量超出了操作系统能处理的数量。在这种情况下,会抛出 Resource temporarily unavailable
异常,程序将终止。
为了避免过度分配,可以使用线程池,其中每个“工作线程”负责处理一个(子)数组,而不是处理嵌套调用。工作线程池由调度器控制,调度器接收排序子数组的请求,并将这些请求传递给下一个可用的工作线程。
示例程序的主要数据结构是一个包含数组迭代器的 std::pair
队列(见图 20.1)。使用两个队列:一个任务队列,接收待分区的数组迭代器。与立即启动新线程(如上例中的 lhs
和 rhs
线程)不同,这里将待排序的范围推送到任务队列中。另一个队列是工作队列:元素从任务队列移动到工作队列,由工作线程处理。
程序的主函数启动工作线程,读取数据,将数组的起始和结束迭代器推送到任务队列中,然后启动调度器。一旦调度器结束,程序会显示排序后的数组:
int main() {
workForce(); // 启动工作线程
readData(); // 将数据读入 vector<int> g_data
g_taskQ.push(Pair(g_data.begin(), g_data.end())); // 准备主要任务
scheduler(); // 排序 g_data
display(); // 显示排序后的元素
}
工作线程池由一组detached线程组成。每个线程代表一个工作线程,实现了 void worker
函数。由于工作线程数量固定,因此不会发生过度分配。一旦数组排序完成,程序结束时这些detached线程会自动结束:
for (size_t idx = 0; idx != g_sizeofWorkforce; ++idx)
std::thread(worker).detach();
调度器会持续运行,直到没有待排序的子数组。当任务队列中还有子数组时,将任务队列的前一个元素移动到工作队列。这减少了工作队列的大小,并为下一个可用的工作线程准备了一个任务。调度器然后等待直到有一个工作线程可用。一旦有工作线程可用,它将通知其中一个工作线程处理待处理的任务,然后调度器继续等待下一个任务。
调度器函数
void scheduler()
{
while (newTask())
{
g_workQ.rawPushFront(g_taskQ); // 将任务队列的内容推送到工作队列
g_workforce.wait(); // 等待工作线程可用
g_worker.notify_all(); // 激活工作线程
}
}
函数 newTask
简单地检查任务队列是否为空。如果为空,并且没有工作线程正在忙碌地排序子数组,那么数组已经排序完成,newTask
返回 false
。当任务队列为空但仍有工作线程忙碌时,可能是因为活跃的工作线程正在将新的子数组维度放置到任务队列中。每当有工作线程活跃时,信号量 g_workforce
的大小会小于工作线程池的大小:
bool wip()
{
return g_workforce.size() != g_sizeofWorkforce;
}
bool newTask()
{
bool done;
std::unique_lock<std::mutex> lk(g_taskMutex);
while ((done = g_taskQ.empty()) && wip())
g_taskCondition.wait(lk);
return !done;
}
每个脱离线程的工作线程执行一个连续的循环。在循环中,它等待调度器的通知。一旦收到通知,它从工作队列中获取任务并对其进行分区。分区可能会产生新的任务。一旦完成任务,工作线程将完成其分配的工作:它增加可用的工作线程数,并通知调度器检查是否所有任务都已完成:
void worker()
{
while (true)
{
g_worker.wait(); // 等待调度器的通知
partition(g_workQ.popFront()); // 执行分区
g_workforce.notify_all(); // 通知调度器
std::lock_guard<std::mutex> lk(g_taskMutex);
g_taskCondition.notify_one(); // 通知调度器有新任务
}
}
小于两个元素的子数组无需分区。所有较大的子数组按其第一个元素进行分区。std::partition
通用算法很好地完成了这一任务,但如果基准元素本身是待分区数组中的一个元素,则基准元素的最终位置是不确定的:它可能位于所有至少等于基准元素的元素序列中的任何位置。可以轻松构造所需的两个子数组:
- 首先调用
std::partition
相对于数组的第一个元素,对数组的其余元素进行分区,返回mid
,指向第一个大于等于数组第一个元素的元素; - 然后交换数组的第一个元素与
mid - 1
指向的元素; - 两个子数组分别从
array.begin()
到mid - 1
(所有小于基准元素的元素),以及从mid
到array.end()
(所有至少等于基准元素的元素)。
定义这两个子数组的两个迭代器对随后被添加到任务队列中,创建两个新任务,供调度器处理:
void partition(Pair const &range)
{
if (range.second - range.first < 2)
return;
auto rhsBegin = std::partition(range.first + 1, range.second,
[=](int value)
{
return value < *range.first;
});
auto lhsEnd = rhsBegin - 1;
std::swap(*range.first, *lhsEnd);
pushTask(range.first, lhsEnd); // 将第一个子数组添加到任务队列
pushTask(rhsBegin, range.second); // 将第二个子数组添加到任务队列
}
共享状态
在线程结束之前,它可能会产生一些结果。这些结果可能需要传达给其他线程。在多线程程序中,有几个类和函数可以用来生成共享状态,使得与其他线程通信变得容易。结果可以是值、对象或异常。
包含这些共享状态的对象称为异步返回对象。然而,由于多线程的性质,一个线程可能会在异步返回对象的结果实际可用之前请求这些结果。在这种情况下,请求线程会被阻塞,等待结果变得可用。异步返回对象提供了 wait
和 get
成员函数,它们分别用于等待结果变得可用,并在结果可用时返回这些异步结果。用来表示结果已经可用的短语是“共享状态已经准备好”。
共享状态由异步提供者来准备。异步提供者是提供结果给共享状态的对象或函数。使共享状态准备好意味着异步提供者:
- 将其共享状态标记为已准备好,以及
- 解除任何等待线程的阻塞(例如,通过允许阻塞成员,如
wait
返回)。
一旦共享状态准备好,它将包含一个值、对象或异常,这可以被访问共享状态的对象检索。在代码等待共享状态准备好时,可能会计算要存储在共享状态中的值或异常。当多个线程尝试访问相同的共享状态时,它们必须使用同步机制(如互斥量)来防止访问冲突。
共享状态使用引用计数来跟踪持有对其引用的异步返回对象或异步提供者的数量。这些返回对象和提供者可以释放对这些共享状态的引用(这称为“释放共享状态”)。当一个返回对象或提供者持有对共享状态的最后一个引用时,共享状态将被销毁。
另一方面,异步提供者也可能放弃其共享状态。在这种情况下,提供者按顺序:
- 将一个类型为
std::future_error
的异常对象存储在其共享状态中,并包含错误条件std::broken_promise
; - 使其共享数据准备好;以及
- 释放其共享数据。
std::future
类的对象(见下一节)是异步返回对象。它们可以由 std::async
(第20.10节)系列函数、std::packaged_task
(第20.11节)类的对象以及 std::promise
(第20.12节)类的对象生成。
异步返回对象:std::future
条件变量允许线程等待数据达到某些值。线程也可能需要等待子线程完成,通常是通过调用子线程的 join
成员函数。等待可能并不总是令人愉快:线程可能会在等待期间做一些有用的事情,或者在未来某个时刻获取子线程产生的结果。
实际上,在线程之间交换数据总是存在一些困难,因为这涉及到共享变量以及使用锁和互斥量来防止数据损坏。与其等待和使用锁,不如启动一些异步任务,使得启动线程(甚至其他线程)可以在将来某个时刻获取结果,而无需担心数据锁或等待时间。对于这种情况,C++ 提供了 std::future
类。
在使用 std::future
类之前,必须包含 <future>
头文件。
std::future
类模板的对象承载异步执行任务产生的结果。std::future
是一个类模板。它的模板类型参数指定了异步执行任务返回的结果的类型。这个类型可以是 void
。
另一方面,异步执行的任务可能会抛出异常(结束任务)。在这种情况下,future
对象会捕获异常,并在请求其返回值(即异步执行任务返回的值)时重新抛出异常。
本节描述了 std::future
类模板的成员。future
对象通常通过工厂函数 std::async
返回的匿名 future
对象,或者通过 std::promise
和 std::packaged_task
类的 get_future
成员进行初始化(将在接下来的章节中介绍)。这些章节提供了 std::future
对象使用的示例。
std::future
的一些成员返回一个强类型枚举 std::future_status
的值。这个枚举定义了三个符号常量:
future_status::ready
:结果已经准备好。future_status::timeout
:等待超时。future_status::deferred
:任务被延迟执行。
错误条件通过 std::future_error
异常返回。这些错误条件由强类型枚举 std::future_errc
的值表示(将在下一节介绍)。
std::future
类本身提供以下构造函数:
future()
:
默认构造函数构造一个不引用共享结果的future
对象。其valid
成员返回false
。future(future &&tmp) noexcept
:
移动构造函数可用。其valid
成员返回在构造函数调用之前tmp.valid()
的返回值。调用移动构造函数后,tmp.valid()
返回false
。
std::future
类不提供复制构造函数或重载的赋值运算符。
以下是 std::future
的成员函数:
future &operator=(future &&tmp)
:
移动赋值运算符从tmp
对象中获取信息;之后,tmp.valid()
返回false
。std::shared_future<ResultType> share() &&
:
返回一个std::shared_future<ResultType>
(参见第20.9节)。调用此函数后,future
的valid
成员返回false
。ResultType get()
:
首先调用wait
(见下文)。一旦wait
返回,关联的异步任务产生的结果将被返回。对于future<Type>
的规格,如果Type
支持移动赋值,则返回移动共享值,否则返回副本。对于future<Type &>
的规格,返回Type &
,对于future<void>
的规格,则不返回任何值。如果共享值是异常,则会抛出异常而不是返回。调用此成员后,future
对象的valid
成员返回false
。bool valid() const
:
如果调用valid
的future
对象引用了由异步任务返回的对象,则返回true
。如果valid
返回false
,则future
对象仍然存在,但除了valid
,仅能安全地调用其析构函数和移动构造函数。如果在valid
返回false
时调用其他成员,将会抛出std::future_error
异常,其值为future_errc::no_state
。void wait() const
:
线程被阻塞,直到关联的异步任务产生的结果可用。std::future_status wait_for(chrono::duration<Rep, Period> const &rel_time) const
:
此成员模板从实际指定的持续时间中推导模板类型Rep
和Period
(参见第4.2.2节)。如果结果包含延迟函数,则什么都不会发生。否则,wait_for
将阻塞,直到结果可用或直到指定的时间rel_time
已过期。可能的返回值有:future_status::deferred
:如果结果包含延迟函数;future_status::ready
:如果结果已经可用;future_status::timeout
:如果因为时间rel_time
已过期而返回。
future_status wait_until(chrono::time_point<Clock, Duration> const &abs_time) const
:
此成员模板从实际指定的时间点abs_time
推导模板类型Clock
和Duration
(参见第4.2.4节)。如果结果包含延迟函数,则什么都不会发生。否则,wait_until
将阻塞,直到结果可用或直到指定的时间点abs_time
已过期。可能的返回值有:future_status::deferred
:如果结果包含延迟函数;future_status::ready
:如果结果已经可用;future_status::timeout
:如果因为时间点abs_time
已过期而返回。
std::future<ResultType>
类声明了以下友元:
std::promise<ResultType>
(参见第20.12节),以及template<typename Function, typename... Args> std::future<typename result_of<Function(Args...)>::type> std::async(std::launch, Function &&fun, Args &&...args)
(参见第20.10节)。
std::future_error
异常与 std::future_errc
枚举
std::future
类的成员可能会通过抛出 std::future_error
异常来返回错误。这些错误条件由强类型枚举 std::future_errc
的值表示,std::future_errc
定义了以下符号常量:
broken_promise
当一个future
对象接收到的值从未由promise
或packaged_task
分配时,会抛出broken_promise
异常。例如,一个promise<int>
对象应该设置其get_future
成员返回的future<int>
对象的值,但如果它没有这样做,则会抛出broken_promise
异常,如以下程序所示:1: std::future<int> fun() 2: { 3: return std::promise<int>().get_future(); 4: } 5: 6: int main() 7: try 8: { 9: fun().get(); 10: } 11: catch (std::exception const &exc) 12: { 13: std::cerr << exc.what() << '\n'; 14: }
在第3行创建了一个 `promise` 对象,但其值从未设置。因此,它“违背了承诺”去产生一个值:当 `main` 尝试在第9行获取其值时,会抛出包含 `future_errc::broken_promise` 值的 `std::future_error` 异常。
- **`future_already_retrieved`**
当尝试多次从 `promise` 或 `packaged_task` 对象(即使最终应准备好)中检索 `future` 对象时,会抛出 `future_already_retrieved` 异常。例如:
```cpp
1: int main()
2: {
3: std::promise<int> promise;
4: promise.get_future();
5: promise.get_future(); // 异常会在这一行抛出
6: }
注意,在第3行定义了 std::promise
对象后,它仅被定义:没有值被分配给它的 future
。即使没有给 future
对象分配值,它仍然是一个有效的对象。即,经过一段时间后,future
应该是准备好的,并且 future
的 get
成员应该产生一个值。因此,第4行成功,但在第5行时会抛出异常,表示“future
已经被检索过”。
-
promise_already_satisfied
当尝试多次为promise
对象分配值时,会抛出promise_already_satisfied
异常。为promise
对象的future
分配值或exception_ptr
只能发生一次。例如:1: int main() 2: { 3: std::promise<int> promise; 4: promise.set_value(15); 5: promise.set_value(155); // 异常会在这一行抛出 6: }
-
no_state
当future
对象的valid
成员返回false
时,调用future
对象的除valid
以外的成员函数会抛出no_state
异常。这发生在例如调用默认构造的future
对象的成员函数时。no_state
不会因为async
工厂函数返回的future
对象或由promise
或packaged_task
类型对象的get_future
成员返回的future
对象而抛出。以下是一个示例:1: int main() 2: { 3: std::future<int> fut; 4: fut.get(); // 异常会在这一行抛出 5: }
std::future_error
类派生自 std::exception
类,并且除了 char const *what() const
成员外,还提供了 std::error_code const &code() const
成员,用于返回与抛出的异常关联的 std::error_code
对象。
共享异步返回对象:std::shared_future
当一个线程激活一个异步提供者(例如 std::async
)时,异步调用函数的返回值通过 std::future
对象在激活线程中变得可用。future
对象不能被其他线程使用。如果需要这种情况(例如,参见本章的最后一节),必须将 future
对象转换为 std::shared_future
对象。在使用 std::shared_future
类之前,必须包含 <future>
头文件。
一旦获得了 shared_future
对象,其 get
成员(见下文)可以重复调用以检索原始 future
对象的结果。以下是一个小示例:
1: int main()
2: {
3: std::promise<int> promise;
4: promise.set_value(15);
5:
6: auto fut = promise.get_future();
7: auto shared1 = fut.share();
8:
9: std::cerr << "Result: " << shared1.get() << "\n"
10: << "Result: " << shared1.get() << "\n"
11: << "Valid: " << fut.valid() << '\n';
12:
13: auto shared2 = fut.share();
14:
15: std::cerr << "Result: " << shared2.get() << "\n"
16: << "Result: " << shared2.get() << '\n';
17: }
在第9行和第10行,承诺的结果被多次检索,但在第7行获取了 shared_future
后,原始的 future
对象不再有相关的共享状态。因此,当再次尝试(在第13行)获取 shared_future
时,会抛出没有关联状态的异常,并且程序会中止。
然而,多个 shared_future
对象的副本可以共存。当多个 shared_future
对象存在时(例如,在不同的线程中),与之关联的异步任务的结果会在同一时刻变得可用。
future
和 shared_future
类之间的关系类似于 unique_ptr
和 shared_ptr
类之间的关系:unique_ptr
只能有一个实例指向数据,而 shared_ptr
可以有多个实例,每个实例都指向相同的数据。
当调用任何 shared_future
对象的成员时,如果 valid() == false
,除了解构函数、移动赋值运算符和 valid
外的其他成员行为是未定义的。
std::shared_future
类支持以下构造函数:
-
shared_future() noexcept
构造一个不引用共享结果的空shared_future
对象。使用此构造函数后,对象的valid
成员返回false
。 -
shared_future(shared_future const &other)
构造一个引用相同结果的shared_future
对象(如果有)。使用此构造函数后,对象的valid
成员返回与other.valid()
相同的值。 -
shared_future(shared_future<Result> &&tmp) noexcept
移动构造一个引用最初由tmp
指向的结果的shared_future
对象(如果有)。使用此构造函数后,对象的valid
成员返回与tmp.valid()
在构造函数调用前相同的值,并且tmp.valid()
返回false
。 -
shared_future(future<Result> &&tmp) noexcept
移动构造一个引用最初由tmp
指向的结果的shared_future
对象(如果有)。使用此构造函数后,对象的valid
成员返回与tmp.valid()
在构造函数调用前相同的值,并且tmp.valid()
返回false
。
该类的析构函数会销毁被调用的 shared_future
对象。如果调用析构函数的对象是最后一个 shared_future
对象,并且没有与当前对象关联的 std::promise
或 std::packaged_task
,则结果也会被销毁。
std::shared_future
类的成员包括:
-
shared_future& operator=(shared_future &&tmp)
移动赋值运算符释放当前对象的共享结果,并将tmp
的结果移动分配给当前对象。调用移动赋值运算符后,当前对象的valid
成员返回与tmp.valid()
在调用前相同的值,且tmp.valid()
返回false
。 -
shared_future& operator=(shared_future const &rhs)
赋值运算符释放当前对象的共享结果,并将rhs
的结果与当前对象共享。调用赋值运算符后,当前对象的valid
成员返回与rhs.valid()
相同的值。 -
Result const &shared_future::get() const
(shared_future<Result &>
和shared_future<void>
的特化也可用。)该成员等待共享结果可用,并随后返回Result const &
。注意,通过get
访问Result
中存储的数据并不具有同步性。程序员需要避免在访问Result
数据时发生竞争条件。如果Result
持有一个异常,当调用get
时会抛出该异常。 -
bool valid() const
如果当前对象引用共享结果,则返回true
。 -
void wait() const
阻塞直到共享结果可用(即,关联的异步任务已生成结果)。 -
future_status wait_for(const chrono::duration<Rep, Period>& rel_time) const
(模板类型Rep
和Period
通常由编译器从实际的rel_time
规范中推导出来。)如果共享结果包含一个延迟函数(参见第20.10节),则不发生任何事情。否则,wait_for
阻塞直到关联异步任务的结果生成,或者直到相对时间rel_time
过期。该成员返回:future_status::deferred
如果共享结果包含一个延迟函数;future_status::ready
如果共享结果可用;future_status::timeout
如果函数返回,因为指定的rel_time
已过期。
-
future_status wait_until(const chrono::time_point<Clock, Duration>& abs_time) const
(模板类型Clock
和Duration
通常由编译器从实际的abs_time
规范中推导出来。)如果共享结果包含一个延迟函数,则不发生任何事情。否则,wait_until
阻塞直到共享结果可用,或者直到指定的时间点abs_time
过期。可能的返回值有:future_status::deferred
如果共享结果包含一个延迟函数;future_status::ready
如果共享结果可用;future_status::timeout
如果函数返回,因为指定的时间点abs_time
已过期。
启动新线程:std::async
在本节中介绍了函数模板 std::async
。async
用于启动异步任务,将值(或 void)返回给调用线程,这在仅使用 std::thread
类时很难实现。
在使用 async
函数之前,必须包含 <future>
头文件。
当使用 std::thread
类的功能启动线程时,启动线程的线程通常在某个时刻调用线程的 join
方法。在此时,线程必须已经完成,否则执行将阻塞直到 join
返回。虽然这通常是一个合理的做法,但并不总是适用:例如,线程实现的函数可能有返回值,或者可能抛出异常。在这些情况下,join
不能使用:如果异常离开线程,则程序将终止。以下是一个示例:
1: void thrower()
2: {
3: throw std::exception();
4: }
5:
6: int main()
7: try
8: {
9: std::thread subThread(thrower);
10: }
11: catch (...)
12: {
13: std::cerr << "Caught exception\n";
14: }
在第3行,thrower
抛出一个异常,离开线程。这个异常没有被 main
的 try
块捕获(因为它是在另一个线程中定义的)。因此,程序终止。
这种情况在使用 std::async
时不会发生。async
可以启动一个新的异步任务,并且激活线程可以通过 std::future
对象检索异步任务实现函数的返回值或任何离开该函数的异常。基本上,async
的调用方式类似于使用 std::thread
启动线程:传递一个函数和可选的参数,这些参数会转发给该函数。
尽管实现异步任务的函数可以作为第一个参数传递,async
的第一个参数也可以是强类型枚举 std::launch
的值:
enum class launch
{
async,
deferred
};
- 传递
launch::async
时,异步任务会立即启动; - 传递
launch::deferred
时,异步任务会被延迟执行; - 当未指定
std::launch
时,默认值为launch::async | launch::deferred
,这使得实现可以自由选择,通常会导致延迟执行异步任务。
以下是使用 async
启动子线程的示例:
1: bool fun()
2: {
3: std::cerr << "hello from fun\n"; return std::cerr.good();
4: }
5:
6: int exceptionalFun()
7: {
8: throw std::exception();
9: }
10:
11: int main()
12: try
13: {
14: auto fut1 = std::async(std::launch::async, fun);
15: auto fut2 = std::async(std::launch::async, exceptionalFun);
16:
17: std::cerr << "fun returned " << std::boolalpha << fut1.get() << '\n';
18: std::cerr << "exceptionalFun did not return " << fut2.get() << '\n';
19: }
20: catch (...)
21: {
22: std::cerr << "caught exception thrown by exceptionalFun\n";
23: }
现在线程会立即启动,虽然结果在第13行左右可用,但抛出的异常不会终止程序。第一个线程的返回值在第16行被获取,第二个线程抛出的异常会被 main
的 try
块捕获(第19行)。
async
函数模板有几个重载版本:
-
基本形式
期望第一个参数是一个函数或函数对象,并返回一个持有函数返回值或函数抛出的异常的std::future
:template <typename Function, class ...Args> std::future<typename std::result_of<Function(Args ...)>::type> std::async(Function &&fun, Args &&...args);
-
成员函数地址
第一个参数也可以是成员函数的地址。在这种情况下,第二个参数是该成员函数所在类的对象(或指向该对象的指针)。任何剩余的参数都传递给成员函数(参见下文的备注)。 -
std::launch
枚举值组合
第一个参数也可以是std::launch
枚举值的组合(使用bit_or
运算符):template <class Function, class ...Args> std::future<typename std::result_of<Function(Args ...)>::type> std::async(std::launch policy, Function &&fun, Args &&...args);
-
指定
std::launch
值的成员函数
如果第一个参数指定了std::launch
值,第二个参数也可以是成员函数的地址。在这种情况下,第三个参数是该成员函数所在类的对象(或指向该对象的指针)。任何剩余的参数都传递给成员函数(参见下文的备注)。
调用 async
时,除 std::launch
参数外的所有参数必须是引用、指针或可移动构造的对象:
-
成员函数
如果指定了成员函数,则调用成员函数的对象必须是一个命名对象、一个匿名对象或一个指向命名对象的指针。 -
命名对象传递
当将命名对象传递给async
函数模板时,会使用拷贝构造函数创建对象的副本,然后将其转发给线程启动器。 -
匿名对象传递
当将匿名对象传递给async
函数模板时,会使用移动构造函数将匿名对象转发给线程启动器。
一旦线程本身启动了另一个线程,移动构造将用于构造一个对象,该对象在线程的整个生命周期内存在。当传递一个指向对象的指针时,子线程使用指针所指向的对象,不需要进行复制或移动构造。然而,在使用对象指针时,程序员需要确保对象的生命周期超过线程的持续时间(注意,这并没有自动保证,因为异步任务可能在 future
的 get
成员被调用之前实际上不会启动)。
由于默认使用 std::launch::deferred | std::launch::async
作为基本的 async
调用参数,因此传递给 async
的函数可能不会立即启动。launch::deferred
策略允许实现者推迟执行,直到程序明确请求函数的结果。考虑以下程序:
void fun()
{
std::cerr << "hello from fun\n";
}
std::future<void> asyncCall(char const *label)
{
std::cerr << label << " async call starts\n";
auto ret = std::async(fun);
std::cerr << label << " async call ends\n";
return ret;
}
int main()
{
asyncCall("First");
asyncCall("Second");
}
尽管在第 9 行调用了 async
,程序输出可能不会立即显示 fun
的输出行。这是因为使用了默认的 launch::deferred
策略:系统会推迟 fun
的执行,直到需要结果,这种情况不会发生。但是,返回的 future
对象有一个 wait
成员。一旦 wait
返回,共享状态必须可用。换句话说:fun
必须已经完成。以下是插入 ret.wait()
行后的结果:
First async call starts
hello from fun
First async call ends
Second async call starts
hello from fun
Second async call ends
实际上,可以在需要结果的地方请求 fun
的评估,甚至在调用 asyncCall
之后,如下例所示:
int main()
{
auto ret1 = asyncCall("First");
auto ret2 = asyncCall("Second");
ret1.get();
ret2.get();
}
在这里,ret1
和 ret2
的 std::future
对象被创建,但它们的 fun
函数尚未评估。评估发生在第 6 和第 7 行,导致以下输出:
First async call starts
First async call ends
Second async call starts
Second async call ends
hello from fun
hello from fun
std::async
函数模板用于启动线程,使其结果对调用线程可用。另一方面,我们可能只能准备(打包)一个任务(线程),但可能需要将任务的完成留给另一个线程。这种场景通过 std::packaged_task
类来实现,下一节将对此进行介绍。
准备任务的执行:std::packaged_task
类模板 std::packaged_task
允许程序将一个函数或仿函数“打包”并将其传递给线程以供进一步处理。处理线程随后调用被打包的函数,并传递给它相应的参数(如果有的话)。完成函数后,packaged_task
的 future
对象将就绪,程序可以获取由函数产生的结果。因此,函数及其调用的结果可以在线程之间传递。在使用类模板 packaged_task
之前,必须包含 <future>
头文件。
在描述这个类的接口之前,首先让我们通过一个例子来了解 packaged_task
的使用。记住 packaged_task
的本质是,程序的一部分准备(打包)一个任务供另一个线程完成,且程序在某个时刻需要已完成任务的结果。
为了澄清这里发生的事情,我们先来看一个现实生活中的类比。时不时我会和我的车库预约,进行汽车保养。在这种情况下,“打包”的是关于我的车的详细信息:汽车的品牌和类型决定了车库在进行保养时执行的操作。我的邻居也有一辆车,偶尔也需要保养。这也产生了一个给车库的“包裹”。在适当的时候,我和邻居将车开到车库(即,包裹被传递给另一个线程)。车库对汽车进行保养(即,调用 packaged_task
中存储的函数 [注意,任务是不同的,取决于汽车的类型]),并执行一些与之相关的操作(例如,记录我的或邻居的车已保养,或订购更换零件)。同时,我和邻居继续处理我们自己的事务(程序在一个单独的线程运行时继续执行)。但在一天结束时,我们希望再次使用我们的车(即,获取与 packaged_task
相关的结果)。在这个例子中,常见的结果是车库的账单,我们必须支付(程序获取 packaged_task
的结果)。
以下是一个演示如何使用 packaged_task
的 C++ 小程序(假设已经包含了所需的头文件和 using namespace std
声明):
mutex carDetailsMutex;
condition_variable condition;
string carDetails;
packaged_task<size_t (std::string const &)> serviceTask; //4
size_t volkswagen(string const &type) //6
{
cout << "performing maintenance by the book for a " << type << '\n';
return type.size() * 75; // 账单金额
}
size_t peugeot(string const &type)
{
cout << "performing quick and dirty maintenance for a " << type << '\n';
return type.size() * 50; // 账单金额
}//16
void garage() //18
{
while (true)
{
unique_lock<mutex> lk(carDetailsMutex);
while (carDetails.empty())
condition.wait(lk);
cout << "servicing a " << carDetails << '\n';
serviceTask(carDetails);//27
carDetails.clear();//28
}
}//30
int main()//32
{
thread(garage).detach(); //34
while (true)//36
{
string car;
if (!getline(cin, car) || car.empty())
break;
{
lock_guard<mutex> lk(carDetailsMutex);
carDetails = car;
}
serviceTask = packaged_task<size_t (string const &)>(
car[0] == 'v' ? volkswagen : peugeot //并提供合适的服务函数
);
auto bill = serviceTask.get_future(); //48
condition.notify_one();//49
cout << "Bill for servicing a " << car << ": EUR " << bill.get() << '\n';
}//52
}//53
代码解释:
- 同步变量定义(第1-3行):定义了用于同步的变量,包括一个互斥锁和条件变量。
packaged_task
定义(第4行):定义了packaged_task
,serviceTask
初始化为一个期望接受string
类型参数并返回size_t
的函数(或仿函数)。- 定义服务任务(第6-16行):定义了两个函数
volkswagen
和peugeot
,分别代表对不同类型的车进行保养时执行的任务,并返回账单金额。 - 车库线程(第18-30行):定义了一个名为
garage
的函数,表示当汽车进厂时车库执行的操作。这些操作由一个分离的线程执行(第34行)。该线程在一个循环中等待,直到获得carDetailsMutex
的锁并且carDetails
不再为空。然后在第27行将carDetails
传递给serviceTask
。最终,在第28行清空carDetails
,为下一次请求做准备。 - 主函数(第32-53行):
- 在第34行启动了一个匿名的分离线程,运行
garage
函数。 - 程序的主循环开始(第36-52行):
- 主线程从标准输入读取命令,直到接收到空行或无行(第38-40行)。
- 由惯例,行的第一个字母表示汽车的品牌(
volkswagen
或peugeot
),然后构造对应的packaged_task
,并提供合适的服务函数(第45行)。 - 接下来,在第48行,存储在
future
中的结果被检索。虽然此时future
可能尚未就绪,但future
对象本身是准备好的,并且作为账单返回。 - 最后,通知车库可以开始保养一辆车(第49行)。
- 主线程通过在第51行调用
bill.get()
获取结果。如果此时汽车仍在保养中,账单尚未准备好,bill.get()
会阻塞,直到账单准备好,并显示保养账单。
- 在第34行启动了一个匿名的分离线程,运行
std::packaged_task
接口详解
现在我们已经看到了一个使用 packaged_task
的程序示例,接下来让我们看看它的接口。需要注意的是,packaged_task
是一个类模板:其模板类型参数指定了实现 packaged_task
对象所执行任务的函数或函数对象的原型。
构造函数与析构函数:
-
packaged_task() noexcept
:
默认构造函数构造一个packaged_task
对象,但不与任何函数或共享状态相关联。 -
explicit packaged_task<ReturnType(Args...)> task(fun)
:
构造一个packaged_task
,用于处理期望参数类型为Args...
且返回值类型为ReturnType
的函数或仿函数fun
。packaged_task
类模板指定ReturnType (Args...)
作为其模板类型参数。构造的对象包含一个共享状态,并包含函数的(移动构造的)副本。可选地,可以将
Allocator
作为第二个模板类型参数指定,在这种情况下,前两个参数为std::allocator_arg_t
和Allocator const &alloc
。std::allocator_arg_t
是一种类型,用于消除构造函数选择的歧义,可以简单地指定为std::allocator_arg_t()
。该构造函数可能抛出
std::bad_alloc
异常或函数的复制或移动构造函数抛出的异常。 -
packaged_task(packaged_task &&tmp) noexcept
:
移动构造函数将tmp
中的任何现有共享状态移动到新构造的对象中,并移除tmp
的共享状态。 -
~packaged_task()
:
该对象的共享状态(如果有)将被放弃。
成员函数:
-
future<ReturnType> get_future()
:
返回一个std::future
对象,该对象持有由单独执行的线程产生的结果。如果get_future
被错误调用,将抛出future_error
异常,包含以下值之一:future_already_retrieved
:如果对包含与当前对象相同共享状态的packaged_task
对象已经调用了get_future
。no_state
:如果当前对象没有共享状态。
注意:任何共享该对象的共享状态的
future
可以访问对象任务返回的结果。 -
void make_ready_at_thread_exit(Args... args)
:
在当前线程退出时调用void operator()(Args... args)
(见下文),一旦与当前线程关联的所有线程存储持续时间的对象被销毁后。 -
packaged_task &operator=(packaged_task &&tmp)
:
移动赋值操作符首先释放当前对象的共享状态(如果有),然后交换当前对象和tmp
。 -
void operator()(Args... args)
:
将args
参数转发给当前对象存储的任务。当存储的任务返回时,其返回值存储在当前对象的共享状态中。否则,任务抛出的任何异常将存储在对象的共享状态中。之后,对象的共享状态变为已就绪,并且所有阻塞在等待对象的共享状态变为已就绪的函数中的线程都会被解除阻塞。如果出现错误,将抛出future_error
异常,包含以下值之一:promise_already_satisfied
:如果共享状态已经变为已就绪。no_state
:如果当前对象没有共享状态。
调用此成员与调用任何访问
packaged_task
结果的(共享)future
对象的成员函数同步。 -
void reset()
:
放弃任何可用的共享状态,将当前对象初始化为packaged_task(std::move(funct))
,其中funct
是对象存储的任务。此成员可能抛出以下异常:bad_alloc
:如果无法为新的共享状态分配内存;- 任务的移动构造函数抛出的任何异常;
- 如果当前对象不包含任何共享状态,将抛出
future_error
并伴随no_state
错误条件。
-
void swap(packaged_task &other) noexcept
:
交换当前对象和other
的共享状态和存储的任务。 -
bool valid() const noexcept
:
如果当前对象包含共享状态,返回true
,否则返回false
。
非成员(自由)函数:
void swap(packaged_task<ReturnType(Args...)> &lhs, packaged_task<ReturnType(Args...)> &rhs) noexcept
:
调用lhs.swap(rhs)
。
std::promise
类详解
除了 std::packaged_task
和 std::async
,类模板 std::promise
也可以用于从单独的线程中获取结果。在使用 promise
类模板之前,必须包含 <future>
头文件。
std::promise
用于从另一个线程中获取结果,而不需要额外的同步操作。考虑以下程序:
void compute(int *ret) {
*ret = 9;
}
int main() {
int ret = 0;
std::thread(compute, &ret).detach();
cout << ret << '\n';
}
这个程序很可能显示值 0
,因为在分离线程完成工作之前,cout
语句已经被执行。在这个例子中,这个问题可以通过使用非分离线程并使用线程的 join
成员来轻松解决,但当使用多个线程时,这需要为每个线程命名并调用 join
。因此,使用 promise
可能是更好的选择:
1: void compute(promise<int> &ref) {
2: ref.set_value(9);
3: }
4:
5: int main() {
6: std::promise<int> prom;
7: std::thread(compute, ref(prom)).detach();
8: cout << prom.get_future().get() << '\n';
9: }
这个例子也使用了分离线程,但其结果保存在一个 promise
对象中,而不是直接赋值给最终变量。promise
对象包含一个持有计算值的 future
对象。future
的 get
成员会阻塞,直到 future
准备就绪,此时结果才可用。这时,分离线程可能已经完成工作,也可能还没有。如果它已经完成了工作,get
将立即返回,否则将有一个短暂的延迟。
当实现某些算法的多线程版本时,不需要使用额外的同步语句,promise
非常有用。例如,考虑矩阵乘法。结果矩阵的每个元素都是两个向量的内积:左矩阵操作数的一行和右矩阵操作数的一列的内积成为结果矩阵的 [row][column]
元素。由于结果矩阵的每个元素都可以独立计算,因此完全可以实现多线程。以下例子中,函数 innerProduct
(第4到第11行)将其结果存储在一个 promise
对象中:
1: int m1[2][2] = {{1, 2}, {3, 4}};
2: int m2[2][2] = {{3, 4}, {5, 6}};
3:
4: void innerProduct(promise<int> &ref, int row, int col) {
5: int sum = 0;
6: for (int idx = 0; idx != 2; ++idx)
7: sum += m1[row][idx] * m2[idx][col];
8: ref.set_value(sum);
9: }
10:
11: int main() {
12: promise<int> result[2][2];
13: for (int row = 0; row != 2; ++row) {
14: for (int col = 0; col != 2; ++col)
15: thread(innerProduct, ref(result[row][col]), row, col).detach();
16: }
17: for (int row = 0; row != 2; ++row) {
18: for (int col = 0; col != 2; ++col)
19: cout << setw(3) << result[row][col].get_future().get();
20: cout << '\n';
21: }
22: }
每个内积由一个单独的(匿名和分离的)线程计算(第17到21行),线程一旦运行时系统允许便会启动。当线程完成后,结果内积可以从 promise
的 future
中获取。由于 future
的 get
成员会阻塞,直到结果实际可用,结果矩阵可以通过按顺序调用这些成员简单地显示出来(第23到28行)。
因此,promise
允许我们使用一个线程来计算一个值(或异常,见下文),这个值可以在将来的某个时间点被另一个线程收集。promise
仍然可用,因此不再需要进一步同步线程和启动线程的程序。如果 promise
对象包含一个异常而不是一个值,其 future
的 get
成员将重新抛出存储的异常。
std::promise
的接口
注意,promise
是一个类模板:其模板类型参数 ReturnType
指定了可以从中检索的 std::future
的模板类型参数。
构造函数和析构函数:
-
promise()
:
默认构造函数构造一个包含共享状态的promise
对象。共享状态可以通过get_future
成员(见下文)返回,但此future
尚未准备好。 -
promise(promise &&tmp) noexcept
:
移动构造函数构造一个promise
对象,将tmp
的共享状态的所有权转移到新构造的对象中。对象构造后,tmp
不再包含共享状态。 -
~promise()
:
对象的共享状态(如果有)被放弃。
成员函数:
-
std::future<ReturnType> get_future()
:
返回一个共享当前对象共享状态的std::future
对象。如果发生错误,将抛出future_error
异常,包含以下值之一:future_already_retrieved
:如果对包含与当前对象相同共享状态的packaged_task
对象已经调用了get_future
。no_state
:如果当前对象没有共享状态。
注意:任何共享该对象的共享状态的
future
可以访问对象任务返回的结果。 -
promise &operator=(promise &&rhs) noexcept
:
移动赋值操作符首先释放当前对象的共享状态(如果有),然后交换当前对象和tmp
。 -
void set_value()
:
将值原子地存储在共享状态中,然后将其标记为已准备就绪。如果发生错误,将抛出future_error
异常,包含以下值之一:promise_already_satisfied
:如果共享状态已经准备好。no_state
:如果当前对象没有共享状态。
或者,值的移动或复制构造函数抛出的任何异常也可能被抛出。
-
void set_exception(std::exception_ptr obj)
:
将exception_ptr
对象原子地存储在共享状态中,并使该状态准备好。如果发生错误,将抛出future_error
异常,包含以下值之一:promise_already_satisfied
:如果共享状态已经准备好。no_state
:如果当前对象没有共享状态。
-
void set_exception_at_thread_exit(exception_ptr ptr)
:
将异常指针存储在共享状态中,但不立即将该状态设置为准备就绪。状态将在当前线程退出时变为准备就绪,一旦与结束线程关联的所有线程存储持续时间的对象都被销毁。如果发生错误,将抛出future_error
异常,包含以下值之一:promise_already_satisfied
:如果共享状态已经准备好。no_state
:如果当前对象没有共享状态。
-
void set_value_at_thread_exit()
:
将值存储在共享状态中,但不立即使该状态准备就绪。状态将在当前线程退出时变为准备就绪,一旦与结束线程关联的所有线程存储持续时间的对象都被销毁。如果发生错误,将抛出future_error
异常,包含以下值之一:promise_already_satisfied
:如果共享状态已经准备好。no_state
:如果当前对象没有共享状态。
-
void swap(promise& other) noexcept
:
交换当前对象和其他对象的共享状态(如果有)。
非成员(自由)函数:
void swap(promise<ReturnType> &lhs, promise<ReturnType> &rhs) noexcept
:
调用lhs.swap(rhs)
。
一个示例:多线程编译
在本节中,我们将开发另一个程序。该示例程序展示了 packaged_tasks
的使用。
与多线程快速排序示例类似,本例也使用了一个工作池。然而,在这个例子中,工作线程实际上并不知道它们的任务是什么。在当前的例子中,任务恰好是相同的,但也可以使用不同的任务,而不需要更新工作线程。
程序使用了一个包含命令规范(d_command
)和任务规范(d_task
)的 Task
类(参考图20.2)。程序的源代码可以在C++注释的 yo/threading/examples/multicompile
目录中找到。
在这个程序中,main
函数首先通过一系列线程启动其工作队伍。随后,编译任务被准备好并通过 jobs
函数推送到任务队列中,工作线程从任务队列中取出任务。一旦编译完成(即工作线程加入主线程后),编译任务的结果由 results
函数处理:
int main() {
workforce(); // 启动工作线程
jobs(); // 准备任务:将所有任务推送到任务队列中
for (thread &thr : g_thread) // 等待工作线程结束
thr.join();
results(); // 显示结果
}
jobs
函数通过 nextCommand
函数接收要编译的文件名,nextCommand
函数会忽略空行,并返回非空行。一旦标准输入流中的所有行都已读取完毕,nextCommand
函数最终会返回一个空行:
string nextCommand() {
string ret;
while (true) {
if (not getline(cin, ret)) // 没有更多的行了
break;
if (not ret.empty()) // 一旦有行内容,就准备好
break;
}
return ret;
}
当遇到非空行时,jobs
函数会使用 g_dispatcher
信号量(第12行)等待一个可用的工作线程。g_dispatcher
信号量初始化为工作线程的数量,当有工作线程处于活跃状态时,信号量值减少,而完成任务的工作线程会将信号量值增加。如果编译失败,则会将 g_done
变量设置为 true
,并且不会执行更多的编译任务(第14、15行)。当 jobs
函数接收到要编译的文件名称时,工作线程可能会检测到编译错误。如果发生这种情况,工作线程将 g_done
变量设置为 true
。一旦 jobs
函数的 while
循环结束,工作线程会再次收到通知(第24行),因为不再有任务需要执行,这些线程将结束它们的执行。
void jobs() {
while (true) {
string line = nextCommand();
if (line.empty()) { // 没有命令?jobs() 完成。
g_done = true;
break;
}
g_dispatcher.wait(); // 等待一个可用的工作线程 //12
if (g_done.load()) // 如果有工作线程发现了错误 //14
break; // 那么无论如何都退出 //15
newTask(line); // 推送一个新任务(及其结果)
g_worker.notify_all(); // 通知工作线程:任务可用了
}
g_worker.notify_all(); // 在没有任务时结束工作线程
}
Task对象的newTask函数为下一个任务做准备。首先会构造一个Task对象。Task包含要编译的文件的名称以及一个packaged_task。它封装了与packaged_task相关的所有活动。以下是其(类内)定义:
using PackagedTask = packaged_task<Result(string const &fname)>;
class Task {
string d_command; // 保存命令字符串
PackagedTask d_task; // 包装任务对象
public:
// 默认构造函数
Task() = default;
// 构造函数,接收命令和任务对象
Task(string const &command, PackagedTask &&tmp)
: d_command(command), d_task(move(tmp)) {}
// 重载函数调用运算符,执行任务
void operator()() {
d_task(d_command);
}
// 返回共享的 future 对象
shared_future<Result> result() {//22
return d_task.get_future().share();
}//25
};
在第22到25行中,提到 result()
返回了一个 shared_future
对象。由于调度器(dispatcher)与处理结果的线程是不同的线程,因此调度器创建的 future
对象必须被共享给处理结果的函数使用。因此,Task::result()
返回的是 shared_future
。
一旦 Task
对象构造完成,其 shared_future
对象会被压入结果队列(result queue)。尽管此时实际的结果还没有计算出来,但 result()
函数最终会被调用,以处理已经被压入结果队列的结果。另外,Task
本身也会被压入任务队列(task queue),并由工作线程(worker)检索处理:
工作线程的任务:
工作线程的任务非常简单:等待下一个任务,从任务队列中检索该任务,然后完成它。工作线程不关心任务内部发生了什么。当被通知有任务等待时,工作线程会执行该任务。然而,最后当所有任务都已经被压入任务队列时,jobs()
函数会再次通知工作线程。在这种情况下,任务队列是空的,工作线程的函数将结束,但在结束之前,它会通知其他工作线程,从而结束所有工作线程,使它们可以加入主线程(main thread):
void worker() {
Task task;
while (true) {
g_worker.wait(); // 等待一个可用的任务
if (g_taskQ.empty()) // 没有任务?结束
break;
g_taskQ.popFront(task); // 从任务队列中取出任务
g_dispatcher.notify_all(); // 通知调度器可以推送下一个任务
task(); // 执行任务
}
g_worker.notify_all(); // 没有任务:通知其他工作线程
}
这部分代码完成了任务的处理描述。接下来是对任务本身的描述。在当前程序中,C++ 源文件将被编译。编译命令被传递给 CmdFork
对象的构造函数,该对象启动编译器作为子进程。编译的结果通过其 childExit
成员(返回编译器的退出码)和 childOutput
成员(返回编译器产生的任何文本输出)获取。如果编译失败,退出值将不为零。在这种情况下,不会发出进一步的编译任务,因为 g_done
被设置为 true
。
以下是 compile()
函数的实现:
Result compile(string const &line) {
string command("/usr/bin/g++ -Wall -c " + line);
CmdFork cmdFork(command);
cmdFork.fork();
Result ret {cmdFork.childExit() == 0, line + "\n" + cmdFork.childOutput()};
if (not ret.ok)
g_done = true;
return ret;
}
results()
函数:
results()
函数会一直执行,直到 newResult
指示没有更多结果为止。程序将显示所有成功完成的编译结果,如果多个工作线程遇到编译错误,则只显示第一个编译错误的输出。以下是 results()
函数的实现:
void results() {
Result result;
string errorDisplay;
while (newResult(result)) {
if (result.ok)
cerr << result.display;
else if (errorDisplay.empty())
errorDisplay = result.display; // 记录第一个编译错误的输出
}
if (not errorDisplay.empty()) // 显示第一个编译错误
cerr << errorDisplay;
}
newResult()
函数:
newResult()
函数控制 results()
的 while 循环。当结果队列不为空时,它返回 true
,在这种情况下,队列的前部元素被存储到外部 Result
对象中,并将其从队列中移除:
bool newResult(Result &result) {
if (g_resultQ.empty())
return false;
result = g_resultQ.front().get();
g_resultQ.pop();
return true;
}
总结:
这个程序通过使用线程池和任务队列,实现了多线程的任务调度与执行。不同线程之间通过 shared_future
共享任务的结果,并通过信号量机制确保任务的有序执行。
事务内存(Transactional Memory)
事务内存(Transactional Memory)用于简化多线程程序中共享数据的访问。事务内存的好处可以通过一个小程序来最好地说明。考虑一种情况,线程需要将信息写入文件。一个简单的示例程序如下:
void fun(int value) {
for (size_t rept = 0; rept != 10; ++rept) {
this_thread::sleep_for(chrono::seconds(1));
cout << "fun " << value << '\n';
}
}
int main() {
thread thr{ fun, 1 };
fun(2);
thr.join();
}
当这个程序运行时,fun 1
和 fun 2
的消息会交错显示。为了防止这种情况,我们通常会定义一个互斥锁(mutex),锁定它,写入消息,然后释放锁:
void fun(int value) {
static mutex guard;
for (size_t rept = 0; rept != 10; ++rept) {
this_thread::sleep_for(chrono::seconds(1));
guard.lock();
cout << "fun " << value << '\n';
guard.unlock();
}
}
事务内存可以帮我们处理锁定。在使用事务内存时,语句被嵌入到一个 synchronized
块中。使用事务内存的 fun
函数如下所示:
void fun(int value) {
for (size_t rept = 0; rept != 10; ++rept) {
this_thread::sleep_for(chrono::seconds(1));
synchronized {
cout << "fun " << value << '\n';
}
}
}
要编译使用事务内存的源文件,需要指定 g++
编译器选项 -fgnu-tm
。
在 synchronized
块内的代码被当作一个整体执行,就像这个块被互斥锁保护一样。与使用互斥锁不同,事务内存是通过软件而不是硬件设施来实现的。
考虑到使用事务内存比使用基于互斥锁的锁定机制要容易得多,事务内存的确看起来好得难以置信。而在某种程度上,它的确如此。当遇到 synchronized
块时,线程会无条件地执行该块的语句。同时,它会详细记录所有的操作。一旦语句执行完成,线程会检查是否有其他线程在它之前开始执行该块。如果是这样,它会使用 synchronized
块的日志撤销其操作。显然,这样做的代价在于:至少会有维护日志的开销,并且如果另一个线程在当前线程之前开始执行同步块,那么还会有撤销操作并重试的额外开销。
事务内存的优势也显而易见:程序员不再需要负责正确地控制对共享内存的访问;遇到死锁的风险消失了,定义互斥锁、锁定和解锁的管理开销也不存在了。特别是对于像写入文件这样本质上较慢的操作,事务内存可以极大地简化代码中的部分内容。
考虑 std::stack
。它的顶层元素可以被检查,但它的 pop
成员函数并不返回顶层元素。传统上,要检索顶层元素并可能移除它,通常需要一个互斥锁来围绕确定堆栈的大小,如果为空,则释放锁并等待。如果不为空,则检索顶层元素,然后从堆栈中移除它。使用事务内存,我们可以得到如下简单的代码:
bool retrieve(stack<Item> &itemStack, Item &item) {
synchronized {
if (itemStack.empty())
return false;
item = std::move(itemStack.top());
itemStack.pop();
return true;
}
}
同步块
(synchronized)的变体有:
-
atomic_noexcept:其复合语句内的语句不得抛出异常。如果抛出了异常,则调用
std::abort
。如果早期的fun
函数指定了atomic_noexcept
而不是synchronized
,编译器会生成一个关于使用插入运算符的错误,因为插入运算符可能会抛出异常。 -
atomic_cancel:尚未被
g++
支持。如果抛出std::bad_alloc
、bad_array_new_length
、bad_cast
、bad_typeid
、bad_exception
、exception
、tx_exception<Type>
以外的异常,则调用std::abort
。如果抛出了一个可接受的异常,那么到目前为止执行的语句将被撤销。 -
atomic_commit:如果从其复合语句中抛出异常,则所有到目前为止已执行的语句将被保留(即,不会被撤销)。
同步输出到流
考虑这样一个情况:一个多线程程序中的不同线程需要写入同一个文件。每个线程写入的信息应该作为一个完整的块出现在该文件中。解决这个问题有几种方法:每个线程可以写入一个与之关联的全局文件,当线程结束时,将这些文件复制到目标文件。或者,将目标文件传递给线程,每个线程定义自己的本地文件,将其信息写入该文件。然后,在线程结束之前,它会锁定对目标文件的访问,将其本地文件复制到目标文件。
最近,C++标准库中添加了 std::osyncstream
类,允许多线程程序线程块级地将信息写入公共流,而不需要定义接收线程特定信息的单独流,然后将这些流复制到目标流。在使用 osyncstream
对象之前,必须包含 <syncstream>
头文件。
osyncstream
类公有继承自 std::ostream
,通过 std::syncbuf
流缓冲区(在下一节中描述)初始化 ostream
基类,执行实际的同步操作。
写入 osyncstream
对象的信息可以显式地复制到目标 ostream
,或者在 osyncstream
的析构函数中自动复制到目标 ostream
。每个线程可以构造自己的 osyncstream
对象,处理其接收到的信息块的复制到目标流。
构造函数:
osyncstream{ostream &out}
:构造一个osyncstream
对象,最终将接收到的信息写入out
。在下面的代码中,out
被称为目标流。osyncstream{osyncstream &&tmp}
:移动构造函数可用。
默认构造函数和拷贝构造函数不可用。
成员函数:
除了从 std::ostream
继承的成员(如 rdbuf
成员,返回指向对象的 syncbuf
的指针,具体在下一节中描述),osyncstream
类还提供以下成员:
get_wrapped
:返回指向目标流的流缓冲区的指针。emit
:将接收到的信息作为一个块复制到目标流。
以下程序示例演示了如何使用 osyncstream
对象:
#include <iostream>
#include <syncstream>
#include <string>
#include <thread>
using namespace std;
void fun(char const *label, size_t count) {
osyncstream out(cout);
for (size_t idx = 0; idx != count; ++idx) {
this_thread::sleep_for(1s);
out << label << ": " << idx << " running...\n";
}
out << label << " ends\n";
}
int main(int argc, char **argv) {
cout << "the 1st arg specifies the #iterators "
"using 3 iterations by default\n";
size_t count = argc > 1 ? stoul(argv[1]) : 3;
thread thr1{ fun, "first", count };
thread thr2{ fun, "second", count };
thr1.join();
thr2.join();
}
- 函数
fun
(第 8 行)被main
从两个线程(第 27 和第 28 行)调用。 - 它定义了一个
osyncstream
对象out
,并通过短暂的秒级暂停,将一些文本行写入out
(第 14 和第 15 行)。 - 在离开
fun
之前,本地out
内容作为一个块写入cout
(第 18 行)。将out
的内容写入cout
也可以通过调用out.emit()
显式请求。
std::syncbuf
流缓冲区
osyncstream
流实际上只是 ostream
的一个包装器,使用 syncbuf
作为其流缓冲区。std::syncbuf
处理实际的同步操作。要使用 syncbuf
流缓冲区,必须包含 <syncstream>
头文件。
syncbuf
流缓冲区将从 ostream
接收到的信息收集到一个内部缓冲区中,它的析构函数和 emit
成员函数将其缓冲区作为一个块刷新到目标流中。
构造函数:
syncbuf()
:默认构造函数,构造一个syncbuf
对象,并将其emit-on-sync
策略设置为false
。explicit syncbuf(streambuf *destbuf)
:构造一个std::syncbuf
对象,并将emit-on-sync
策略设置为false
,使用destbuf
作为目标流的流缓冲区。syncbuf(syncbuf &&rhs)
:移动构造函数,将rhs
的内容移动到构造的syncbuf
对象中。
std::syncbuf
流缓冲区
osyncstream
流实际上只是 ostream
的一个包装器,使用 syncbuf
作为其流缓冲区。std::syncbuf
负责处理实际的同步操作。要使用 syncbuf
流缓冲区,必须包含 <syncstream>
头文件。
syncbuf
流缓冲区将从 ostream
接收到的信息收集到一个内部缓冲区中,它的析构函数和 emit
成员函数将其缓冲区的内容作为一个整体刷新到目标流中。
构造函数:
syncbuf()
:默认构造函数,构造一个syncbuf
对象,并将其emit-on-sync
策略设置为false
。explicit syncbuf(streambuf *destbuf)
:构造一个std::syncbuf
对象,并将emit-on-sync
策略设置为false
,使用destbuf
作为目标流的流缓冲区。syncbuf(syncbuf &&rhs)
:移动构造函数,将rhs
的内容移动到构造的syncbuf
对象中。
使用 osyncstream
进行多线程编译
第20.13节描述了一个多线程程序的构建,用于执行编译任务。在这个程序中,使用了独立的线程作为工作线程,这些线程将它们的结果推送到一个结果队列中。在程序结束时,results
函数处理队列中的结果,显示成功编译的源文件名称,以及(如果有编译失败)第一个失败源文件的名称和错误信息。
在这个程序中,使用了一个结果队列来存储结果,并使用互斥量确保工作线程不能同时将结果推送到结果队列中。使用 osyncstream
对象后,结果队列及其互斥保护方案不再需要(修改后的程序源代码可以在 C++ Annotations 的 yo/threading/examples/osyncmulticompile
目录中找到)。
在使用 osyncstream
对象后,程序改为使用一个单独的目标流 fstream g_out{"/tmp/out", ios::trunc | ios::in | ios::out}
,其 compile
函数定义了一个本地的 osyncstream
对象,确保其输出作为一个块发送到 g_out
:
void compile(string const &line) {
if (g_done.load()) return;
string command("/usr/bin/g++ -Wall -c " + line);
CmdFork cmdFork(command);
cmdFork.fork();
int exitValue = cmdFork.childExit();
osyncstream out(g_out);//13
out << exitValue << ' ' << line << '\n';
if (exitValue != 0) {
out << cmdFork.childOutput() << '\n' << g_marker << '\n';
g_done = true;//18
}
// out.emit(); // 由 out 的析构函数处理
}
- 在第13行定义了
osyncstream out
对象,并在第14和第18行将编译结果写入out
。 - 在第14行将编译结果和源文件名称插入到
out
。 - 如果编译失败,则在第18行将编译器的错误信息插入到
out
中,并以一个标记g_marker
结束,用于results
函数识别错误信息的结束。
由于编译结果不再传递给其他线程,因此不再需要定义 shared_future<Result>
。实际上,由于 compile
函数自行处理编译结果,它的返回类型为 void
,而 packaged_task
也不再返回任何内容。因此,Task
类不再需要 result()
成员函数。相反,其函数调用运算符在完成任务后调用任务的 get_future
,以便正确地获取 packaged_task
可能引发的异常。以下是简化后的 Task
类:
using PackagedTask = packaged_task<void(string const &fname)>;
class Task {
string d_command;
PackagedTask d_task;
public:
Task() = default;
Task(string const &command, PackagedTask &&tmp)
: d_command(command), d_task(move(tmp)) {}
void operator()() {
d_task(d_command);
d_task.get_future(); // 处理潜在的异常
}
};
在 main
函数结束时调用 results
函数:
void results() {
g_out.seekg(0);
int value;
string line;
string errorDisplay;
while (g_out >> value >> line) { // 处理 g_out 的内容
g_out.ignore(100, '\n');
if (value == 0) { // 编译成功: 显示源文件名称
cerr << line << '\n';
continue;
} // 编译失败:
if (not errorDisplay.empty()) { // 处理第一个错误后: 跳过
do {
getline(g_out, line);
} while (line != g_marker);
continue;
}
// 第一个编译错误:
errorDisplay = line + '\n'; // 记录源文件名称
while (true) { // 以及错误信息
getline(g_out, line);
if (line == g_marker) break;
errorDisplay += line + '\n';
}
}
cerr << errorDisplay;
// 最终插入错误信息(如果有)
}
- 每次编译开始时,都会有一个编译结果和一个源文件名称,这些在第9行的
while
条件中被提取。 - 如果编译成功(第13行),则显示源文件名称。
- 如果编译失败,则只显示第一个失败编译的相关信息(所有失败的编译信息也可以显示,但本程序仅显示第一个遇到的编译失败的信息)。如果已经遇到编译失败,则跳过接下来的错误信息(第19到28行)。
- 第一个遇到的编译错误的信息被收集到
errorDisplay
中(第30到39行)。 - 一旦
g_out
被完全读取,errorDisplay
被显示(第42行),它要么是空的,要么包含第一个遇到的编译失败的错误信息。
函数模板和变量模板
C++ 支持一种语法结构,允许程序员定义和使用完全通用(或抽象)的函数或类,这些函数或类基于泛型类型和/或(可能是推断的)常量值。在关于抽象容器(第 12 章)和 STL(第 18 章)的章节中,我们已经使用了这些结构,这些结构通常被称为模板机制。
模板机制允许我们定义类和算法,相当独立于最终用于模板的实际类型。每当使用模板时,编译器会生成与模板所使用的特定数据类型相匹配的代码。这段生成的代码在编译时从模板的定义生成。生成的代码称为模板的实例化。
本章将涵盖模板的语法特性。介绍了模板类型参数、模板非类型参数和函数模板的概念,并提供了多个模板的示例(本章及第 25 章均有)。模板类在第 22 章中讨论。由于种种原因,C++ 中的可变参数函数已被弃用。然而,可变参数模板则是另一回事,可变参数模板是完全可以接受的。函数模板和类模板都可以定义为可变参数模板。这两种形式在第 22.5 节中介绍。
语言中已经提供的模板包括抽象容器(参见第 12 章)、字符串(参见第 5 章)、流(参见第 6 章)和通用算法(参见第 19 章)。因此,模板在现代 C++ 中发挥着核心作用,不应被视为语言的冷僻特性。
模板应该像通用算法一样看待:它们是一种生活方式;C++ 软件工程师应主动寻找使用模板的机会。最初,模板可能显得相当复杂,您可能会倾向于回避它们。然而,随着时间的推移,您会越来越欣赏它们的优势和好处。最终,您将能够识别出使用模板的机会。届时,您的精力应该不再集中在构造普通函数和类(即非模板的函数或类)上,而是集中在构造模板上。
本章从介绍函数模板开始,重点介绍所需的语法。本章为其他有关模板的章节奠定了基础。
定义函数模板
函数模板的定义与普通函数的定义非常相似。函数模板具有函数头、函数体、返回类型、可能的重载定义等。然而,与普通函数不同,函数模板总是使用一个或多个形式类型(formal type):这些类型可以是几乎任何现有的(类或基本)类型。让我们看一个简单的例子。以下函数 add
期望两个 Type
类型的参数,并返回它们的和:
Type add(Type const &lhs, Type const &rhs)
{
return lhs + rhs;
}
注意,上述函数的定义与其描述非常吻合。它接受两个参数,并返回它们的和。现在考虑如果我们为,例如 int
类型定义这个函数会发生什么。我们会写:
int add(int const &lhs, int const &rhs)
{
return lhs + rhs;
}
到目前为止,一切正常。然而,如果我们要添加两个 double
类型的值,我们将重载这个函数:
double add(double const &lhs, double const &rhs)
{
return lhs + rhs;
}
可能我们会被迫构造大量的重载版本:例如,为 string
、size_t
等定义重载版本。总之,我们需要为每个支持 operator+
和拷贝构造函数的类型构造一个重载版本。由于 C++ 的强类型特性,所有这些基本上相同的函数的重载版本是必需的。因为这个原因,构建一个真正通用的函数而不使用模板机制是不可行的。
幸运的是,我们已经看到函数模板的一个重要部分。我们的初始函数 add
实际上是这样的函数的一个实现,尽管它还不是一个完整的模板定义。如果我们将第一个 add
函数交给编译器,它会生成类似于以下的错误信息:
error: `Type' was not declared in this scope
error: parse error before `const'
错误信息的确如此,因为我们没有定义 Type
。当我们将 add
更改为一个完整的模板定义时,可以避免这种错误。为此,我们查看函数的实现,发现 Type
实际上是一个形式类型(formal type)名。与其他实现相比,很明显我们可以将 Type
替换为 int
得到第一个实现,将 Type
替换为 double
得到第二个实现。
完整的模板定义允许 Type
作为形式类型(formal type)名。使用 template
关键字,我们在初始定义的前面加上这一行,得到以下函数模板定义:
template <typename Type>
Type add(Type const &lhs, Type const &rhs)
{
return lhs + rhs;
}
在这个定义中,我们区分以下几点:
-
关键字
template
:用于开始一个模板定义或声明。 -
尖括号括起来的列表:紧跟在
template
后面。这是一个包含一个或多个用逗号分隔的元素的列表。这个尖括号括起来的列表叫做模板参数列表。使用多个元素的模板参数列表可能如下所示:template <typename Type1, typename Type2>
-
模板参数列表中的形式类型(formal type)名:在模板参数列表中,我们找到形式类型(formal type)名
Type
。它是一个形式类型(formal type)名,可以与函数定义中的形式参数名相比较。到目前为止,我们只遇到过函数中的形式变量名。模板将形式名的概念提升到一个更高的层次。模板允许类型名成为形式化的,而不仅仅是变量名本身。Type
是一个形式类型(formal type)名,这通过typename
关键字在模板参数列表中进行标记。形式类型(formal type)名如Type
也称为模板类型参数。模板非类型参数也存在,并将在稍后介绍。其他 C++ 文献有时使用
class
关键字代替typename
。因此,在其他文献中,模板定义可能以如下行开始:template <class Type>
在 C++ 注解中,优先使用
typename
而非class
,理由是模板类型参数毕竟是类型名(一些作者更喜欢class
而非typename
,最终这只是个人偏好)。 -
模板关键字和模板参数列表:被称为模板头。
-
函数头:它类似于普通函数头,尽管模板的类型参数必须在参数列表中使用。当函数最终被调用时,使用实际参数的实际类型,编译器将根据这些实际类型推断出应该使用哪个版本(即匹配实际参数类型的重载)函数模板。函数被调用的点是编译器创建实际调用的函数,这个过程称为实例化。函数头也可以使用形式类型(formal type)来指定其返回值。这一特性在
add
模板定义中实际上已经使用了。 -
函数参数:被指定为
Type const &
参数。这具有通常的含义:这些参数是对Type
对象或值的引用,并且函数不会修改这些值。 -
函数体:与普通函数体类似。在函数体内,形式类型(formal type)名可以用于定义或声明变量,这些变量可以像其他局部变量一样使用。但是有一些限制。看
add
的函数体,可以清楚地看到使用了operator+
和拷贝构造函数,因为函数返回了一个值。这允许我们对Type
类型在add
函数模板中的使用做出以下限制:Type
应该支持operator+
Type
应该支持拷贝构造函数
因此,虽然
Type
可以是string
,但它永远不能是ostream
,因为流类既不支持operator+
也不支持拷贝构造函数。
模板的正常作用域规则和标识符可见性规则适用。在模板定义的作用域内,形式类型(formal type)名会覆盖更广泛作用域中的同名标识符。
关于模板参数的考虑
我们已经成功设计了第一个函数模板:
template <typename Type>
Type add(Type const &lhs, Type const &rhs)
{
return lhs + rhs;
}
再看一下 add
的参数。通过指定 Type const &
而不是 Type
,可以防止不必要的拷贝,同时允许将基本类型的值作为参数传递给函数。因此,当调用 add(3, 4)
时,int{4}
被赋值给 Type const &rhs
。一般来说,函数参数应该定义为 Type const &
以防止不必要的拷贝。在这种情况下,编译器足够聪明,能够处理“对引用的引用”,这是语言通常不支持的。例如,考虑以下 main
函数(在这里以及在以下简单示例中,假设模板和所需的头文件及命名空间声明已提供):
int main()
{
size_t const &var = size_t{ 4 };
cout << add(var, var) << '\n';
}
这里,var
是对常量 size_t
的引用。它被作为参数传递给 add
,从而将 lhs
和 rhs
初始化为 Type const &
类型的 size_t const &
值。编译器将 Type
解释为 size_t
。另外,参数也可以使用 Type &
来指定,而不是 Type const &
。这种(非 const
)指定的缺点是,临时值不能再传递给函数。以下代码因此无法编译:
int main()
{
cout << add(string{ "a" }, string{ "b" }) << '\n';
}
这里,string const &
不能用于初始化 string &
。如果 add
定义为 Type &&
参数,则上述程序将会编译通过。此外,以下示例正确编译,因为编译器决定 Type
显然是 string const
:
int main()
{
string const &s = string{ "a" };
cout << add(s, s) << '\n';
}
从这些示例中我们可以得出以下结论:
- 一般来说,函数参数应该指定为
Type const &
以防止不必要的拷贝。 - 模板机制非常灵活。形式类型被解释为普通类型、
const
类型、指针类型等,具体取决于实际提供的类型。经验法则是形式类型用作实际类型的通用掩码,形式类型名涵盖了实际类型中必须涵盖的部分。
一些示例,假设参数定义为 Type const &
:
提供的参数 | 实际使用的 Type |
---|---|
size_t const | size_t |
size_t | size_t |
size_t * | size_t * |
size_t const * | size_t const * |
这个表格展示了在模板函数中,提供的参数类型与实际使用的 Type
类型之间的关系。
关于模板参数的考虑
我们已经定义了第一个函数模板:
template <typename Type, size_t Size>
Type sum(Type const (&array)[Size])
{
Type tp{}; // 注意: 必须存在默认构造函数。
for (size_t idx = 0; idx < Size; idx++)
tp += array[idx];
return tp;
}
这个模板定义引入了以下新概念和特性:
-
模板参数列表。这个模板参数列表包含两个元素。第一个元素是一个常见的模板类型参数,但第二个元素有一个非常特定的类型:
size_t
。在模板参数列表中使用的特定(即非形式)类型的模板参数称为模板非类型参数。模板非类型参数定义了一个常量表达式的类型,该表达式必须在模板实例化时已知,并且用现有类型(如size_t
)来指定。 -
函数头。函数的头部有一个参数:
Type const (&array)[Size]
这个参数定义了
array
为一个对具有Size
个Type
元素的数组的引用,且该数组不可被修改。 -
模板参数的使用。在参数定义中,
Type
和Size
都被使用。Type
是模板的类型参数,但Size
也是一个模板参数。它是一个size_t
类型,其值必须在编译函数模板的实际调用时由编译器推断出来。因此,Size
必须是一个常量表达式。这样的常量表达式被称为模板非类型参数,其类型在模板的参数列表中指定。 -
函数模板调用。当调用函数模板时,编译器必须能够推断出
Type
的具体值,以及Size
的值。由于函数sum
只有一个参数,编译器只能从实际参数中推断Size
的值。如果提供的参数是一个已知且固定大小的数组(而不是指向Type
元素的指针),编译器才能做到这一点。因此,以下main
函数中的第一个语句将正确编译,但第二个语句将不会编译:int main() { int values[5]; int *ip = values; cout << sum(values) << '\n'; // 编译正确 cout << sum(ip) << '\n'; // 编译失败 }
-
函数体内的定义。在函数体内使用了
Type tp{}
来定义和初始化tp
为默认值。这里没有使用固定值(如0)。另外,要小心不要使用Type tp()
,因为这会声明一个函数tp
,它没有参数并返回Type
。通常,当需要显式初始化值时,应使用空的大括号。显式调用类型的构造函数的优点主要在于Type
是基本类型时。例如,如果Type
是int
,则Type tp{}
将tp
初始化为零,而Type tp
将导致tp
具有未定义的值。然而,所有类型,即使是基本类型,也支持默认构造函数(一些类可能选择不实现默认构造函数,或使其不可访问;但大多数类都提供默认构造函数)。Type tp = Type()
是一个真正的初始化:tp
由Type
的默认构造函数初始化,而不是使用Type
的复制构造函数将Type
的副本赋值给tp
。值得注意的是(尽管与当前主题无关),即使
Type tp(Type())
看起来也像是正确的初始化,它也不能被使用。通常,可以为对象的定义提供初始化参数,例如string s("hello")
。为什么Type tp = Type()
被接受,而Type tp(Type())
不被接受?当使用Type tp(Type())
时,它不会导致错误消息。因此,我们不会立即发现它不是Type
对象的默认初始化。相反,编译器在使用tp
时开始生成错误消息。这是因为在 C++(以及 C 中)编译器会尽可能识别函数或函数指针:函数优先规则。根据这个规则,Type()
(由于括号对)被解释为一个期望无参数并返回Type
的函数指针。编译器将这样做,除非明显不可能这样做。在初始化Type tp = Type()
中,编译器看不到函数指针,因为Type()
被解释为Type (*)()
,即使tp
被声明为一个期望一个函数指针并返回Type
的函数。举例来说,tp
可以被定义为:Type tp(Type (*funPtr)()) { return (*funPtr)(); }
-
公共成员要求。与第一个函数模板类似,
sum
也假设Type
的类存在某些公共成员。这次是operator+=
和Type
的复制构造函数。 -
模板定义中的使用规则。与类定义类似,模板定义中不应包含
using
指令或声明:模板可能在使用过程中出现覆盖程序员意图的情况:可能会由于模板作者和程序员使用不同的using
指令而导致歧义或其他冲突(例如,cout
变量在std
命名空间和程序员自己的命名空间中都定义)。相反,在模板定义中仅应使用完全限定的名称,包括所有必需的命名空间规范。
auto
和 decltype
在第3.3.7节中介绍了 auto
关键字,而 decltype
关键字则表现出一些不同的行为。本节集中讲解 decltype
。不同于 auto
,decltype
总是跟在一个括号中的表达式后面(例如,decltype(variable)
)。
示例说明
假设我们有一个函数定义了一个参数 std::string const &text
。在函数内部,我们可能会遇到以下两种定义:
auto scratch1 {text};
decltype(text) scratch2 = text;
-
auto
:编译器推断出一个普通类型,因此scratch1
是std::string
,并使用复制构造来初始化它。 -
decltype
:decltype
确定text
的类型是std::string const &
,这会被用作scratch2
的类型,因此scratch2
是std::string const &
,引用了text
所指向的字符串。decltype
的标准行为是:当提供一个变量的名称时,它被替换为该变量的类型。
使用表达式
另外,decltype
还可以指定一个表达式。虽然变量本身就是一个表达式,但在 decltype
的上下文中,我们将“表达式”定义为任何比普通变量指定更复杂的表达式。它可以像 (variable)
一样简单,即变量名放在括号中。
当使用表达式时,编译器会确定是否可以将引用附加到表达式的类型。如果可以,decltype(expression)
将被替换为这种 lvalue 引用的类型(即 expression-type &
)。如果不可以,decltype(expression)
将被替换为表达式的普通类型。
以下是一些示例:
int *ptr;
decltype(ptr) ref = ptr;
// decltype 的参数是普通变量,因此使用 ptr 的类型:int *ref = ptr。
// decltype(ptr) 被替换为 int *。
int *ptr;
decltype((ptr)) ref = ptr;
// decltype 的参数是一个表达式,因此使用 int *&ref = ptr。
// decltype((ptr)) 被替换为 int *&。
int value;
decltype(value + value) var = value + value;
// decltype 的参数是一个表达式,因此编译器尝试使用 int &(int &var = value + value),
// 由于 value + value 是一个临时值,var 的类型不能是 int &,因此 decltype(...) 被替换为 int。
// 即 value + value 的类型。
std::string lines[20];
decltype(lines[0]) ref = lines[6];
// decltype 的参数是一个表达式,因此使用 std::string &ref = lines[6]。
// decltype(...) 被替换为 std::string &。
std::string &&strRef = std::string{};
decltype(strRef) ref = std::move(strRef);
// decltype 的参数是普通变量,因此使用变量的类型:std::string &&ref = std::move(strRef)。
// decltype(...) 被替换为 std::string &&。
std::string &&strRef2 = std::string{};
decltype((strRef2)) ref2 = strRef2;
// decltype 的参数是一个表达式,因此使用 std::string && &ref = strRef2。
// 这自动变成 std::string &ref = strRef2,这在这里是可以的。
// decltype 被替换为 std::string &。
decltype(auto)
的使用
此外,可以使用 decltype(auto)
规范,在这种情况下,decltype
的规则应用于 auto
。因此,auto
用于确定初始化表达式的类型。然后,如果初始化表达式是一个普通变量,则使用表达式的类型。否则,如果可以将引用添加到表达式的类型中,则 decltype(auto)
将被替换为对表达式类型的引用。
以下是一些示例:
int *ptr;
decltype(auto) ptr2 = ptr;
// auto 推导出 ptr 的类型:int *,ptr 是一个普通变量,因此 decltype(auto) 被替换为 int *。
int value;
decltype(auto) ret = value + value;
// auto 推导出 int,value + value 是一个表达式,因此尝试使用 int &。然而,value + value 不能赋值给引用,
// 所以使用表达式的类型:decltype(auto) 被替换为 int。
std::string lines[20];
decltype(auto) line = lines[0];
// auto 推导出 std::string,lines[0] 是一个表达式,因此尝试使用 std::string &。std::string &line = lines[0] 是可以的,
// 所以 decltype(auto) 被替换为 std::string &。
decltype(auto) ref = std::string{};
// auto 推导出 std::string,std::string{} 是一个表达式,因此尝试使用 std::string &。然而,std::string &ref = std::string{} 是无效的初始化,
// 所以使用 std::string 本身:decltype(auto) 被替换为 std::string。
实践中的使用
在实际中,decltype(auto)
最常用于函数模板中定义返回类型。看一下以下结构体定义(不使用函数模板,但说明 decltype(auto)
的工作原理):
struct Data
{
std::vector<std::string> d_vs;
std::string *d_val = new std::string[10];
Data()
: d_vs(1)
{}
auto autoFun() const
{
return d_val[0];
}
decltype(auto) declArr() const
{
return d_val[0];
}
decltype(auto) declVect() const
{
return d_vs[0];
}
};
-
autoFun
返回auto
。由于d_val[0]
被传递给auto
,auto
推导为std::string
,函数的返回类型是std::string
。 -
declArr
返回decltype(auto)
。由于d_val[0]
是一个表达式,表示一个std::string
,decltype(auto)
被推导为std::string &
,这成为函数的返回类型。 -
declVect
返回decltype(auto)
。由于d_vs[0]
是一个表达式,表示std::string
,decltype(auto)
被推导为std::string &
。然而,由于declVect
也是一个const
成员,这个引用应该是std::string const &
。decltype(auto)
能够识别这一点,因此函数的返回类型变成std::string const &
。
如果你想知道为什么 declArr
的返回类型没有 const
,而 declVect
的返回类型有 const
,请查看 d_vs
和 d_val
:在其函数上下文中,d_vs
和 d_val
都是常量,但 d_val
(即 const *
)指向的是非 const
的 std::string
对象。因此,declArr
不需要返回 std::string const &
,而 declVect
应该返回 std::string const &
。
declval
decltype
关键字用于确定表达式的类型。要使用 decltype
,必须有一个可用的表达式。如果一个函数模板定义了一个 typename Class
模板参数,而函数模板需要使用 Class::fun()
的返回类型,返回类型可能并不立即可用。此类问题可以通过使用函数模板 std::declval
来解决,该模板定义在 <utility>
头文件中。
std::declval
函数模板定义了一个模板类型参数,并返回一个对该模板类型参数的类的 rvalue 引用,而无需实际创建一个临时对象。由于可以使用 rvalue 引用,因此可以调用其 fun
函数,然后通过 decltype
得到该函数的返回类型。传递给 declval
的类类型没有特定的构造函数要求:它不必有默认构造函数或公共构造函数(但会使用访问权限)。
考虑以下函数模板:
template <typename Type>
decltype(std::declval<Type>().fun()) value()
{
return 12.5;
}
函数 value
的返回类型被定义为未知的 Type::fun
的返回类型。
通过定义两个结构体,它们都有 fun
成员函数,可以得到 value
的实际返回类型。在 main
中分别返回 int
和 double
,结果是输出 12 12.5
:
struct Integral
{
int fun() const; // 实现不需要
};
struct Real
{
double fun() const; // 同样
};
int main()
{
std::cout << value<Integral>() << ' ' << value<Real>() << '\n';
}
解释
std::declval<Type>()
用于生成一个Type
的 rvalue 引用,这样可以调用Type
的成员函数fun()
来确定其返回类型。decltype(std::declval<Type>().fun())
确定了fun()
的返回类型,并将其用作value
函数模板的返回类型。- 在
main
函数中,value<Integral>()
返回int
,而value<Real>()
返回double
,这对应于Integral
和Real
中fun()
的返回类型。
延迟指定返回类型
传统的 C++ 要求函数模板必须明确指定其返回类型,或者将返回类型作为模板类型参数指定。考虑以下函数:
int add(int lhs, int rhs)
{
return lhs + rhs;
}
将上述函数转换为函数模板可以这样写:
template <typename Lhs, typename Rhs>
Lhs add(Lhs lhs, Rhs rhs)
{
return lhs + rhs;
}
不幸的是,当函数模板被调用为 add(3, 3.4)
时,预期的返回类型可能是 double
而不是 int
。为了解决这个问题,可以添加一个额外的模板类型参数来指定返回类型,但这样必须显式地指定类型:
add<double>(3, 3.4);
使用 decltype
(参见第 3.3.7 节)来定义返回类型不会起作用,因为在 decltype
使用时 lhs
和 rhs
并不为编译器所知。因此,下面的尝试将无法编译:
template <typename Lhs, typename Rhs>
decltype(lhs + rhs) add(Lhs lhs, Rhs rhs)
{
return lhs + rhs;
}
基于 decltype
的函数返回类型定义可能变得相当复杂。使用延迟指定返回类型的语法可以简化这种复杂性,它允许使用 decltype
来定义函数的返回类型。它主要用于函数模板,但也可以用于普通的(非模板)函数:
template <typename Lhs, typename Rhs>
auto add(Lhs lhs, Rhs rhs) -> decltype(lhs + rhs)
{
return lhs + rhs;
}
当在语句中使用此函数,例如 cout << add(3, 3.4)
时,得到的结果将是 6.4
,这比 6
更可能是预期的结果。为了减少函数返回类型定义的复杂性,考虑以下示例:
template <typename T, typename U>
decltype((*(T*)0)+(*(U*)0)) add(T t, U u);
这段代码相当难以阅读。像 *(T*)0
这样的术语定义了 0
,使用 C 风格的强制转换,将其视为类型 T
的指针,然后进行解引用,产生一个类型为 T
的值(尽管该值本身并不存在)。对于 decltype
表达式中的第二个术语也是如此。结果类型用作 add
的返回类型。使用延迟指定返回类型可以得到等效的简化版本:
template <typename T, typename U>
auto add(T t, U u) -> decltype(t + u);
许多人认为这种写法更易于理解。
在 decltype
中指定的表达式不一定使用参数 lhs
和 rhs
本身。以下函数定义中使用了 lhs.length
而不是 lhs
本身:
template <typename Class, typename Rhs>
auto add(Class lhs, Rhs rhs) -> decltype(lhs.length() + rhs)
{
return lhs.length() + rhs;
}
在 decltype
编译时可见的任何变量都可以用于 decltype
表达式。还可以处理通过成员指针进行的成员选择。以下代码旨在指定成员函数的地址作为 add
的第一个参数,然后使用其返回值类型来确定函数模板的返回类型。示例如下:
std::string global{"hello world"};
template <typename MEMBER, typename RHS>
auto add(MEMBER mem, RHS rhs) -> decltype((global.*mem)() + rhs)
{
return (global.*mem)() + rhs;
}
int main()
{
std::cout << add(&std::string::length, 3.4) << '\n'; // 输出:14.4
}
通过引用传递参数(引用包装器)
在使用本节讨论的引用包装器之前,需要包含 <functional>
头文件。
存在一些情况,其中编译器无法推断出参数是以引用而不是值的方式传递给函数模板。在以下示例中,函数模板 outer
接收 int x
作为其参数,编译器会推断 Type
为 int
:
template <typename Type>
void outer(Type t)
{
t.x();
}
void useInt()
{
int arg;
outer(arg);
}
编译当然会失败(因为 int
类型的值没有 x
成员),编译器会明确报告推断的类型,例如:
在函数 'void outer(Type) [with Type = int]' 中: ...
另一种错误类型发生在使用 call
函数模板时。call
是一个期望函数类型参数的函数模板。传递给 call
的函数是 sqrtArg
,它定义了一个对 double
的引用:sqrtArg
修改了传递给它的变量。
void sqrtArg(double &arg)
{
arg = sqrt(arg);
}
template<typename Fun, typename Arg>
void call(Fun fun, Arg arg)
{
fun(arg);
cout << "In call: arg = " << arg << '\n';
}
第一次使用 call
时,call(sqrtArg, value)
不会修改 value
:编译器推断 Arg
为 double
值,因此将 value
以值的方式传递给 call
,从而阻止了 sqrtArg
修改 main
的变量。
为了改变 main
的变量 value
,编译器必须被告知 value
应以引用方式传递。注意,我们不希望将 call
的模板参数定义为引用参数,因为在其他情况下,以值的方式传递参数可能是合适的。
在这些情况下,可以使用 ref(arg)
和 cref(arg)
引用包装器。它们接受一个参数并将其返回为(常量)引用类型的参数。要实际改变 value
,可以通过 ref(value)
传递它给 call
,如以下 main
函数所示:
int main()
{
double value = 3;
call(sqrtArg, value);
cout << "Passed value, returns: " << value << '\n';
call(sqrtArg, ref(value));
cout << "Passed ref(value), returns: " << value << '\n';
}
这段代码的输出将是:
In call: arg = 1.73205
Passed value, returns: 3
In call: arg = 1.73205
Passed ref(value), returns: 1.73205
使用局部和匿名类型作为模板参数
通常,类型有名称。然而,也可以定义匿名类型。例如:
enum
{
V1,
V2,
V3
};
在这里,enum
定义了一个未命名或匿名类型。当定义函数模板时,编译器通常会从其参数中推断模板类型参数的类型:
template <typename T>
void fun(T &&t);
fun(3);
// T 是 int
fun('c');
// T 是 char
然而,也可以这样使用:
fun(V1);
// T 是上述枚举类型的值
在 fun
中,即使是匿名类型,也可以定义一个 T
类型的变量:
template <typename T>
void fun(T &&t)
{
T var(t);
}
局部定义的类型的值或对象也可以作为参数传递给函数模板。例如:
void definer()
{
struct Local
{
double dVar;
int iVar;
};
Local local; // 使用局部类型
fun(local); // OK: T 是 'Local'
}
模板参数推导
本节集中于编译器推导实际模板类型参数的过程。这些类型在调用函数模板时通过一种称为模板参数推导的过程进行推导。正如我们已经看到的,编译器能够为单个形式模板类型参数替换各种实际类型。然而,并非所有的转换都是可能的。特别是当函数具有多个相同模板类型参数的参数时,编译器在确定实际接受的参数类型时会非常严格。
当编译器推导模板类型参数的实际类型时,只考虑实际使用的参数类型。局部变量或函数的返回值不会被考虑在内。这是可以理解的。在函数调用时,编译器只能确定函数模板参数的类型。调用时,编译器绝对看不到函数的局部变量的类型。而且,函数的返回值可能不会被实际使用,或者可能被赋值给一个子范围(或超范围)类型的变量,这会影响推导的模板类型参数。因此,在以下示例中,编译器将无法调用 fun()
,因为它无法推导 Type
模板类型参数的实际类型:
template <typename Type>
Type fun()
// 无法调用 `fun()`,因为编译器无法推导 `Type` 的实际类型
{
return Type{};
}
尽管编译器无法处理对 fun()
的调用,但可以使用显式类型说明来调用 fun()
。例如,fun<int>()
调用 fun
,并将其实例化为 int
类型。这当然与编译器参数推导不同。
一般来说,当一个函数有多个相同模板类型参数的参数时,实际的类型必须完全相同。因此,虽然:
void binarg(double x, double y);
可以使用一个 int
和一个 double
进行调用,其中 int
参数被隐式转换为 double
,但类似的函数模板不能使用 int
和 double
参数调用:编译器不会自动将 int
提升为 double
,决定 Type
应该是 double
:
template <typename Type>
void binarg(Type const &p1, Type const &p2)
{}
int main()
{
binarg(4, 4.5); // ?? 编译失败:实际类型不同
}
那么,编译器在推导模板类型参数的实际类型时应用了哪些转换?编译器执行了三种类型的参数类型转换和第四种针对函数模板非类型参数的转换。如果无法通过这些转换推导实际类型,则函数模板不会被考虑。编译器执行的转换包括:
- 左值转换:从左值创建右值。
- 资格转换:将
const
修饰符插入到非常量参数类型中。 - 转换到基类:当调用提供了模板派生类类型的参数时,使用模板基类实例化基类。
- 函数模板非类型参数的标准转换:这不是模板类型参数的转换,而是指对函数模板的任何剩余非类型参数。对于这些函数参数,编译器执行任何可用的标准转换(例如,从
int
到size_t
、从int
到double
等)。
各种模板参数类型推导转换的目的是为了匹配函数参数与函数参数,而不是匹配函数参数,以确定各种模板类型参数的实际类型。
左值转换
左值转换有三种类型:
-
左值到右值转换
当需要右值但提供了左值时,会应用左值到右值转换。这种情况发生在将变量作为参数传递给需要值参数的函数时。例如:
template<typename Type> Type negate(Type value) { return -value; } int main() { int x = 5; x = negate(x); // 左值 (x) 转换为右值 (复制 x) }
在这个例子中,变量
x
被作为参数传递给negate
函数。由于negate
函数的参数是值参数,因此x
需要被转换为右值进行处理,最后得到的结果被赋值回x
。
(优化吗这个部分没看懂) -
数组到指针转换
当数组名被赋值给指针变量时,会应用数组到指针转换。这种转换通常与定义了指针参数的函数一起使用。这些函数经常接收数组作为其参数。数组的地址被分配给指针参数,并用于推导相应模板参数的类型。例如:
template<typename Type> Type sum(Type *tp, size_t n) { return accumulate(tp, tp + n, Type()); } int main() { int x[10]; sum(x, 10); }
在这个例子中,数组
x
的地址被传递给sum
函数,sum
函数期望一个指向某种类型的指针。通过数组到指针转换,x
的地址被视为指针值分配给tp
,从而推导出Type
是int
类型。 -
函数到指针转换
这种转换最常与定义了指向函数的指针参数的函数模板一起使用。当调用这样的函数时,可以将函数的名称作为其参数。函数的地址被分配给指针参数,从而推导出模板类型参数。例如:
#include <cmath> template<typename Type> void call(Type (*fp)(Type), Type const &value) { (*fp)(value); } int main() { call(sqrt, 2.0); }
在这个例子中,函数
sqrt
的地址被传递给call
函数,call
函数期望一个返回Type
类型并以Type
为参数的函数指针。通过函数到指针转换,sqrt
的地址被分配给fp
,从而推导出Type
是double
类型(注意sqrt
是函数的地址,而不是指向函数的变量,因此应用了左值转换)。参数
2.0
不能指定为2
,因为没有int sqrt(int)
的原型。此外,函数的第一个参数指定为Type (*fp)(Type)
,而不是Type (*fp)(Type const &)
,这与我们之前讨论如何指定函数模板参数的类型(倾向于使用引用而不是值)有所不同。然而,fp
的参数Type
不是函数模板参数,而是函数fp
指向的函数的参数。由于sqrt
的原型是double sqrt(double)
,而不是double sqrt(double const &)
,因此call
的参数fp
必须指定为Type (*fp)(Type)
。这个规定是严格的。
附加限定符转换
附加限定符转换会将 const
或 volatile
附加到指针上。这种转换在函数模板的类型参数显式指定了 const
(或 volatile
),但函数的参数不是 const
或 volatile
实体时应用。在这种情况下,编译器会添加 const
或 volatile
。随后,编译器会推导模板的类型参数。例如:
template<typename Type>
Type negate(Type const &value)
{
return -value;
}
int main()
{
int x = 5;
x = negate(x);
}
在这里,我们看到函数模板的参数 Type const &value
是一个对 const Type
的引用。然而,传递的参数 x
不是一个 const int
,而是一个可以修改的 int
。通过应用附加限定符转换,编译器将 const
添加到 x
的类型中,因此它与 int const x
匹配。这样,编译器可以将 Type const &value
匹配到 int const &
,从而推导出 Type
必须是 int
类型。
转换为基类
虽然类模板的构造将在第22章讨论,但我们已经广泛使用了类模板。例如,抽象容器(参见第12章)是作为类模板定义的。类模板可以像普通类一样参与类层次结构的构建。
在第22.11节中,将展示如何从另一个类模板派生一个类模板。由于类模板的派生仍需进一步讲解,以下讨论可能会显得有些提前。读者当然可以简要跳至第22.11节,之后再返回阅读此节内容。
在本节中,为了论证,我们假设类模板 Vector
已经从 std::vector
派生出来。进一步假设构造了以下函数模板,用于使用某个函数对象 obj
对向量进行排序:
template <typename Type, typename Object>
void sortVector(std::vector<Type> vect, Object const &obj)
{
sort(vect.begin(), vect.end(), obj);
}
为了不区分大小写地排序 std::vector<std::string>
对象,可以构造一个类 CaseLess
:
class CaseLess
{
public:
bool operator()(std::string const &before, std::string const &after) const
{
return strcasecmp(before.c_str(), after.c_str()) < 0;
}
};
现在,可以使用 sortVector()
对各种向量进行排序。例如:
int main()
{
std::vector<std::string> vs;
std::vector<int> vi;
sortVector(vs, CaseLess()); // 对 vs 进行排序
sortVector(vi, std::less<int>()); // 对 vi 进行排序
}
通过将类模板的基类转换应用到 sortVector
,该函数模板现在也可以用于对 Vector
对象进行排序。例如:
int main()
{
Vector<std::string> vs;
Vector<int> vi;
sortVector(vs, CaseLess()); // 对 vs 进行排序
sortVector(vi, std::less<int>()); // 对 vi 进行排序
}
在这个例子中,将 Vector
作为参数传递给 sortVector
。通过将类模板基类转换应用到 Vector
,编译器会将 Vector
视为 std::vector
,从而能够推导模板的类型参数。因此,对于 Vector vs
,推导出的类型是 std::string
;对于 Vector vi
,推导出的类型是 int
。
模板参数推导算法
编译器使用以下算法来推导模板类型参数的实际类型:
-
识别函数模板的参数:首先,根据调用的函数的参数来识别函数模板的参数。
-
关联模板类型参数与实际参数类型:对于函数模板参数列表中的每个模板参数,将模板类型参数与对应参数的类型关联起来(例如,如果参数是
int x
,且函数参数是Type &value
,那么模板类型参数Type
就是int
)。 -
应用允许的转换:在将参数类型与模板类型参数匹配的过程中,必要时应用三种允许的转换(参见第21.4节)。
-
确保相同模板类型参数的一致性:如果在函数模板中使用了相同的模板类型参数,那么这些模板类型的推导结果必须完全匹配。例如,以下函数模板不能使用
int
和double
参数调用:template <typename Type> Type add(Type const &lhs, Type const &rhs) { return lhs + rhs; }
调用此函数模板时,必须使用两个相同的类型(当然允许使用三种标准转换)。如果模板推导机制不能为相同的模板类型找到一致的实际类型,则该函数模板将不会被实例化。
声明函数模板
到目前为止,我们只定义了函数模板。在多个源文件中包含函数模板定义会有一些后果,虽然这些后果并不严重,但值得了解。
-
模板定义的头文件:像类接口一样,模板定义通常包含在头文件中。每当编译器读取包含模板定义的头文件时,它必须处理完整的定义。即使编译器实际上没有使用这个模板,它也必须这么做。这会稍微减慢编译速度。例如,在我的旧笔记本上,编译像
algorithm
这样的模板头文件的时间大约是编译一个普通头文件(如cmath
)的四倍。iostream
头文件的处理时间甚至是cmath
的15倍。显然,处理模板对编译器来说是个严肃的问题。然而,这种缺点不应该过于严肃地对待。编译器的模板处理能力不断提高,而计算机也在不断变得更快。几年前的困扰如今几乎不可察觉。 -
模板实例化:每次模板实例化时,其代码都会出现在生成的目标模块中。然而,如果在多个目标文件中存在使用相同实际类型的多个模板实例化,“单一定义规则” 将被提升。链接器会筛选掉多余的实例化(即,重复的模板实例化定义)。在最终的程序中,只有一个特定的模板类型参数集的实例化会保留下来(参见第21.6节的示例)。因此,链接器有额外的任务(即筛选多余的实例化),这会稍微减慢链接过程。
-
模板声明与定义:有时,仅仅需要模板的引用或指针,而不需要模板的定义。在这种情况下,强迫编译器每次都处理完整的模板定义会不必要地减慢编译过程。
-
模板元编程:在模板元编程的上下文中(参见第23章),有时甚至不需要提供模板实现。只需创建基于声明的特化(参见第21.9节)。因此,在某些情况下,模板的定义可能不是必需的。软件工程师可以选择声明模板,而不是在多个源文件中重复包含模板定义。
当模板被声明时,编译器不必一次又一次地处理模板的定义;仅凭模板声明不会创建任何实例化。任何实际需要的实例化必须在其他地方存在(当然,这对于声明一般来说都是适用的)。与普通函数的情况不同,普通函数通常存储在库中,而模板目前无法存储在库中(尽管编译器可以构建预编译头文件)。因此,使用模板声明会给软件工程师带来额外的负担,他们必须确保所需的实例化存在。下面介绍了一种简单的方法来实现这一点。
要创建一个函数模板声明,只需将函数体替换为分号。这与普通函数声明的方式完全相同。例如,之前定义的函数模板 add
可以简单地声明为:
template <typename Type>
Type add(Type const &lhs, Type const &rhs);
我们已经遇到过模板声明。iosfwd
头文件可以在不需要 ios
类及其派生类实例化的源文件中包含。例如,要编译声明:
std::string getCsvLine(std::istream &in, char const *delim);
不需要包含 string
和 istream
头文件。只需包含一个 #include <iosfwd>
就足够了。处理 iosfwd
需要的时间只占处理 string
和 istream
头文件所需时间的一小部分。
实例化声明
如果声明函数模板可以加快程序的编译和链接阶段,那么我们如何确保在最终链接程序时所需的函数模板实例化是可用的呢?
为了解决这个问题,我们可以使用模板声明的一种变体,即显式实例化声明。显式实例化声明包括以下元素:
- 起始部分:以关键字
template
开头,但省略模板参数列表。 - 函数模板的返回类型和名称:紧接着指定函数模板的返回类型和名称。
- 类型说明列表:函数模板名称后面跟随一个类型说明列表。类型说明列表是一个用尖括号括起来的类型名称列表。每个类型都指定模板参数列表中对应模板类型参数的实际类型。
- 函数模板的参数列表:最后指定函数模板的参数列表,以分号结尾。
尽管这只是一个声明,但编译器将其理解为请求实例化该特定函数模板变体。
通过使用显式实例化声明,程序所需的所有模板函数实例化可以集中在一个文件中。这个文件应该是一个普通的源文件,包含模板定义头文件,并随后指定所需的显式实例化声明。由于这是一个源文件,而不是其他源文件包含的头文件,因此在包含所需的头文件后,可以安全地使用命名空间的使用指令和声明。以下是一个例子,展示了我们之前的 add
函数模板,为 double
、int
和 std::string
类型实例化所需的显式实例化声明:
#include "add.h"
#include <string>
using namespace std;
template int add<int>(int const &lhs, int const &rhs);
template double add<double>(double const &lhs, double const &rhs);
template string add<string>(string const &lhs, string const &rhs);
如果我们疏忽了,忘记提及程序所需的某个实例化,那么只需将缺少的实例化声明添加到上述列表中。重新编译该文件并重新链接程序后,就可以解决问题。
函数模板的实例化
与普通函数不同,函数模板在编译器读取其定义时并不会立即生成代码。模板只是一个配方,告诉编译器在需要时如何创建特定的代码。这个过程很像在烹饪书中读到的食谱——你阅读如何制作蛋糕的步骤并不意味着你在读完食谱后已经实际制作了那个蛋糕。
那么,函数模板到底何时会被实例化呢?编译器会在以下两种情况下决定实例化模板:
-
在使用时实例化:例如,当函数
add
被调用并传入一对size_t
类型的值时,会触发模板实例化。 -
当获取函数模板的地址时:例如,以下代码会实例化
add
模板:char (*addptr)(char const &, char const &) = add;
引发编译器实例化模板的语句所在的位置被称为模板的实例化点(point of instantiation)。实例化点对函数模板的代码有重要影响,这些影响将在第21.13节中讨论。
编译器并不总是能够明确地推导出模板的类型参数。当编译器报告不明确的情况时,软件工程师需要解决这个问题。请看以下代码:
#include "add.h"
#include <iostream>
size_t fun(int (*f)(int *p, size_t n));
double fun(double (*f)(double *p, size_t n));
int main() {
std::cout << fun(add);
}
当编译这个小程序时,编译器会报告一个它无法解决的歧义。对于每个 fun
函数的重载版本,编译器都可以实例化一个 add
函数,导致出现如下错误:
error: call of overloaded 'fun(<unknown type>)' is ambiguous
note: candidates are: int fun(size_t (*)(int*, size_t))
note: double fun(double (*)(double*, size_t))
这种情况当然应该避免。只有在没有歧义的情况下,函数模板才能被实例化。当编译器的函数选择机制(参见第21.14节)生成了多个候选函数时,就会产生歧义。解决这些歧义的任务由我们承担。可以通过一个简单的 static_cast
来解决这个问题(通过这种方式,我们可以在所有可能的可用选项中进行选择):
#include "add.h"
#include <iostream>
int fun(int (*f)(int const &lhs, int const &rhs));
double fun(double (*f)(double const &lhs, double const &rhs));
int main() {
std::cout << fun(
static_cast<int (*)(int const &, int const &)>(add)
);
}
但尽量避免使用类型转换是一个好的编程实践。在接下来的第21.7节中,将会讲解如何避免类型转换。
实例化:避免“代码膨胀”
正如在第21.5节中提到的,链接器会从最终程序中移除模板的相同实例化,只保留每个唯一的模板类型参数集对应的一个实例化。为了说明链接器的行为,我们可以按照以下步骤进行:
-
首先我们构建几个源文件:
-
source1.cc
定义了一个函数fun
,为int
类型的参数实例化了add
模板,并包括了add
模板的定义。它使用PointerUnion
显示add
的地址:union PointerUnion { int (*fp)(int const &, int const &); void *vp; };
以下是使用
PointerUnion
的程序:#include <iostream> #include "add.h" #include "pointerunion.h" void fun() { PointerUnion pu = { add }; std::cout << pu.vp << '\n'; }
-
source2.cc
定义了相同的函数fun
,但仅仅声明了合适的add
模板(使用模板声明,而不是实例化声明)。以下是source2.cc
的代码:#include <iostream> #include "pointerunion.h" template<typename Type> Type add(Type const &, Type const &); void fun() { PointerUnion pu = { add }; std::cout << pu.vp << '\n'; }
-
main.cc
再次包括了add
模板的定义,声明了函数fun
并定义了main
,同时为int
类型的参数定义了add
并显示add
函数的地址。它还调用了函数fun
。以下是main.cc
的代码:#include <iostream> #include "add.h" #include "pointerunion.h" void fun(); int main() { PointerUnion pu = { add }; fun(); std::cout << pu.vp << '\n'; }
-
-
所有源文件都被编译成目标模块。注意
source1.o
(使用 g++ 4.3.4 版本编译时大小为1912字节)的大小与source2.o
(1740字节)的不同。由于source1.o
包含了add
的实例化,它比仅包含模板声明的source2.o
略大。现在我们可以开始进行我们的实验。 -
链接
main.o
和source1.o
,我们显然链接了两个目标模块,每个模块都包含相同模板函数的一个实例化。生成的程序输出如下:0x80486d8 0x80486d8
此外,生成程序的大小为 6352 字节。
-
链接
main.o
和source2.o
,这次我们链接了一个包含add
模板实例化的目标模块和另一个仅包含相同模板函数声明的目标模块。因此,生成的程序只能包含所需模板函数的一个实例化。这个程序的大小与第一个程序完全相同,并且输出也是一样的。
通过这个小实验,我们可以得出以下结论:
- 链接器确实从最终程序中移除了相同的模板实例化。
- 仅使用模板声明不会导致模板的实例化。
使用显式模板类型
在上一节中,我们看到编译器在尝试实例化模板时可能会遇到歧义。举例来说,当函数(如 fun
)的重载版本存在并期望不同类型的参数时,歧义的产生是因为两个参数都可能由函数模板的实例化提供。解决这种歧义的直观方法是使用 static_cast
,但应尽可能避免使用类型转换。
对于函数模板,确实可以通过使用显式模板类型参数来避免 static_cast
。显式模板类型参数可以用于告知编译器在实例化模板时应使用的实际类型。要使用显式类型参数,可以在函数名后面跟上实际模板类型参数列表,该列表后面可能还会跟上函数的参数列表。在实际模板参数列表中提到的实际类型将用于指导编译器在实例化模板时使用哪些类型。以下是上一节中的例子,现在使用了显式模板类型参数:
#include "add.h"
#include <iostream>
int fun(int (*f)(int const &lhs, int const &rhs));
double fun(double (*f)(double const &lhs, double const &rhs));
int main() {
std::cout << fun(add<int>) << '\n';
}
显式模板类型参数可以在编译器无法判断应实际使用哪种类型的情况下使用。例如,在第21.4节中定义了函数模板 Type fun()
。要实例化此函数以适用于 double
类型,可以调用 fun<double>()
。
函数模板的重载
让我们再次看看 add
模板。该模板设计用于返回两个实体的和。如果我们想计算三个实体的和,我们可以写成这样:
int main()
{
add(add(2, 3), 4);
}
对于偶尔的情况,这种解决方案是可以接受的。但是,如果我们需要经常加三个实体,那么一个期望三个参数的重载 add
函数可能会是一个有用的函数。这个问题有一个简单的解决方法:函数模板是可以重载的。
要定义一个重载的函数模板,只需在其头文件中放入多个模板定义。对于 add
函数,这样做会简化为:
template <typename Type>
Type add(Type const &lhs, Type const &rhs)
{
return lhs + rhs;
}
template <typename Type>
Type add(Type const &lhs, Type const &mid, Type const &rhs)
{
return lhs + mid + rhs;
}
重载的函数不一定要定义为处理简单的值。像所有重载函数一样,一组唯一的函数参数就足以定义一个重载的函数模板。例如,以下是一个可以用来计算向量元素和的重载版本:
template <typename Type>
Type add(std::vector<Type> const &vect)
{
return accumulate(vect.begin(), vect.end(), Type());
}
在重载函数模板时,我们不必限制自己仅使用函数的参数列表。模板的类型参数列表本身也可以重载。add
模板的最后一个定义允许我们指定一个向量作为第一个参数,但不包括 deque
或 map
。当然,可以为这些类型的容器构建重载版本,但我们应考虑如何把握界限。一种更好的方法似乎是寻找这些容器的共同特征。如果找到了,我们可能能够基于这些共同特征定义一个重载的函数模板。
提到的容器的一个共同特征是它们都支持 begin
和 end
成员,这些成员返回迭代器。利用这一点,我们可以定义一个模板类型参数来表示必须支持这些成员的容器。但仅仅提到一个简单的“容器类型”并不能告诉我们它是为哪种数据类型实例化的。因此,我们需要第二个模板类型参数来表示容器的数据类型,从而重载模板的类型参数列表。下面是 add
模板的重载版本:
template <typename Container, typename Type>
Type add(Container const &cont, Type const &init)
{
return std::accumulate(cont.begin(), cont.end(), init);
}
可能有人会想,是否可以将 init
参数从参数列表中省略,因为 init
通常有一个默认的初始化值。答案是“可以”,但这会带来一些复杂性。我们可以将 add
函数定义如下:
template <typename Type, typename Container>
Type add(Container const &cont)
{
return std::accumulate(cont.begin(), cont.end(), Type());
}
然而请注意,模板的类型参数需要重新排序,这是必要的,因为在像 int x = add(vectorOfInts);
这样的调用中,编译器无法确定 Type
。在重新排序模板类型参数后,将 Type
放在前面,可以为第一个模板类型参数提供显式模板类型参数:
int x = add<int>(vectorOfInts);
在这个例子中,我们提供了一个 vector<int>
参数。有人可能会想,为什么我们必须显式指定 int
才能让编译器确定模板类型参数 Type
。实际上,我们不需要这样做。还有第三种模板参数存在,即模板模板参数,它允许编译器直接从实际的容器参数中确定 Type
。模板模板参数将在第23.4节中讨论。
使用重载函数模板的示例
在定义了这些重载版本后,我们可以编译如下的函数:
using namespace std;
int main()
{
vector<int> v;
add(3, 4); // 1 (见下文)
add(v); // 2
add(v, 0); // 3
}
-
在语句1中,编译器识别出两个相同的类型,都是
int
类型。因此,它实例化了我们最初定义的add<int>
模板。 -
在语句2中,只使用了一个参数。因此,编译器会寻找只需要一个参数的重载版本的
add
。它找到了期望std::vector
的重载函数模板,并推断模板的类型参数必须是int
,因此实例化了add<int>(std::vector<int> const &)
。 -
在语句3中,编译器再次遇到包含两个参数的参数列表。然而,这次参数的类型不相同,所以不能使用
add
模板的第一个定义。但它可以使用最后一个定义,该定义期望两个不同类型的实体。由于std::vector
支持begin
和end
,编译器现在能够实例化函数模板add<std::vector<int>, int>(std::vector<int> const &, int const &)
。
在为两个相同模板类型参数和两个不同模板类型参数定义 add
函数模板后,我们已经用尽了使用具有两个模板类型参数的 add
函数模板的可能性。
函数模板重载中的歧义
虽然可以定义另一个 add
函数模板,但这会引入歧义,因为编译器将无法选择应使用哪个具有两个不同类型函数参数的重载版本。例如,当定义如下代码时:
#include "add.h"
template <typename T1, typename T2>
T1 add(T1 const &lhs, T2 const &rhs)
{
return lhs + rhs;
}
int main()
{
add(3, 4.5);
}
编译器会报告类似于以下内容的歧义:
error: call of overloaded `add(int, double)' is ambiguous
error: candidates are: Type add(const Container&, const Type&)
[with Container = int, Type = double]
error: T1 add(const T1&, const T2&)
[with T1 = int, T2 = double]
现在回想一下接受三个参数的重载函数模板:
template <typename Type>
Type add(Type const &lhs, Type const &mvalue, Type const &rhs)
{
return lhs + mvalue + rhs;
}
可以认为这个函数的一个缺点是它只接受相同类型的参数(例如,三个 int
、三个 double
等)。为了改善这种情况,我们定义了另一个重载函数模板,这次接受任意类型的参数。这个函数模板只能在 operator+
定义于实际使用的类型之间时使用,但除此之外似乎没有问题。下面是接受任意类型参数的重载版本:
template <typename Type1, typename Type2, typename Type3>
Type1 add(Type1 const &lhs, Type2 const &mid, Type3 const &rhs)
{
return lhs + mid + rhs;
}
现在我们定义了上述两个期望三个参数的重载函数模板,让我们像这样调用 add
:
add(1, 2, 3);
我们是否应该在这里期望有歧义?毕竟,编译器可能会选择前一个函数,并推断 Type == int
,但它也可能选择后一个函数,并推断 Type1 == int, Type2 == int
和 Type3 == int
。值得注意的是,编译器并不会报告任何歧义。
没有报告歧义的原因如下:如果重载的模板函数是使用较少和较多的专业化模板类型参数定义的(例如,较少专业化:所有类型不同;较多专业化:所有类型相同),则编译器会在可能的情况下选择较为专业化的函数。
经验法则是:重载的函数模板必须允许指定唯一的模板类型参数组合,以防止在选择哪个重载函数模板实例化时产生歧义。函数模板类型参数列表中模板类型参数的顺序并不重要。例如,尝试实例化以下任意一个函数模板会导致歧义:
template <typename T1, typename T2>
void binarg(T1 const &first, T2 const &second)
{}
template <typename T1, typename T2>
void binarg(T2 const &first, T1 const &second)
{}
这不应该让人感到惊讶。毕竟,模板类型参数只是形式名称。它们的名称(如 T1
、T2
或 Whatever
)没有具体意义。
声明重载的函数模板
与任何函数一样,重载函数也可以声明,既可以使用普通声明,也可以使用实例化声明。还可以使用显式模板参数类型。示例如下:
-
声明一个接受特定容器的函数模板
add
:template <typename Container, typename Type> Type add(Container const &container, Type const &init);
-
使用实例化声明(在这种情况下,编译器必须已经看到模板的定义):
template int add<std::vector<int>, int> (std::vector<int> const &vect, int const &init);
-
使用显式模板类型参数:
std::vector<int> vi; int sum = add<std::vector<int>, int>(vi, 0);
为不同类型特化模板
最初的 add
模板定义了两个相同类型的参数,它适用于所有支持 operator+
和复制构造函数的类型。然而,这些假设并不总是成立。例如,对于 char *s
,使用 operator+
或复制构造函数是不合适的。编译器试图实例化函数模板,但由于 operator+
对指针没有定义,编译将失败。
在这种情况下,编译器可能能够解析模板类型参数,但可能会发现标准实现无用或产生错误。为了解决这个问题,可以定义模板的显式特化。模板的显式特化为已经存在通用定义的函数模板提供了特定的模板类型参数的实现。正如前面所提到的,编译器总是优先选择更具体的函数而不是更一般的函数。因此,显式特化会在可能的情况下被选择。
显式特化提供了一个专门的实现,其中专用的类型会替代模板类型参数。在函数模板的代码中,这种特定的类型会一致地替换模板类型参数。例如,如果显式特化的类型是 char const *
,那么在模板定义中:
template <typename Type>
Type add(Type const &lhs, Type const &rhs)
{
return lhs + rhs;
}
Type
必须被替换为 char const *
,结果是一个具有以下原型的函数:
char const *add(char const *const &lhs, char const *const &rhs);
让我们尝试使用这个函数:
int main(int argc, char **argv)
{
add(argv[0], argv[1]);
}
然而,编译器忽略了我们的特化,尝试实例化初始函数模板。这失败了,让我们困惑为什么编译器没有选择显式特化。以下是编译器的操作步骤:
add
被调用时,使用了char *
参数。- 两个类型是相同的,因此编译器推导出
Type
等于char *
。 - 现在它检查特化。
char *
模板类型参数能否匹配char const *const &
模板参数?在这里,可能会出现第 21.4 节中提到的允许的转换。资格转换似乎是唯一可行的,允许编译器将一个const
参数绑定到一个非const
参数。 - 因此,在
Type
的上下文中,编译器可以将某个Type
或Type const
的参数匹配到Type const &
。 Type
本身没有被修改,因此Type
是char *
。- 接下来,编译器检查可用的显式特化。它找到一个为
char const *
专门化的版本。 - 由于
char const *
不是char *
,它拒绝了显式特化,使用了通用形式,导致编译错误。
如果我们的 add
函数模板还需要处理 char *
模板类型参数,那么可能需要定义另一个显式特化,为 char *
提供原型:
char *add(char *const &lhs, char *const &rhs);
而不是定义另一个显式特化,可以设计一个重载的函数模板,接受指针类型。以下是一个接受任意类型指针的函数模板:
template <typename Type>
Type *add(Type const *t1, Type const *t2)
{
std::cout << "Pointers\n";
return new Type;
}
那么,什么实际类型可以绑定到上述函数参数呢?在这种情况下,只能接受 Type const *
,允许传递 char const *
参数。这里没有应用资格转换的机会。资格转换允许编译器将 const
添加到非 const
参数,但必须在参数本身(而不是 Type
)指定为 const
或 const &
时。
查看 t1
,它被定义为 Type const *
。这里没有 const
指向参数(否则会是 Type const *const t1
或 Type const *const &t1
)。因此,资格转换不能应用。
由于上述重载函数模板仅接受 char const *
参数,它不会接受(没有 reinterpret_cast
的情况下) char *
参数。因此,main
的 argv
元素不能传递给我们的重载函数模板。
避免过多的特化
我们是否需要定义另一个重载函数模板,这次期待 Type *
参数?虽然可以这样做,但到某个点上,我们应该意识到这种方法并不具有扩展性。就像普通函数和类一样,函数模板也应该有一个概念上清晰的目的。试图在重载函数模板中添加重载函数模板会迅速使模板变成一个杂乱的方案。不要使用这种方法。更好的做法是构建模板,使其适合其原始目的,考虑偶尔的特殊情况,并在文档中清楚地描述其目的。
在某些情况下,构造模板显式特化可能是合理的。对于我们的 add
函数模板,两个特化(一个用于 const
指针,一个用于非 const
指针)可能是合适的。以下是如何构建这些特化:
- 从关键字
template
开始。 - 接着写一个空的尖括号。这表示编译器必须存在一个与我们即将定义的模板匹配的模板原型。如果我们错误地没有这样的模板,编译器将报告错误,例如:
error: template-id `add<char*>' for `char* add(char* const&, char* const&)' does not match any template declaration
- 然后定义函数的头部。它必须匹配初始函数模板的原型,或是模板显式实例化声明的形式(参见第 21.5.1 节),如果其特化类型不能从函数的参数中确定。它必须指定正确的返回类型、函数名称、可能的显式模板类型参数以及函数的参数列表。
- 最后定义函数体,提供所需的特化实现。
以下是两个 add
函数模板的显式特化,分别期待 char *
和 char const *
参数:
template <>
char *add<char *>(char *const &p1, char *const &p2)
{
std::string str(p1);
str += p2;
return strcpy(new char[str.length() + 1], str.c_str());
}
template <>
char const *add<char const *>(char const *const &p1, char const *const &p2)
{
static std::string str;
str = p1;
str += p2;
return str.c_str();
}
模板显式特化通常包含在包含其他函数模板实现的文件中。
特化的声明
模板显式特化可以以通常的方式进行声明,即用分号替换其主体。当声明模板显式特化时,紧随 template
关键字的角括号对是必不可少的。如果省略角括号,编译器将默默地处理它,导致编译时间略有增加。
在声明模板显式特化(或使用实例化声明)时,如果编译器能够从函数的参数中推导出这些类型,则可以省略显式指定模板类型参数。由于 char
和 char const *
的特化就是这种情况,它们也可以这样声明:
template <> char *add(char *const &p1, char *const &p2);
template <> char const *add(char const *const &p1, char const *const &p2);
如果省略 template <>
,函数声明将不再是一个函数模板声明,而是一个普通函数声明。这并不是错误:函数模板和普通(非模板)函数可以互相重载。普通函数在允许的类型转换方面没有模板函数那么严格。这可能是偶尔用普通函数重载模板的一个理由。
函数模板显式特化不仅仅是函数模板的另一个重载版本。虽然重载版本可以定义一组完全不同的模板参数,但特化必须使用与其非特化版本相同的模板参数集。编译器会在实际模板参数与特化定义的类型匹配时使用特化(遵循最特化的参数集匹配参数集的规则)。对于不同的参数集,必须使用函数(或函数模板)的重载版本。
使用插入运算符时的复杂情况
现在我们已经讨论了显式特化和重载,让我们考虑当一个类定义了 std::string
转换运算符时会发生什么。
转换运算符被保证用作右值。这意味着定义了字符串转换运算符的类的对象可以赋值给,例如,std::string
对象。然而,当尝试将定义了字符串转换运算符的对象插入到流中时,编译器会抱怨我们尝试将不适当的类型插入到 ostream
中。
另一方面,当这个类定义了 int
转换运算符时,插入操作则可以顺利进行。
这种区别的原因是:对于基本类型(如 int
),operator<<
被定义为一个普通的(自由)函数,但在插入字符串时,它被定义为一个函数模板。因此,当尝试插入一个定义了字符串转换运算符的对象时,编译器会访问所有重载的插入运算符版本,这些版本是插入到 ostream
对象中的。
由于没有可用的基本类型转换,基本类型的插入运算符无法使用。而对于模板参数来说,可用的转换不允许编译器查找转换运算符,因此,定义了字符串转换运算符的类的对象不能被插入到 ostream
中。
如果希望能够将这种类的对象插入到 ostream
中,则该类必须定义其自己的重载插入运算符(除了用于将类对象作为右值在字符串赋值中使用的字符串转换运算符外)。
数值极限
头文件 <climits>
定义了各种类型的常量,例如 INT_MAX
定义了 int
类型可以存储的最大值。
<climits>
中定义的常量的缺点是它们是固定的极限值。假设你编写了一个函数模板,该模板接受某种类型的参数。例如:
template<typename Type>
Type operation(Type &&type);
假设这个函数应该返回 Type
的最大负值(如果 type
是负值),或者最大正值(如果 type
是正值)。如果 type
不是整数值,则应该返回 0。
怎么做呢?
由于 <climits>
中的常量只能在类型已知的情况下使用,因此唯一的方法似乎是为各种整数类型创建函数模板特化,例如:
template<>
int operation<int>(int &&type)
{
return type < 0 ? INT_MIN : INT_MAX;
}
<limits>
提供了一个替代方案。要使用这些功能,必须包含头文件 <limits>
。
numeric_limits
类模板提供了回答各种关于数值类型问题的成员。在介绍这些成员之前,让我们看看如何将 operation
函数模板实现为一个单一的函数模板:
template<typename Type>
Type operation(Type &&type)
{
return
not numeric_limits<Type>::is_integer ? 0 :
type < 0 ? numeric_limits<Type>::min() :
numeric_limits<Type>::max();
}
现在,operation
可以用于所有语言的基本类型。
下面是 numeric_limits
提供的功能概述。请注意,numeric_limits
定义的成员函数返回 constexpr
值。numeric_limits
为 Type
定义的成员可以如下使用:
numeric_limits<Type>::member // 数据成员
numeric_limits<Type>::member() // 成员函数
member只是代表成员的而已
-
Type denorm_min():
如果Type
可用:返回其最小正非规范化值;否则,返回numeric_limits<Type>::min()
。 -
int digits:
返回Type
值的非符号位的数量,或者(对于浮点类型)返回尾数中的数字数量。 -
int digits10:
返回表示Type
值所需的数字数量,而不改变其值。 -
Type constexpr epsilon():
返回Type
中 1 和 1 之间的最小值。 -
float_denorm_style has_denorm:
非规范化浮点值表示使用可变数量的指数位。has_denorm
成员返回Type
的非规范化值信息:denorm_absent
:Type
不允许非规范化值;denorm_indeterminate
:Type
可能使用非规范化值;编译器无法在编译时确定;denorm_present
:Type
使用非规范化值;
-
bool has_denorm_loss:
如果由于使用非规范化(而不是不精确结果)而检测到精度损失,则返回true
。 -
bool has_infinity:
如果Type
有一个表示正无穷大的表示,则返回true
。 -
bool has_quiet_NaN:
如果Type
有一个表示非信号‘Not-a-Number’值的表示,则返回true
。 -
bool has_signaling_NaN:
如果Type
有一个表示信号‘Not-a-Number’值的表示,则返回true
。 -
Type constexpr infinity():
如果Type
可用:返回其正无穷大值。 -
bool is_bounded:
如果Type
包含有限的值集,则返回true
。 -
bool is_exact:
如果Type
使用精确表示,则返回true
。 -
bool is_iec559:
如果Type
使用 IEC-559(IEEE-754)标准,则返回true
。这类类型始终返回true
的has_infinity
、has_quiet_NaN
和has_signaling_NaN
,而infinity()
、quiet_NaN()
和signaling_NaN()
返回非零值。 -
bool is_integer:
如果Type
是整数类型,则返回true
。 -
bool is_modulo:
如果Type
是“模”类型,则返回true
。模类型的值可以总是相加,但加法可能会“环绕”,产生比加法的两个操作数中的任何一个小的结果。 -
bool is_signed:
如果Type
是带符号的,则返回true
。 -
bool is_specialized:
对于Type
的特化,返回true
。 -
Type constexpr lowest():
Type
的最低有限值:没有其他有限值小于返回的值。对于浮点类型,这个值等于min()
返回的值。 -
T constexpr max():
Type
的最大值。 -
T constexpr min():
Type
的最小值。对于非规范化浮点类型,返回最小正规范化值。 -
int max_exponent:
浮点类型Type
的指数的最大正整数值,产生有效的Type
值。 -
int max_exponent10:
基数为 10 的指数的最大整数值,产生有效的Type
值。 -
int min_exponent:
浮点类型Type
的指数的最小负整数值,产生有效的Type
值。 -
int min_exponent10:
基数为 10 的指数的最小负整数值,产生有效的Type
值。 -
Type constexpr quiet_NaN():
如果Type
可用:返回其非信号‘Not-a-Number’值。 -
int radix:
如果Type
是整数类型:表示的基数;如果Type
是浮点类型:表示的指数的基数。 -
Type constexpr round_error():
Type
的最大舍入误差。 -
float_round_style round_style:
Type
使用的舍入风格。它有以下枚举值之一:round_toward_zero
: 值舍入到零;round_to_nearest
: 值舍入到最接近的表示值;round_toward_infinity
: 值舍入到无穷大;round_toward_neg_infinity
: 如果舍入到负无穷大;round_indeterminate
: 如果舍入风格在编译时无法确定。
-
Type constexpr signaling_NaN():
如果Type
可用:返回其信号‘Not-a-Number’值。 -
bool tinyness_before:
如果Type
允许在舍入之前检测到微小值,则返回true
。 -
bool traps:
如果Type
实现了陷阱,则返回true
。
多态包装器用于函数对象
在 C++ 中,指向(成员)函数的指针有相当严格的右值要求。它们只能指向匹配其类型的函数。当定义模板时,函数指针的类型可能依赖于模板参数,这就会成为一个问题。
为了解决这个问题,可以使用多态(函数对象)包装器。多态包装器可以引用函数指针、成员函数或函数对象,只要它们的参数类型和数量匹配即可。
在使用多态函数包装器之前,必须包含头文件 <functional>
。
多态函数包装器通过 std::function
类模板提供。其模板参数是要为其创建包装器的函数的原型。以下是一个定义多态函数包装器的示例,该包装器可以指向一个期望两个 int
值并返回一个 int
的函数:
std::function<int(int, int)> ptr2fun;
这里,模板参数是 int (int, int)
,表示一个期望两个 int
参数并返回一个 int
的函数。其他原型将返回其他匹配的函数包装器。
这样的函数包装器现在可以用来指向为其创建的任何函数。例如,plus<int> add
创建了一个 functor,定义了一个 int operator()(int, int)
的函数调用成员。由于这符合 int (int, int)
的函数原型,因此我们的 ptr2fun
可以指向 add
:
ptr2fun = add;
如果 ptr2fun
尚未指向任何函数(例如,它只是定义了),而尝试通过它调用一个函数,则会抛出 std::bad_function_call
异常。此外,一个尚未分配函数地址的多态函数包装器在逻辑表达式中代表 false
(就像它是一个值为零的指针一样):
std::function<int(int)> ptr2int;
if (not ptr2int)
cout << "ptr2int is not yet pointing to a function\n";
多态函数包装器还可以用来引用函数、functors 或其他多态函数包装器,这些包装器的原型对于参数或返回值存在标准转换。例如:
bool predicate(long long value);
void demo()
{
std::function<int(int)> ptr2int;
ptr2int = predicate; // OK, 参数和返回类型可转换
struct Local
{
short operator()(char ch);
};
Local object;
std::function<short(char)> ptr2char(object);
ptr2int = object; // OK, object 是一个 functor,其函数
// 操作符具有可转换的参数和返回类型
ptr2int = ptr2char; // OK, 现在使用多态函数包装器
}
编译模板定义和实例化
考虑以下 add
函数模板的定义:
template <typename Container, typename Type>
Type add(Container const &container, Type init)
{
return std::accumulate(container.begin(), container.end(), init);
}
在这里,std::accumulate
使用 container
的 begin
和 end
成员。container.begin()
和 container.end()
被称为依赖于模板类型参数的调用。编译器在看到 container
的接口之前,不能检查 container
是否确实具有返回输入迭代器的 begin
和 end
成员。
另一方面,std::accumulate
本身与任何模板类型参数无关。它的参数依赖于模板参数,但函数调用本身不依赖于模板参数。模板体中独立于模板类型参数的语句称为不依赖于模板类型参数。
当编译器遇到模板定义时,它会验证所有不依赖于模板参数的语句的语法正确性。即,编译器必须已经看到所有类定义、类型定义、函数声明等,这些都是这些语句中使用的。如果编译器没有看到所需的定义和声明,则会拒绝模板的定义。因此,在将上述模板提交给编译器时,必须首先包含 numeric
头文件,因为这个头文件声明了 std::accumulate
。
对于依赖于模板参数的语句,编译器不能执行如此广泛的语法检查。它无法验证 Container
类型是否实际具有 begin
成员。在这些情况下,编译器执行表面检查,假设所需的成员、运算符和类型最终会变得可用。
程序源代码中模板实例化的位置称为模板实例化点。在实例化点,编译器推断模板参数的实际类型。在这个点上,它检查依赖于模板类型参数的模板语句的语法正确性。这意味着编译器在实例化点必须已经看到所需的声明。作为一个经验法则,你应该确保编译器在每个模板实例化点读取了所有所需的声明(通常是:头文件)。对于模板的定义本身,可以制定更宽松的要求。定义被读取时,仅需要提供不依赖于模板类型参数的语句所需的声明。
另一方面,在实例化点,编译器还必须访问模板的定义。因此,模板定义通常放在头文件中:如果模板必须在某个源文件中实例化,则源文件包含头文件(例如 template.h
),以便编译器可以验证模板是否可以为实际使用的类型进行实例化(类模板也是如此,下一章将介绍)。以下是一个简短的示例:
-
add
函数模板的定义在add.h
中:template <typename Type> Type add(Type const &fst, Type const &snd) { return fst + snd; }
-
要使用它,
fun.h
包含add.h
:#include <iostream> #include "add.h" void fun(size_t lhs, size_t rhs) { std::cout << add(lhs, rhs) << '\n'; }
函数模板定义可以通过在定义前加上 inline
关键字来定义为内联函数。例如:
template <typename Type>
inline Type add(Type const &fst, Type const &snd)
{
return fst + snd;
}
正如总是一样,inline
应仅用于简短的单行函数定义。
函数选择机制
当编译器遇到一个函数调用时,它必须在存在重载函数的情况下决定调用哪个函数。之前我们提到过“选择最具体的函数”这一原则,这是一种相对直观的描述编译器函数选择机制的方法。在本节中,我们将更详细地了解这一机制。
假设我们让编译器编译以下 main
函数:
int main()
{
process(3, 3);
}
此外,假设编译器在编译 main
时已经遇到以下函数声明:
template <typename T>
void process(T &t1, int i);
template <typename T1, typename T2>
void process(T1 const &t1, T2 const &t2);
template <typename T>
void process(T const &t, double d);
template <typename T>
void process(T const &t, int i);
template <>
void process<int, int>(int i1, int i2);
void process(int i1, int i2);
void process(int i, double d);
void process(double d, int i);
void process(double d1, double d2);
void process(std::string s, int i);
int add(int, int);
编译器在读取了 main
的语句后,必须决定实际要调用哪个函数。它按照以下步骤进行:
-
构建候选函数集:这个集合包含所有满足以下条件的函数:
- 在调用点可见;
- 函数名称与调用的函数相同。
因为函数
add
名字不同,所以它被从集合中移除。编译器剩下了 10 个候选函数。 -
构建可行函数集:可行函数是那些可以应用类型转换,使函数的参数类型与实际参数类型匹配的函数。这意味着可行函数的参数个数必须至少与实际参数的个数相匹配。
函数 10 的第一个参数是
std::string
。因为std::string
不能通过int
值初始化,所以没有合适的转换,这使得函数 10 被从候选函数列表中移除。double
参数可以保留。标准转换存在于int
到double
,所以所有具有普通double
参数的函数可以保留。因此,可行函数集由函数 1 到 9 组成。
此时,编译器会尝试确定模板类型参数的类型。此步骤将在以下小节中概述。
确定模板类型参数
在确定了候选函数集和可行函数集之后,编译器必须确定模板类型参数的实际类型。在尝试将实际类型匹配到模板类型参数时,编译器可以使用三种标准模板参数转换程序之一(参见第 21.4 节)。在这个过程中,编译器得出结论,对于函数 1 中的 T &t1
参数,由于参数 3
是常量 int
值,因此无法确定 T
的类型。因此,函数 1 被从可行函数列表中移除。编译器现在面临以下潜在实例化的函数模板和普通函数集:
void process(T1 [= int] const &t1, T2 [= int] const &t2);
void process(T [= int] const &t, double d);
void process(T [= int] const &t, int i);
void process<int, int>(int i1, int i2);
void process(int i1, int i2);
void process(int i, double d);
void process(double d, int i);
void process(double d1, double d2);
编译器为每个可行函数分配一个直接匹配计数值。直接匹配计数值是可以匹配函数参数的实际参数个数,而不需要进行(自动)类型转换。例如,对于函数 2,这个计数值是 2;对于函数 7,它是 1;对于函数 9,它是 0。函数现在按直接匹配计数值降序排序:
匹配计数 | 函数 |
---|---|
2 | void process(T1 [= int] const &t1, T2 [= int] const &t2); |
2 | void process(T [= int] const &t, int i); |
2 | void process<int, int>(int i1, int i2); |
2 | void process(int i1, int i2); |
1 | void process(T [= int] const &t, double d); |
1 | void process(int i, double d); |
1 | void process(double d, int i); |
1 | void process(double d1, double d2); |
如果最上面的值没有冲突,那么相应的函数将被选择,函数选择过程完成。
当多个函数位于最上面时,编译器会验证是否遇到歧义。歧义的情况是指需要类型转换的参数序列不同。例如,考虑函数 3 和 8。使用 D 代表“直接匹配”,C 代表“转换”,则参数对于函数 3 的匹配是 D,C,而对于函数 8 的匹配是 C,D。假设 2、4、5 和 6 不可用,那么编译器会报告歧义,因为函数 3 和 8 的参数/参数匹配过程序列不同。同样,函数 7 和 8 之间也存在这种差异,但函数 3 和 7 之间没有这种差异。
在这种情况下,最上面的值存在冲突,编译器继续处理相关函数的子集(函数 2、4、5 和 6)。对于这些函数,每个函数都关联一个“普通参数计数”,计数函数的非模板参数数量。函数按照这个计数值降序排序:
普通参数计数 | 函数 |
---|---|
2 | void process(int i1, int i2); |
1 | void process(T [= int] const &t, int i); |
0 | void process(T1 [= int] const &t1, T2 [= int] const &t2); |
0 | void process<int, int>(int i1, int i2); |
这时,最上面的值没有冲突。相应的函数(process(int, int)
,函数 6)被选择,函数选择过程完成。函数 6 在 main
的函数调用语句中被使用。
如果函数 6 没有定义,则会使用函数 4。如果函数 4 和函数 6 都没有定义,则选择过程将继续进行,函数 2 和函数 5:
普通参数计数 | 函数 |
---|---|
0 | void process(T1 [= int] const &t1, T2 [= int] const &t2); |
0 | void process<int, int>(int i1, int i2); |
在这种情况下,再次遇到冲突,选择过程继续进行。每个具有最高普通参数计数的函数都关联一个“函数类型”值,这些函数按其函数类型值降序排序。值 2 关联到普通函数,值 1 关联到模板显式特化,值 0 关联到普通函数模板。
函数选择机制总结
如果最上面的值没有冲突,那么相应的函数将被选择,函数选择过程完成。如果存在冲突,编译器会报告歧义,无法确定调用哪个函数。假设只有函数 2 和函数 5 存在,那么选择步骤将导致以下排序:
函数 | 类型 |
---|---|
void process<int, int>(int i1, int i2); | 1 |
void process(T1 [= int] const &t1, T2 [= int] const &t2); | 0 |
在这种情况下,模板显式特化的函数 5 会被选择。以下是函数模板选择机制的总结(参见图 Figure 21.1):
- 构建候选函数集:名称相同的函数。
- 构建可行函数集:参数数量正确且有可用的类型转换。
- 确定模板类型,剔除无法确定类型参数的模板。
- 按直接匹配计数值降序排序:如果最上面值没有冲突,则选择相应的函数,完成选择过程。
- 检查与最上面值相关的函数:检查是否存在自动类型转换序列的歧义。如果遇到不同的序列,报告歧义并终止选择过程。
- 按普通参数计数值降序排序:如果最上面值没有冲突,则选择相应的函数,完成选择过程。
- 按函数类型值降序排序:函数类型值为 2 的普通函数,1 为模板显式特化,0 为普通函数模板。如果最上面值没有冲突,则选择相应的函数,完成选择过程。
- 报告歧义并终止选择过程。
这些步骤帮助编译器在面对重载函数时决定正确的调用函数。
SFINAE:替换失败不是错误
考虑以下结构体定义:
struct Int
{
using type = int;
};
虽然在此时将 using
声明嵌入结构体中可能显得有些奇怪,但在第 23 章中,我们会遇到这种做法非常有用的情况。它允许我们定义一个模板所需的变量类型。例如(忽略以下函数参数列表中的 typename
,但请参见第 22.2.1 节以获取详细信息):
template <typename Type>
void func(typename Type::type value)
{
}
在调用 func(10)
时,必须显式指定 Int
,因为可能有很多结构体定义了 type
:编译器需要一些帮助。正确的调用是 func<Int>(10)
。现在很明确是使用了 Int
,编译器可以正确推断 value
是 int
类型。
但是,模板可能会被重载,我们的下一个定义是:
template <typename Type>
void func(Type value)
{}
在调用这个函数时,我们指定 func<int>(10)
,这也能正常编译。
如前所述,当编译器确定要实例化哪个模板时,它会创建一个可行函数的列表,并通过匹配实际参数类型与可行函数的参数类型来选择要实例化的函数。为此,它必须确定参数的类型,这里就出现了问题。当评估 Type = int
时,编译器会遇到 func(int::type)
(第一个模板定义)和 func(int)
(第二个模板定义)的原型。但是没有 int::type
,因此在某种程度上,这会生成一个错误。错误是由于将提供的模板类型参数与各种模板定义中使用的类型匹配所导致的。
由此引发的类型问题不会被视为错误,而仅仅是表明该特定类型无法用于该特定模板。因此,该模板将从候选函数列表中被移除。
这一原则被称为“替换失败不是错误”(SFINAE),编译器常常利用它来选择不仅仅是简单的重载函数(如这里所示),还用于在可用的模板特化之间进行选择(参见第 23.6.1 节和第 23.9.3 节)。
使用 if constexpr
进行条件函数定义
除了常见的 if (cond)
选择语句,C++ 语言还支持 if constexpr (cond)
语法。尽管它可以在所有需要标准 if
选择语句的地方使用,但其特定用途是在函数模板内部:if constexpr
允许编译器根据 if constexpr
的 (cond) 子句的编译时求值条件(有条件地)实例化模板函数的元素。
以下是一个示例:
1: void positive();
2: void negative();
3:
4: template <int value>
5: void fun()
6: {
7: if constexpr (value > 0)
8: positive();
9: else if constexpr (value < 0)
10: negative();
11: }
12:
13: int main()
14: {
15: fun<4>();
16: }
- 在第 7 行和第 9 行,
if constexpr
语句开始使用。由于value
是模板的非类型参数,其值在编译时是可用的,因此条件部分的值也是可用的。 - 在第 15 行调用
fun<4>()
:因此第 7 行的条件为真,第 9 行的条件为假。 - 编译器因此以如下方式实例化
fun<4>()
:
void fun<4>()
{
positive();
}
注意,if constexpr
语句本身不会生成可执行代码:它被编译器用来选择应该实例化哪些部分。在这个例子中,只实例化了 positive
,这必须在程序的链接阶段之前就已经可用,以确保程序能够正确完成链接。
模板声明语法总结
在这一节中,总结了声明模板的基本语法构造。定义模板时,结束的分号应该被函数体替代。并非所有模板声明都可以转换为模板定义。如果可以提供定义,会明确指出。
-
普通模板声明(可以提供定义):
template <typename Type1, typename Type2> void function(Type1 const &t1, Type2 const &t2);
-
模板实例化声明(不能提供定义):
template void function<int, double>(int const &t1, double const &t2);
-
使用显式类型的模板(不能提供定义):
void (*fp)(double, double) = function<double, double>; void (*fp)(int, int) = function<int, int>;
-
模板显式特化(可以提供定义):
template <> void function<char *, char *>(char *const &t1, char *const &t2);
-
声明友元函数模板的模板声明(在类模板中,不能提供定义):
friend void function<Type1, Type2>(parameters);
类模板
模板不仅可以用于函数,也可以用于完整的类。构造类模板是为了处理不同类型的数据。例如,当一个类需要处理多种数据类型时,可以使用类模板。在C++中,类模板非常常见:第12章讨论了如 vector
、stack
和 queue
等数据结构,这些都是以类模板的形式实现的。使用类模板时,算法与数据完全分离。要使用特定数据结构与特定数据类型,只需在定义或声明类模板对象时指定数据类型(例如 stack<int> iStack
)。
在本章中,将讨论如何构造和使用类模板。从某种意义上说,类模板与面向对象编程(第14章)中的机制类似。面向对象编程通过从基类派生类,允许程序员推迟算法的实现。在基类中,算法可能只是部分实现。实际的数据定义和处理可以在派生类定义时推迟。类似地,模板允许程序员推迟数据类型的指定。这在抽象容器中最为明显,抽象容器完全指定了算法,同时将操作数据的类型完全未指定。
Sutter 和 Alexandrescu 在他们的《C++ 编码标准》(Addison-Wesley,2005年)一书中,提到了一种有趣的对应关系,即类模板的某些使用与第14章中的多态性。动态多态性是指通过重写虚函数,当基类指针指向某个对象时,实际调用的函数取决于该对象的类型。静态多态性则是在模板上下文中遇到的,静态多态性与动态多态性的比较将在第22.12节中讨论。
一般来说,类模板的使用比多态性更为简单。创建 stack<int>
类型的栈比派生一个新类 Istack: public stack
并实现所有必要的成员函数来定义一个类似的 stack<int>
类型更为容易。然而,每次为不同类型定义一个类模板对象时,可能需要重新实例化一个完整的类。在面向对象编程中,派生类使用基类中已经存在的函数,而不是复制函数,这一点与类模板有所不同(但请参见第22.11节)。
我们之前已经使用了类模板。例如,vector<int>
和 vector<string>
是常见的对象。这些模板定义和实例化的数据类型是容器类型的固有部分。强调的是,正是类模板类型及其模板参数的组合,而不是单纯的类模板类型,定义或生成了一个类型。所以 vector<int>
是一个类型,vector<string>
也是一个类型。这些类型可以通过 using
声明进行指定:
using IntVector = std::vector<int>;
using StringVector = std::vector<std::string>;
IntVector vi;
StringVector vs;
与函数模板一样,类模板的类接口及类成员函数的实现必须在实例化点可用。因此,类模板的接口和实现通常会放在头文件中,然后由使用这些类的源文件包含(参见第21.13节)。
模板参数推导
函数模板和类模板之间的一个重要区别是,函数模板的模板参数通常可以由编译器推导出来,而类模板的模板参数可能需要由用户显式指定。这并非总是必要的,但由于类模板的模板类型是类模板类型的固有部分,因此必须让编译器完全清楚类模板的类型。
例如,考虑下面的函数模板:
template <typename T>
void fun(T const ¶m);
你可以这样调用它:
vector<int> vi;
fun(vi);
编译器可以推导出 T == vector<int>
。
另一方面,如果我们有如下的类模板:
template <typename T>
struct Fun
{
T d_data;
Fun();
};
我们不能这样做:
Fun fun;
因为 Fun
不是一个类型,编译器无法推导出预期的类型是什么。
有时,编译器能够推导出预期的类型。例如:
vector vi{ 1, 2, 3, 4 };
在这种情况下,编译器从提供的值中推导出 int
。编译器足够智能,可以选择最一般的类型,例如在以下示例中,double
被推导出来:
vector vi{ 1, 2.5 };
编译器在类型未被显式指定时会尽力推导出类型,并且会保持在安全范围内。因此,第一个示例中的 vector
是 vector<int>
,而第二个示例中的 vector
是 vector<double>
。
尽管编译器在许多情况下愿意并能够推导出类型,但这同时也可能成为混淆的源头。在第一个示例中,只使用了非负值,那么为什么不定义 vector<unsigned>
或 vector<size_t>
呢?在第二个示例中,编译器推导出 vector<double>
,但 vector<float>
也可能被使用。
为了避免混淆,作为一个经验法则,最好在定义类类型对象时显式指定模板类型。这几乎不需要任何额外的努力,并且完全清楚地表明你的意图和意思。因此,优先使用:
vector<int> vi{ 1, 2, 3 };
vector<double> vd{ 1, 2.5 };
而不是使用隐式类型的定义。
模板参数推导的简单定义
以下是一些示例,展示了如何定义模板以及编译器如何推导模板参数。
起始点:
template <class... Types> // 任意类型集合
class Deduce {
public:
Deduce(Types... params); // 构造函数
void fun(); // 成员函数
};
一些定义示例:
-
推导:
Deduce first{1}; // 1: int // -> Deduce<int> first{1};
-
没有类型参数:
Deduce second; // 没有 Types -> Deduce<> second;
-
右值引用:
Deduce &&ref = Deduce<int>{1}; // int 这个地方我试了貌似要Deduce<int> 指定才行 // -> Deduce<int> &&ref
-
模板实例化:
template <class Type> Deduce third{static_cast<Type *>(0)};
third
是一个从指定类型构造Deduce
对象的模板。指针的类型仅仅是指定类型的指针(例如,指定third<int>
意味着int *
)。由于third
的参数类型是已知的(即int *
),编译器推导出third{0}
是Deduce<int *>
。auto x = third<int>; // x 的完整定义:Deduce<int *> x{0};
成员函数的使用:
x.fun(); // OK: 成员函数 fun 被名为 x 的对象调用
third<int>.fun(); // OK: 成员函数 fun 被匿名对象调用
不能编译的示例:
extern Deduce object; // 对象定义
Deduce *pointer = 0; // 指针定义
Deduce function(); // 不是对象定义
// 任何类型集合都可能被指定
模板参数推导的过程:
-
形成可用构造函数的列表: 列表包含所有普通构造函数和构造函数模板(即定义为成员模板的构造函数)。
-
为列表中的每个元素形成对应的虚构的函数: 编译器为构造函数模板形成函数模板,为普通构造函数形成普通函数。
-
这些imaginary虚构的函数的返回类型是构造函数的类类型,使用原始类模板的模板参数:
template <class ...Types> Deduce<Types ...> imaginary(Types ...params);
对于
Deduce::Deduce
构造函数,形成的imaginary虚构的函数为上述形式。 -
应用普通参数推导和重载解析: 如果找到最佳匹配的imaginary函数,则使用该函数的类类型(或特化)。否则,程序会出错。
应用示例:
对于 Deduce first{1}
,first imaginary函数赢得了重载决议,结果是模板参数推导出 int
,因此定义为 Deduce<int> first{1}
。
嵌套类模板的情况:
当类模板嵌套在另一个类模板中时,嵌套类模板的名称依赖于外部类的类型。外部类提供了内层类模板的名称限定符。在这种情况下,模板参数推导用于嵌套类,但(由于名称限定符不使用)不会用于外部类。以下是一个示例:
添加嵌套类模板到 Outer
:
template <class OuterType>
class Outer {
public:
template <class InnerType>
struct Inner {
Inner(OuterType);
Inner(OuterType, InnerType);
template <typename ExtraType>
Inner(ExtraType, InnerType);
};
};
定义:
Outer<int>::Inner inner{2.0, 1};
在这种情况下,编译器使用这些imaginary函数:
template <typename InnerType>
Outer<int>::Inner<InnerType> // 复制构造函数
imaginary(Outer<int>::Inner<InnerType> const &);
template <typename InnerType>
Outer<int>::Inner<InnerType> // 移动构造函数
imaginary(Outer<int>::Inner<InnerType> &&);
template <typename InnerType>
Outer<int>::Inner<InnerType> // 第一个声明的构造函数
imaginary(int);
template <typename InnerType>
Outer<int>::Inner<InnerType> // 第二个声明的构造函数
imaginary(int, InnerType);
template <typename InnerType>
template <typename ExtraType>
Outer<int>::Inner<InnerType> // 第三个声明的构造函数
imaginary(ExtraType, InnerType);
对 imaginary(2.0, 1)
的模板参数推导结果为 double
(第一个参数)和 int
(第二个参数)。重载解析优先选择最后一个imaginary函数,因此 ExtraType
为 double
和 InnerType
为 int
。因此:
Outer<int>::Inner inner{ 2.0, 1 };
// 定义为:Outer<int>::Inner<int> inner{ 2.0, 1 };
显式转换
查看以下类接口:
template <class T>
struct Class
{
std::vector<T> d_data;
struct Iterator
{
using type = T;
bool operator!=(Iterator const &rhs);
Iterator operator++(int);
type const &operator*() const;
// ...
};
Class();
Class(T t);
Class(Iterator begin, Iterator end);
template <class Tp>
Class(Tp first, Tp second)
{}
Iterator begin();
Iterator end();
};
构造函数 Class(Iterator begin, Iterator end)
的实现可能类似于以下内容:
template <class T>
Class<T>::Class(Iterator begin, Iterator end)
{
while (begin != end)
d_data.push_back(*begin++);
}
在这里,d_data
是一个存储 T
值的容器。现在,可以通过 Class::Iterator
对象构造 Class
对象:
Class<int> source;
...
Class<int> dest{source.begin(), source.end()};
然而,简单的模板参数推导过程无法从提供的参数中推导出
Class<Class::Iterator::type>
类型:type
不是直接可用的。相比之下,Class
的第三个构造函数中,
Class intObject{12};
允许编译器创建一个假想的函数,如下面:
template <class Type>
Class<Type> imaginary(Type param)
在这种情况下,Type
显然是 int
,因此构造了 Class<int>
对象。
当我们尝试对 Class(Iterator, Iterator)
做类似的处理时,我们得到:
template <class Iterator>
Class<???> imaginary(Iterator, Iterator);
在这里,Class
的模板参数与 Iterator
并不直接相关,编译器无法推导出其类型,因此编译失败。
类似的情况适用于第四个构造函数,它接收两个 Tp
参数,这两个参数与 Class
的模板类型都是独立的。
在这种情况下,简单的类型模板参数推导过程失败了。不过,并非所有希望都破灭:可以使用显式转换,即定义为显式指定的推导规则,添加到类的接口中。
显式指定的推导规则将类模板构造函数签名与类模板类型关联起来。它指定了使用具有指定签名的构造函数构造的类模板对象的模板参数。显式指定的推导规则的通用语法形式如下:
类模板构造函数签名 -> 结果类类型 ;
让我们应用到 Class(Iterator begin, Iterator end)
上。其签名是:
template <class Iterator>
Class(Iterator begin, Iterator end)
要求 Iterator
定义一个 typename type
,我们现在可以形成一个结果类类型:
Class<typename Iterator::type>
现在我们可以将两者结合在一起,形成一个显式指定的推导规则(作为单独的行添加到 Class
的接口中):
template <class Iterator>
Class(Iterator begin, Iterator end) -> Class<typename Iterator::type>;
//声明这么一条就可以 模板是class Iterator 而不是class T
添加此推导规则到 Class
的接口后,以下构造函数调用将成功编译:
Class src{12};
// 已经可以工作
Class dest1{src.begin(), src.end()};
// begin() 和 end() 返回 Class<int>::Iterator 对象。
// typename Class<int>::Iterator::type 被定义为 int,
// 因此定义了 Class<int> dest1。
struct Double
{
using type = double;
// ... 成员 ...
};
Class dest2{Double{}, Double{}};
// 在这里,结构体 Double 定义了 typename double type,
// 所以定义了 Class<double> dest2。
在类内部,编译器使用(如前所述)类本身来仅仅引用类名:当在类 Class
内部引用 Class
时,编译器将其视为 Class<T>
。因此,Class
的拷贝构造函数的声明和定义头文件如下:
Class(Class const &other);
// 声明
template <class T>
// 定义
Class<T>::Class(Class const &other)
{
// ...
}
有时,默认类型不是您想要的,此时必须显式指定所需的类型。考虑在 Class
中添加一个成员 dup
:
template <typename T>
template <typename Tp>
auto Class<T>::dup(Tp first, Tp second)
{
return Class{ first, second }; // 可能不是你想要的
}
在这里,因为我们在 Class
内部,编译器推导出 Class<T>
必须被返回。但在前面的例子中,我们决定在从迭代器初始化 Class
时,应构造并返回 Class<typename Tp::type>
。要实现这一点,显式指定所需的类型:
template <typename T>
template <typename Tp>
auto Class<T>::dup(Tp first, Tp second) {
// OK: 显式指定的 Class 类型
return Class<typename Tp::type>{ first, second };
}
正如这个例子所示,简单的(隐式)或显式的推导规则并不总是必须使用:它们可以在许多标准情况下使用,其中显式指定类的模板参数看起来是多余的。模板参数推导是为了简化使用类模板时的对象构造,但最终您仍然可以显式指定模板参数。
定义类模板
在介绍了函数模板的构建之后,我们现在可以进入下一步:构建类模板。许多有用的类模板已经存在。为了不依赖于已有的类模板,我们将构建一个新的类模板,这个类模板可能非常有用。
新的类实现了一个循环队列。循环队列具有固定数量的最大元素数 max_size
。新元素从队列的尾部插入,只有队列的头部和尾部元素可以被访问。只有头部元素可以从循环队列中移除。当 n
个元素已经被添加时,下一个元素将重新插入到队列的(物理)第一个位置。循环队列允许插入,直到它包含 max_size
个元素。只要循环队列至少包含一个元素,就可以从中移除元素。尝试从空的循环队列中移除元素或向已满的循环队列中添加另一个元素将导致异常被抛出。除了其他构造函数外,循环队列还必须提供一个构造函数,用于初始化具有 max_size
元素的对象。这个构造函数必须为 max_size
元素分配内存,但不应调用这些元素的默认构造函数(提示使用 placement new 运算符)。循环队列应该提供值语义以及移动构造函数。
请注意,在上述描述中,实际用于循环队列的数据类型并未提及。这明确表明,我们的类可以很好地定义为一个类模板。或者,类也可以定义为某个具体的数据类型,然后在将类转换为类模板时进行抽象。实际的类模板构建将在下一节中提供,我们将开发名为 CirQue
(循环队列)的类模板。
构建循环队列:CirQue
本节将展示如何构建一个类模板。在这里,我们将开发类模板 CirQue
(循环队列)。这个类模板有一个模板类型参数 Data
,表示存储在循环队列中的数据类型。该类模板的接口大致如下所示:
template<typename Data>
class CirQue
{
// 成员声明
};
类模板的定义开始方式类似于函数模板的定义:
构造循环队列:CirQue
本节将通过开发一个类模板 CirQue(循环队列)来说明类模板的构造。这个类模板有一个模板类型参数 Data
,表示存储在循环队列中的数据类型。该类模板接口的概要如下所示:
template<typename Data>
class CirQue
{
// 成员声明
};
类模板的定义和函数模板的定义类似,都以 template
关键字开头,接着是模板参数列表。模板参数列表是一个包含一个或多个逗号分隔元素的列表,如下所示:
template <typename Type1, typename Type2, typename Type3>
当一个类模板定义了多个模板类型参数时,它们会依次与在定义该类模板对象时提供的模板类型参数列表中的元素匹配。例如:
template <typename Type1, typename Type2, typename Type3>
class MultiTypes
{
// 类定义
};
MultiTypes<int, double, std::string> multiType;
// Type1 是 int, Type2 是 double, Type3 是 std::string
在模板参数列表中,我们找到了形式类型名(对于 CirQue 类模板是 Data
)。它是一个形式的(类型)名称,类似于函数模板参数列表中使用的形式类型名称。
在模板头之后,定义了类的接口。类的接口可以使用在模板头中定义的形式类型名称作为类型名称。
一旦定义了 CirQue 类模板,它就可以用来创建各种类型的循环队列。由于其中一个构造函数需要一个 size_t
类型的参数,用于定义循环队列中可以存储的最大元素数量,因此循环队列可以像这样定义:
CirQue<int> cqi(10); // 最大可存储 10 个整数
CirQue<std::string> cqstr(30); // 最大可存储 30 个字符串
正如本章介绍部分所提到的,类模板的名称和为其实例化的数据类型的组合定义了一种数据类型。同样要注意,定义一个 std::vector
(存储某种数据类型)和定义一个 CirQue
(存储某种数据类型)之间的相似性。
与 std::map
容器类似,类模板也可以定义多个模板类型参数。
回到 CirQue 类模板。CirQue 必须能够存储 max_size
个 Data
元素。这些元素最终存储在由指针 Data* d_data
指向的内存中,最初指向的是原始内存。新元素被添加到 CirQue 的尾部,使用指针 Data* d_back
指向下一个将要存储元素的位置。同样地,Data* d_front
指向 CirQue 的第一个元素。两个 size_t
类型的数据成员用于监控 CirQue 的填充状态:d_size
表示当前存储在 CirQue 中的元素数量,d_maxSize
表示 CirQue 能容纳的最大元素数量。因此,CirQue 的数据成员是:
size_t d_size;
size_t d_maxSize;
Data *d_data;
Data *d_front;
Data *d_back;
非类型参数
函数模板参数可以是模板类型参数,也可以是模板非类型参数(实际上还有第三种模板参数,即模板模板参数,会在第 23 章第 23.4 节讨论)。类模板也可以定义非类型参数。与函数模板的非类型参数一样,类模板的非类型参数必须是(整型)常量,其值必须在对象实例化时已知。不过与函数模板的非类型参数不同,类模板非类型参数的值不会通过传递给类模板成员的参数由编译器推导出来。
假设我们扩展 CirQue 类模板的设计,使其定义第二个(非类型)参数 size_t Size
。我们的意图是在构造函数中使用这个 Size
参数,定义一个 Size
个 Data
类型元素的数组参数。CirQue 类模板现在变成如下形式(仅展示相关的构造函数):
template <typename Data, size_t Size>
class CirQue
{
// ... 数据成员
public:
CirQue(Data const (&arr)[Size]);
// ...
};
template <typename Data, size_t Size>
CirQue<Data, Size>::CirQue(Data const (&arr)[Size])
: d_maxSize(Size),
d_size(0),
d_data(operator new(Size * sizeof(Data))),
d_front(d_data),
d_back(d_data)
{
std::copy(arr, arr + Size, back_inserter(*this));
}
不幸的是,这种设置并不能满足我们的需求,因为模板非类型参数的值不会由编译器推导出来。当编译器被要求编译以下 main
函数时,它会报告模板参数的要求数量与实际数量不匹配:
int main() {
int arr[30];
CirQue<int> ap{arr};
}
编译器报告的错误信息为:
在函数 'int main()' 中:
错误:模板参数数量错误(1,应该是2)
错误:为 'template<class Data, size_t Size>' 类提供了错误的模板参数
将 Size
定义为具有默认值的非类型参数也不起作用。除非显式指定值,否则编译器总是使用默认值。我们可能会考虑在模板的参数类型列表中指定 size_t Size = 0
,以便除非需要其他值,否则 Size
可以是 0。但是这样做会导致默认值 0 与上述 main
函数中定义的数组 arr
的实际大小不匹配。编译器使用默认值时,会报告如下错误:
在实例化 'CirQue<int, 0>' 时:
...
错误:创建大小为零 ('0') 的数组
因此,尽管类模板可以使用非类型参数,但在定义该类的对象时,必须始终像类型参数一样明确指定它们的值。可以为这些非类型参数指定默认值,这样当非类型参数未明确指定时,编译器会使用默认值。
需要注意的是:默认模板参数值(无论是类型还是非类型模板参数)不能在定义模板成员函数时指定。通常情况下:函数模板定义(因此:类模板成员函数)不应指定默认模板(非)类型参数。如果要为类模板成员使用默认模板参数,必须在类接口中指定。
类似于函数模板的非类型参数,类模板的非类型参数的默认参数值只能指定为常量:
- 全局变量具有常量地址,可以用作非类型参数的参数。
- 局部变量和动态分配的变量的地址在源文件编译时未知,因此不能用作非类型参数的参数。
- 左值转换是允许的:如果指针被定义为非类型参数,可以指定数组名称。
- 资格转换是允许的:指向非常量对象的指针可以与定义为常量指针的非类型参数一起使用。
- 提升是允许的:当指定非类型参数的默认参数时,可以使用“窄”数据类型的常量(例如,可以在需要
int
时使用short
,在需要double
时使用long
)。 - 整型转换是允许的:如果指定了
size_t
参数,也可以使用int
。 - 变量不能用于指定模板非类型参数,因为它们的值不是常量表达式。不过使用
const
修饰符定义的变量可以使用,因为它们的值永远不会改变。
虽然我们到目前为止还未能成功定义接受数组作为参数的 CirQue 类的构造函数,但我们还有其他选择。在下一节中,将描述一种可以实现此目标的方法。
成员模板
我们之前尝试定义一个模板非类型参数,并希望通过编译器将其初始化为数组元素的数量。然而,这个尝试失败了,因为在调用构造函数时,模板的参数不会被隐式推导出来。相反,当定义类模板的对象时,必须显式指定模板参数。由于模板参数是在调用模板的构造函数之前显式指定的,因此编译器不需要进行任何推导,而是可以直接使用这些显式指定的模板参数。
相比之下,当使用函数模板时,实际的模板参数是通过调用函数时使用的参数来推导出来的。这为解决我们的问题提供了一条途径。如果将构造函数本身转换为函数模板(即让它拥有自己的模板声明),那么编译器将能够推导出非类型参数的值,而不再需要通过类模板的非类型参数来显式指定它。
类模板的成员(函数或嵌套类)如果本身也是模板,就称为成员模板。成员模板的定义与其他模板一样,包括自己的模板声明。
当将我们之前的 CirQue(Data const (&array)[Size])
构造函数转换为成员模板时,类模板的 Data
类型参数仍然可以使用,但我们必须为成员模板提供它自己的非类型参数。它在(部分展示的)类接口中的声明如下所示:
template <typename Data>
class CirQue
{
public:
template <size_t Size>
explicit CirQue(Data const (&arr)[Size]);
};
它的实现如下:
template <typename Data>
template <size_t Size>
CirQue<Data>::CirQue(Data const (&arr)[Size])
: d_size(0),
d_maxSize(Size),
d_data(static_cast<Data *>(operator new(sizeof(arr)))),
d_front(d_data),
d_back(d_data)
{
std::copy(arr, arr + Size, std::back_inserter(*this));
}
实现中使用了 STL 的 copy
算法和 back_inserter
适配器,将数组的元素插入到 CirQue
中。为了使用 back_inserter
,CirQue
必须提供两个(公共的)using
声明(参考第 18.2.2 节):
using value_type = Data;
using const_reference = value_type const &;
成员模板具有以下特点:
-
必须使用两个模板声明:首先是类模板的模板声明,然后是成员模板的模板声明;
-
普通的访问规则适用:成员模板可以被程序用来构造特定数据类型的
CirQue
对象。对于类模板来说,定义对象时必须指定数据类型。例如,要从数组int array[30]
中构造一个CirQue
对象,可以这样定义:CirQue<int> object(array);
-
任何成员(不仅仅是构造函数)都可以定义为成员模板;
-
当成员模板在其类接口下面实现时,类模板的模板声明必须位于成员模板的函数模板声明之前;
-
成员模板的实现必须指定其正确的作用域。成员模板被定义为
CirQue
类的成员,并为其正式模板参数类型Data
实例化; -
声明和实现中的模板参数名称必须相同;
-
成员模板应定义在其正确的命名空间环境中。定义类模板的文件组织应该如下所示:
namespace SomeName { template <typename Type, ...> // 类模板定义 class ClassName { ... }; template <typename Type, ...> // 非内联成员定义 ClassName<Type, ...>::member(...) { ... } } // 命名空间结束
仍然存在一个可能发生的问题。假设除了上面的成员模板之外,还定义了 CirQue<Data>::CirQue(Data const *data)
构造函数。可以定义某种(此处不再详细说明)协议,允许构造函数确定应存储在 CirQue
对象中的元素数量。当我们定义如下代码时:
CirQue<int> object{ array };
编译器将使用后者的构造函数,而不是成员模板。因为后者的构造函数比成员模板更为专门化(参见第 21.14 节)。解决此类问题的方法是将构造函数 CirQue(Data const *data)
也定义为成员模板,或者定义一个使用两个参数的构造函数,第二个参数用于定义要复制的元素数量。
在使用前者的构造函数(即成员模板)时,必须定义一个模板类型参数 Data2
。这里不能使用 Data
,因为成员模板的模板参数不允许覆盖其类的模板参数。使用 Data2
而不是 Data
可以解决这个微妙的问题。构造函数 CirQue(Data2 const *)
的声明可以出现在 CirQue
的头文件中:
template <typename Data>
class CirQue
{
template <typename Data2>
explicit CirQue(Data2 const *data);
};
下面是代码中如何选择两个构造函数来定义两个 CirQue
对象:
int main()
{
int array[30];
int *iPtr = array;
CirQue<int> ac{array}; // 调用 CirQue(Data const (&arr)[Size])
CirQue<int> acPtr{iPtr}; // 调用 CirQue(Data2 const *)
}
CirQue的构造函数和成员函数
是时候再次回到CirQue的设计和构建上来了。
类CirQue提供了各种成员函数。在构建类模板成员时,应遵循正常的设计原则。类模板的类型参数最好定义为 Type const &
,而不是 Type
,以防止不必要的大型数据结构复制。模板类构造函数应使用成员初始化器而不是在构造函数体内的成员赋值。成员函数定义最好不要在类内提供,而是放在类接口的下方。由于类模板的成员函数本身是函数模板,因此它们的定义应提供在提供类接口的头文件中。它们可以被赋予 inline
属性。
CirQue声明了几个构造函数和(公共)成员(它们的定义也提供了;所有定义都放在类接口的下方)。
以下是构造函数和析构函数:
-
explicit CirQue(size_t maxSize = 0)
:构造函数初始化一个能够存储maxSize
个Data
元素的CirQue。由于构造函数的参数提供了默认参数值,此构造函数也可以用作默认构造函数,从而允许我们定义例如CirQue的向量。构造函数将Cirque对象的d_data
成员初始化为一块原始内存,并将d_front
和d_back
初始化为d_data
。由于类模板成员函数本身是函数模板,因此它们在类模板接口外的实现必须以类模板的模板头开始。以下是CirQue(size_t)
构造函数的实现:template<typename Data> CirQue<Data>::CirQue(size_t maxSize) : d_size(0), d_maxSize(maxSize), d_data( maxSize == 0 ? 0 : static_cast<Data *>(operator new(maxSize * sizeof(Data))) ), d_front(d_data), d_back(d_data) {}
-
CirQue(CirQue<Data> const &other)
:复制构造函数没有特殊功能。它使用私有支持成员inc
来递增d_back
(见下文),并使用placement new
将其他元素的Data
复制到当前对象中。复制构造函数的实现非常简单:template<typename Data> CirQue<Data>::CirQue(CirQue<Data> const &other) : d_size(other.d_size), d_maxSize(other.d_maxSize), d_data( d_maxSize == 0 ? 0 : static_cast<Data *>(operator new(d_maxSize * sizeof(Data))) ), d_front(d_data + (other.d_front - other.d_data)) { Data const *src = other.d_front; d_back = d_front; for (size_t count = 0; count != d_size; ++count) { new(d_back) Data(*src); d_back = inc(d_back); if (++src == other.d_data + d_maxSize) src = other.d_data; } }
-
CirQue(CirQue<Data> &&tmp)
:移动构造函数仅将当前对象的d_data
指针初始化为0
并将临时对象与当前对象交换(见下面的成员swap
)。CirQue的析构函数检查d_data
并立即返回当它为零时。实现如下:template<typename Data> CirQue<Data>::CirQue(CirQue<Data> &&tmp) : d_data(0) { swap(tmp); }
-
CirQue(Data const (&arr)[Size])
:此构造函数被声明为成员模板,提供Size
非类型参数。它为Size
个数据元素分配空间,并将arr
的内容复制到新分配的内存中。实现如下:template <typename Data> template <size_t Size> CirQue<Data>::CirQue(Data const (&arr)[Size]) : d_size(0), d_maxSize(Size), d_data(static_cast<Data *>(operator new(sizeof(arr)))), d_front(d_data), d_back(d_data) { std::copy(arr, arr + Size, std::back_inserter(*this)); }
-
CirQue(Data const *data, size_t size)
:此构造函数的行为非常类似于上一个,但提供了指向第一个Data
元素的指针和一个size_t
来提供要复制的元素数量。在我们当前的设计中,此构造函数的成员模板变体被排除在设计之外。由于此构造函数的实现与前一个构造函数非常相似,因此作为练习留给读者。 -
~CirQue()
:析构函数检查d_data
成员。如果为零,则未分配任何内容,析构函数立即返回。这可能发生在两种情况下:循环队列不包含任何元素或信息被某些移动操作从临时对象中获取,并将临时对象的d_data
成员设置为零。否则,d_size
元素将通过显式调用它们的析构函数被销毁,然后将元素的原始内存返回到公共池。实现如下:template<typename Data> CirQue<Data>::~CirQue() { if (d_data == 0) return; for (; d_size--; ) { d_front->~Data(); d_front = inc(d_front); } operator delete(d_data); }
以下是Cirque的成员:
-
CirQue &operator=(CirQue<Data> const &other)
:复制赋值运算符有标准实现:template<typename Data> CirQue<Data> &CirQue<Data>::operator=(CirQue<Data> const &rhs) { CirQue<Data> tmp(rhs); swap(tmp); return *this; }
-
CirQue &operator=(CirQue<Data> &&tmp)
:移动赋值运算符也有标准实现。由于其实现仅调用swap
,因此它被定义为inline
函数模板:template<typename Data> inline CirQue<Data> &CirQue<Data>::operator=(CirQue<Data> &&tmp) { swap(tmp); return *this; }
-
void pop_front()
:从CirQue中删除d_front
指向的元素。如果CirQue为空,则抛出异常。异常作为CirQue<Data>::EMPTY
值抛出,由CirQue<Data>::Exception
枚举定义(见push_back
)。实现非常简单(显式调用被删除元素的析构函数):template<typename Data> void CirQue<Data>::pop_front() { if (d_size == 0) throw EMPTY; d_front->~Data(); d_front = inc(d_front); --d_size; }
-
void push_back(Data const &object)
:向CirQue添加另一个元素。如果CirQue已满,则抛出CirQue<Data>::FULL
异常。CirQue可能抛出的异常在其Exception
枚举中定义:enum Exception { EMPTY, FULL };
一个
object
的副本使用placement new
安装在CirQue的原始内存中,并且d_size
被递增。template<typename Data> void CirQue<Data>::push_back(Data const &object) { if (d_size == d_maxSize) throw FULL; new (d_back) Data(object); d_back = inc(d_back); ++d_size; }
-
void swap(CirQue<Data> &other)
:交换当前CirQue对象与另一个CirQue<Data>
对象;template<typename Data> void CirQue<Data>::swap(CirQue<Data> &other) { static size_t const size = sizeof(CirQue<Data>); char tmp[size]; memcpy(tmp, &other, size); memcpy(reinterpret_cast<char *>(&other), this, size); memcpy(reinterpret_cast<char *>(this), tmp, size); }
其余的公共成员全部由单行代码组成,并实现为 inline
函数模板:
-
Data &back()
:返回指向d_back
所指向元素的引用(如果CirQue为空,结果未定义):template<typename Data> inline Data &CirQue<Data>::back() { return d_back == d_data ? d_data[d_maxSize - 1] : d_back[-1]; }
-
Data &front()
:返回指向d_front
所指向元素的引用(如果CirQue为空,结果未定义):template<typename Data> inline Data &CirQue<Data>::front() { return *d_front; }
-
bool empty() const
:如果CirQue为空,则返回true
:template<typename Data> inline bool CirQue<Data>::empty() const { return d_size == 0;
}
- `bool full() const`:如果CirQue已满,则返回 `true`:
```cpp
template<typename Data>
inline bool CirQue<Data>::full() const {
return d_size == d_maxSize;
}
-
size_t size() const
:返回当前存储在CirQue中的元素数量:template<typename Data> inline size_t CirQue<Data>::size() const { return d_size; }
-
size_t maxSize() const
:返回CirQue中可存储的最大元素数量:template<typename Data> inline size_t CirQue<Data>::maxSize() const { return d_maxSize; }
最后,类有一个私有成员 inc
,返回一个在CirQue的原始内存中循环递增的指针:
template<typename Data>
Data *CirQue<Data>::inc(Data *ptr) {
++ptr;
return ptr == d_data + d_maxSize ? d_data : ptr;
}
使用CirQue对象
当类模板的对象被实例化时,只有所有实际使用到的模板成员函数的定义必须已经被编译器看到。
这种模板的特性可以进一步优化,达到将每个定义存储在一个单独的函数模板定义文件中的程度。在这种情况下,只有实际需要的函数模板定义才需要被包含。然而,这种做法几乎从未被使用。
相反,通常定义类模板的方法是先定义接口,然后在类模板的接口下面立即定义剩余的函数模板(将一些函数定义为内联函数)。
现在类CirQue已经定义好了,它可以被使用了。要使用这个类,必须为特定的数据类型实例化它的对象。在以下示例中,它被初始化为数据类型std::string:
#include "cirque.h"
#include <iostream>
#include <string>
using namespace std;
int main() {
CirQue<string> ci(4);
ci.push_back("1");
ci.push_back("2");
cout << ci.size() << ' ' << ci.front() << ' ' << ci.back() << '\n';
ci.push_back("3");
ci.pop_front();
ci.push_back("4");
ci.pop_front();
ci.push_back("5");
cout << ci.size() << ' ' << ci.front() << ' ' << ci.back() << '\n';
CirQue<string> copy(ci);
copy.pop_front();
cout << copy.size() << ' ' << copy.front() << ' ' << copy.back() << '\n';
int arr[] = {1, 3, 5, 7, 9};
CirQue<int> ca(arr);
cout << ca.size() << ' ' << ca.front() << ' ' << ca.back() << '\n';
}
这个程序产生以下输出:
2 1 2
3 3 5
2 4 5
5 1 9
类模板的默认参数
与函数模板不同,类模板的模板参数可以有默认参数值。这对于模板类型参数和非类型模板参数都适用。如果定义了默认模板参数并且在实例化类模板对象时没有为其模板参数指定值,那么将使用模板参数的默认值。
在定义默认值时,应该考虑它们是否适用于类的大多数实例化情况。例如,对于类模板CirQue
,模板的类型参数列表可以通过指定int
作为默认类型进行修改:
template <typename Data = int>
尽管可以指定默认参数,编译器仍然需要知道对象定义引用的是模板。在使用默认模板参数实例化类模板对象时,类型说明可以省略,但尖括号必须保留。假设CirQue
类有一个默认类型,则可以定义该类的一个对象,如下所示:
CirQue<> intCirQue(10);
在定义模板成员时,不能指定默认模板参数。因此,例如,push_back
成员的定义必须始终以相同的模板说明开始:
template <typename Data>
当类模板使用多个模板参数时,所有参数都可以有默认值。与函数的默认参数类似,一旦使用了默认值,所有剩余的模板参数也必须使用其默认值。模板类型说明列表不能以逗号开始,也不能包含多个连续的逗号。
类模板的声明
类模板也可以被声明。这在需要前向声明类的情况下可能会很有用。要声明一个类模板,只需去掉它的接口部分(即大括号之间的部分):
template <typename Data>
class CirQue;
在声明类模板时,也可以指定默认模板参数。然而,不能在类模板的声明和定义中同时指定默认模板参数。作为经验法则,应该在声明中省略默认模板参数,因为类模板声明在实例化对象时从未被使用,仅在少数情况下用作前向引用。需要注意的是,这与普通类中成员函数的默认参数值的指定方式不同。对于普通类,默认参数值总是在类接口中声明成员函数时指定。
防止模板实例化
在C++中,模板会在以下情况下实例化:当函数模板或类模板对象的地址被取用,或者当函数模板或类模板被使用时。如第22.1.7节所述,可以(前向)声明一个类模板,以允许定义指向该模板类的指针或引用,或将其用作返回类型。
在其他情况下,模板会在使用时实例化。如果这种情况发生得非常频繁(例如,在许多不同的源文件中),可能会显著减慢编译过程。幸运的是,C++允许程序员使用extern template
语法来防止模板实例化。例如:
extern template class std::vector<int>;
声明了类模板之后,它可以在其翻译单元中使用。例如,以下函数可以正确编译:
#include <vector>
#include <iostream>
using namespace std;
extern template class vector<int>;
void vectorUser() {
vector<int> vi;
cout << vi.size() << '\n';
}
需要注意的是:
- 仅声明并不会使类定义可用。仍需包含
vector
头文件,以便编译器了解vector
类的功能。但是,由于extern template
声明,当前编译单元中不会实例化所使用的成员。 - 编译器假设(正如它总是做的)声明的内容已经在其他地方实现。在这种情况下,编译器遇到了隐式声明:程序实际使用的
vector
类的功能并没有逐个声明,而是通过extern template
声明作为一组进行声明。这不仅适用于显式使用的成员,还包括隐藏的成员(如复制构造函数、移动构造函数、转换运算符、在提升期间调用的构造函数等):编译器假设所有这些成员已经在其他地方实例化。 - 尽管上述源文件可以编译,但在链接器构建最终程序之前,模板的实例化必须是可用的。为此,可能需要构造一个或多个源文件,其中包含所有所需的实例化。
在独立程序中,可以推迟定义所需的成员,等待链接器抱怨未解析的外部引用。然后,可以用这些未解析的外部引用创建一系列实例化声明,并将其链接到程序中以满足链接器的要求。然而,这不是一件简单的任务,因为声明必须严格匹配类接口中的成员声明。一个更简单的方法是定义一个实例化源文件,其中实际实例化了程序所需的所有功能,这些功能在程序中从未被调用。通过将这个实例化函数添加到包含main
的源文件中,可以确保所有所需的成员也会被实例化。以下是如何做到这一点的示例:
#include <vector>
#include <iostream>
extern void vectorUser();
int main() {
vectorUser();
// 这部分从未被调用。添加它是为了确保声明的模板的所有所需功能也被实例化。
namespace {
void instantiator() {
std::vector<int> vi;
vi.size();
}
}
}
最后但同样重要的是:一个完全匹配的类模板实例化声明(例如,对于std::vector<int>
)如下所示:
template class std::vector<int>;
将其添加到源文件中会实例化整个类,即所有成员现在都会被实例化。这可能不是你想要的,因为这可能不必要地增加最终可执行文件的大小。
另一方面,如果已知所需的模板成员已经在其他地方实例化,那么可以使用extern template
声明以防止当前编译单元中的成员实例化,这可能会加快编译速度。例如:
// 编译器假设 std::vector<int> 的所需成员已经在其他地方实例化
extern template class std::vector<int>;
int main() {
std::vector<int> vi(5); // 构造函数和 operator[] 不会被实例化
++vi[0];
}
通用 lambda 表达式
通用 lambda 表达式可以使用 auto
来定义其参数。当使用时,lambda 表达式会根据实际参数的类型进行实例化。由于 auto
是通用的,使用 auto
定义的不同参数可以实例化为不同的类型。以下是一个示例(假设已指定所有所需的头文件和命名空间):
#include <vector>
#include <string>
#include <iostream>
#include <numeric> // For std::accumulate
using namespace std;
int main() {
auto lambda = [](auto lhs, auto rhs) {
return lhs + rhs;
};
vector<int> values {1, 2, 3, 4, 5};
vector<string> text {"a", "b", "c", "d", "e"};
cout <<
accumulate(values.begin(), values.end(), 0, lambda) << '\n' <<
accumulate(text.begin(), text.end(), string{}, lambda) << '\n';
}
在第3到6行定义了通用 lambda 函数,并将其赋值给 lambda
标识符。然后,在第12和13行将 lambda
传递给 accumulate
。在第12行,它被实例化为处理 int
值的加法操作;在第13行,它被实例化为处理 std::string
值的加法操作:相同的 lambda 被实例化为两个完全不同的函数对象,这些对象仅在 main
函数中局部可用。
通用 lambda 表达式实际上是类模板。为了说明:上述通用 lambda 表达式的示例也可以使用以下类模板实现:
struct Lambda {
template <typename LHS, typename RHS>
auto operator()(LHS const &lhs, RHS const &rhs) const {
return lhs + rhs;
}
};
auto lambda = Lambda{};
这种身份意味着,在 lambda 表达式的参数列表中使用 auto
遵循模板参数推导规则(参见第21.4节),这些规则与 auto
通常的操作方式有所不同。
另一个扩展是通用 lambda 表达式捕获外部作用域变量的方式。以前,变量只能按值或按引用捕获。因此,一个仅支持移动构造的外部作用域变量不能按值传递给 lambda 函数。这一限制已被取消,允许从任意表达式初始化变量。这不仅允许在 lambda 引入器中进行移动初始化,还允许在通用 lambda 中初始化变量,即使它们在 lambda 表达式的外部作用域中没有相应命名的变量。在这种情况下,可以使用初始化表达式,如下所示:
auto fun = [value = 1] {
return value;
};
这个 lambda 函数(当然)返回 1:声明的捕获从初始化表达式中推导出类型,就像使用了 auto
一样。
为了使用移动初始化,应该使用 std::move
。例如,unique_ptr
仅支持移动赋值。在这里,它通过 lambda 函数返回其值:
std::unique_ptr<int> ptr(new int(10));
auto fun = [value = std::move(ptr)] {
return *value;
};
在通用 lambda 表达式中,关键字 auto
表明编译器在实例化 lambda 函数时决定使用哪些类型。因此,通用 lambda 表达式实际上是一个类模板,尽管它看起来不像一个。例如,以下 lambda 表达式定义了一个通用类模板,可以像这样使用:
char const *target = "hello";
auto lambda = [target](auto const &str) {
return str == target;
};
vector<string> vs{stringVectorFactory()};
find_if(vs.begin(), vs.end(), lambda);
这工作正常,但如果你以这种方式定义 lambda,你应该准备好处理复杂的错误消息,如果解引用的迭代器的类型和 lambda 的(隐式假定的)str
类型不匹配的话。以下是一个示例,展示了通用 lambda 表达式如何在其他通用 lambda 表达式中使用:类模板也可以用于此。在第1到9行定义了一个通用 lambda 表达式 accumulate
,定义了一个第二个参数,该参数用作函数:因此它的参数应可用作函数。一个 functor 绝对是这样的,而第二个通用 lambda 表达式 lambda
在第11到14行中提供了它。在第21行,accumulate
和 lambda
被实例化以便它们对 int
向量进行操作;在第22行,它们被实例化以处理 string
向量:
auto accumulate(auto const &container, auto function) {
auto accu = decltype(container[0]){};
for (auto &value: container)
accu = function(accu, value);
return accu;
}
auto lambda = [](auto lhs, auto rhs) {
return lhs + rhs;
};
int main() {
vector<int> values = {1, 2, 3, 4, 5};
vector<string> text = {"a", "b", "c", "d", "e"};
cout << accumulate(values, lambda) << '\n' <<
accumulate(text, lambda) << '\n';
}
在某些情况下,通用 lambda 可能过于通用,导致实现冗长,而这些实现并不总是模板所需的。考虑一个指定了 auto &it
参数的通用 lambda,但除此之外,它还应指定一个类型为 ValueType
的参数,该参数应由它的类定义。这种参数需要使用 decltype
(可能还需要使用 std::decay_t
)来检索其实际类型。在 lambda 的主体内,可以使用 using
声明来使类型可用,但这再次需要使用 std::decay_t
和 decltype
的冗长规范。以下是一个示例:
auto generic = [](auto &it, typename std::decay_t<decltype(it)>::ValueType value) {
using Type = std::decay_t<decltype(it)>;
typename Type::ValueType val2{ value };
Type::staticMember();
};
为了避免这种冗长,可以像普通模板一样定义通用 lambda 函数,在这种情况下,模板头紧随 lambda 引入器之后。使用这种替代形式,通用 lambda 的定义变得简单明了:
auto generic = []<typename Type>(Type &it, typename Type::ValueType value) {
typename Type::ValueType val2{ value };
Type::staticMember();
};
静态数据成员
当在类模板中定义静态成员时,它们会为每个新的类型实例化一个静态成员。由于这些是静态成员,所以每种类型只会有一个这样的成员。例如,在下面的类中:
template <typename Type>
class TheClass
{
static int s_objectCounter;
};
对于每种不同的 Type
规格,TheClass<Type>::s_objectCounter
只会存在一个。以下对象定义会导致只实例化一个静态变量,该变量在两个对象之间共享:
TheClass<int> theClassOne;
TheClass<int> theClassTwo;
提到静态成员在接口中并不意味着这些成员实际上已经定义。它们只是被声明了,必须在单独的地方定义。对于类模板的静态成员情况也是如此。静态成员的定义通常放在模板类接口之后。例如,静态成员 s_objectCounter
的定义如下:
template <typename Type>
int TheClass<Type>::s_objectCounter = 0;
这里,s_objectCounter
是一个 int
类型,因此与模板类型参数 Type
无关。对于相同的 Type
的多个实例化不会造成问题,因为链接器会从最终的可执行文件中删除除一个之外的所有实例化(参见第21.5节)。
在需要指向类本身的对象的列表结构中,定义静态变量时必须使用模板类型参数 Type
。示例如下:
template <typename Type>
class TheClass
{
static TheClass *s_objectPtr;
};
template <typename Type>
TheClass<Type> *TheClass<Type>::s_objectPtr = 0;
如通常所见,变量名可以从后往前读取,以理解定义的内容:s_objectPtr
是指向 TheClass<Type>
对象的指针。
当定义模板类型参数类型的静态变量时,当然不应该将初始值设置为 0。通常使用默认构造函数(例如 Type()
)会更合适。示例如下:
template <typename Type>
Type TheClass<Type>::s_type = Type();
// s_type 的定义
扩展使用 typename
关键字
到目前为止,typename
关键字已被用于指示模板类型参数。然而,它还可以用于在模板内部消除歧义。考虑以下函数模板:
template <typename Type>
Type function(Type t)
{
Type::Ambiguous *ptr;
return t + *ptr;
}
当编译器处理这段代码时,它会出现类似于以下的错误信息:
error: 'ptr' was not declared in this scope
这个错误信息看起来有些困惑,因为程序员的意图是声明一个指向类模板 Type
中定义的 Ambiguous
类型的指针。但是编译器在处理 Type::Ambiguous
时可能会有多种解释。显然,编译器无法检查 Type
本身来揭示 Type
的真实类型,因为 Type
是一个模板,Type
的实际定义还不可用。
编译器面临两种可能性:要么 Type::Ambiguous
是一个静态成员,要么它是 Type
的一个子类型。根据标准,编译器必须假设前者,因此语句:
Type::Ambiguous *ptr;
被解释为 Type::Ambiguous
乘以(现在未声明的)实体 ptr
。错误信息的原因就显而易见了:在这个上下文中,ptr
是未知的。
为了消除代码中的歧义,在模板类型参数的子类型引用时必须使用 typename
关键字。因此,上述代码应改为:
template <typename Type>
Type function(Type t)
{
typename Type::Ambiguous *ptr;
return t + *ptr;
}
类模板经常定义子类型。当这些子类型出现在模板定义中作为模板类型参数的子类型时,必须使用 typename
关键字来标识它们是子类型。示例:一个类模板 Handler
定义了 typename Container
作为其模板类型参数。它还定义了一个数据成员,存储容器的 begin
成员返回的迭代器。此外,Handler
提供了一个构造函数,接受任何支持 begin
成员的容器。Handler
的类接口如下:
template <typename Container>
class Handler
{
Container::const_iterator d_it;
public:
Handler(Container const &container)
: d_it(container.begin())
{}
};
设计这个类时我们考虑了什么?
typename Container
代表任何支持迭代器的容器。- 容器假设支持
begin
成员。 d_it(container.begin())
的初始化显然取决于模板的类型参数,因此它仅检查基本的语法正确性。- 同样,容器假设支持一个子类型
const_iterator
,定义在类Container
中。
最终考虑的一个提示是需要 typename
。如果省略 typename
,当实例化 Handler
时,编译器会产生一个奇怪的编译错误:
#include "handler.h"
#include <vector>
using namespace std;
int main()
{
vector<int> vi;
Handler<vector<int>> ph{ vi };
}
报告的错误:
handler.h:4: error: syntax error before `;' token
显然,Handler
类中的 Container::const_iterator d_it;
行引发了问题。编译器将其解释为静态成员而非子类型。使用 typename
可以解决这个问题:
template <typename Container>
class Handler
{
typename Container::const_iterator d_it;
...
};
编译器确实假定 X::a
是类 X
的成员 a
的一个说明,这一点在尝试使用以下 Handler
构造函数的实现时尤为明显:
Handler(Container const &container)
: d_it(container.begin())
{
size_t x = Container::ios_end;
}
报告的错误:
error: `ios_end' is not a member of type `std::vector<int, std::allocator<int> >'
现在考虑如果函数模板不返回 Type
值,而是返回 Type::Ambiguous
值时的情况。同样,必须使用 typename
来指明子类型:
template <typename Type>
typename Type::Ambiguous function(Type t)
{
return t.ambiguous();
}
typename
还可以嵌入到 using
声明中。这通常减少了其他地方出现的声明和定义的复杂性。在下一个示例中,Iterator
类型被定义为模板类型 Container
的一个子类型。Iterator
现在可以在没有使用 typename
关键字的情况下使用:
template <typename Container>
class Handler
{
using Iterator = Container::const_iterator;
};
为不同类型特化类模板
类 CirQue
可以用于许多不同的类型。它们的共同特征是可以简单地被类的 d_data
成员指向。但情况并非总是如此简单。如果 Data
结果是一个 vector<int>
,那么传统的 CirQue
实现可能无法使用,此时可以考虑使用特化。考虑到特化时,还应该考虑继承。通常,派生自接受不兼容数据结构作为参数的类模板的类,但其他方面与原始类模板相同,可以很容易地设计出来。继承相对于特化的开发优势很明显:继承类继承了基类的成员,而特化没有任何继承。所有由原始类模板定义的成员都必须在类模板的特化中重新实现。
这里考虑的特化是一种真正的特化,因为特化中的数据成员和表示方法与原始的 CirQue
类模板大相径庭。因此,原始类模板定义的所有成员都必须被修改以适应特化的数据组织。
像函数模板特化一样,类模板特化以模板头开始,模板头可能为空。如果模板参数被直接特化,模板参数列表保持为空(例如,CirQue
的模板类型参数 Data
被特化为 char *
数据)。但模板参数列表也可能显示为 typename Data
,当特化为 vector<Data>
时,即一个存储任意类型数据的向量。这导致以下原则:
模板特化通过函数或类模板名称后的模板参数列表来识别,而不是通过空的模板参数列表来识别。类模板特化可能有非空的模板参数列表。如果是这样,则定义了一个部分类模板特化。
完全特化的类具有以下特征:
- 完全特化的类模板必须在通用类模板定义之后提供。由于它是特化,编译器必须首先看到原始的类模板。
- 完全特化的类模板的模板参数列表为空。
- 所有类的模板参数都给出明确的类型名称或(对于非类型参数)明确的值。这些明确的参数在特化模板的类名后面紧接着插入的模板参数规范列表中提供。
- 特化类模板中的所有成员使用特化的类型和原始模板定义中使用的类型。
- 原始模板的所有成员(可能有一些构造函数的例外)应由特化重新定义。如果特化中遗漏了某个成员,则不能用于特化类模板对象。
- 特化可以定义附加成员(但可能不应该,因为这打破了原始和特化类模板之间的一对一对应关系)。
- 特化类模板的成员函数可以由特化类声明,并在类接口下方实现。如果它们的实现跟随类接口,它们可以不以模板 <> 头开始,但必须立即以成员函数的头开始。
类模板特化示例
以下是 CirQue
类的一个完全特化示例,特化为 vector<int>
。特化类的所有成员都被声明了,但只有非平凡的成员实现被提供。特化类使用传递给构造函数的 vector
的副本,并使用其 vector
数据成员实现循环队列:
#ifndef INCLUDED_CIRQUEVECTOR_H_
#define INCLUDED_CIRQUEVECTOR_H_
#include <vector>
#include "cirque.h"
template<>
class CirQue<std::vector<int>>
{
using IntVect = std::vector<int>;
IntVect d_data;
size_t d_size;
using iterator = IntVect::iterator;
iterator d_front;
iterator d_back;
public:
using value_type = int;
using const_reference = value_type const &;
enum Exception
{
EMPTY,
FULL
};
CirQue();
CirQue(IntVect const &iv);
CirQue(CirQue<IntVect> const &other);
CirQue &operator=(CirQue<IntVect> const &other);
int &back();
int &front();
bool empty() const;
bool full() const;
size_t maxSize() const;
size_t size() const;
void pop_front();
void push_back(int const &object);
void swap(CirQue<IntVect> &other);
private:
iterator inc(iterator const &iter);
};
CirQue<std::vector<int>>::CirQue()
: d_size(0)
{}
CirQue<std::vector<int>>::CirQue(IntVect const &iv)
: d_data(iv),
d_size(iv.size()),
d_front(d_data.begin()),
d_back(d_data.begin())
{}
CirQue<std::vector<int>>::CirQue(CirQue<IntVect> const &other)
: d_data(other.d_data),
d_size(other.d_size),
d_front(d_data.begin() + (other.d_front - other.d_data.begin())),
d_back(d_data.begin() + (other.d_back - other.d_data.begin()))
{}
CirQue<std::vector<int>> &CirQue<std::vector<int>>::operator=(
CirQue<IntVect> const &rhs)
{
CirQue<IntVect> tmp(rhs);
swap(tmp);
}
void CirQue<std::vector<int>>::swap(CirQue<IntVect> &other)
{
char tmp[sizeof(CirQue<IntVect>)];
memcpy(tmp, &other, sizeof(CirQue<IntVect>));
memcpy(reinterpret_cast<char *>(&other), this, sizeof(CirQue<IntVect>));
memcpy(reinterpret_cast<char *>(this), tmp, sizeof(CirQue<IntVect>));
}
void CirQue<std::vector<int>>::pop_front()
{
if (d_size == 0)
throw EMPTY;
d_front = inc(d_front);
--d_size;
}
void CirQue<std::vector<int>>::push_back(int const &object)
{
if (d_size == d_data.size())
throw FULL;
*d_back = object;
d_back = inc(d_back);
++d_size;
}
inline int &CirQue<std::vector<int>>::back()
{
return d_back == d_data.begin() ? d_data.back() : d_back[-1];
}
inline int &CirQue<std::vector<int>>::front() {
return *d_front;
}
CirQue<std::vector<int>>::iterator CirQue<std::vector<int>>::inc(
CirQue<std::vector<int>>::iterator const &iter)
{
iterator tmp(iter + 1);
tmp = tmp == d_data.end() ? d_data.begin() : tmp;
return tmp;
}
#endif
接下来的示例展示了如何使用特化的 CirQue
类:
static int iv[] = {1, 2, 3, 4, 5};
int main()
{
vector<int> vi(iv, iv + 5);
CirQue<vector<int>> ci(vi);
cout << ci.size() << ' ' << ci.front() << ' ' << ci.back() << '\n';
ci.pop_front();
ci.pop_front();
CirQue<vector<int>> cp;
cp = ci;
cout << cp.size() << ' ' << cp.front() << ' ' << cp.back() << '\n';
cp.push_back(6);
cout << cp.size() << ' ' << cp.front() << ' ' << cp.back() << '\n';
}
/*
输出:
5 1 5
3 3 5
4 3 6
*/
部分特化 偏特化
在前一节中介绍了类模板的特化。在本节中,我们将介绍这种特化的一种变体,即部分特化,涉及到模板参数的数量和类型。部分特化可以定义在具有多个模板参数的类模板上。函数模板不能被部分特化,因为部分特化的函数模板实际上只是为其参数中的某些特定类型量身定制的函数模板。由于函数模板可以重载,因此“部分特化”函数模板只意味着需要为这些特定参数类型定义重载。
部分特化是对模板类型参数的某些特定值进行专门化。也可以在意图是特化类模板时,参数化数据类型的情况下使用类模板部分特化。
以 CirQue<vector<int>>
类为例,在设计 CirQue<vector<int>>
时,你可能会问需要实现多少个特化版本。例如:vector<int>
、vector<string>
、vector<double>
?只要 vector
中的数据类型行为类似于 int
(即是值类型的类),答案是:不需要定义多个特化版本。可以通过参数化数据类型来实现部分特化:
template <typename Data>
class CirQue<std::vector<Data>>
{
// 实现...
};
上述类是特化的,因为模板参数列表附加在 CirQue
类名称后面。但由于类模板本身有一个非空的模板参数列表,它实际上被识别为部分特化。一个特征将部分特化的类模板成员函数的实现与完全特化的类模板成员函数的实现区分开来:部分特化的类模板成员函数实现需要一个模板头部。而完全特化的类模板成员函数实现则不需要模板头部。
实现 CirQue
的部分特化并不困难,留给读者作为练习(提示:只需将 int
替换为 Data
,如在前一节的 CirQue<vector<int>>
特化中)。记住要在 iterator
前加上 typename
(如 using iterator = DataVect::iterator
),如在第 22.2.1 节中讨论的那样。
在接下来的小节中,我们将专注于特化类模板的非类型模板参数。这些部分特化将使用矩阵代数中的一些简单概念进行说明,矩阵代数是线性代数的一个分支。
插曲:一些简单的矩阵代数概念
在本节中介绍一些简单的矩阵代数术语。这些术语将在下一节中用于说明和讨论类模板的部分特化。对矩阵代数熟练的读者可以跳过这一节,而不会影响理解。
矩阵通常被认为是一个由若干行和列组成的表格,填满了数字。我们可以立即看到模板的应用:这些数字可以是普通的 double
值,但也可以是复数,对于这种情况,我们的复数容器(参见第 12.5 节)可能会派上用场。因此,我们的类模板提供了一个 DataType
模板类型参数。当构造矩阵时,该参数被指定。一些使用 double
值的简单矩阵如下:
-
单位矩阵(3x3 矩阵):
1 0 0 0 1 0 0 0 1 \begin{matrix} 1 & 0 & 0 \\ 0 & 1 & 0 \\ 0 & 0 & 1 \end{matrix} 100010001 -
矩形矩阵(2x4 矩阵):
1 2 4 8 0 0.5 3.5 18 \begin{matrix} 1 & 2 & 4 & 8 \\ 0 & 0.5 & 3.5 & 18 \\ \end{matrix} 1020.543.5818 -
一行矩阵(1x4 矩阵),也称为 行向量:
1 2 4 8 \begin{matrix} 1 & 2 & 4 & 8 \\ \end{matrix} 1248
(列向量的定义类似)
矩阵上定义了各种操作,例如加法、减法或乘法。在这里,我们不关注这些操作,而是集中于一些简单的操作:计算边际和总和。
边际是矩阵行元素的和或矩阵列元素的和。这两种边际分别称为 行边际 和 列边际。
- 行边际 是通过对每一行的所有元素求和得到的,并将这些行的和放入一个对应元素为行数的(列)向量中。
- 列边际 是通过对每一列的所有元素求和得到的,并将这些列的和放入一个对应元素为列数的(行)向量中。
- 矩阵所有元素的总和当然可以通过其中一个边际元素的和来计算。
以下示例展示了一个矩阵、其边际以及其值的总和:
矩阵 行边际 1 2 3 6 4 5 6 15 列边际 5 7 9 21 ( 总和 ) \begin{matrix} & \text{矩阵} & & &{行边际}\ \\ &1 & 2 & 3 & 6 \\ &4 & 5 & 6 & 15 \\ \text{列边际}&5 & 7 & 9 & 21(总和) \text{} \end{matrix} 列边际矩阵145257369行边际 61521(总和)
矩阵类模板
我们将首先介绍一个定义矩阵的类模板。在定义了这个类模板之后,我们将继续定义几个特化版本。
由于矩阵由固定数量的行和列组成(矩阵的维度),这些维度在使用矩阵时通常不会改变,因此我们可以考虑将这些值指定为模板非类型参数。DataType = double
将在大多数情况下使用。因此,double
可以作为模板的默认类型参数。由于这是一个合理的默认值,DataType
模板类型参数在模板类型参数列表中排在最后。
我们的模板类 Matrix
的初始定义如下:
template <size_t Rows, size_t Columns, typename DataType = double>
class Matrix;
我们希望这个类模板提供什么功能呢?
-
存储矩阵元素的位置。可以定义为一个包含
Rows
行,每行包含Columns
个DataType
类型元素的数组。由于矩阵的维度是已知的,因此可以使用数组而不是指针。由于矩阵的行和列向量通常被使用,该类可以使用using-declarations
来表示它们。因此,类接口的初始部分包含:using MatrixRow = Matrix<1, Columns, DataType>; using MatrixColumn = Matrix<Rows, 1, DataType>; MatrixRow d_matrix[Rows];
-
构造函数:需要一个默认构造函数和(例如)一个从流初始化矩阵的构造函数。由于类不使用指针,因此不需要复制构造函数或移动构造函数。同样,不需要重载赋值运算符或析构函数。实现如下:
template <size_t Rows, size_t Columns, typename DataType> Matrix<Rows, Columns, DataType>::Matrix() { std::fill(d_matrix, d_matrix + Rows, MatrixRow()); } template <size_t Rows, size_t Columns, typename DataType> Matrix<Rows, Columns, DataType>::Matrix(std::istream &str) { for (size_t row = 0; row < Rows; row++) for (size_t col = 0; col < Columns; col++) str >> d_matrix[row][col]; }
-
运算符 []:类的
operator[]
成员(及其const
变体)仅处理第一个索引,返回对整个MatrixRow
的引用。MatrixRow
中元素的检索方法会稍后介绍。为了保持示例简单,没有实现数组边界检查:template <size_t Rows, size_t Columns, typename DataType> Matrix<1, Columns, DataType>& Matrix<Rows, Columns, DataType>::operator[](size_t idx) { return d_matrix[idx]; }
-
计算边际和总和:现在进入有趣的部分:计算矩阵的边际和所有元素的总和。我们将定义
MatrixColumn
类型为包含矩阵行边际的类型,MatrixRow
类型为包含矩阵列边际的类型。同时,所有元素的总和可以视为一个 1x1 矩阵。边际可以视为矩阵的特殊形式。为了表示这些边际,我们可以构造部分特化定义类模板MatrixRow
和MatrixColumn
对象,并构造一个处理 1x1 矩阵的部分特化。这些部分特化用于计算矩阵的边际和所有元素的总和。
在专注于这些部分特化之前,我们将使用它们来实现计算矩阵的边际和所有元素的总和的成员函数:
template <size_t Rows, size_t Columns, typename DataType>
Matrix<1, Columns, DataType> Matrix<Rows, Columns, DataType>::columnMarginals() const
{
return MatrixRow(*this);
}
template <size_t Rows, size_t Columns, typename DataType>
Matrix<Rows, 1, DataType> Matrix<Rows, Columns, DataType>::rowMarginals() const
{
return MatrixColumn(*this);
}
template <size_t Rows, size_t Columns, typename DataType>
DataType Matrix<Rows, Columns, DataType>::sum() const
{
return rowMarginals().sum();
}
MatrixRow 部分特化
类模板的部分特化可以为任何(子集)模板参数定义。它们可以为模板类型参数和模板非类型参数定义。我们的第一个部分特化定义了一个通用矩阵的行,主要用于构造列边际。以下是这种部分特化的设计方法:
-
部分特化开始:部分特化以一个模板头部开始,该头部定义了在部分特化中未特化的所有模板参数。这个模板头部不能指定任何默认值(例如
DataType = double
),因为默认值已经在通用类模板定义中指定了。部分特化必须跟在通用类模板定义之后,否则编译器会提示它不知道正在特化哪个类。模板头部之后是类的接口部分。由于这是一个类模板(部分)特化,所以类名后面必须跟一个模板参数列表,指定部分特化使用的模板参数。这些参数指定了模板参数的一些显式类型或值。剩余的类型则从部分特化的模板参数列表中直接复制。例如,MatrixRow
部分特化指定了通用类模板中Rows
非类型参数的值为 1(因为我们这里讨论的是单行)。Columns
和DataType
仍然需要指定。因此,MatrixRow
部分特化的定义如下:template <size_t Columns, typename DataType> // 不允许默认值 class Matrix<1, Columns, DataType>;
-
MatrixRow 的数据成员:
MatrixRow
保存单行的数据。因此,它需要一个数据成员来存储Columns
个DataType
类型的值。由于Columns
是一个常量值,d_column
数据成员可以定义为一个数组:DataType d_column[Columns];
-
构造函数:部分特化的构造函数需要特别注意。默认构造函数很简单。它仅仅使用
DataType
的默认构造函数来初始化MatrixRow
的数据元素:template <size_t Columns, typename DataType> Matrix<1, Columns, DataType>::Matrix() { std::fill(d_column, d_column + Columns, DataType()); }
另一个构造函数用于用通用矩阵对象的列边际初始化
MatrixRow
对象。这要求我们提供一个非特化的Matrix
参数。这里的规则是定义一个成员模板,使我们能够保持参数的一般性质。通用Matrix
模板需要三个模板参数,其中两个已经由模板特化提供。第三个参数在成员模板的模板头部中提到。由于该参数指的是通用矩阵的行数,它被称为Rows
。以下是第二个构造函数的实现,它使用通用矩阵对象的列边际初始化
MatrixRow
的数据:template <size_t Columns, typename DataType> template <size_t Rows> Matrix<1, Columns, DataType>::Matrix(Matrix<Rows, Columns, DataType> const &matrix) { std::fill(d_column, d_column + Columns, DataType()); for (size_t col = 0; col < Columns; col++) for (size_t row = 0; row < Rows; row++) d_column[col] += matrix[row][col]; }
这个构造函数的参数是一个
Matrix
模板的引用,使用了额外的Row
模板参数以及部分特化的模板参数。 -
成员函数:为了满足当前需求,我们不需要额外的成员。要访问
MatrixRow
的数据元素,重载operator[]()
是非常有用的。当然,const
变体的实现方式与非const
变体类似。其实现如下:template <size_t Columns, typename DataType> DataType &Matrix<1, Columns, DataType>::operator[](size_t idx) { return d_column[idx]; }
现在,我们已经定义了通用的 Matrix
类以及定义单行的部分特化。当矩阵使用 Row = 1
定义时,编译器会选择行的特化。例如:
Matrix<4, 6> matrix; // 使用通用 Matrix 模板
Matrix<1, 6> row;// 使用部分特化
MatrixColumn 部分特化
MatrixColumn
的部分特化构造方式类似于 MatrixRow
。以下是它的主要特点(完整的 Matrix
类模板定义及其所有特化可以在 C++ Annotations 的 Gitlab 网站上的 yo/classtemplates/examples/matrix.h
文件中找到):
-
部分特化模板头部:部分特化以一个模板头部开始。现在,类接口为通用类模板的第二个模板参数指定了一个固定值。这表明我们可以为每一个模板参数构造部分特化,而不仅仅是第一个或最后一个参数:
template <size_t Rows, typename DataType> class Matrix<Rows, 1, DataType>;
-
构造函数:其构造函数的实现方式与
MatrixRow
构造函数类似。构造函数的具体实现留给读者练习(可以在matrix.h
文件中找到)。 -
成员函数 sum:定义了一个额外的成员函数
sum
来计算MatrixColumn
向量元素的总和。它简单地使用std::accumulate
泛型算法来实现:template <size_t Rows, typename DataType> DataType Matrix<Rows, 1, DataType>::sum() { return std::accumulate(d_row, d_row + Rows, DataType()); }
1x1 矩阵:避免歧义
读者可能会疑问,当我们定义如下矩阵时会发生什么:
Matrix<1, 1> cell;
这会是 MatrixRow
还是 MatrixColumn
的特化?答案是:两者都不是。这是因为列和行都可以使用(不同的)模板部分特化,因此存在歧义。如果确实需要这种矩阵,还需要设计另一个专门的模板特化。
由于这个模板特化对于获取矩阵元素的总和非常有用,因此这里也涵盖了它。
-
模板头部:这个类模板部分特化也需要一个模板头部,这次只指定
DataType
。类定义指定了两个固定值:1 行和 1 列:template <typename DataType> class Matrix<1, 1, DataType>;
-
构造函数:特化定义了通常的一批构造函数。期望更通用的
Matrix
类型的构造函数再次作为成员模板实现。例如:template <typename DataType> template <size_t Rows, size_t Columns> Matrix<1, 1, DataType>::Matrix(Matrix<Rows, Columns, DataType> const &matrix) : d_cell(matrix.rowMarginals().sum()) {} template <typename DataType> template <size_t Rows> Matrix<1, 1, DataType>::Matrix(Matrix<Rows, 1, DataType> const &matrix) : d_cell(matrix.sum()) {}
-
成员函数:由于
Matrix<1, 1>
基本上是对DataType
值的封装,我们需要成员函数来访问这个值。类型转换运算符可能很有用,但如果转换运算符未被编译器使用(这发生在编译器有选择时,见第 11.3 节),我们还定义了一个get
成员函数来获取值。以下是访问器(省略了它们的 const 变体):template <typename DataType> Matrix<1, 1, DataType>::operator DataType &() { return d_cell; } template <typename DataType> DataType &Matrix<1, 1, DataType>::get() { return d_cell; }
最后,下面的 main
函数示例展示了如何使用 Matrix
类模板及其部分特化:
#include <iostream>
#include "matrix.h"
using namespace std;
int main(int argc, char **argv)
{
Matrix<3, 2> matrix(cin);
Matrix<1, 2> colMargins(matrix);
cout << "Column marginals:\n";
cout << colMargins[0] << " " << colMargins[1] << '\n';
Matrix<3, 1> rowMargins(matrix);
cout << "Row marginals:\n";
for (size_t idx = 0; idx < 3; idx++)
cout << rowMargins[idx] << '\n';
cout << "Sum total: " << Matrix<1, 1>(matrix) << '\n';
}
生成的输出(基于输入:1 2 3 4 5 6
):
Column marginals:
9 12
Row marginals:
3
7
11
Sum total: 21
变参模板
到目前为止,我们遇到的模板都定义了固定数量的模板参数。然而,模板也可以定义为变参模板,允许在实例化时传递任意数量的参数。
变参模板适用于函数模板和类模板。变参模板允许我们指定任意数量的模板参数和任意类型。
变参模板的引入是为了避免定义许多重载模板,并能够创建类型安全的变参函数。
尽管 C(和 C++)支持变参函数,但在 C++ 中,它们的使用一直被弃用,因为这些函数 notoriously 类型不安全。变参函数模板可以用于处理目前无法通过 C 风格变参函数正确处理的对象。
变参模板的模板头部使用短语 typename ...Params
(Params
是形式名称)。一个变参类模板 Variadic
可以被声明如下:
template<typename ...Params>
class Variadic;
假设类模板的定义可用,那么这个模板可以用任意数量的模板参数来实例化。例如:
class Variadic<
int,
std::vector<int>,
std::map<std::string, std::vector<int>>
> v1;
变参模板的模板参数列表也可以为空。例如:
class Variadic<> empty;
如果不希望使用空的模板参数列表,可以通过提供一个或多个固定参数来防止这种情况。例如:
template<typename First, typename ...Rest>
class tuple;
C 的函数 printf
是一个著名的类型不安全的函数。当它实现为变参函数模板时,它会变成一个类型安全的函数。这不仅将函数转换为类型安全的函数,而且它也自动扩展为接受任何可以由 C++ 定义的类型。以下是可能的变参函数模板 printcpp
的声明:
template<typename ...Params>
void printcpp(std::string const &strFormat, Params ...parameters);
在声明中使用的省略号(…)有两个作用:
- 在模板头部,它写在模板参数名称的左侧,用于声明一个参数包。参数包允许我们在实例化模板时指定任意数量的模板参数。参数包可以用于绑定类型和非类型模板参数到模板参数上。
- 在模板实现中,它出现在模板包的参数名称的右侧。在这种情况下,它表示一系列的模板参数,这些参数随后会与函数参数匹配,函数参数在省略号的右侧提供。在这里,省略号被称为展开操作符,它“展开”函数参数列表中的一系列参数,从而隐式定义了函数的参数。
C++ 没有直接访问单独模板参数的语法。然而,可以递归地访问这些参数。以下是一个示例:
template<typename ...Params>
struct StructName
{
enum: size_t { s_size = sizeof ...(Params) };
};
对于 StructName<int, char>
,s_size
被初始化为 2。
定义和使用变参模板
与变参模板参数相关的参数在函数或类模板的实现中并不可直接访问。我们必须使用其他方法来获取这些参数。
通过定义变参模板的部分特化,显式定义一个额外的模板类型参数,我们可以将参数包的第一个模板参数与这个额外(第一个)类型参数关联起来。以下是变参函数模板(例如,printcpp
,见前一节)的设置方法:
printcpp
函数至少接收一个格式字符串。格式字符串之后可以指定任意数量的附加参数。- 如果格式字符串后没有参数,则无需使用函数模板。可以定义一个重载的(非模板)函数来处理这种情况。
- 变参函数模板处理所有其他情况。在这种情况下,格式字符串后总是至少有一个参数。该参数的类型与变参模板函数的第一个(普通)模板类型参数
First
匹配。任何剩余参数的类型绑定到模板函数的第二个模板参数,即参数包。 - 变参函数模板处理紧跟格式字符串的参数。然后,它递归调用自身,传递格式字符串和参数包到递归调用中。
- 如果递归调用仅接收格式字符串,则调用重载的(非模板)函数(见第21.14节)结束递归。否则,参数包的第一个参数与递归调用的
First
参数匹配。随着递归调用的参数包的大小减少,递归最终停止。
重载的非模板函数打印格式字符串的剩余部分,同时检查是否有任何遗漏的格式说明符:
void printcpp(std::string const &format)
{
size_t left = 0;
size_t right = 0;
while (true)
{
if ((right = format.find('%', right)) == std::string::npos)
break;
if (format.find("%%", right) != right)
throw std::runtime_error("printcpp: missing arguments");
++right;
std::cout << format.substr(left, right - left);
left = ++right;
}
std::cout << format.substr(left);
}
下面是变参函数模板的实现:
template<typename First, typename ...Params>
void printcpp(std::string const &format, First value, Params ...params)
{
size_t left = 0;
size_t right = 0;
while (true)
{
if ((right = format.find('%', right)) == std::string::npos)
// 1
throw std::runtime_error("printcpp: too many arguments");
if (format.find("%%", right) != right)
break;
// 2
}
++right;
std::cout << format.substr(left, right - left);
left = ++right;
std::cout << format.substr(left, right - left) << value;
printcpp(format.substr(right + 1), params...);
}
在 1 处,格式字符串被搜索是否包含参数说明符 %
。如果未找到,则函数被调用时参数过多,抛出异常;
在 2 处,验证是否遇到 %%
。如果只见到单个 %
,则 while 循环结束,将格式字符串插入到 std::cout
中,直到 %
之后是 value
,递归调用接收格式字符串的其余部分以及剩余的参数包;
如果见到 %%
,则格式字符串被插入到第二个 %
之前,并且继续处理格式字符串。
确保在编译器处理函数模板的定义之前至少声明重载函数,否则在编译函数模板时不会调用重载的 printcpp
函数。
与 C 的 printf
函数不同,printcpp
仅识别 %
和 %%
作为格式说明符。上述实现不识别例如字段宽度的说明符。类型说明符如 %c
和 %x
当然是不必要的,因为 ostream
的插入操作符知道插入到 ostream
中的参数的类型。扩展格式说明符,以使字段宽度等被 printcpp
实现识别,留作读者练习。
下面是一个调用 printcpp
的示例:
printcpp("Hello % with %%main%% called with % args and a string showing %\n",
"world", argc, "A String"s);
完美转发
考虑 std::string
的成员函数 insert
。std::string::insert
有多个重载实现。它可以用来插入由字符串或 const char*
参数提供的文本(完全或部分);可以插入指定次数的单个字符;可以使用迭代器指定字符范围;等等。总之,std::string
提供了多达五个重载的 insert
成员函数。
假设存在一个类 Inserter
,用于将信息插入到各种对象中。这样的类可能有一个 std::string
类型的数据成员,用于插入信息。Inserter
的接口只需要部分复制 std::string
的接口以实现这一点:只需复制 std::string::insert
的接口。这样,成员函数通常只包含一条语句(调用对象的数据成员的相应成员函数),因此这些成员函数往往被内联实现。这些包装函数仅仅是将参数转发到对象的数据成员的匹配成员函数。
另一个例子是工厂函数,这些函数也经常将其参数转发到它们返回的对象的构造函数中。
C++ 通过提供完美转发来简化和泛化参数的转发,这通过右值引用和变参模板实现。使用完美转发时,传递给函数的参数会以类型安全的方式“完美地转发”到嵌套函数中。为了使用完美转发,嵌套函数必须定义与转发参数在类型和数量上匹配的参数列表。
完美转发的实现很简单:
- 转发函数定义为模板(通常是变参模板,但单参数转发也可以实现,定义单个参数时可以省略省略号);
- 转发函数的参数列表是一个右值引用参数包(例如,
Params &&...params
); - 使用
std::forward
将转发函数的参数转发到嵌套函数,同时保持参数的类型和数量。在使用std::forward
之前,必须包含<utility>
头文件; - 使用以下语法调用嵌套函数,指定其参数:
std::forward<Params>(params)...
在下面的示例中,使用完美转发实现了 Inserter::insert
成员函数,它可以调用 std::string::insert
的任何一个五个重载成员函数。实际调用的 insert
函数现在完全取决于传递给 Inserter::insert
的参数的类型和数量:
class Inserter
{
std::string d_str;
// 以某种方式初始化
public:
// 构造函数未实现,但见下文
Inserter();
Inserter(std::string const &str);
Inserter(Inserter const &other);
Inserter(Inserter &&other);
};
template<typename ...Params>
void insert(Params &&...params)
{
d_str.insert(std::forward<Params>(params)...);
}
使用完美转发的工厂函数也可以很容易地实现。与定义四个重载工厂函数相比,现在只需一个工厂函数即可。通过为工厂函数提供一个额外的模板类型参数,指定要构造的类,工厂函数变成了一个完全通用的工厂函数:
template <typename Class, typename ...Params>
Class factory(Params &&...params)
{
return Class(std::forward<Params>(params)...);
}
以下是一些使用示例:
Inserter inserter(factory<Inserter>("hello"));
std::string delimiter(factory<std::string>(10, '='));
Inserter copy(factory<Inserter>(inserter));
函数 std::forward
由标准库提供。它不进行任何魔法,而只是将 params
作为一个匿名对象返回。这样,它的行为类似于 std::move
,后者也将对象的名称移除,返回为一个匿名对象。展开操作符与 std::forward
的使用无关,它仅仅告诉编译器逐个将 forward
应用于每个参数。因此,它的行为类似于 C 的变参函数使用的省略号操作符。
完美转发在第21.4.5节中介绍:一个模板函数定义了 Type &¶m
,其中 Type
是模板类型参数,如果函数以 Tp &
类型的参数调用,则将 Type &&
转换为 Tp &
。否则,将 Type
绑定到 Tp
,param
定义为 Tp &¶m
。结果是,左值参数绑定到左值类型(Tp &
),而右值参数绑定到右值类型(Tp &&
)。
函数 std::forward
仅将参数(及其类型)传递给被调用的函数或对象。以下是其简化实现:
template <typename T>
T &&forward(T &&a)
{
return a;
}
由于 T &&
在使用左值(或左值引用)调用 forward
时转化为左值引用,而在使用右值引用调用 forward
时保持为右值引用,且 forward
(像 std::move
一样)将传递给 forward
的变量匿名化,因此参数值在将其类型从函数的参数传递到被调用函数的参数时被转发。
这称为完美转发,因为只有当调用“外部”函数(例如,工厂函数)时使用的参数的类型与嵌套函数(例如,类构造函数)的参数类型完全匹配时,嵌套函数才会被调用。因此,完美转发是实现类型安全的工具。
对 forward
的一个美观改进是要求 forward
的用户显式指定使用的类型,而不是让编译器通过函数模板参数类型推导过程来推断类型。这通过一个小的支持结构模板实现:
template <typename T>
struct identity
{
using type = T;
};
这个结构仅定义 identity::type
为 T
,但由于它是一个结构,它必须显式指定。不能从函数的参数本身确定。因此,上述 forward
的实现的微小修改变为(见第22.2.1节了解 typename
的用法):
template <typename T>
T &&forward(typename identity<T>::type &&arg)
{
return arg;
}
现在 forward
必须显式声明 arg
的类型,如:
std::forward<Params>(params)
使用 std::forward
函数和右值引用规范不限于参数包的上下文。由于右值引用到模板类型参数的特殊处理(见第21.4.5节),它们也可以有效地用于将单个函数参数转发。例如,下面的示例展示了如何将函数的参数从模板转发到作为指针传递给模板的函数:
template<typename Fun, typename ArgType>
void caller(Fun fun, ArgType &&arg)
{
fun(std::forward<ArgType>(arg));
}
函数 display(std::ostream &out)
和 increment(int &x)
现在都可以通过 caller
调用。示例:
caller(display, std::cout);
int x = 0;
caller(increment, x);
展开操作符
展开操作符用于在许多情况下获取模板参数。除了递归(如第22.5.1节所示),没有其他机制可以获取变参模板的单个类型和值。
展开操作符也可以用于定义从任意数量的基类派生的模板类。实现方法如下:
template <typename ...BaseClasses>
class Combi: public BaseClasses...
// 从基类派生
{
public:
// 使用完美转发指定基类对象
Combi(BaseClasses &&...baseClasses)
: BaseClasses(baseClasses)... // 使用基类初始化器
{}
// 为每个基类
};
这允许我们定义结合了任意数量其他类特性的类。如果类 Combi
从类 A
、B
和 C
派生,那么 Combi
也就是 A
、B
和 C
。当然,它应该有一个虚拟析构函数。Combi
对象可以传递给期望指向或引用其任意基类类型对象的函数。以下示例定义了一个 Combi
类,派生自一个复数的向量、一个字符串和一个整数对与双精度浮点数对(使用与所用 Combi
类型指定的类型序列相匹配的统一初始化器):
using MultiTypes = Combi<
std::vector<std::complex<double>>, std::string, std::pair<int, double>>;
MultiTypes mt = { {3.5, 4}, "mt", {1950, 1.72} };
相同的构造方法也可以用于定义支持变参类型列表(如元组)的模板数据成员。这样的类可以设计如下:
template <typename ...Types>
struct Multi
{
std::tuple<Types...> d_tup; // 定义用于 Types 类型的元组
Multi(Types ...types)
: d_tup(std::forward<Types>(types)...) // 从 Multi 的参数初始化 d_tup
{}
};
在转发参数包时使用省略号是至关重要的。编译器将其遗漏视为错误。在以下结构定义中,程序员意图将参数包传递给嵌套对象的构造函数,但在指定模板参数时遗漏了省略号,结果出现了“参数包未展开”错误消息:
template <int size, typename ...List>
struct Call
{
Call(List &&...list)
{
Call<size - 1, List &&> call(std::forward<List>(list)...);
}
};
程序员应该使用以下定义来代替上面的定义:
Call<size - 1, List &&...> call(std::forward<List>(list)...);
非类型变参模板
变参模板不仅可以定义模板类型,也可以使用非类型模板参数。以下函数模板接受任意数量的 int
值,并将这些值转发给一个类模板。类模板定义了一个枚举值 result
,该值由函数返回,除非没有指定 int
值,此时返回 0。
template <int ...Ints>
int forwarder()
{
return computer<Ints ...>::result; // 转发 Ints
}
template <>
// 当没有提供 int 值时的特化
int forwarder<>()
{
return 0;
}
使用示例:
cout << forwarder<1, 2, 3>() << ' ' << forwarder<>() << '\n';
sizeof
操作符也可以用于变参非类型参数:在第一个函数模板中,sizeof...(Ints)
在 forwarder<1, 2, 3>()
中将返回 3。
变参非类型参数用于定义变参字面量操作符,详见第23.3节。
折叠表达式
接受可变数量参数(可能有不同类型)的函数通常使用变参模板来处理。实现通常处理第一个参数,然后将其余参数传递给一个重载函数,这些重载函数由编译器为这些剩余参数类型定义。其中一个重载版本(接受零个或一个参数)结束了编译器的递归实现。
有时,参数必须使用二元操作符(如 arg1 + arg2 + ...
)进行组合。在这种情况下,可以使用折叠表达式来代替传统的变参模板组合参数。
所有二元操作符(包括赋值、复合赋值和逗号操作符)都可以在折叠表达式中使用。
-
一元左折叠 是一种折叠表达式,形式如下:
(... op expr)
其中,
op
是在折叠表达式中使用的二元操作符,expr
是基于函数参数表示的变参表达式。例如:template <typename ReturnType, typename ...Params> ReturnType sum(Params ...params) { return (... + params); }
如果使用比变参标识符更复杂的表达式,则必须明确地将表达式括起来,例如:
return (... + (2 * params));
在一元折叠表达式中,所有与参数包类型匹配的函数参数都使用指定的操作符进行组合。例如,
sum<int>(1, 2, 3);
返回 6。对于使用折叠表达式的函数模板,没有特殊的限制。可以直接返回折叠表达式的结果,但结果也可以在其他表达式中使用(例如将其值插入到
ostream
中),或用于初始化变量或对象。此外,参数的类型不必完全相同:唯一的要求是完全展开的表达式(例如1 + 2 + 3
)必须是有效的表达式。 -
一元右折叠 是一种折叠表达式,结果与其一元左折叠替代形式相同,但将省略号和
params
标识符交换:(expr op ...)
一元左折叠和一元右折叠统称为一元折叠。
-
二元折叠 是以下形式的折叠表达式:
(expr1 op ... op expr2)
这里,
expr1
是表示可变参数的标识符,expr2
必须是那个标识符。另一个表达式可以是任何有效的表达式(如一元折叠,两个表达式必须明确界定)。此外,两个操作符必须相同。如果二元操作符被重载,它将会在适用的地方被使用。一个著名的例子是
operator<<
在std::ostream
对象上的定义。对于operator<<
定义的二元折叠,不仅可以进行移位操作,还可以将一系列参数插入到cout
中:template <class ...Pack> ostream &out2(ostream &out, Pack &&...args) { return (out << ... << args); };
另一个有趣的例子是逗号操作符的折叠表达式:
template <class ...Pack> void call(Pack &&...args) { (... , args()); };
这个一元折叠会依次调用每个参数。参数可以是函数地址、函数对象或 Lambda 函数。请注意,在定义变参列表时使用了右值引用,以防止对可能传递给
call
的函数对象进行复制。假设struct Functor
定义了一个函数对象,并且定义了void function()
,那么可以这样调用call
:Functor functor; call(functor, function, functor, []() { // ... } );
最后:不要忘记围绕折叠表达式的括号:它们是必需的!
元组
C++ 提供了一种通用的对偶容器:元组(tuple),在本节中将介绍。在使用元组之前,必须包含头文件 <tuple>
。
与 std::pair
容器只有两个成员且功能有限不同,元组具有稍多的功能,可以包含任意数量的不同数据类型。在这方面,元组可以看作是模板对 C 的结构体(struct)的回应。
元组的通用声明(和定义)使用变参模板语法:
template <class ...Types>
class tuple;
以下是其用法的示例:
using tuple_idsc = std::tuple<int, double &, std::string, char const *>;
double pi = 3.14;
tuple_idsc idsc(59, pi, "hello", "fixed");
// 访问字段:
std::get<2>(idsc) = "hello world";
std::get<idx>(tupleObject)
函数模板返回对元组 tupleObject
的第 idx
个(零基)字段的引用。索引作为函数模板的非类型模板参数指定。
基于类型的元组地址可以用于元组定义中仅使用一次的元组类型(如果同一类型被重复使用,引用该类型会引入歧义)。下一个示例展示了如何按类型引用上面示例中的元素:
get<int>(idsc) // 59
get<double &>(idsc) // 3.14
get<std::string>(idsc) // "hello"s
get<char const *>(idsc) // "fixed"
元组可以在不指定初始值的情况下构造。基本类型字段被初始化为零;类类型字段通过其默认构造函数初始化。需要注意的是,在某些情况下,元组的构造可能成功,但使用可能会失败。考虑以下示例:
tuple<int &> empty;
cout << get<0>(empty);
在这里,元组 empty
不能被使用,因为它的 int &
字段是未定义的引用。然而,empty
的构造是成功的。
如果两个元组的类型列表相同,它们可以相互赋值;如果其组成类型支持拷贝构造函数,则也可以进行拷贝构造和赋值。如果右侧的类型可以转换为匹配的左侧类型,或者左侧的类型可以从匹配的右侧类型构造,则元组(数量和(可转换)类型匹配)可以使用关系操作符进行比较。在这方面,元组类似于对偶(pair)。
元组提供以下静态元素(使用编译时初始化):
std::tuple_size<Tuple>::value
返回元组类型Tuple
定义的类型数量。例如:cout << tuple_size<tuple_idsc>::value << '\n'; // 显示:4
std::tuple_element<idx, Tuple>::type
返回Tuple
的第idx
个元素的类型。例如:tuple_element<2, tuple_idsc>::type text; // 定义 std::string 类型的 text
展开操作符也可以用于将构造函数的参数转发到元组数据成员。考虑一个定义为变参模板的类 Wrapper
:
template <typename ...Params>
class Wrapper
{
// ...
public:
Wrapper(Params &&...params);
};
这个类可以有一个元组数据成员,该数据成员应该由初始化 Wrapper
类对象时使用的类型和值初始化,使用完美转发。类似于一个类可以从其模板类型继承(见第 22.5.3 节),它可以将其类型和构造函数参数转发到其元组数据成员:
template <typename ...Params>
class Wrapper
{
std::tuple<Params...> d_tuple; // 与 Wrapper 本身使用的相同类型
public:
Wrapper(Params &&...params)
:
// 使用 Wrapper 的参数初始化 d_tuple
d_tuple(std::forward<Params>(params)...)
{}
};
元组与结构化绑定
结构化绑定在第 3.3.7.1 节中介绍了。这一节集中在将结构化绑定声明与结构体的数据成员关联,作为 POD 值返回。然而,结构化绑定还可以以更通用的方式使用,通过将它们与元组(tuple)关联来实现。通过这种方式,结构化绑定不必仅仅与数据成员关联,它们还可以持有由类成员返回的值。
结构化绑定声明与元组之间的关联非常强。事实上,这种关联如此强大,以至于标准明确允许用户定义元组的特化,即使元组特化存在于标准命名空间中,这通常是程序员无法直接修改的(当然,使用其功能除外)。
为了使结构化绑定能够与类成员关联,需要执行以下步骤:
-
提供
get
成员模板:- 类必须提供重载的
get
成员模板,使用int
(或其他整型)特化,每个特化返回一个类元素(例如,一个成员函数)。if constexpr
子句的可用性使得将所有这些特化组合到一个成员模板中变得容易。 - 另外,还可以定义一个在类外的函数模板,允许将结构化绑定与类成员关联,即使你不是该类的作者。在这种情况下,函数定义了一个
ClassType [cv] &object
参数。
- 类必须提供重载的
-
提供
std::tuple_size<Type>
的特化:- 提供一个
std::tuple_size<Type>
的特化,定义static size_t const value
为可以与get<idx>
函数指定的索引值的数量。虽然在标准命名空间中定义实体通常是禁忌的,但在这种特殊情况下,C++ 标准允许这样的特化。
- 提供一个
-
提供
std::tuple_element<idx, Type>
的特化:- 提供一个
std::tuple_element<idx, Type>
的特化,定义type
为匹配get<idx>
返回类型的类型。
- 提供一个
通过以这种方式使用结构化绑定,可以获得巨大的灵活性。只要类提供返回值的成员,这些成员就可以与结构化绑定变量关联。成员函数甚至不必返回立即可用的值(例如,作为数据成员访问器),其返回值也可以在引用相应的结构化绑定变量时动态计算。
为了说明如何将结构化绑定与类成员关联,我们使用以下类定义:
class Euclid
{
size_t d_x;
size_t d_y;
public:
Euclid(size_t x = 0, size_t y = 0);
double distance() const; // sqrt(d_x * d_x + d_y * d_y)
};
class Data
{
std::string d_text{ "hello from Data" };
Euclid d_euclid;
public:
void setXY(size_t x, size_t y);
Euclid factory() const;
double distance() const;
std::string const &text() const;
};
第一步:为 Data
类定义一个(成员)模板 get
。如果 Data
是我们自己的类,我们可以添加一个成员模板 get
。如果我们只对访问 Data
的公共成员感兴趣,我们可以从 Data
继承出一个类 DataGet
,并为该类提供 get
成员模板。另一种可能性是定义一个自由的 get
函数模板。get
函数必须返回我们感兴趣的内容。为了做出适当的选择,我们使用整型(int
, size_t
, …)选择值,该函数模板因此只有一个非类型模板参数。建议使用 if constexpr
子句来定义多个特化,这大大简化了函数的定义。
我们的 get
函数定义了选择器 0 对应 factory
,选择器 1 对应 distance
,选择器 2 对应 text
。distance
成员函数仅返回 d_euclid.distance()
,并且 Euclid::distance
是在运行时评估的,使用 d_x
和 d_y
值。因此,distance
是一个例子,在稍后引用第三个结构化绑定变量时会在运行时评估。
以下是 get
成员模板的定义:
template <size_t Nr>
auto get() const
{
if constexpr (Nr == 0)
return factory();
if constexpr (Nr == 1)
return distance();
if constexpr (Nr == 2)
return text();
static_assert(Nr >= 0 && Nr < 3);
}
这个函数仍然不够理想。考虑它对值 2 的特化:它返回 Data::text()
。由于 auto
仅检查返回的数据类型,get<2>()
返回一个 std::string
,而不是 text
的返回类型,即 std::string const &
。为了使用 Data
的成员函数实际返回的类型,get
的返回类型应定义为 decltype(auto)
,而不仅仅是 auto
:
template <size_t Nr>
decltype(auto) get() const
{
if constexpr (Nr == 0)
return factory();
if constexpr (Nr == 1)
return distance();
if constexpr (Nr == 2)
return text();
static_assert(Nr >= 0 && Nr < 3);
}
当将 get
定义为自由函数模板时,必须提供一个参数 Data const &data
(如果成员函数可能会修改数据的成员,则为 Data &data
),返回参数的成员函数。例如:
// 定义为自由函数模板的 get:
template <size_t Nr>
decltype(auto) get(Data const &data)
{
if constexpr (Nr == 0)
return data.factory();
if constexpr (Nr == 1)
return data.distance();
if constexpr (Nr == 2)
return data.text();
}
第二步:关注 std::tuple
特化。这些特化定义在 std
命名空间内部(使用 namespace std { ... }
或 std::tuple...
)。
对于 Data
的 std::tuple_size
特化定义 static size_t const value
为可以使用 get<idx>
函数指定的索引值的数量:
template<>
struct std::tuple_size<Data>
{
static size_t const value = 3;
};
对于 Data
的 std::tuple_element
特化返回与 get
成员模板各种返回类型匹配的类型。它的实现也提供了一个很好的示例,其中使用了 declval
:get<Nr>
特化的返回类型必须确定。但是,为了获得该返回类型,需要一个 Data
对象,而仅提到 Data
并不能获得该对象。然而,declval<Data>()
定义了一个右值引用,可以传递给 get<Nr>
。但函数的返回值并不需要,所以对象不必构造。只需要它的返回类型。因此,通过将 get<Nr>
调用包围在 decltype
中,不会构造对象,仅使用其返回类型:
template<size_t Nr>
struct std::tuple_element<Nr, Data>
{
using type = decltype(declval<Data>().get<Nr>());
// 如果 get<Nr> 是自由函数,则使用:
// using type = decltype(get<Nr>(declval<Data>()));
};
由于 tuple_size
和 tuple_element
直接与 Data
类相关,它们的定义应该放在 Data
的头文件中,在 Data
类接口的下方。
以下是如何在 main
函数中使用这些定义,展示了单个对象访问和使用范围基于循环的访问:
int main()
{
Data data;
auto &[ ef, dist, txt ] = data;
// 或者:
// auto &&[ ef, dist, txt ] = Data{};
cout << dist << ' ' << txt << '\n';
Data array[5];
for (auto &[ ef, dist, txt]: array)
cout << "for: " << dist << ' ' << txt << '\n';
}
计算函数对象的返回类型
如第 19 章详细说明的那样,函数对象在泛型算法中扮演了重要角色。类似于泛型算法,函数对象也可以被定义为类模板的成员。如果这些类的函数调用运算符(operator()
)定义了参数,那么这些参数的类型也可以通过将函数调用运算符本身定义为成员模板来抽象化。示例如下:
template <typename Class>
class Filter
{
Class obj;
public:
template <typename Param>
Param operator()(Param const ¶m) const
{
return obj(param);
}
};
模板类 Filter
是一个包裹类 Class
的包装器,通过自己的函数调用运算符过滤 Class
的函数调用运算符。在上面的例子中,Class
的函数调用运算符的返回值被直接传递,但当然也可以进行其他操作。
指定为 Filter
的模板类型参数的类型可能有多个函数调用运算符:
struct Math
{
int operator()(int x);
double operator()(double x);
};
现在,Math
对象可以使用 Filter<Math> fm
进行过滤,具体使用 Math
的第一个或第二个函数调用运算符,取决于实际的参数类型。使用 fm(5)
时会使用 int
版本,使用 fm(12.5)
时会使用 double
版本。
然而,如果函数调用运算符具有不同的返回类型和参数类型,这种方案就不适用了。因此,以下类 Convert
不能与 Filter
一起使用:
struct Convert
{
double operator()(int x); // int 转 double
std::string operator()(double x); // double 转 string
};
可以使用 std::result_of<Functor(Typelist)>
类模板来成功解决这个问题。在使用 std::result_of
之前,必须包含头文件 <functional>
。
result_of
类模板提供了一个 using
声明(type
),表示由 Functor<TypeList>
返回的类型。可以按如下方式使用 result_of
来改进 Filter
的实现:
template <typename Class>
class Filter
{
Class obj;
public:
template <typename Arg>
typename std::result_of<Class(Arg)>::type
operator()(Arg const &arg) const
{
return obj(arg);
}
};
使用此定义,可以构造 Filter<Convert> fc
。现在,fc(5)
返回一个 double
,而 fc(4.5)
返回一个 std::string
。
Convert
类必须定义其函数调用运算符及其返回类型之间的关系。预定义的函数对象(如标准模板库中的那些)已经这样做了,但自定义函数对象必须显式地进行此操作。
如果一个函数对象类仅定义了一个函数调用运算符,它可以通过 using
声明指定其返回类型。如果上述 Convert
类只定义了两个函数调用运算符中的第一个,那么在类的 public
部分的 using
声明应为:
using type = double;
如果定义了多个函数调用运算符,每个运算符都有自己的签名和返回类型,则按以下方式设置签名与返回类型之间的关联(都在类的 public
部分):
-
定义一个通用的
result
结构体,如下所示:template <typename Signature> struct result;
-
对于每个函数调用签名,定义一个
result
的特化。例如,Convert
的第一个函数调用运算符产生:template <typename Class> struct result<Class(int)> { using type = double; };
Convert
的第二个函数调用运算符产生:template <typename Class> struct result<Class(double)> { using type = std::string; };
-
在函数调用运算符有多个参数的情况下,规格应再次提供正确的签名。例如,带有
int
和double
参数并返回size_t
的函数调用运算符得到:template <typename Class> struct result<Class(int, double)> { using type = size_t; };
实例化类模板
类模板在定义类模板的对象时被实例化。当定义或声明类模板对象时,模板参数必须明确指定。当指定默认模板参数值时,也需要指定模板参数(参见第 22.4 节,其中 double
被用作模板的 DataType
参数的默认类型)。模板参数的实际值或类型从不通过参数进行推断,这与函数模板参数不同。因此,要定义一个复杂值元素的矩阵,使用以下语法:
Matrix<3, 5, std::complex> complexMatrix;
由于类模板 Matrix
使用了默认数据类型,可以像这样定义一个双精度值元素的矩阵:
Matrix<3, 5> doubleMatrix;
可以使用 extern
关键字来声明类模板对象。例如,要声明 complexMatrix
矩阵,可以使用:
extern Matrix<3, 5, std::complex> complexMatrix;
一个类模板声明足以编译返回值或参数为类模板类型的函数。例如,下面的源文件可以编译,尽管编译器没有看到 Matrix
类模板的定义。泛型类和(部分)特化都可以被声明。一个期望或返回类模板对象、引用或参数的函数会自动成为一个函数模板。这是必要的,以允许编译器根据可能传递给函数的各种实际参数类型来调整函数:
#include <cstddef>
template <size_t Rows, size_t Columns, typename DataType = double>
class Matrix;
template <size_t Columns, typename DataType>
class Matrix<1, Columns, DataType>;
Matrix<1, 12> *function(Matrix<2, 18, size_t> &mat);
当使用类模板时,编译器必须首先看到它们的实现。因此,模板成员函数必须在模板实例化时为编译器所知。这并不意味着模板类的所有成员都必须在定义类模板对象时实例化或被看到。编译器只会实例化实际使用的成员。以下是一个简单的类 Demo
的示例,它有两个构造函数和两个成员函数。当我们在 main
中使用一个构造函数并调用一个成员函数时,观察结果对象文件和可执行程序的大小。接下来,我们修改类定义,将未使用的构造函数和成员函数注释掉。然后再次编译和链接程序。现在观察这些大小是否与前一个相同。还有其他方法可以证明只有使用的成员才会被实例化。可以使用 nm
程序,它显示对象文件的符号内容。使用 nm
我们会得出相同的结论:只有实际使用的模板成员函数会被实例化。以下是用于我们小实验的类模板 Demo
。在 main
中仅调用了第一个构造函数和第一个成员函数,因此只实例化了这些成员:
#include <iostream>
template <typename Type>
class Demo
{
Type d_data;
public:
Demo();
Demo(Type const &value);
void member1();
void member2(Type const &value);
};
template <typename Type>
Demo<Type>::Demo()
:
d_data(Type())
{}
template <typename Type>
void Demo<Type>::member1()
{
d_data += d_data;
}
// 注释掉以下成员,然后再次编译:
template <typename Type>
Demo<Type>::Demo(Type const &value)
:
d_data(value)
{}
template <typename Type>
void Demo<Type>::member2(Type const &value)
{
d_data += value;
}
int main()
{
Demo<int> demo;
demo.member1();
}
处理类模板和实例化
在第 21.13 节中介绍了依赖于模板参数的代码和不依赖于模板参数的代码之间的区别。这种区别在定义和使用类模板时也适用。
不依赖于模板参数的代码在模板定义时由编译器验证。如果类模板中的成员函数使用了 qsort
函数,那么 qsort
不依赖于模板参数。因此,在编译器遇到 qsort
的函数调用时,编译器必须已经读取了 <cstdlib>
头文件。在实践中,这意味着编译器在能够编译类模板定义之前必须先读取 <cstdlib>
头文件。
另一方面,如果模板定义了一个 <typename Ret>
模板类型参数来参数化某个模板成员函数的返回类型,如下所示:
Ret member();
则编译器可能会在以下位置遇到 member
或其所属的类:
-
类模板对象定义的位置。这称为类模板对象的实例化点。在这个点,编译器必须已经读取了类模板的实现,并且对成员函数如
member
的语法正确性进行了基本检查。编译器不会接受像Ret && *member
这样的定义或声明,因为 C++ 不支持返回对右值引用的指针的函数。此外,编译器还会检查用于实例化对象的实际类型名称是否有效。这个类型名称必须在对象的实例化点为编译器所知。 -
模板成员函数的使用位置。这称为模板成员函数的实例化点。在这里,
Ret
参数必须已经被指定(或推导)并且在这一点上,依赖于Ret
模板参数的member
的语句会检查其语法正确性。例如,如果member
包含如下语句:Ret tmp(Ret(), 15);
从原则上讲,这是一个语法上有效的语句。然而,当
Ret = int
时,这条语句无法编译,因为int
没有一个接受两个int
参数的构造函数。请注意,当编译器实例化成员函数的类对象时,这不是问题。在对象的实例化点,其成员函数member
并未被实例化,因此无效的int
构造仍然不会被检测到。
声明友元
友元函数通常被构建为类的支持(自由)函数,这些函数不能被实现为类的成员。输出流的插入操作符是一个众所周知的例子。友元类通常在嵌套类的上下文中出现。此时,内部类将外部类声明为其友元(或者反过来)。在这种情况下,我们可以看到一种支持机制:内部类是为了支持外部类而构造的。
与普通类一样,类模板也可以声明其他函数和类为其友元。相反,普通类也可以声明模板类为其友元。在这里,友元被构建为一个特殊的函数或类,用于增强或支持声明类的功能。尽管friend
关键字可以由任何类型的类(普通类或模板类)使用,但在使用类模板时,应区分以下几种情况:
-
类模板可以声明普通函数或类为其友元。这是一种常见的友元声明,比如用于
ostream
对象的插入操作符。 -
类模板可以声明另一个函数模板或类模板为其友元。在这种情况下,友元的模板参数可能需要被指定。
- 如果友元的模板参数的实际值必须等于声明友元的类的模板参数,则该友元被称为绑定友元类或函数模板。在这种情况下,指定友元声明的模板的模板参数决定了友元类或函数的模板参数的值。绑定友元导致模板的参数与友元的模板参数之间的一一对应关系。
-
在最一般的情况下,类模板可以声明另一个函数模板或类模板为其友元,而不管友元的实际模板参数是什么。
- 在这种情况下,声明的是未绑定的友元类或函数模板。友元类或函数模板的模板参数仍然需要指定,并且与声明友元的类的模板参数没有预定义的关系。如果一个类模板有各种类型的数据成员,这些类型由其模板参数指定,并且需要让另一个类可以直接访问这些数据成员,那么我们希望在指定友元时,可以指定任何当前的模板参数。与其指定多个绑定友元,不如声明一个泛型(未绑定)的友元,仅在需要时指定友元的实际模板参数。
-
上述情况下声明模板为友元的情况,也可能在使用普通类时遇到:
-
声明普通友元的普通类已经在第15章中讨论过。
-
当普通类在声明其友元时指定了具体的模板参数时,相当于绑定友元。
-
当普通类声明一个泛型模板为其友元时,相当于未绑定友元。
-
模板中使用普通类或函数作为友元
类模板可以声明普通函数、普通成员函数或普通类为其友元。这样的友元可以访问类模板的私有成员。
具体类和普通函数可以被声明为友元,但在声明类的单个成员函数为友元之前,编译器必须先看到声明该成员的类的接口。我们来考虑几种不同的情况:
-
类模板可以声明一个普通函数为其友元。然而,为什么我们要声明一个普通函数为友元并不是完全清楚。通常我们会将声明友元的类的对象传递给这样的函数。对于类模板,这要求我们为(友元)函数提供模板参数而不指定其类型。由于语言不支持像
void function(std::vector<Type> &vector)
这样的构造,除非该函数本身是模板,所以不太清楚如何以及为什么要构造这样的友元。一个原因可能是允许该函数访问类的私有静态成员。此外,这样的友元可以实例化将其声明为友元的类的对象。这将允许友元函数直接访问这些对象的私有成员。例如:template <typename Type> class Storage { friend void basic(); static size_t s_time; std::vector<Type> d_data; public: Storage(); }; template <typename Type> size_t Storage<Type>::s_time = 0; template <typename Type> Storage<Type>::Storage() {} void basic() { Storage<int>::s_time = time(0); Storage<double> storage; std::sort(storage.d_data.begin(), storage.d_data.end()); }
-
声明普通类为类模板的友元可能有更多的应用。在这种情况下,普通的(友元)类可以实例化类模板的任何类型的对象。然后,友元类可以访问实例化的类模板的所有私有成员:
template <typename Type> class Composer { friend class Friend; std::vector<Type> d_data; public: Composer(); }; class Friend { Composer<int> d_ints; public: Friend(std::istream &input); }; inline Friend::Friend(std::istream &input) { std::copy(std::istream_iterator<int>(input), std::istream_iterator<int>(), back_inserter(d_ints.d_data)); }
-
另外,可以只声明普通类的单个成员函数为友元。这要求编译器在声明友元之前读取友元类的接口。省略必需的析构函数和重载的赋值运算符,下面是一个类的示例,其中的成员
sorter
被声明为Composer
类的友元:template <typename Type> class Composer; class Friend { Composer<int> *d_ints; public: Friend(std::istream &input); void sorter(); }; template <typename Type> class Composer { friend void Friend::sorter(); std::vector<Type> d_data; public: Composer(std::istream &input) { std::copy(std::istream_iterator<int>(input), std::istream_iterator<int>(), back_inserter(d_data)); } }; inline Friend::Friend(std::istream &input) : d_ints(new Composer<int>{input}) {} inline void Friend::sorter() { std::sort(d_ints->d_data.begin(), d_ints->d_data.end()); }
在这个示例中,
Friend::d_ints
是一个指针成员。它不能是Composer<int>
对象,因为当编译器读取Friend
的类接口时,还没有看到Composer
类的接口。如果忽略这一点并定义数据成员Composer<int> d_ints
,则会导致编译器生成错误:error: field `d_ints' has incomplete type
“不完全类型”的错误是因为编译器在这一点上知道
Composer
类的存在,但由于它还没有看到Composer
的接口,所以不知道d_ints
数据成员的大小。
将特定类型实例化的模板作为友元
当友元类或函数模板与类模板之间存在一对一的模板参数映射时,友元本身也是模板。在这种情况下,可以有以下几种情况:
-
函数模板作为类模板的友元。在这种情况下,我们不会遇到将普通函数声明为类模板友元时遇到的问题。由于友元函数模板本身就是模板,因此可以为其提供所需的模板参数,使其成为声明该类的友元。这些声明的组织如下:
- 声明绑定模板友元函数的类模板被定义;
- (友元)函数模板被定义,并且现在可以访问类模板的所有(私有)成员。
绑定模板友元声明在模板函数名后立即指定所需的模板参数列表。如果没有附加模板参数列表,它将仍然是普通的友元函数。以下是一个显示如何创建字典条目子集的绑定友元的示例。对于实际的例子,返回
!key1.find(key2)
的专用函数对象可能更有用。对于当前的示例,使用operator==
是可以接受的:template <typename Key, typename Value> class Dictionary { friend Dictionary<Key, Value> subset<Key, Value>(Key const &key, Dictionary<Key, Value> const &dict); std::map<Key, Value> d_dict; public: Dictionary(); }; template <typename Key, typename Value> Dictionary<Key, Value> subset(Key const &key, Dictionary<Key, Value> const &dict) { Dictionary<Key, Value> ret; std::remove_copy_if(dict.d_dict.begin(), dict.d_dict.end(), std::inserter(ret.d_dict, ret.d_dict.begin()), std::bind2nd(std::equal_to<Key>(), key)); return ret; }
-
将完整的类模板声明为类模板的友元。在这种情况下,友元类的所有成员可以访问声明该友元的类的所有私有成员。由于只需要声明友元类,因此声明的组织比声明函数模板为友元时要容易得多。以下示例中,
Iterator
类被声明为Dictionary
类的友元。因此,Iterator
能够访问Dictionary
的私有数据。这里有几个需要注意的有趣点:-
为了将类模板声明为友元,友元类只需要在被声明为友元之前被声明为类模板:
template <typename Key, typename Value> class Iterator; template <typename Key, typename Value> class Dictionary { friend class Iterator<Key, Value>;
-
然而,即使编译器尚未看到友元类的接口,友元类的成员也可能已经被使用:
template <typename Key, typename Value> template <typename Key2, typename Value2> Iterator<Key2, Value2> Dictionary<Key, Value>::begin() { return Iterator<Key, Value>(*this); } template <typename Key, typename Value> template <typename Key2, typename Value2> Iterator<Key2, Value2> Dictionary<Key, Value>::subset(Key const &key) { return Iterator<Key, Value>(*this).subset(key); }
-
当然,编译器最终必须看到友元类的接口。由于它是
Dictionary
的支持类,因此可以安全地定义一个由友元类的构造函数初始化的std::map
数据成员。构造函数然后可以访问Dictionary
的私有数据成员d_dict
:template <typename Key, typename Value> class Iterator { std::map<Key, Value> &d_dict; public: Iterator(Dictionary<Key, Value> &dict) : d_dict(dict.d_dict) {}
Iterator
成员begin
可以返回一个映射迭代器。由于编译器不知道映射实例化的样子,map<Key, Value>::iterator
是一个模板子类型。因此,它不能直接使用,而必须以typename
为前缀(请参见下一个示例中的begin
函数的返回类型):template <typename Key, typename Value> typename std::map<Key, Value>::iterator Iterator<Key, Value>::begin() { return d_dict.begin(); }
-
-
在上一个示例中,我们可能决定只有
Dictionary
才能构造Iterator
(可能因为我们概念上认为Iterator
是Dictionary
的子类型)。这很容易通过在Iterator
的私有部分定义其构造函数,并将Dictionary
声明为Iterator
的友元来实现。因此,只有Dictionary
可以创建Iterator
。通过声明Iterator
的构造函数为Dictionary
特定类型的友元,我们声明了一个绑定友元。这确保只有特定类型的Dictionary
可以使用与其自身模板参数相同的模板参数创建Iterator
。以下是实现方式:template <typename Key, typename Value> class Iterator { friend Dictionary<Key, Value>::Dictionary(); std::map<Key, Value> &d_dict; Iterator(Dictionary<Key, Value> &dict); public:
在这个示例中,
Dictionary
的构造函数是Iterator
的友元。该友元是一个模板成员。其他成员也可以被声明为类的友元。在这些情况下,必须使用它们的原型,同时指定它们返回值的类型。假设std::vector<Value> sortValues()
是Dictionary
的成员函数,那么匹配的绑定友元声明为:friend std::vector<Value> Dictionary<Key, Value>::sortValues();
-
类模板可以定义自由成员函数,这些函数应能够访问类模板实例化的数据,但仅限于匹配类型的情况。例如,一个类模板可能需要使自由成员
operator==
可用。如果这是一个要求模板ClassTemplate
,它需要一个typename Type
模板类型参数,那么在类模板接口之前,必须先声明自由成员:template<typename Type> bool operator==(ClassTemplate<Type> const &lhs, ClassTemplate<Type> const &rhs);
然后,在类接口中,可以将
operator==
声明为友元,指定operator==
为专用函数模板(注意函数名后面的<>
):template <typename Type> class ClassTemplate { friend bool operator==<>(ClassTemplate<Type> const &lhs, ClassTemplate<Type> const &rhs); ... };
现在类已经声明,可以紧接着实现
operator==
。 -
最后,以下示例可以作为绑定友元有用情况的原型:
template <typename T> // 一个函数模板 void fun(T *t) { t->not_public(); }; template<typename X> // 一个自由成员函数模板 bool operator==(A<X> const &lhs, A<X> const &rhs); template <typename X> // 一个类模板 class A { // fun() 被用作绑定到 A 的友元 // 对于 X 的实例化,不管 X 是什么 friend void fun<A<X>>(A<X> *); // operator== 仅作为 A<X> 的友元 friend bool operator==<>(A<X> const &lhs, A<X> const &rhs); int d_data = 10; public: A(); private: void not_public(); }; template <typename X> A<X>::A() { fun(this); } template <typename X> void A<X>::not_public() {} template<typename X> // 可以访问 lhs/rhs 的私有数据 bool operator==(A<X> const &lhs, A<X> const &rhs) { return lhs.d_data == rhs.d_data; } int main() { A<int> a; fun(&a); // fun 为 A<int> 实例化 }
嵌套类的自由运算符作为友元
当在类模板中嵌套类时,可能希望为该嵌套类提供自由运算符,这些运算符绑定到外围类。这个情况通常出现在定义嵌套迭代器时,使用自由运算符 operator==
优于成员函数 operator==(iterator const &rhs) const
,因为成员函数不允许 lhs
操作数的提升。
可以在嵌套类中将自由运算符 operator==
声明为友元,这样它自动成为绑定友元。例如:
#include <string>
template <typename Type>
struct String {
struct iterator {
std::string::iterator d_iter;
friend bool operator==(iterator const &lhs, iterator const &rhs) {
return lhs.d_iter == rhs.d_iter;
}
};
iterator begin() {
return iterator{};
}
};
int main() {
String<int> str;
return str.begin() == str.begin();
}
然而,这要求在类内实现,应该避免这种做法,因为它将接口和实现结合在一起,降低了代码的清晰度。
但当我们将实现移出接口时,会遇到 operator==
是函数模板的问题,必须在类接口中将其声明为函数模板。编译器会建议确保函数模板已经声明,并在函数名后加上 <>
。但是,当你在 String
类模板之前声明:
template <typename Type>
bool operator==(Type const &lhs, Type const &rhs);
然后像这样实现 operator==
:
template <typename Type>
inline bool operator==(String<Type>::iterator const &lhs,
String<Type>::iterator const &rhs) {
return lhs.d_iter == rhs.d_iter;
}
编译器仍然会报错,提示将 operator==
声明为非函数。
那么如何解决这个问题呢?有两种已知的方法可以解决这个问题。一种是由Radu Cosma(我们2021-2022学年C++课程的助教)提出的,另一种解决方案见于23.13.7.2节。
Radu的建议
Radu 提出了一个 SFINAE(Substitution Failure Is Not An Error)应用的解决方案:通过定义一个自由运算符,该运算符提供一个由嵌套类唯一定义的类型,并在自由函数的实现中为其提供一个默认值,编译器会自动选择适当的重载函数。这适用于多个声明嵌套类的类以及定义多个嵌套类的类。以下是两个类分别声明嵌套类迭代器的示例:
template <typename Type>
struct String {
struct iterator {
using StringType_iterator = int;
friend bool operator==<>(iterator const &lhs, iterator const &rhs);
std::string::iterator d_iter;
};
iterator begin() {
return iterator{};
}
};
template <typename Type>
struct Container {
struct iterator {
using ContainerType_iterator = int;
friend bool operator==<>(iterator const &lhs, iterator const &rhs);
int *d_ptr;
};
iterator begin() {
return iterator{};
}
};
注意,嵌套类将自由函数声明为函数模板的特化。
然后,为每个嵌套类提供自由运算符的实现。这些实现是提供模板类型 Type
的函数模板,以及第二个类型,即嵌套类的唯一命名的 int
类型,用于实现自由运算符:
template <typename Type, Type::StringType_iterator = 0>
inline bool operator==(Type const &lhs, Type const &rhs) {
return lhs.d_iter == rhs.d_iter;
}
template <typename Type, Type::ContainerType_iterator = 0>
inline bool operator==(Type const &lhs, Type const &rhs) {
return lhs.d_ptr == rhs.d_ptr;
}
main
函数展示了这两个自由运算符的使用:
int main() {
String<int> str;
Container<int> cnt;
return str.begin() == str.begin() && cnt.begin() == cnt.begin();
}
未绑定的模板作为友元
当友元被声明为未绑定友元时,它只是声明了一个现有模板作为其友元(无论它是如何实例化的)。这种方式在某些情况下非常有用,例如友元需要能够实例化声明该友元的类模板的对象,从而使友元能够访问实例化对象的私有成员。函数、类和成员函数都可以声明为未绑定友元。
以下是声明未绑定友元的语法约定:
声明函数模板为未绑定友元
声明一个函数模板为未绑定友元意味着该函数模板的任何实例化都可以实例化类模板对象并访问其私有成员。假设定义了以下函数模板:
template <typename Iterator, typename Class, typename Data>
Class &ForEach(Iterator begin, Iterator end, Class &object,
void (Class::*member)(Data &));
这个函数模板可以在以下类模板 Vector2
中声明为未绑定友元:
template <typename Type>
class Vector2 : public std::vector<std::vector<Type>> {
template <typename Iterator, typename Class, typename Data>
friend Class &ForEach(Iterator begin, Iterator end, Class &object,
void (Class::*member)(Data &));
...
};
如果函数模板定义在某个命名空间内,那么该命名空间也必须在友元声明中提到。假设 ForEach
定义在命名空间 FBB
中,那么其友元声明应如下:
template <typename Iterator, typename Class, typename Data>
friend Class &FBB::ForEach(Iterator begin, Iterator end, Class &object,
void (Class::*member)(Data &));
以下示例演示了未绑定友元的使用。类 Vector2
存储了模板类型参数 Type
的元素的向量。它的 process
成员允许 ForEach
调用其私有成员 rows
。rows
成员反过来又使用另一个 ForEach
来调用其私有成员 columns
。因此,Vector2
使用了两个 ForEach
的实例化,这清楚地表明了使用未绑定友元的需求。假设 Type
类对象可以插入到 ostream
对象中(ForEach
函数模板的定义可以在 https://fbb-git.gitlab.io/cppannotations/ 提供的 cplusplus.yo.zip 文件中找到)。以下是程序:
template <typename Type>
class Vector2 : public std::vector<std::vector<Type>> {
using iterator = typename Vector2<Type>::iterator;
template <typename Iterator, typename Class, typename Data>
friend Class &ForEach(Iterator begin, Iterator end, Class &object,
void (Class::*member)(Data &));
public:
void process();
private:
void rows(std::vector<Type> &row);
void columns(Type &str);
};
template <typename Type>
void Vector2<Type>::process() {
ForEach<iterator, Vector2<Type>, std::vector<Type>>(
this->begin(), this->end(), *this, &Vector2<Type>::rows);
}
template <typename Type>
void Vector2<Type>::rows(std::vector<Type> &row) {
ForEach(row.begin(), row.end(), *this, &Vector2<Type>::columns);
std::cout << '\n';
}
template <typename Type>
void Vector2<Type>::columns(Type &str) {
std::cout << str << " ";
}
using namespace std;
int main() {
Vector2<string> c;
c.push_back(vector<string>(3, "Hello"));
c.push_back(vector<string>(2, "World"));
c.process();
return 0;
}
生成的输出:
Hello Hello Hello
World World
类似地,声明一个完整的类模板为友元
这允许友元类的所有实例化都可以实例化声明友元的类模板的对象。在这种情况下,声明友元的类应提供对其友元类的不同实例化有用的功能(例如,使用不同模板参数的实例化)。语法约定与声明未绑定的函数模板作为友元时类似:
template <typename Type>
class PtrVector {
template <typename Iterator, typename Class>
friend class Wrapper; // 未绑定的友元类
};
现在,类模板 Wrapper
的所有成员都可以使用其 Type
参数的任何实际类型实例化 PtrVector
。这允许 Wrapper
实例化访问 PtrVector
的所有私有成员。
当仅某些类模板的成员需要访问另一个类模板的私有成员时
例如,另一个类模板有私有构造函数,而只有某些第一个类模板的成员需要实例化第二个类模板的对象,则第二个类模板可以声明只有那些需要访问其私有成员的第一个类模板的成员为友元。同样,友元类的接口可以不指定。然而,必须告知编译器友元成员的类确实是一个类。因此,必须提供该类的前向声明。下面的例子中,PtrVector
声明 Wrapper::begin
为其友元。注意 Wrapper
类的前向声明:
template <typename Iterator>
class Wrapper;
template <typename Type>
class PtrVector {
template <typename Iterator>
friend PtrVector<Type> Wrapper<Iterator>::begin(Iterator const &t1);
...
};
扩展的友元声明
通过扩展的友元声明(也适用于类模板),模板类型参数可以被声明为友元。然而,模板类型参数不一定必须是一个适合使用 friend
关键字的类型,比如 int
。在这些情况下,友元声明将被简单地忽略。
考虑以下类模板,该模板声明 Friend
为友元:
template <typename Friend>
class Class {
friend Friend;
void msg(); // 私有成员,显示一些消息
};
现在,一个实际的 Friend
类可以访问 Class
的所有成员:
class Concrete {
Class<Concrete> d_class;
Class<std::string> d_string;
public:
void msg() {
d_class.msg(); // 可以:调用私有的 Class<Concrete>::msg()
// d_string.msg(); // 编译失败:msg() 是私有的
}
};
类似地,声明 Class<int> intClass
也是可以的,但在这种情况下,友元声明将被简单地忽略。毕竟,没有“int
成员”可以访问 Class<int>
的私有成员。
类模板的继承
类模板也可以用于继承。在类模板被用于类继承时,应区分以下几种情况:
- 一个已有的类模板被用作基类来派生一个普通类。派生类本身在一定程度上仍然是一个类模板,但在定义派生类的对象时,这一点被部分隐藏了。
- 一个已有的类模板被用作基类来派生另一个类模板。在这种情况下,类模板的特性仍然非常明显。
- 一个普通类被用作基类来派生一个模板类。这种有趣的混合体允许我们构建部分已编译的类模板。
这些类模板继承的三种变体将在本节和接下来的章节中详细阐述。
考虑以下基类:
template<typename T>
class Base {
T const &t;
public:
Base(T const &t);
};
上述类是一个类模板,可以用作以下派生类模板 Derived
的基类:
template<typename T>
class Derived: public Base<T> {
public:
Derived(T const &t);
};
template<typename T>
Derived<T>::Derived(T const &t)
: Base<T>(t) {}
其他组合也是可能的。基类可以通过指定模板参数来实例化,从而将派生类变成普通类(以下显示了一个类对象的定义):
class Ordinary: public Base<int> {
public:
Ordinary(int x);
};
inline Ordinary::Ordinary(int x)
: Base(x) {}
Ordinary ordinary{ 5 };
这种方法允许我们向类模板添加功能,而无需构建派生类模板。
类模板的继承基本上遵循与普通类继承相同的规则,不涉及类模板。然而,一些类模板继承的特定细微差别可能容易引起混淆,例如在从派生类中调用模板基类的成员时使用 this
的情况。关于使用 this
的原因在第23.1节中进行了讨论。在接下来的章节中,将重点介绍类继承的具体内容。
从类模板派生普通类
当一个现有的类模板作为基类用于派生一个普通类时,类模板参数在定义派生类接口时被指定。如果在某个上下文中,现有的类模板缺乏特定功能,那么可以考虑从类模板派生一个普通类。例如,虽然 std::map
可以很容易地与 find_if()
泛型算法结合使用,但它需要创建一个类和至少两个额外的函数对象。如果这被认为是过多的开销,那么可以考虑通过扩展类模板来实现量身定制的功能。
例如,一个程序可以执行从键盘输入的命令,并接受所有唯一的命令缩写。例如,命令列表可以输入为 l
、li
、lis
或 list
。通过从 map<string, void (Handler::*)(string const &cmd)>
派生一个类 Handler
并定义一个成员函数 process(string const &cmd)
来实际处理命令,可以使程序简单地执行以下 main()
函数:
int main() {
string line;
Handler cmd;
while (getline(cin, line)) {
cmd.process(line);
}
}
类 Handler
本身是从 std::map
派生的,其中 map
的值是指向 Handler
成员函数的指针,这些函数期望处理用户输入的命令行。Handler
的特点如下:
-
类的派生:该类从
std::map
派生,期望每个命令的关联值是处理这些命令的成员函数的指针。由于Handler
仅仅使用map
来定义命令和处理函数之间的关联,并使map
的类型可用,因此使用了私有继承:class Handler : private std::map<std::string, void (Handler::*)(std::string const &cmd)>
-
静态数据成员:实际的关联可以使用静态私有数据成员定义:
s_cmds
是一个Handler::value_type
数组,s_cmds_end
是一个常量指针,指向数组最后一个元素的后面:static value_type s_cmds[]; static value_type *const s_cmds_end;
-
构造函数:构造函数简单地从这两个静态数据成员初始化
map
。它可以以内联方式实现:inline Handler::Handler() : std::map<std::string, void (Handler::*)(std::string const &cmd)>(s_cmds, s_cmds_end) {}
-
成员函数
process
:process
成员函数迭代map
的元素。一旦命令行中的第一个词与命令的初始字符匹配,执行相应的命令。如果找不到匹配的命令,则输出错误消息:void Handler::process(std::string const &line) { istringstream istr(line); string cmd; istr >> cmd; for (iterator it = begin(); it != end(); it++) { if (it->first.find(cmd) == 0) { (this->*it->second)(line); return; } } cout << "Unknown command: " << line << '\n'; }
从类模板派生类模板
虽然可以从类模板派生普通类,但这样做的结果相比于模板基类的通用性会受到限制。如果通用性很重要,派生一个类模板通常是更好的选择。这可以让我们扩展现有的类模板,添加新功能或重写现有功能。
下面是一个示例,其中模板类 SortVector
是从现有的类模板 std::vector
派生的。这个类允许我们使用任何数据成员的排序标准对其元素进行分层排序。为了实现这一点,SortVector
的数据类型必须提供用于比较其成员的专用成员函数。例如,如果 SortVector
的数据类型是 MultiData
对象,则 MultiData
应实现以下原型的成员函数:
bool (MultiData::*)(MultiData const &rhv) const;
假设 MultiData
有两个数据成员 int d_value
和 std::string d_text
,并且这两个成员都可以用于分层排序,那么 MultiData
应提供以下两个成员函数:
bool intCmp(MultiData const &rhv) const; // 返回 d_value < rhv.d_value
bool textCmp(MultiData const &rhv) const; // 返回 d_text < rhv.d_text
此外,假设 MultiData
对象已经定义了 operator<<
和 operator>>
。
SortVector
类模板直接从 std::vector
模板类派生。我们的实现继承了基类的所有成员。它还提供了两个简单的构造函数:
template <typename Type>
class SortVector: public std::vector<Type>
{
public:
SortVector() {}
SortVector(Type const *begin, Type const *end)
: std::vector<Type>(begin, end) {}
};
它的成员函数 hierarchicSort
是该类的真正用途。它定义了分层排序的标准。它需要一个指向成员函数指针数组的指针,以及一个表示数组大小的 size_t
。
数组的第一个元素表示 Type
的最重要排序标准,数组的最后一个元素表示类的最不重要排序标准。由于 stable_sort
泛型算法被明确设计为支持分层排序,因此该成员函数使用该算法来排序 SortVector
的元素。由于分层排序中,最不重要的标准应首先排序,因此 hierarchicSort
的实现很简单。它需要一个支持类 SortWith
,其对象通过传递给 hierarchicSort
成员的成员函数的地址进行初始化:
template <typename Type>
void SortVector<Type>::hierarchicSort(
bool (Type::**arr)(Type const &rhv) const, size_t n)
{
while (n--)
stable_sort(this->begin(), this->end(), SortWith<Type>(arr[n]));
}
SortWith
类是一个简单的包装类,用于包装一个谓词函数的指针。由于它依赖于 SortVector
的实际数据类型,因此 SortWith
也必须是一个类模板:
template <typename Type>
class SortWith
{
bool (Type::*d_ptr)(Type const &rhv) const;
public:
SortWith(bool (Type::*ptr)(Type const &rhv) const)
: d_ptr(ptr) {}
bool operator()(Type const &lhv, Type const &rhv) const
{
return (lhv.*d_ptr)(rhv);
}
};
以下示例可以嵌入到 main
函数中,用于演示:
-
创建一个
SortVector
对象来处理MultiData
对象。使用copy
泛型算法将SortVector
对象填充从标准输入流输入的信息。初始化对象后,将其元素显示到标准输出流中:SortVector<MultiData> sv; copy(istream_iterator<MultiData>(cin), istream_iterator<MultiData>(), back_inserter(sv));
-
初始化一个指向成员函数的指针数组,其中
textCmp
是最重要的排序标准:bool (MultiData::*arr[])(MultiData const &rhv) const = { &MultiData::textCmp, &MultiData::intCmp, };
-
对数组的元素进行排序并将结果显示到标准输出流中:
sv.hierarchicSort(arr, 2);
-
然后交换成员函数指针数组中的两个元素,并重复上一步:
swap(arr[0], arr[1]); sv.hierarchicSort(arr, 2);
编译程序后,执行以下命令:
echo a 1 b 2 a 2 b 1 | a.out
将产生以下输出:
a 1 b 2 a 2 b 1
====
a 1 a 2 b 1 b 2
====
a 1 b 1 a 2 b 2
从普通类派生类模板
普通类可以作为基类,用于派生类模板。这种继承结构的优势在于基类的成员可以预先编译。当类模板的对象实例化时,只有实际使用的派生类模板的成员才需要被实例化。
这种方法可以用于所有成员函数的实现与模板参数无关的类模板。这些成员可以在一个单独的类中定义,然后作为类模板的基类。
以下是这种方法的一个示例。我们将开发一个从普通类 TableType
派生的类模板 Table
。类 Table
显示某种类型的元素,具有可配置的列数。元素可以水平显示(前 k 个元素占据第一行)或垂直显示(前 r 个元素占据第一列)。在将表的元素插入流时,表由一个单独的类(TableType
)处理,该类实现表的展示。由于表的元素被插入到流中,文本(或字符串)的转换在 Table
中实现,而字符串的处理留给 TableType
。
让我们先看看 Table
的接口特征:
-
类
Table
是一个类模板,只需要一个模板类型参数:Iterator
,它指向某种数据类型的迭代器:template <typename Iterator> class Table: public TableType {
-
Table
不需要任何数据成员。所有数据操作都是由TableType
执行的。 -
Table
有两个构造函数。构造函数的前两个参数是用于迭代要输入到表中的元素的迭代器。构造函数要求我们指定希望表具有的列数以及填充方向。FillDirection
是一个由TableType
定义的枚举,具有HORIZONTAL
和VERTICAL
的值。为了允许Table
的用户控制标题、页脚、标题、水平和垂直分隔符,一个构造函数有一个TableSupport
的引用参数。TableSupport
类将在后续阶段开发,作为一个虚拟类,允许客户端控制这些内容。构造函数如下所示:Table(Iterator const &begin, Iterator const &end, size_t nColumns, FillDirection direction); Table(Iterator const &begin, Iterator const &end, TableSupport &tableSupport, size_t nColumns, FillDirection direction);
-
这两个构造函数是
Table
唯一的公共成员。两个构造函数都使用基类初始化器初始化其TableType
基类,然后调用类的私有成员fill
将数据插入到TableType
基类对象中。构造函数的实现如下:template <typename Iterator> Table<Iterator>::Table(Iterator const &begin, Iterator const &end, TableSupport &tableSupport, size_t nColumns, FillDirection direction) : TableType(tableSupport, nColumns, direction) { fill(begin, end); } template <typename Iterator> Table<Iterator>::Table(Iterator const &begin, Iterator const &end, size_t nColumns, FillDirection direction) : TableType(nColumns, direction) { fill(begin, end); }
-
类的
fill
成员遍历由构造函数的前两个参数定义的元素范围[begin, end)
。如后面所见,TableType
定义了一个受保护的数据成员std::vector<std::string> d_string
。对迭代器指向的数据类型的要求是,该数据类型可以插入到流中。因此,fill
使用一个ostringstream
对象获取数据的文本表示,然后将其附加到d_string
:template <typename Iterator> void Table<Iterator>::fill(Iterator it, Iterator const &end) { while (it != end) { std::ostringstream str; str << *it++; d_string.push_back(str.str()); } }
这完成了类 Table
的实现。请注意,这个类模板只有三个成员,其中两个是构造函数。因此,在大多数情况下,只需实例化两个函数模板:一个构造函数和类的 fill
成员。例如,以下代码定义了一个具有四列、从标准输入流中提取的垂直填充的表:
Table<istream_iterator<string>> table(istream_iterator<string>(cin), 4, TableType::VERTICAL);
填充方向被指定为 TableType::VERTICAL
。它也可以使用 Table
来指定,但由于 Table
是一个类模板,因此其规范会稍微复杂一些:
Table<istream_iterator<string>>::VERTICAL;
现在我们已经设计了派生类 Table
,让我们关注类 TableType
。它的基本特征如下:
-
它是一个普通类,旨在作为
Table
的基类。 -
它使用各种私有数据成员,其中包括
d_colWidth
,一个存储每列宽度的向量,以及d_indexFun
,指向返回元素table[row][column]
的类成员函数,具体取决于表的填充方向。TableType
还使用TableSupport
指针和引用。构造函数不需要TableSupport
对象时,使用TableSupport *
来分配(默认)TableSupport
对象,然后使用TableSupport &
作为对象的别名。另一个构造函数将指针初始化为 0,并使用引用数据成员来引用其参数提供的TableSupport
对象。或者,也可以使用静态TableSupport
对象来初始化前一个构造函数中的引用数据成员。其余私有数据成员可能是自解释的:TableSupport *d_tableSupportPtr; TableSupport &d_tableSupport; size_t d_maxWidth; size_t d_nRows; size_t d_nColumns; WidthType d_widthType; std::vector<size_t> d_colWidth; size_t (TableType::*d_widthFun)(size_t col) const; std::string const &(TableType::*d_indexFun)(size_t row, size_t col) const;
-
存储在表中的实际字符串对象存储在受保护的数据成员中:
std::vector<std::string> d_string;
-
受保护的构造函数执行基本任务:它们初始化对象的数据成员。以下是一个期望引用
TableSupport
对象的构造函数:#include "tabletype.ih" TableType::TableType(TableSupport &tableSupport, size_t nColumns, FillDirection direction) : d_tableSupportPtr(0), d_tableSupport(tableSupport), d_maxWidth(0), d_nRows(0), d_nColumns(nColumns), d_widthType(COLUMNWIDTH), d_colWidth(nColumns), d_widthFun(&TableType::columnWidth), d_indexFun(direction == HORIZONTAL ? &TableType::hIndex : &TableType::vIndex) {}
-
一旦
d_string
被填充,表就通过Table::fill
进行初始化。受保护的成员init
调整d_string
的大小,使其大小恰好为rows x columns
,并确定每列的最大宽度。其实现很简单:#include "tabletype.ih" void TableType::init() { if (!d_string.size()) return; // 没有元素,什么也不做 d_nRows = (d_string.size() + d_nColumns - 1) / d_nColumns; d_string.resize(d_nRows * d_nColumns); // 强制完整表 // 确定每列的最大宽度 for (size_t col = 0; col < d_nColumns; col++) { size_t width = 0; for (size_t row = 0; row < d_nRows; row++) { size_t len = stringAt(row, col).length(); if (width < len) width = len; } d_colWidth[col] = width; } }
-
公共成员
insert
用于通过插入运算符(operator<<
)将Table
插入到流中。首先,它通知TableSupport
对象表的维度。接下来,它展示表,允许TableSupport
对象编写标题、页脚和分隔符:#include "tabletype.ih" ostream &TableType::insert(ostream &ostr) const { if(!d_nRows) return ostr; d_tableSupport.setParam(ostr, d_nRows, d_colWidth, d_widthType == EQUALWIDTH ? d_maxWidth : 0); for (size_t row = 0; row < d_nRows; row++) { d_tableSupport.hline(row); for (size_t col = 0; col < d_nColumns; col++) { size_t colwidth = width(col); d_tableSupport.vline(col); ostr << setw(colwidth) << stringAt(row, col); d_tableSupport.vline(); } d_tableSupport.hline(); } return ostr;
- cplusplus.yo.zip 压缩包包含了 TableSupport 的完整实现。该实现位于 yo/classtemplates/examples/table 目录中。它的大部分成员是私有的。在这些私有成员中,以下两个成员分别用于返回水平填充表格和垂直填充表格的 [row][column] 元素:
inline std::string const &TableType::hIndex(size_t row, size_t col) const { return d_string[row * d_nColumns + col]; } inline std::string const &TableType::vIndex(size_t row, size_t col) const { return d_string[col * d_nRows + row]; }
TableSupport
类用于显示表格的标题、页脚、标题和分隔符。它具有四个虚拟成员函数:
hline(size_t rowIndex)
:在显示rowIndex
行的元素之前调用。hline()
:在显示最后一行后立即调用。vline(size_t colIndex)
:在显示colIndex
列的元素之前调用。vline()
:在显示所有元素的一行后立即调用。
有关完整的 Table
、TableType
和 TableSupport
类的实现,请参见 cplusplus.yo.zip
压缩包中的 yo/classtemplates/examples/table
目录。大部分剩余成员都是私有的。
下面是一个示例程序,展示了这些类的使用:
#include <iostream>
#include <string>
#include <iterator>
#include <sstream>
#include "tablesupport/tablesupport.h"
#include "table/table.h"
using namespace std;
using namespace FBB;
int main(int argc, char **argv)
{
size_t nCols = 5;
if (argc > 1)
{
istringstream iss(argv[1]);
iss >> nCols;
}
istream_iterator<string> iter(cin);
Table<istream_iterator<string>> table(iter, istream_iterator<string>(), nCols,
argc == 2 ? TableType::VERTICAL : TableType::HORIZONTAL);
cout << table << '\n';
}
示例生成的输出:
示例 1:`echo a b c d e f g h i j | demo 3`
a e i
b f j
c g
d h
示例 2:`echo a b c d e f g h i j | demo 3 h`
a b c
d e f
g h i
j
静态多态性
第14章介绍了多态性。多态性使我们能够使用基类的接口来调用在派生类中定义的实现。传统上,这涉及为多态类定义虚表(Vtable),其中包含指向可以在派生类中被重写的函数的指针。多态类的对象会隐式地包含指向其类虚表的指针。这种类型的多态性被称为动态多态性,它使用了晚期绑定(late binding),即在运行时而不是编译时决定要调用哪个函数。
然而,在许多情况下,动态多态性实际上并不是必需的。通常,传递给期望基类引用的函数的派生类对象是固定的:在程序中的固定位置使用固定的类类型来创建对象。这些对象的多态性在接收这些对象的函数内部被使用,函数期望的是基类的引用。举个例子,考虑从网络套接字读取信息。一个类 SocketBuffer
是从 std::streambuf
派生的,std::stream
接收指向 SocketBuffer
的指针时,实际上只是使用 std::streambuf
的接口。然而,实际上,通过使用多态性,std::stream
的实现与 SocketBuffer
中定义的函数进行通信。
这种方案的缺点是,首先,在期望多态基类引用的函数内部,执行速度会因为晚期绑定而有所下降。成员函数不是直接调用,而是通过对象的虚指针和其派生类的虚表间接调用。其次,使用动态多态性的程序相比使用静态绑定的程序往往会变得臃肿。这种代码臃肿是由于在链接时需要满足所有提及的引用,这要求链接器链接所有在所有多态类的虚表中存储地址的函数,即使这些函数实际上从未被调用过。
静态多态性允许我们避免这些缺点。它可以作为动态多态性的替代方案,适用于上述不变条件成立的情况。静态多态性,也称为“奇异递归模板模式”(CRTP),是模板元编程的一个示例(参见第23章以获取更多模板元编程的示例)。
虽然动态多态性基于虚指针、虚表和函数重写的概念,静态多态性则利用了函数模板(即成员模板)仅在实际调用时编译成可执行代码的事实。这允许我们编写代码,其中调用的函数在编写代码时实际上是不存在的。然而,这不应该过于担忧。毕竟,我们在调用抽象基类的纯虚函数时也使用了类似的方法。该函数确实会被调用,但最终调用哪个函数是在稍后的时间确定的。使用动态多态性时,它是在运行时确定的,而使用静态多态性时,它是在编译时确定的。
不必将静态多态性和动态多态性视为互相排斥的多态性变体。实际上,可以将它们结合使用,充分发挥各自的优点。
本节包含几个子部分:
- 首先,介绍并说明了静态多态性的语法;
- 接下来,展示如何将使用动态多态性的类转换为使用静态多态性的类;
- 最后,说明静态多态性如何用于减少实现工作量。
静态多态性允许我们仅实现一次,而不是在仅使用动态多态性时重复实现。
静态多态性的示例
在静态多态性中,类模板充当动态多态性中的基类角色。这个类模板声明了几个成员,这些成员类似于多态基类的成员:它们要么是支持成员,要么是调用在派生类中重写的成员。在动态多态性的上下文中,这些可重写的成员是基类的虚拟成员。在静态多态性的上下文中,没有虚拟成员。相反,静态多态基类(下文称为“基类”)声明了一个模板类型参数(下文称为“派生类类型”)。接下来,基类的接口成员调用派生类类型的成员。
下面是一个简单的示例:一个作为基类的类模板。它的公共接口包含一个成员。但不同于动态多态性的是,类的接口中没有任何显示多态行为的成员(即,没有声明‘virtual’成员):
template <class Derived>
struct Base
{
void interface();
};
让我们详细看看成员 interface
。这个成员由接收基类的引用或指针的函数调用,但它可能调用在派生类类型中必须可用的成员。在调用派生类类型的成员之前,必须先有一个派生类类型的对象。这个对象通过继承获得。派生类类型将从基类继承。因此,Base 的 this 指针也是 Derived 的 this 指针。
暂时忘记多态性:当我们有一个类 Derived: public Base
时(由于继承),可以使用 static_cast<Derived *>
将 Base *
转换为 Derived
对象。由于我们不使用动态多态性,因此不适用 dynamic_cast
。但由于 Base *
实际上指向的是 Derived
类对象,因此 static_cast
是合适的。
因此,为了从 interface
内部调用 Derived
类成员,我们可以使用以下实现(记住 Base 是 Derived 的基类):
template<class Derived>
void Base<Derived>::interface()
{
static_cast<Derived *>(this)->polymorphic();
}
值得注意的是,当编译器得到这个实现时,它无法确定 Derived 是否真的从 Base 派生。也无法确定 Derived 类是否确实提供了成员 polymorphic
。编译器仅假设这是真的。如果是,那么提供的实现就是语法上正确的。使用模板的一个关键特性是实现的可行性最终在函数的实例化点确定(参见第21.6节)。在那时,编译器会验证函数 polymorphic
是否确实可用。
因此,为了使用上述方案,我们必须确保:
- 派生类类型实际上是从基类派生的;
- 派生类类型定义了成员
polymorphic
。
第一个要求通过使用奇异递归模板模式来满足:
class First: public Base<First>
在这种奇异模式中,类 First
从 Base
派生,而 Base
本身是为 First
实例化的。这是可以接受的,因为编译器已经确定了 First
类型的存在。在这一点上,这就是编译器所需要的全部。
第二个要求通过定义成员 polymorphic
来满足。在第14章中我们看到,虚拟和重写成员属于类的私有接口。我们可以在这里应用相同的理念,将 polymorphic
放在 First
的私有接口中,通过声明 friend void Base<First>::interface();
使其可以从基类访问。
First
的完整类接口现在可以设计,接下来是 polymorphic
的实现:
class First: public Base<First>
{
friend void Base<First>::interface();
private:
void polymorphic();
};
void First::polymorphic()
{
std::cout << "polymorphic from First\n";
}
注意,类 First
本身不是一个类模板:它的成员可以单独编译并存储在例如库中。此外,与动态多态性一样,成员 polymorphic
具有完全访问 First
所有数据成员和成员函数的权限。
现在可以设计多个像 First
一样的类,每个类提供自己对 polymorphic
的实现。例如,类 Second
的成员 Second::polymorphic
可以像这样实现:
void Second::polymorphic()
{
std::cout << "polymorphic from Second\n";
}
Base
的多态性在定义一个函数模板时变得明显,该模板中调用了 Base::interface
。同样,编译器在读取以下函数模板的定义时简单地假设存在成员接口:
template <class Class>
void fun(Class &object)
{
object.interface();
}
只有在这个函数实际被调用时,编译器才会验证生成代码的可行性。在以下的 main
函数中,将一个 First
对象传递给 fun
:First
通过基类声明 interface
,并且 First::polymorphic
被 interface
调用。编译器将在这时(即调用 fun
时)检查 First
是否确实有成员 polymorphic
。接下来,将一个 Second
对象传递给 fun
,编译器再次检查 Second
是否有成员 Second::polymorphic
:
int main()
{
First first;
fun(first);
Second second;
fun(second);
}
使用静态多态性也有一些缺点:
-
首先,“将
Second
对象传递给fun
” 这一说法从形式上是不正确的,因为fun
是一个函数模板,调用fun(first)
和fun(second)
的函数实际上是不同的函数,而不仅仅是用不同参数调用一个函数。使用静态多态性时,每次使用其模板参数实例化都会生成全新的代码,这在模板(例如fun
)实例化时生成。这是创建静态多态基类时需要考虑的一点。如果基类定义了数据成员和成员函数,并且这些附加成员被派生类类型使用,则每个成员都为每个派生类类型有自己独立的实例化。这也导致代码臃肿,尽管与动态多态性观察到的臃肿有所不同。这种代码臃肿通常可以通过将基类从其自身的(普通的,非模板)基类派生来减少,将所有不依赖于其模板类型参数的静态多态基类元素封装起来。 -
其次,如果动态创建不同类型的静态多态对象(使用
new
操作符),则返回的指针类型都是不同的。此外,指向其静态多态基类的指针类型彼此不同。这些指针不同,因为它们是指向Base<Derived>
的指针,表示不同Derived
类型的不同类型。因此,与动态多态性不同,这些指针不能被收集到例如指向基类指针的共享指针的向量中。由于不同的基类类型,因此不存在直接的静态多态等价物来虚析构函数。 -
第三,如下一节所示,使用多个继承层次设计静态多态类并非易事。
总结来说,静态多态性最适合用于以下情况:
- 使用的派生类类型数量较少;
- 使用的派生类对象数量固定;
- 静态多态基类本身设计简洁(可能将一些代码封装在其自身的普通基类中)。
将动态多态类转换为静态多态类
如果你决定将一些动态多态类转换为静态多态类,该怎么做呢?
在第14章中介绍了基类 Vehicle
和一些派生类。Vehicle
、Car
和 Truck
的接口如下(涉及其多态行为的成员):
class Vehicle
{
public:
int mass() const;
private:
virtual int vmass() const;
};
class Car: public Vehicle
{
private:
int vmass() const override;
};
class Truck: public Car
{
private:
int vmass() const override;
};
在将动态多态类转换为静态多态类时,我们必须意识到多态类具有两个重要特征:
- 定义设施:它们定义了由派生类继承的设施(数据成员、成员函数)(例如,
Vehicle::mass
),即可继承的接口。 - 实现可重定义的接口:派生类以适合其目的的方式实现可重定义的接口(例如,
Truck::vmass
)。
对于静态多态类,这两个特征应完全分开。正如前面一节中所述,静态多态派生类通过将自身类类型作为基类类型参数来从基类继承。如果只有一个继承层次(即一个基类和一个或多个直接从该基类派生的类),这种方式效果良好。
但在多层次继承的情况下(例如,Truck -> Car -> Vehicle
),Truck
的继承声明会成为问题。以下是尝试使用静态多态和多层次继承的初步实现:
template <class Derived>
class Vehicle
{
public:
void mass()
{
static_cast<Derived *>(this)->vmass();
}
};
class Car: public Vehicle<Car>
{
friend void Vehicle<Car>::mass();
void vmass();
};
class Truck: public Car
{
void vmass();
};
- 如果
Truck
从Car
继承,那么Truck
隐式地从Vehicle<Car>
继承,因为Car
从Vehicle<Car>
继承。因此,当调用Truck{}.mass()
时,激活的不是Truck::vmass
,而是Car
的vmass
函数。然而,Truck
必须从Car
继承,以使用Car
的保护特性并将Car
的公共特性添加到自己的公共接口中。 - 多重继承也无法解决这个问题:当
Truck
从Vehicle<Truck>
和Car
继承时,会导致Truck
也继承自Vehicle<Car>
(通过Truck
的Car
基类),编译失败,因为编译器在实例化Vehicle::mass
时遇到了模糊性:它应该调用Class::vmass
还是调用Truck::vmass
?
为了解决这个问题(即确保 Truck{}.mass()
调用 Truck::vmass
),必须将可重定义的接口与可继承的接口分开。在派生类中,使用标准继承将(直接或间接)基类的保护和公共接口提供给下一层继承,如下图左侧所示。这些左侧的类用作下一层继承的基类(除了 TruckBase
,但 TruckBase
可以用作另一层继承的基类)。
这条继承线声明了类的可继承接口。每个左侧的类都是右侧单个类的基类:VehicleBase
是 Vehicle
的基类,TruckBase
是 Truck
的基类。左侧的类包含与实现静态多态性无关的所有成员。这是一种实现多层静态多态性的设计原则,左侧类将其匹配的右侧派生类模板声明为友元,以允许右侧的派生类模板访问左侧类的所有成员,包括私有成员。例如,VehicleBase
声明 Vehicle
为友元:
class VehicleBase
{
template <class Derived>
friend class Vehicle;
// 所有原本在 Vehicle 中但与实现静态多态性无关的成员在此声明,例如:
size_t d_massFactor = 1;
};
左侧的顶层类(VehicleBase
)奠定了静态多态性的基础,通过定义使用静态重定义函数的接口的一部分。例如,使用好奇递归模板模式,它定义了一个调用派生类 vmass
函数的类成员 mass
(此外,它可以使用其非类模板基类的所有成员)。例如:
template <class Derived>
class Vehicle: public VehicleBase
{
public:
int mass() const
{
return d_massFactor * static_cast<Derived const *>(this)->vmass();
}
};
实际调用哪个 vmass
函数取决于 Derived
类中的实现,这些类继承自 Vehicle
(以及它们自己的 ...Base
类)。这些类使用好奇递归模板模式。例如:
class Car: public CarBase, public Vehicle<Car>
因此,如果 Car
实现了自己的 vmass
函数,该函数可以使用 CarBase
的任何成员,则调用 Vehicle
的 mass
函数时将调用该函数。例如:
template <class Vehicle>
void fun(Vehicle &vehicle)
{
cout << vehicle.mass() << '\n';
}
int main()
{
Car car;
fun(car);
// 调用 Car 的 vmass
Truck truck;
fun(truck);
// 调用 Truck 的 vmass
}
总结,将动态多态类转换为静态多态类的步骤如下:
- 首先,从基类开始,将
Vehicle
的非重定义接口移动到VehicleBase
类,并将Vehicle
本身转换为一个静态多态基类。一般来说,将原多态基类中不使用或实现虚成员的所有成员移到XBase
类。 VehicleBase
声明Vehicle
为友元,以允许Vehicle
完全访问其原来的成员,这些成员现在在VehicleBase
中。Vehicle
的成员引用重定义接口,即其成员调用其Derived
模板类型参数的成员。在此实现中,Vehicle
不实现自己的vmass
成员。如果这不方便,可以为Vehicle
的Derived
类指定默认类,实现原多态基类的重定义接口(允许像Vehicle<> vehicle
这样的定义)。- 同样,其余类将与静态多态性无关的成员移动到基类。例如,
Car
将这些成员移动到CarBase
,Truck
将这些成员移动到TruckBase
。 - 使用标准线性继承 从
VehicleBase
到CarBase
,然后到TruckBase
。 - 每个其余的类(例如
Car
和Truck
)都是类模板,继承自其基类,并且使用好奇递归模板模式继承自Vehicle
。 - 每个这些剩余类 现在可以实现自己的重定义接口版本,如
Vehicle
的成员所使用的。
这种设计模式可以扩展到任何级别的继承:对于每个新级别,构建一个基类,从当前最深层的 XXXBase
类继承,并从 Vehicle<XXX>
继承,实施自己关于重定义接口的想法。
与静态多态类一起使用的函数必须是函数模板。例如:
template <class Vehicle>
void fun(Vehicle &vehicle)
{
cout << vehicle.mass() << '\n';
}
这里,Vehicle
只是一个形式名称。当将对象传递给 fun
时,必须提供一个成员 mass
,否则编译将失败。如果对象实际上是 Car
或 Truck
,则其 Vehicle<Type>
静态基类成员 mass
被调用,这又使用静态多态性调用由实际传递的类类型实现的成员 vmass
。以下 main
函数显示了分别为1000和15000的结果:
int main()
{
Car car;
fun(car);
Truck truck;
fun(truck);
}
请注意,这个程序实现了 fun
两次,而不是动态多态性的情况下的单次实现。这对于 Vehicle
类模板也是如此:每个 Car
类型和 Truck
类型都需要两个实现。静态多态性程序会稍微更快一些。(一个可编译的静态多态性示例可以在 C++ Annotations 的源代码分发中的 yo/classtemplates/examples/staticpolymorphism/polymorph.cc
文件中找到。)
使用静态多态性避免重新实现
在动态多态环境中,静态多态性可以有效地避免重复实现代码。考虑一种情况,我们有一个类,其中包含指向某个多态基类类型的指针的容器(如第14章中的 Vehicle
类)。如何将这样的容器复制到另一个容器?这里我们并不打算使用共享指针,而是希望进行完整的复制。
显然,我们需要复制指针所指向的对象,并将这些新指针分配到复制对象的容器中。
原型设计模式通常用于创建多态类对象的副本,给定指向其基类的指针。为了应用原型设计模式,我们需要在所有派生类中实现 newCopy
。这本身并不复杂,但静态多态性可以很好地用于避免为每个派生类重新实现这个函数。
我们从一个抽象基类 VehicleBase
开始,声明一个纯虚成员 newCopy
:
struct VehicleBase
{
virtual ~VehicleBase();
VehicleBase *clone() const; // 调用 newCopy
// 在此声明任何定义公共用户接口的额外成员
private:
VehicleBase *newCopy() const = 0;
};
接下来,我们定义一个静态多态类 CloningVehicle
,它从 VehicleBase
派生(注意我们因此结合了动态和静态多态性)。该类提供了 newCopy
的通用实现。这是可能的,因为所有派生类都可以使用这个实现。同时,CloningVehicle
将为每种具体的车辆类型(如 Car
、Truck
、AmphibiousVehicle
等等)重新实现。因此,CloningVehicle
并不像 VehicleBase
那样被共享,而是为每种新的车辆类型实例化。
静态多态类的核心特征是它可以通过对自身类型的 static_cast
使用其类模板类型参数。像 newCopy
这样的成员函数总是以相同的方式实现,即通过使用派生类的复制构造函数。以下是 CloningVehicle
类:
template <class Derived>
class CloningVehicle: public VehicleBase
{
VehicleBase *newCopy() const
{
return new Derived(*static_cast<Derived const *>(this));
}
};
就这样了。所有的车辆类型现在应该从 CloningVehicle
派生,这样它们会自动获得自己的 newCopy
实现。例如,一个 Car
类看起来如下:
class Car: public CloningVehicle<Car>
{
// Car 的接口,不需要声明或实现 newCopy,
// 但需要一个复制构造函数。
};
定义了 std::vector<VehicleBase*> original
后,我们可以像这样创建 original
的副本:
for (auto pointer : original)
duplicate.push_back(pointer->clone());
无论指针实际指向的车辆类型是什么,它们的 clone
成员函数将返回指向其自身类型的新分配对象的指针。