第十七章 用于大型程序的工具
C++ 解决问题规模多样,对复杂问题尤其需用异常处理、命名空间和多重继承增强代码管理、库整合和概念表达,以适应大规模编程对错误处理、模块组合及高级功能设计的高要求。
17.1. 异常处理
异常处理允许C++程序中不同部分通过抛出和捕获异常来通信和处理错误。当检测到无法本地解决的问题时,抛出异常,并由能够处理该问题的代码捕获。
异常抛出:
在检测到错误(如ISBN不匹配)时,使用throw关键字抛出异常对象(如std::runtime_error),包含错误信息。
异常捕获:
在可能抛出异常的代码周围使用try块,并在其后用catch块捕获特定类型的异常。捕获后,可以处理错误(如打印错误信息)。
异常处理要点:
理解异常抛出时控制流的转移,捕获时如何匹配异常类型,以及异常对象如何传递错误信息。
17.1.1. 抛出类类型的异常
异常处理是C++中一种强大的错误管理机制,通过抛出和捕获异常对象来实现。
异常抛出与捕获
异常通过throw关键字抛出对象,对象的类型决定哪个catch块将被激活。
catch块匹配离抛出点最近且能处理该异常类型的代码。
异常对象由编译器管理,保证在异常处理期间可访问,并在处理完毕后销毁。
异常对象的传递
抛出表达式的结果被复制来创建异常对象,因此被抛出的类型必须可复制。
抛出数组或函数时,它们会被自动转换为指向首元素的指针或函数指针。
异常与继承
抛出表达式时,异常对象的类型由静态编译时类型决定。
如果抛出的是继承体系中的对象,则异常对象的类型将是该对象的静态类型。
异常与指针
抛出指针时要特别小心,因为必须确保在捕获异常时指针所指向的对象仍然存在。
抛出指向局部对象的指针通常是不安全的,因为局部对象在函数返回后将被销毁。
抛出指针时,应确保处理代码中的对象生命周期覆盖异常处理的整个过程。
// 抛出异常
void mightThrow() {
throw std::runtime_error("An error occurred");
}
// 捕获异常
void handleError() {
try {
mightThrow(); //抛出异常
} catch (const std::runtime_error& e) { //捕获异常
std::cerr << "Caught exception: " << e.what() << std::endl;
}
}
// 错误示例:抛出指向局部对象的指针
void badThrow() {
int* localPtr = new int(10); // 注意:这里使用了new,但在实际场景中应避免这样
throw localPtr; // 危险:localPtr指向的对象将在函数返回后被销毁
// 注意:这里为了示例简化了内存管理,实际中应使用智能指针或避免抛出指针
}
// 正确处理指针的方式(尽管通常不推荐抛出指针)
void safePointerThrow() {
std::shared_ptr<int> safePtr = std::make_shared<int>(10);
throw safePtr; // 抛出智能指针更安全,因为智能指针会管理内存
// 注意:即便如此,捕获时仍需谨慎处理智能指针的生命周期
}
17.1.2. 栈展开
C++中异常处理的机制,包括栈展开、局部对象的析构、析构函数不应抛出异常、以及构造函数中的异常处理。
异常处理流程
抛出异常:当throw语句执行时,当前函数的执行被暂停,开始查找匹配的catch子句。
栈展开:如果在当前函数中找到了匹配的catch子句,则处理异常,程序正常执行;否则,退出当前函数(释放局部对象并调用析构函数),继续在调用者函数中查找。这个过程一直持续到找到匹配的catch子句或程序终止。
局部对象析构:在栈展开期间,会释放局部对象的内存并调用它们的析构函数(如果对象是类类型)。这确保了资源的正确释放。
析构函数与异常
析构函数内部应该避免抛出异常。如果在析构函数中抛出异常且未被捕获,程序将调用std::terminate(),通常导致程序非正常退出。
构造函数与异常
构造函数内部可以抛出异常,但如果对象只是部分构造,则已构造的成员需要被适当撤销。
#include <iostream>
#include <stdexcept>
class Resource {
public:
Resource() { std::cout << "Resource constructed\n"; }
~Resource() noexcept { std::cout << "Resource destructed\n"; } //noexcept声明的函数如果抛出异常,会直接调用terminated函数终止程序
void riskyOperation() { throw std::runtime_error("Failed"); }
};
void mightFail() {
Resource res;
res.riskyOperation(); // 可能抛出异常
// 如果riskyOperation抛出异常,则res的析构函数将被调用
}
int main() {
try {
mightFail();
} catch (const std::runtime_error& e) {
std::cerr << "Caught exception: " << e.what() << std::endl;
}
// 如果mightFail中没有异常,程序将正常结束
// 如果mightFail中抛出异常并被捕获,则异常处理代码将执行
// 无论如何,Resource的析构函数都将被调用
return 0;
}
17.1.3. 捕获异常
异常处理中的catch子句
在C++中,catch子句用于捕获并处理异常。每个catch子句后面跟着一个异常说明符,它定义了该子句能够捕获的异常类型。
异常说明符
类型:必须是完全类型,不能是前向声明。
形参名:可选,用于在catch块中引用异常对象。
复制与引用:
如果不是引用,则异常对象会被复制到catch形参中。
如果是引用,则catch形参直接引用异常对象。
匹配规则
严格匹配:
除了少数特殊情况外(如非const到const的转换、派生类到基类的转换、数组和函数指针的转换),异常类型和catch说明符类型必须完全匹配。
第一个匹配:
找到的第一个可以处理该异常的catch子句会被选中,因此派生类型的catch子句必须出现在基类类型之前。
#include <iostream>
#include <exception>
using namespace std;
class BaseException : public exception {
public:
const char* what() const noexcept override {
return "BaseException";
}
};
class DerivedException : public BaseException {
public:
const char* what() const noexcept override {
return "DerivedException";
}
};
int main() {
try {
throw DerivedException(); // 抛出派生类异常
} catch (DerivedException& e) {
cout << "Caught DerivedException: " << e.what() << endl;
} catch (BaseException& e) {
cout << "Caught BaseException: " << e.what() << endl; // 这行不会被执行
}
return 0;
}
17.1.4. 重新抛出
重新抛出异常
在catch块中,如果你决定当前处理逻辑不足以完全处理异常,你可以通过简单的throw;语句重新抛出当前捕获的异常。这个空throw语句会重新抛出当前正在处理的异常对象,而不是一个新的异常对象。
只能在catch块或其调用的函数中使用:throw;只能在catch块内或其直接调用的函数内部使用。
动态类型保持:当异常被重新抛出时,即使catch块的形参是基类类型,实际抛出的异常对象仍然保持其原始的动态类型(即派生类型,如果有的话)。
引用与非引用形参
引用形参:如果catch块的形参是通过引用捕获的,那么对形参的任何修改都会反映到原始异常对象上。当重新抛出异常时,这些修改会一起被传播。
非引用形参:如果catch块的形参不是通过引用捕获的,那么形参是原始异常对象的一个副本。对形参的修改不会影响原始异常对象,重新抛出的异常对象将保持不变。
17.1.5. 捕获所有异常的处理代码
捕获所有异常的 catch 子句
在C++中,可以使用catch(...)子句来捕获任何类型的异常。这个子句是一个通配符,可以匹配任何被抛出的异常。这在你不确定可能会抛出哪些类型的异常时非常有用。
try {
// 可能抛出异常的代码
} catch (...) {
// 放置处理未知异常的代码
}
与重新抛出表达式结合使用
有时,即使函数无法完全处理被抛出的异常,它也可能需要在退出前执行一些清理工作或其他必要的操作。在这些情况下,你可以在catch(...)子句中完成这些工作,并使用throw;语句重新抛出捕获的异常,以便让上层调用者有机会处理它。
void manip() {
try {
// 执行可能抛出异常的操作
} catch (...) {
// 在这里执行清理工作或记录日志
// ...
// 然后重新抛出异常
throw; //throw后跟空的内容,代表正在处理的异常直接抛出
}
}
catch(...) 子句的位置
catch(...)子句可以单独使用,作为唯一的catch子句来捕获所有异常。它也可以与其他具体的catch子句结合使用,但在这种情况下,它必须是最后一个catch子句,因为一旦匹配到catch(...),后面的任何catch子句都将不会被考虑。
try {
// 可能抛出多种异常的代码
} catch (const std::runtime_error& e) {
// 处理特定类型的异常
} catch (...) {
// 处理所有其他类型的异常
// 必须是最后一个catch子句
}
17.1.6. 函数测试块与构造函数
在C++中,如果构造函数需要处理来自其初始化列表中的异常,那么必须将整个构造函数定义为一个函数try块。这通过在成员初始化列表之前放置try关键字,并在构造函数体周围使用复合语句(即大括号{})来完成。这样做之后,紧随其后的catch子句可以捕获并处理在初始化列表或构造函数体内抛出的任何异常。
注意,下面将构造函数写为函数测试块的做法其实不符合C++标准,这里仅供学习。
关键字 try 出现在成员初始化列表之前,并且测试块的复合语句包围了构造函数的函数体。catch 子句既可以处理从成员初始化列表中抛出的异常,也可以处理从构造函数函数体中抛出的异常。
template <class T>
class Handle {
public:
Handle(T *p) try
: ptr(p), use(new size_t(1)) // 成员初始化列表
catch (const std::bad_alloc &e) {
// 处理内存分配失败的代码
handle_out_of_memory(e);
// 注意:这里通常不会继续构造对象,因此可能还需要一些额外逻辑来确保对象状态正确
} {
// 构造函数体(可以为空,因为所有初始化都已在初始化列表中完成)
}
private:
T* ptr;
size_t* use;
void handle_out_of_memory(const std::bad_alloc &e) {
// 处理内存不足的逻辑,如记录错误、释放已分配的资源等
std::cerr << "Memory allocation failed: " << e.what() << std::endl;
// 注意:在实际应用中,这里可能还需要执行一些清理工作
// 但由于构造函数已经失败,对象的状态可能是未定义的
}
};
C++标准不允许在构造函数声明中直接使用try来包围成员初始化列表,这些初始化操作在构造函数体执行之前完成,并且它们并不属于构造函数体的一部分。
17.1.7. 异常类层次
17.1.8. 自动资源释放
在C++中,异常安全是编程时需要关注的重要方面。当异常发生时,确保已分配的资源得到适当释放是非常重要的。为了实现这一目标,C++鼓励使用RAII(Resource Acquisition Is Initialization)技术,即通过类来管理资源的分配和释放。
RAII 技术概述
RAII:
资源获取即初始化。它是一种在C++中管理资源(如动态分配的内存、文件句柄、锁等)的常用技术。
核心思想:
通过类的构造函数来分配资源,并通过析构函数来释放资源。这样,无论函数是正常返回还是由于异常而退出,只要类的实例超出了作用域,其析构函数就会自动被调用,从而释放资源。
class Resource {
public:
Resource(parameters p) : r(allocate(p)) {} // 构造函数分配资源
~Resource() { release(r); } // 析构函数释放资源
// 可能还需要定义拷贝构造函数和赋值运算符
private:
resource_type* r; // 被管理的资源
// 辅助函数
static resource_type* allocate(parameters p); // 分配资源
static void release(resource_type* p); // 释放资源
};
// 使用 Resource 类的示例
void fcn() {
Resource res(args); // 分配资源
// 可能会抛出异常的代码
// ...
} // 当函数退出时(无论是否因为异常),res 的析构函数都会自动调用,释放资源
17.1.9. auto_ptr 类
auto_ptr 是 C++ 标准库中的一个模板类,用于管理动态分配的对象,确保在发生异常时也能安全地释放资源。然而,需要注意的是,auto_ptr 在 C++11 及以后的版本中已被废弃,并被 std::unique_ptr 取代,因为 auto_ptr 的复制和赋值行为可能导致意外的资源释放。
auto_ptr 的基本特性
模板类:接受一个类型参数,用于指定 auto_ptr 可以管理的对象类型。
异常安全:在函数退出或发生异常时,自动释放所管理的资源。
所有权:每个 auto_ptr 对象“拥有”一个动态分配的对象,当 auto_ptr 被销毁或重置时,其管理的对象也会被删除。
复制和赋值:复制或赋值 auto_ptr 会导致所有权转移,原 auto_ptr 变为未绑定状态。
#include <memory> // 包含 auto_ptr 的头文件(注意:在 C++11 及以后应使用 unique_ptr)
#include <string>
void f() {
// 使用 auto_ptr 管理动态分配的 int
std::auto_ptr<int> ap(new int(42));
// 如果这里发生异常,ap 的析构函数将自动调用,释放 int 对象
}
// 使用 auto_ptr 管理动态分配的 string
std::auto_ptr<std::string> ap1(new std::string("Brontosaurus"));
*ap1 = "TRex"; // 修改 ap1 指向的 string 对象
if (!ap1->empty()) { // 调用 string 对象的成员函数
// ...
}
// 复制 auto_ptr,所有权转移
std::auto_ptr<std::string> ap2(ap1); // ap1 现在未绑定,ap2 拥有原对象
// 赋值 auto_ptr,同样导致所有权转移
std::auto_ptr<std::string> ap3(new std::string("Pterodactyl"));
ap3 = ap2; // ap3 现在拥有 ap2 的对象,ap2 未绑定
// 重置 auto_ptr
if (!ap3.get()) { // 检查 ap3 是否未绑定
ap3.reset(new std::string("New Dinosaur")); // 绑定新对象
}
注意事项
不要将 auto_ptr 存储在标准容器中:
因为复制和赋值会改变原 auto_ptr 的状态,这违反了容器对元素的要求。
避免使用 auto_ptr:
在 C++11 及以后的版本中,应使用 std::unique_ptr 替代 auto_ptr,因为 unique_ptr 提供了更安全、更灵活的资源管理功能。
不能直接赋值指针给 auto_ptr:
必须使用 reset 方法来改变 auto_ptr 管理的对象。
17.1.10. 异常说明
异常说明是C++中用于声明函数可能抛出的异常类型的一种机制。它帮助调用者了解函数的安全性,即函数在出错时是否会抛出异常,以及会抛出哪些类型的异常。
1. 异常说明的基本形式
非空异常说明:指定函数可能抛出的异常类型列表。
void recoup(int) throw(runtime_error); // recoup 可能抛出 runtime_error 或其派生类型
空异常说明:表明函数不会抛出任何异常。
void no_problem() throw(); // no_problem 承诺不抛出任何异常
2. 违反异常说明
如果函数抛出了未在异常说明中列出的异常,将调用 std::unexpected() 函数,默认情况下将导致程序终止。
void f() throw() { throw exception(); } // 违反异常说明,将调用 unexpected
3. 异常说明的用途
确定函数不抛出异常:这对于编写异常安全的代码很有帮助,因为调用者知道不需要处理异常。
编译器优化:如果编译器知道函数不会抛出异常,它可能会进行更激进的优化。
4. 成员函数的异常说明
成员函数的异常说明跟在形参表之后。对于const成员函数,异常说明跟在const限定符之后。
class MyClass {
public:
void myFunction() const throw(runtime_error); // const成员函数
};
5. 析构函数的异常说明
析构函数应尽可能不抛出异常,因为它们在异常处理过程中被调用,可能会掩盖原始异常。
标准库中的许多类(如std::string)保证它们的析构函数不会抛出异常。
6. 异常说明与虚函数
派生类中重写的虚函数可以有不同的异常说明,但派生类的异常说明必须至少与基类中的一样严格或更严格。
class Base {
public:
virtual void func() throw(runtime_error);
};
class Derived : public Base {
public:
virtual void func() throw(); // 正确,更严格
// virtual void func() throw(std::underflow_error); // 错误,更宽松
};
17.1.11. 函数指针的异常说明
异常说明与函数指针
在C++中,函数的异常说明是函数类型的一部分,它指定了该函数可能抛出的异常类型。当这些函数被赋值给函数指针时,指针的异常说明必须满足一定的条件。
规则概述
赋值兼容性:
如果函数A的异常说明比函数B的更严格(即A能抛出的异常类型集是B的子集),则可以将函数B的地址赋给指向函数A的函数指针。
严格性:
如果目标函数指针的异常说明比源函数的更严格(即限制更多),则赋值是合法的。
无异常函数:
如果一个函数指针被声明为不抛出任何异常(throw()),则只能指向那些同样不抛出任何异常的函数。
具有严格的异常说明的指针可以兼容异常声明宽松的指针。
无异常函数指针只能指向无异常函数。
17.2. 命名空间
17.2.1. 命名空间的定义
命名空间的定义与作用
定义:
命名空间是一个作用域,用于封装一组相关的名字(如类名、函数名等),以避免全局命名冲突。
语法:
以namespace关键字开始,后跟命名空间名称和一对花括号{},其中包含命名空间的成员声明。
namespace MyNamespace {
class MyClass {};
void myFunction();
}
避免命名冲突
问题:在复杂程序中,尤其是使用多个独立开发的库时,全局作用域中的名字很容易发生冲突。
解决方案:使用命名空间将不同库或模块中的名字封装起来,从而避免冲突。
使用命名空间中的成员
直接访问:使用命名空间名::成员名的形式直接访问。
MyNamespace::MyClass obj;
MyNamespace::myFunction();
简化访问:使用using声明或using指令简化对命名空间成员的访问。
using MyNamespace::MyClass;
MyClass obj; // 现在可以直接使用MyClass
// 或使用using namespace指令
using namespace MyNamespace;
MyClass obj;
myFunction();
命名空间的不连续性
命名空间可以在多个部分中定义,这些部分可以是分散在多个文件中的。
所有部分定义的命名空间成员共同构成了该命名空间。
接口与实现的分离
命名空间的定义也可以像类和函数一样,将接口(声明)和实现(定义)分离到不同的文件中。
// MyNamespace.h 头文件(接口)
namespace MyNamespace {
class MyClass {
public:
MyClass();
void myMethod();
};
}
// MyNamespace.cpp 源文件(实现)
#include "MyNamespace.h"
MyNamespace::MyClass::MyClass() {
// 构造函数实现
}
void MyNamespace::MyClass::myMethod() {
// 方法实现
}
//用户代码
#include "MyNamespace.h"
int main() {
MyNamespace::MyClass obj;
obj.myMethod();
return 0;
}
17.2.2. 嵌套命名空间
嵌套命名空间的概念:
嵌套命名空间是一个命名空间的内部定义的另一个命名空间,其作用域被限定在外围命名空间内。在嵌套命名空间中定义的成员(如类、函数等)默认只在该命名空间内部可见,外围命名空间之外的代码需要通过限定名(即包含外围命名空间和嵌套命名空间名称的完整路径)来访问这些成员。
用途:
嵌套命名空间在大型库或项目中特别有用,因为它可以帮助组织和管理代码,防止不同部分之间的命名冲突。
/*嵌套命名空间示例*/
namespace cplusplus_primer {
// 第一个嵌套命名空间:定义库的Query部分
namespace QueryLib {
class Query { /* ... */ };
Query operator&(const Query&, const Query&);
// ... 其他Query相关的定义
}
// 第二个嵌套命名空间:定义库的Sales_item部分
namespace Bookstore {
class Item_base { /* ... */ };
class Bulk_item : public Item_base { /* ... */ };
// ... 其他Bookstore相关的定义
}
}
// 访问嵌套命名空间中的成员
// 例如,访问QueryLib中的Query类
cplusplus_primer::QueryLib::Query myQuery;
// 访问Bookstore中的Bulk_item类
cplusplus_primer::Bookstore::Bulk_item myBulkItem;
17.2.3. 未命名的命名空间
未命名的命名空间(也称为匿名命名空间)是一个特殊的命名空间,它在定义时没有显式名称。它主要用于限制作用域到单个文件内,避免全局命名冲突。未命名的命名空间中的成员(如变量、类型等)在定义它们的文件内部可直接访问,无需前缀限定,但在该文件外部不可见。
特点:
局部于文件:
未命名的命名空间定义仅在当前文件内有效,不跨越多个文件。
不连续定义:
在同一文件内,未命名的命名空间可以分散定义,但所有定义属于同一命名空间。
避免命名冲突:
与全局作用域隔离,可以在不同文件中定义同名实体而不会冲突。
生命周期:
其中的变量在程序启动时创建,在程序结束时销毁。
嵌套:
未命名的命名空间可以嵌套在其他命名空间中,此时需要通过外围命名空间访问。
注意事项:
在全局作用域中,未命名的命名空间中的成员不能与全局变量等重名,否则会导致命名冲突。
头文件中定义未命名的命名空间可能会导致包含该头文件的每个文件都拥有独立的命名空间实例,通常不推荐这种做法。
17.2.4. 命名空间成员的使用
在C++中,使用长命名空间名时,可以通过几种方式简化对命名空间成员的访问。这些方法包括using声明、命名空间别名和using指示。
1. using声明
using声明允许你在特定作用域内引入一个命名空间中的单个成员,而不需要每次都使用完整的命名空间前缀。这有助于减少代码冗余,同时保持对命名空间的明确引用。
using std::string;
string s = "Hello"; // 直接使用string,无需std::
2. 命名空间别名
命名空间别名提供了一种为长命名空间名创建短同义词的方法。这有助于简化对嵌套命名空间成员的访问。
namespace primer = cplusplus_primer;
primer::QueryLib::Query tq; // 使用别名简化访问
3. using指示
与using声明不同,using指示会将整个命名空间中的所有名字引入到当前作用域中,这可能导致命名冲突。因此,它通常只在局部作用域(如函数内部)或命名空间的实现文件中使用。
void func() {
using namespace std;
cout << "Hello, world!" << endl; // 直接使用cout和endl
}
17.2.5. 类、命名空间和作用域
命名空间和作用域
命名空间是作用域:
名字从声明点开始可见,直至其声明的作用域结束。查找名字时,会从当前作用域向外层作用域逐级查找。
嵌套命名空间:
内部命名空间的成员会隐藏外围命名空间中同名的成员。
namespace A {
int i;
namespace B {
int i; // 隐藏A::i
int f1() { return i; } // 返回B::i
}
}
类成员查找
类成员查找:
首先在成员本身中查找,然后是类中(包括基类),最后是外围作用域(包括命名空间)。
成员定义例外:
类定义体内可以引用在定义文本之后声明的成员。
namespace A {
class C1 {
int i; // 隐藏A::i
int j;
public:
C1() : i(0), j(0) {} // 初始化C1::i和C1::j
int f1() { return A::k; } // 返回A::k
};
int k;
}
实参相关的查找
接受类类型形参的函数:
当函数接受类类型(或其指针、引用)作为形参时,该函数(包括重载操作符)在用类类型对象作为实参时可见,即使没有明确的using声明。
std::string s;
getline(std::cin, s); // 无需std::限定符,因为getline是std::string的接口部分
隐式友元声明
友元函数:
如果类在命名空间中定义了一个友元函数,且该函数没有在其他地方声明,则这个友元函数隐式地在该命名空间中声明。
namespace A {
class C {
friend void f(const C&); // f成为A的成员
};
}
void f2() {
A::C cobj;
f(cobj); // 调用A::f,无需A::限定符
}
17.2.6. 重载与命名空间
命名空间与函数重载
每个命名空间维护自己的作用域,因此不同命名空间的函数不能互相重载。但是,同一命名空间内的函数可以重载。
函数匹配过程
找到候选函数集:所有在调用点可见且与被调用函数同名的函数都是候选者。
选择可行函数:形参数目与实参数目相同,且每个形参都能与对应实参匹配的函数是可行的。
选择最佳匹配:从可行集合中选择一个最佳匹配进行调用。如果没有可行函数,则调用出错;如果有多个可行函数但没有最佳匹配,则调用具有二义性。
命名空间对函数匹配的影响
using 声明/指示:
可以将命名空间中的函数添加到候选函数集合中。
类类型形参的名字查找:
如果函数有类类型形参,则会在定义这些类及其基类的命名空间中查找同名函数,并将它们添加到候选集合中,即使这些函数在调用点不可见。
重载与using声明
using声明只能声明名字,不能指定参数列表。
如果命名空间中有重载函数,using声明会引入所有同名函数。
如果using声明引入的函数与当前作用域中已存在的函数重载冲突(即具有相同的函数名和参数列表),则会导致编译错误。
重载与using指示
using指示将命名空间中的所有成员(包括函数)提升到外围作用域。
如果命名空间中的函数与外围作用域中已存在的函数同名,则它们会构成重载集合。
namespace NS {
void print(int) { /* ... */ }
void print(double) { /* ... */ }
}
void print(const std::string&) { /* ... */ }
using namespace NS; // 引入NS命名空间中的所有成员
void foo() {
print(1); // 调用NS::print(int)
print(3.14); // 调用NS::print(double)
print("Hello"); // 调用全局的print(const std::string&)
}
// 如果有多个using指示
namespace AW {
void print(int) { /* ... */ }
}
namespace Primer {
void print(double) { /* ... */ }
}
using namespace AW;
using namespace Primer;
void bar() {
print(1); // 调用AW::print(int)
print(3.14); // 调用Primer::print(double)
}
17.2.7. 命名空间与模板
模板的显式特化必须在定义通用模板的命名空间中声明,以确保特化与模板的命名空间一致,并且可以通过重新打开命名空间或使用命名空间名字限定模板名来定义特化。
#include <iostream>
// 定义在命名空间ns中的通用模板
namespace ns {
template<typename T>
void print(T value) {
std::cout << "General: " << value << std::endl;
}
}
// 显式特化ns::print模板,针对int类型
// 重新打开命名空间ns并定义特化
namespace ns {
template<>
void print<int>(int value) {
std::cout << "Specialization for int: " << value << std::endl;
}
}
// 或者,使用命名空间名字限定模板名来定义特化(在命名空间外部)
// 注意:这种方式通常用于模板特化不直接与原始模板在同一文件或作用域中定义时
// 但在这个例子中,为了保持精简,我们不会展示这种方式的实际代码,因为它通常涉及更复杂的场景
int main() {
ns::print(10); // 调用特化版本
ns::print(3.14); // 调用通用模板版本
return 0;
}
17.3. 多重继承与虚继承
17.3.1. 多重继承
多重继承允许一个类从多个基类派生,从而继承它们的属性和方法。尽管这种机制提供了更大的灵活性,但也带来了设计上的复杂性和潜在的二义性问题。
定义多重继承
在C++中,多重继承通过在派生类的定义中列出多个基类(用逗号分隔)来实现。
class ZooAnimal { /* ... */ };
class Bear : public ZooAnimal { /* ... */ };
class Endangered { /* ... */ };
class Panda : public Bear, public Endangered { /* ... */ };
构造函数和析构函数
构造函数:
派生类的构造函数负责初始化所有基类子对象的基类部分。构造函数初始化列表可以控制传递给基类构造函数的参数,但基类构造函数的调用顺序由基类在派生列表中的顺序决定,与初始化列表中的顺序无关。
析构函数:
析构函数的调用顺序与构造函数相反,首先调用派生类的析构函数,然后按照基类在派生列表中的逆序调用基类析构函数。
虚函数和动态绑定
虚函数在多重继承中同样有效,通过基类指针或引用调用虚函数时,将使用对象的实际类型来确定调用哪个版本的函数。
如果多个基类中有同名的虚函数,通过派生类对象调用该函数时会产生二义性,需要显式指定基类名来消除二义性。
复制控制和赋值
派生类的复制构造函数和赋值操作符负责复制或赋值所有基类子对象。如果派生类没有定义这些操作,则使用合成的版本。
名字查找和二义性
在多重继承中,名字查找会同时检查所有基类的继承子树。如果多个基类中有同名的成员,则对该成员的不加限定使用会导致编译时错误。
可以通过显式指定基类名或使用using声明来解决二义性。
class ZooAnimal {
public:
virtual void print(std::ostream& os) const { /* ... */ }
virtual ~ZooAnimal() {}
};
class Bear : public ZooAnimal {
public:
using ZooAnimal::print; //using显式声明指定继承ZooAnimal的print
void print(std::ostream& os) const override { /* Bear-specific print */ }
// ...
};
class Endangered {
public:
void print(std::ostream& os) const { /* Endangered-specific print */ }
// ...
};
class Panda : public Bear, public Endangered {
public:
using Bear::print; // 解决print的二义性,选择Bear的print
void fullPrint(std::ostream& os) const {
Bear::print(os); // 显式调用Bear的print
Endangered::print(os); // 显式调用Endangered的print
}
// ...
};
// 使用
Panda ying_yang;
ying_yang.print(std::cout); // 调用Bear的print
ying_yang.fullPrint(std::cout); // 同时调用Bear和Endangered的print
17.3.2. 虚继承
虚继承简介
在C++中,多重继承可能导致基类在派生类中多次出现,这通常不是期望的,特别是在需要共享基类状态(如缓冲区或条件状态)时。
为了解决这个问题,C++引入了虚继承。虚继承确保无论基类在继承层次中出现多少次,派生类中只会有一个基类的共享实例。
虚继承的声明
通过在派生列表中使用virtual关键字,可以声明一个类通过虚继承从其基类派生。
class ios { ... };
class istream : public virtual ios { ... };
class ostream : virtual public ios { ... };
class iostream : public istream, public ostream { ... };
在这个例子中,iostream类通过虚继承从istream和ostream继承,而istream和ostream又通过虚继承从ios继承,确保iostream对象中只有一个ios实例。
虚继承的初始化
在虚继承中,虚基类的初始化由最底层的派生类负责。这解决了在多重继承中可能发生的多次初始化问题。
class ZooAnimal { ... };
class Bear : virtual public ZooAnimal { ... };
class Raccoon : virtual public ZooAnimal { ... };
class Panda : public Bear, public Raccoon { ... };
Panda::Panda(std::string name, bool onExhibit)
: ZooAnimal(name, onExhibit, "Panda"),
Bear(name, onExhibit),
Raccoon(name, onExhibit),
// 其他成员初始化
{
// 构造函数体
}
在这个例子中,Panda构造函数负责初始化ZooAnimal基类,而Bear和Raccoon的构造函数中的ZooAnimal初始化将被忽略。
构造函数与析构函数的调用次序
在虚继承中,基类的构造函数调用次序是:
虚基类的构造函数(按它们在继承层次中的深度优先顺序调用)。
非虚基类的构造函数(按它们在派生类中的声明顺序调用)。
析构函数的调用次序与构造函数相反。
class Character { ... };
class BookCharacter : public Character { ... };
class ToyAnimal { ... };
class Bear : public virtual ZooAnimal { ... };
class TeddyBear : public BookCharacter, public Bear, public virtual ToyAnimal { ... };
// 构造函数调用次序:
// ZooAnimal(); // Bear的虚基类
// ToyAnimal(); // TeddyBear的虚基类
// Character(); // BookCharacter的非虚基类
// BookCharacter(); // TeddyBear的非虚基类
// Bear(); // TeddyBear的非虚基类
// TeddyBear(); // 最底层派生类
// 析构函数调用次序相反
第18章 探讨C++新标准
18.1 复习前面介绍过的C++11功能
18.1.1 新类型
long long 和 unsigned long long:支持64位(或更宽)的整型。
char16_t 和 char32_t:分别支持16位和32位的字符表示。
原始字符串:允许字符串字面量包含换行符和反斜杠而无需转义。
18.1.2 统一的初始化
C++11引入了统一的初始化语法,使用大括号{ }进行初始化,适用于内置类型和用户定义类型(类对象)。
int x = {5};
double y{2.75};
short quar[5]{4, 5, 2, 76, 1};
int* ar = new int[4]{2, 4, 6, 7};
class Stump {
public:
Stump(int r, double w) : roots(r) {}
private:
int roots;
double weight;
};
Stump si(3, 15.6); // 旧风格
Stump s2{5, 43.4}; // C++11风格
Stump s3 = {4, 32.1}; // C++11风格
防止缩窄
初始化列表语法可防止将大数值赋给无法存储它的较小类型变量。
char c1{1.57e27}; // 编译时错误
char c2{(459585821)}; // 编译时错误,超出范围
char c3{66}; // 允许,int-to-char,在范围内
double c4{66}; // 允许,int-to-double
3. std::initializer_list
C++11引入了std::initializer_list模板类,允许构造函数或函数接收一个初始化列表作为参数。
#include <initializer_list>
#include <vector>
std::vector<int> v1{1, 2, 3, 4}; // 使用initializer_list
double sum(std::initializer_list<double> il) {
double tot = 0;
for (auto p = il.begin(); p != il.end(); ++p) tot += *p;
return tot;
}
int main() {
double total = sum({2.5, 3.1, 4.0}); // 传递initializer_list
}
18.1.3 声明
1. auto 关键字
C++11中的auto关键字用于自动类型推断,要求变量必须被显式初始化。这大大简化了复杂类型的声明。
auto x = 10; // x 是 int 类型
auto* ptr = &x; // ptr 是 int* 类型
auto func = []() { return 1.0; }; // func 是 lambda 表达式对应的函数对象类型
2. decltype 关键字
decltype用于查询表达式的类型,并在编译时用于类型推导。这对于模板编程特别有用。
int i = 0;
decltype(i) j; // j 是 int 类型 相当于 int j
decltype(i + 0.5) k; // k 是 double 类型 相当于 double k
3. 返回类型后置
C++11允许在函数名和参数列表之后(而不是之前)指定返回类型,这对于模板编程中使用decltype特别有用。auto 配合 -> decltype 实现返回类型后置
template<typename T, typename U>
auto add(T t, U u) -> decltype(t + u) {
return t + u;
}
4. 模板别名 using =
C++11引入了using =语法作为typedef的补充,一般是用作命名空间的别名,特别是它可以用于模板的别名定义。
template<typename T>
using Vec = std::vector<T>;
Vec<int> intVec; // 相当于 std::vector<int> intVec;
template<typename T>
using Array12 = std::array<T, 12>;
Array12<double> doubleArray; // 相当于 std::array<double, 12> doubleArray;
5. nullptr 关键字
nullptr是C++11中引入的一个关键字,用于表示空指针。它比使用整数0更安全,因为它有专门的指针类型,不会与整数混淆。
int* ptr = nullptr; // 使用 nullptr 表示空指针
// 如果函数接受 int 参数,传递 nullptr 会导致编译错误
void func(int x) {}
// func(nullptr); // 编译错误
func(0); // 编译通过,但不建议这样表示空指针
18.1.4 智能指针
智能指针的演变
在C++中,使用new从堆(自由存储区)分配内存后,必须显式使用delete来释放这些内存以避免内存泄漏。
为了简化这一过程,C++早期引入了auto_ptr智能指针,它试图自动管理内存释放。然而,auto_ptr的使用存在局限性,特别是复制和赋值操作会导致原本的指针悬空。
C++11中的智能指针
C++11标准摒弃了auto_ptr,并引入了三种新的智能指针:
unique_ptr:
表示对某个对象的独占所有权。当unique_ptr被销毁时,它所拥有的对象也会被自动删除。unique_ptr不支持复制语义,但支持移动语义,这使其非常适合与STL容器一起使用。
#include <memory>
std::unique_ptr<int> ptr(new int(10));
// ... 使用ptr
// 当ptr离开作用域时,它指向的int对象会被自动删除
shared_ptr:
表示对某个对象的共享所有权。多个shared_ptr可以指向同一个对象,当最后一个指向该对象的shared_ptr被销毁或重置时,对象才会被删除。shared_ptr通过控制块来跟踪有多少shared_ptr指向同一个对象。
#include <memory>
std::shared_ptr<int> ptr1 = std::make_shared<int>(10);
std::shared_ptr<int> ptr2 = ptr1; // ptr1和ptr2共享所有权
// ... 使用ptr1和ptr2
// 当ptr1和ptr2都离开作用域时,int对象才会被删除
weak_ptr:
不拥有其所指向的对象,但它可以监视 shared_ptr的存亡,以避免循环引用等问题。weak_ptr必须与shared_ptr配合使用,它不会增加对象的共享计数。
(注意:这里没有直接给出weak_ptr的代码示例,因为它通常与shared_ptr一起使用来解决特定问题,如循环引用。)
18.1.5 异常规范方面的修改
以前,C++允许开发者在函数声明中指定一个异常规范,这是一个可选的语法特性,用于指明函数可能抛出的异常类型。(其实就是函数名后面跟个throw指明可能抛出的异常类型,要是抛出规范之外的异常就terminated函数终止程序)。开销大,代码复杂。
C++11标准摒弃了旧的异常规范语法,并引入了noexcept关键字作为替代方案。noexcept关键字用于明确指出一个函数是否抛出异常:
如果函数不会抛出任何异常,可以将其声明为noexcept。
如果函数可能抛出异常,则不使用noexcept。
#include <iostream>
// 声明一个不会抛出异常的函数
void safeFunction() noexcept {
// 函数体,保证不会抛出异常
std::cout << "This function is safe and will not throw." << std::endl;
}
// 没有使用noexcept的函数,可能抛出异常
void riskyFunction() {
// 函数体,可能包含抛出异常的代码
throw std::runtime_error("This function might throw.");
}
int main() {
try {
safeFunction(); // 调用安全的函数
// riskyFunction(); // 如果取消注释,将引发异常,且由于noexcept的保证,调用者需要处理
} catch (const std::exception& e) {
std::cout << "Caught an exception: " << e.what() << std::endl;
}
return 0;
}
18.1.6 作用域内枚举
C++传统枚举(也称为"旧式"枚举或"非作用域枚举")虽然提供了创建名称常量的能力,但存在几个局限性:类型检查较弱、枚举成员的作用域受限于枚举定义的作用域(可能导致命名冲突)、以及底层类型可能在不同编译器间不一致,影响可移植性。
为了解决这些问题,C++11引入了所谓的"作用域枚举"(也称为"强类型枚举"或"类枚举"),它通过使用class或struct关键字来定义。
// 传统的C++枚举(非作用域枚举)
enum Color { RED, GREEN, BLUE };
// C++11作用域枚举(使用class或struct定义)
enum class NewColor { RED, GREEN, BLUE };
// 使用作用域枚举
void printColor(NewColor color) {
switch (color) {
case NewColor::RED:
std::cout << "Red" << std::endl;
break;
case NewColor::GREEN:
std::cout << "Green" << std::endl;
break;
case NewColor::BLUE:
std::cout << "Blue" << std::endl;
break;
default:
std::cout << "Unknown color" << std::endl;
}
}
int main() {
// 访问作用域枚举成员时必须显式限定
printColor(NewColor::RED);
// printColor(RED); // 这会编译失败,因为RED不在全局作用域中
return 0;
}
18.1.7 对类的修改
1. 显式转换运算符
C++11之前,只有构造函数可以是显式的(使用explicit关键字),以防止隐式类型转换。C++11扩展了这一特性,允许转换函数(也称为类型转换运算符)也成为显式的。
class A {
public:
explicit A(int x) {} // 显式构造函数
};
class B {
public:
operator int() const { return 42; } // 隐式转换
explicit operator double() const { return 3.14; } // 显式转换
};
2. 类内成员初始化
在C++11之前,类成员只能在构造函数中初始化(通过初始化列表或在构造函数体内)。C++11引入了类内成员初始化的特性,允许在类定义时直接初始化成员变量。
/*类成员在构造函数中初始化*/
class MyClass {
public:
int mem1;
double mem2;
MyClass(int x, double y) : mem1(x), mem2(y) {}
};
/*类成员直接在类内定义时初始化*/
class MyClass {
public:
int mem1 = 0; // 直接初始化
double mem2 {1.23}; // 使用大括号初始化
MyClass(int x, double y) : mem1(x), mem2(y) {} // 构造函数覆盖类内初始化
};
在上面的例子中,mem1和mem2在类定义时就被初始化了。然而,如果在构造函数中提供了成员初始化列表,那么这些初始化将覆盖类内初始化。
18.1.8 模板和STL方面的修改
1. 基于范围的for循环:
用于数组和容器的增强型for循环。它自动处理迭代器的开始和结束,使得遍历元素更加直观。
int prices[] = {10, 20, 30};
for (auto x : prices) {
// 对每个元素x执行操作
}
2. 新的STL容器:
forward_list:提供单向链表的功能,比list更节省空间。
unordered_map, unordered_multimap, unordered_set, unordered_multiset:基于哈希表的容器,提供平均常数时间复杂度的查找、插入和删除操作。
array:固定大小的数组容器,提供了STL容器的接口,包括begin()和end()方法,支持基于范围的for循环。
std::array<int, 3> arr = {1, 2, 3};
3. 新的STL方法:
cbegin(), cend(), crbegin(), crend():返回const迭代器,适用于不需要修改容器内容的情况。
4. valarray升级:
C++11为valarray添加了begin()和end()成员函数,使其能够使用基于范围的STL算法。
valarray数组支持行列式计算。
5. 摒弃export关键字:
C++98引入的export关键字原意用于模板的分离编译,但因其复杂性和不实用性,C++11废弃了这一用法,但保留了关键字供未来使用。
6. 尖括号的改进:
C++11之前,在模板声明中嵌套使用尖括号时,通常需要空格来避免与右移操作符>>混淆。C++11放宽了这一要求,使得模板声明更加清晰和简洁。
// C++11之前可能需要这样写
std::vector<std::vector<int> > matrix;
// C++11及之后可以省略空格
std::vector<std::vector<int>> matrix;
18.1.9 右值引用
传统C++中的左值引用(现在通常就称为引用)允许我们将一个标识符(如变量名)与一个左值(如变量或解除引用的指针)关联起来,左值是可以获取其地址的表达式,并且最初可以出现在赋值语句的左侧。然而,通过使用const修饰符,我们可以创建只能读取但不能修改的左值引用。
C++11引入了右值引用的概念,使用&&表示。右值引用允许我们将一个标识符与右值(通常出现在赋值语句右侧的值,如字面常量、表达式的结果或函数的返回值,且这些值通常不持有可访问的地址)关联起来。尽管右值本身通常不能获取其地址,但一旦它们被右值引用捕获,它们就被存储在特定的位置,从而可以通过该引用获取其地址。
右值引用的一个关键特性是它们允许我们“捕获”临时对象的生命周期,这为实现移动语义提供了基础。移动语义允许我们在某些情况下(如对象作为函数参数传递或返回时)避免不必要的复制,从而提高性能。
#include <iostream>
int main() {
int x = 10, y = 13;
int&& r1 = x + y; // r1是一个右值引用,绑定到表达式x + y的结果(临时对象)
// 注意:直接对r1赋值是不合法的,因为r1是右值引用,但它绑定的值(23)是临时的
// 演示右值引用可以延长临时对象的生命周期
// 假设我们有一个函数,它接受一个右值引用参数
void process(int&& val) {
// 在这里,val是右值引用,它绑定到传入的临时对象或右值
// 我们可以访问val,就像它是一个普通的左值一样(尽管不能修改它,除非函数参数是非const的)
std::cout << "Processing value: " << val << std::endl;
}
// 调用process,传入一个右值(字面常量)
process(20);
// 注意:下面的代码是概念性的,实际中不能直接对r1赋值,因为r1绑定的是临时对象
// 但它说明了右值引用如何捕获临时对象的“生命”
return 0;
}
// 注意:上面的process函数示例中,我们实际上并没有展示如何通过右值引用来“移动”资源,
// 因为移动语义通常涉及更复杂的场景,如类的移动构造函数和移动赋值运算符。
18.2 移动语义和右值引用
18.2.1 为何需要移动语义
在C++11之前,当使用函数返回值初始化新对象时,会进行深复制,这包括动态分配的内存和数据的完整复制。然而,这种复制往往是多余的,因为临时对象很快就会被销毁。完全可以直接使用临时对象分配的内存,而不必在初始化时新开辟内存。
移动语义旨在避免这种不必要的复制,通过直接“窃取”临时对象的资源(如动态分配的内存)来初始化新对象,从而提高效率。
Resource createResource(size_t size) {
return Resource(size); //调用Resource的构造函数
}
int main() {
Resource r1 = createResource(1024); // 这里会发生深复制
// 假设后续代码...
return 0;
}
18.2.2 移动语义示例
通过定义移动构造函数和移动赋值运算符,可以实现移动语义。这些函数使用右值引用作为参数,以识别何时可以使用临时对象进行初始化或赋值。
class Useless {
public:
int* pc;
size_t size;
// 常规复制构造函数
Useless(const Useless& f) : pc(new int[f.size]), size(f.size) {
// 深复制...
}
// 移动构造函数
Useless(Useless&& f) noexcept : pc(f.pc), size(f.size) {
//....省略浅复制过程
f.pc = nullptr; // 窃取资源后,将原对象指针置为空
f.size = 0; // 可选,用于调试
}
// 移动赋值运算符
Useless& operator=(Useless&& f) noexcept {
if (this != &f) { //f是一个右值引用
delete[] pc; // 删除旧资源
pc = f.pc;
size = f.size;
f.pc = nullptr;
f.size = 0;
}
return *this;
}
// 其他成员函数...
};
// 使用示例
Useless x;
Useless y = std::move(x); //初始化,使用移动构造函数
x = std::move(y); // 赋值,使用移动赋值运算符
18.2.3 移动构造函数解析
右值引用:让编译器知道何时可以使用移动语义。
移动构造函数:在参数为右值引用时,直接窃取临时对象的资源。
const:移动构造函数参数不能是const,因为需要修改原始对象(将其资源置为空)。
18.2.4 赋值
与构造函数类似,移动赋值运算符也使用右值引用,以优化赋值操作。如果源对象是一个临时对象或即将被销毁的对象,则可以使用其资源来避免不必要的复制。
18.2.5 强制移动
对于左值,不能直接使用移动构造函数或移动赋值运算符。
但可以使用std::move函数将其转换为右值引用,从而强制使用移动操作。std::move实际上并不移动任何内容,它只是将其参数转换为右值引用,从而允许移动语义的发生。
Useless one, two;
two = std::move(one); // 强制使用移动赋值运算符
需要注意的是,即使使用了std::move,如果目标类型没有定义移动构造函数或移动赋值运算符,或者这些函数被删除,编译器仍然会回退到复制构造函数或复制赋值运算符。
18.3 新的类功能
18.3.1 特殊的成员函数
C++11在原有的四个特殊成员函数(默认构造函数、复制构造函数、赋值运算符、析构函数)基础上,新增了移动构造函数和移动赋值运算符。
移动构造函数和移动赋值运算符:用于处理资源转移的场景,提高性能。当对象被临时创建并立即用作另一个对象的初始化或赋值时,这些函数允许“窃取”资源而不是复制。
编译器自动提供:如果未定义这些特殊成员函数,编译器会根据需要自动提供默认版本。但如果有自定义的析构函数、复制构造函数、复制赋值运算符、移动构造函数或移动赋值运算符,编译器不会自动提供其他未定义的特殊成员函数。
18.3.2 默认的方法和禁用的方法
default关键字:用于显式声明使用编译器的默认实现。例如,如果提供了移动构造函数,但还想使用默认的复制构造函数,可以显式声明它。
class MyClass {
public:
MyClass() = default; // 使用默认的默认构造函数
MyClass(MyClass&&) = default; // 使用默认的移动构造函数
// ...
};
delete关键字:禁用特定的成员函数。例如,禁用复制构造函数和赋值运算符以防止对象被复制。
class MyClass {
public:
MyClass(const MyClass&) = delete; // 禁用复制构造函数
MyClass& operator=(const MyClass&) = delete; // 禁用复制赋值运算符
// ...
};
18.3.3 委托构造函数
允许一个构造函数调用另一个构造函数来初始化对象,避免代码重复。
class MyClass {
public:
MyClass() : MyClass(0) {} // 委托给接受一个int参数的构造函数
MyClass(int x) : value(x) {}
private:
int value;
};
/*
使用初始化列表(: MyClass(0))来委托给接受一个int参数的构造函数。
这意味着,当创建MyClass类型的对象而不提供任何参数时,
它实际上会调用MyClass(int x)构造函数,并将0作为参数传递。
*/
18.3.4 继承构造函数
允许派生类继承基类的构造函数,简化代码。
class Base {
public:
Base(int, double) {}
// ...
};
class Derived : public Base {
public:
using Base::Base; // 继承Base的所有构造函数(除了默认、复制和移动构造函数)
// ...
};
18.3.5 管理虚方法:override和final
override:用于明确指出一个成员函数是重写基类中的虚函数。如果声明不匹配,编译器将报错。
class Base {
public:
virtual void f(int) {}
};
class Derived : public Base {
public:
void f(int) override {} // 正确重写
// void f(char) override {} // 错误:不匹配,将报错
};
final:用于防止派生类进一步重写特定的虚函数。
class Base {
public:
virtual void f() final {} // 禁止在派生类中重写
};
class Derived : public Base {
public:
void f() override {} // 错误:Base::f() 是 final 的
};
18.4 Lambda表达式
在C++11及以后的版本中,lambda表达式(也称为lambda函数或lambda表达式)的引入极大地增强了C++的表达能力,特别是在与STL算法结合使用时。Lambda表达式提供了一种简洁的方式来定义匿名函数对象,这些对象可以捕获其所在作用域中的变量,并作为参数传递给接受函数指针或函数对象的STL算法。
Lambda表达式的基本语法
Lambda表达式的基本语法如下:
[capture](parameters) mutable -> return_type {
// 函数体
}
capture:捕获列表,指定lambda表达式体中可以访问的外部变量。捕获方式可以是按值(=)或按引用(&)。
parameters:参数列表,与普通函数相同。
mutable:一个可选的说明符,表示lambda表达式体内的变量可以修改。
return_type:返回类型,可以省略,编译器会根据函数体自动推断。
函数体:包含lambda表达式要执行的代码。
Lambda表达式的使用场景
Lambda表达式在C++中非常有用,特别是在以下场景中:
简洁性:对于简单的函数对象,lambda表达式提供了一种更简洁的替代方案,避免了定义整个函数或函数符类的需要。
局部性:lambda表达式允许将函数定义放在其使用的地方附近,这有助于保持代码的清晰和可维护性。
捕获外部变量:lambda表达式可以捕获其所在作用域中的变量,这使得它们能够访问和操作这些变量,这在处理回调函数或事件处理器时特别有用。
#include <iostream>
#include <vector>
#include <numeric> // 包含std::accumulate
int main() {
std::vector<int> nums = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
int sum = std::accumulate(nums.begin(), nums.end(), 0,
[](int acc, int val) { return acc + (val % 2 == 0 ? val : 0); });
std::cout << "Sum of even numbers: " << sum << std::endl;
return 0;
}
[ ]:这是Lambda表达式的捕获列表,它是空的,意味着这个Lambda表达式不会捕获其所在作用域中的任何变量。在这个例子中,我们不需要捕获外部变量,因为Lambda表达式所需的所有信息(即累加器acc和当前值val)都是通过std::accumulate的参数传入的。
(int acc, int val):这是Lambda表达式的参数列表,它定义了Lambda表达式可以接受哪些参数。在这个例子中,它接受两个整型参数:acc(累加器,用于存储到目前为止的和)和val(当前正在处理的向量元素的值)。
{ return acc + (val % 2 == 0 ? val : 0); }:这是Lambda表达式的函数体,它指定了当Lambda表达式被调用时应该执行的操作。在这个例子中,函数体包含了一个返回语句,该语句使用三元运算符? :来检查val是否为偶数(即val % 2 == 0是否为真)。如果val是偶数,则返回val并将其加到累加器acc上;如果val是奇数,则返回0(即不对累加器acc做任何更改)。
将这个Lambda表达式作为std::accumulate的第四个参数传递时,它会在每次迭代中调用,std::accumulate会提供当前的累加值(初始值为0)和向量中的当前元素值作为参数。因此,Lambda表达式实际上实现了一个自定义的累加逻辑,即只累加偶数元素的值。
18.5 包装器
在C++中,std::function 是一个通用函数包装器,它可以包装几乎所有可调用的实体,包括普通函数、lambda 表达式、函数对象、以及可调用对象等。这使得 std::function 成为在泛型编程中处理可调用对象时非常有用的工具,特别是当你不确定或不想限制可调用对象的类型时。
std::function 的基本用法
std::function 模板通常需要一个类型参数,这个参数指定了函数的签名(即返回类型和参数类型)。例如,std::function<double(double)> 可以包装任何接受一个 double 参数并返回一个 double 的可调用对象。
/*修改前的代码,多次实例化*/
#include <iostream>
template<typename T, typename F>
void use_f(T x, F f) {
static int count = 0;
std::cout << &count << " " << f(x) << std::endl;
++count;
}
// ... 其他代码,包括可调用对象的定义和 use_f 的调用
/*修改后的代码*/
#include <iostream>
#include <functional>
template<typename T>
void use_f(T x, std::function<double(T)> f) {
static int count = 0;
std::cout << &count << " " << f(x) << std::endl;
++count;
}
int main() {
double dub(double x) { return x * 2.0; }
struct Fp { double operator()(double x) const { return x + 1.0; } };
auto fl = [](double x) { return x * x; };
use_f(3.0, dub);
use_f(3.0, Fp()(3.0)); // 注意这里不能直接传 Fp,因为 Fp 是类型不是对象
use_f(3.0, fl);
use_f(3.0, [](double x) { return x / 2.0; });
use_f(3.0, static_cast<double(*)(double)>(dub));
use_f(3.0, std::function<double(double)>(Fp()(3.0))); // 错误用法,需要传递可调用对象
// 正确的使用 Fp 类型
Fp fp_obj;
use_f(3.0, fp_obj);
// 使用临时 std::function 对象
use_f(3.0, std::function<double(double)>([](double x) { return x * 3.0; }));
}
18.6 可变参数模板
可变参数模板概述
可变参数模板(Variadic Templates)是C++11引入的一个强大特性,允许模板函数和模板类接受任意数量和类型的参数。这主要通过模板参数包和函数参数包实现。
模板参数包和函数参数包
模板参数包:使用省略号(...)表示,可以包含任意数量和类型的模板参数。
函数参数包:使用省略号(...)表示,可以包含任意数量和类型的函数参数。
示例:显示参数列表
假设要编写一个show_list函数,该函数接受任意数量和类型的参数(只要这些参数可以被std::cout显示),并将它们以逗号分隔的形式输出。
展开参数包
参数包不能直接通过索引访问,但可以使用递归或C++17引入的折叠表达式来“展开”它们。
/*折叠表达式*/
#include <iostream>
template<int... Ns>
int sum() {
return (... + Ns); // 使用折叠表达式来计算所有Ns的和
}
int main() {
std::cout << "Sum: " << sum<1, 2, 3, 4>() << std::endl; // 输出: Sum: 10
return 0;
}
递归实现
递归是处理可变参数模板的一种常见方法。基本思路是:
处理参数包中的第一个参数。
递归调用自身,处理剩余的参数。
提供一个终止递归的基准情况(通常是参数包为空)。
template<typename T, typename... Args>
void show_list(const T& first, Args... args) {
std::cout << first;
if constexpr (sizeof...(args) > 0) {
std::cout << ", ";
show_list(args...);
} else {
std::cout << std::endl;
}
}
// 使用示例
show_list(1, "Hello", 3.14, std::string("World"));
/*
if constexpr 会在编译时评估条件,并根据条件的结果来包含或排除代码块。
这意味着,如果条件在编译时确定为 false,
则 if constexpr 对应的代码块将不会被编译到最终的程序中,这有助于减少编译后的程序大小
*/
18.7 C++11新增的其他功能
C++11 总结与精要
1. 并行编程
C++11支持多线程编程,通过thread_local关键字和线程库(thread, mutex, condition_variable, future)提供工具。
多线程适合处理并行任务,但需注意线程安全和同步问题。
2. 新增库
随机数库 (<random>):提供更复杂的随机数生成器和分布。
时间库 (<chrono>):处理时间间隔。
元组库 (<tuple>):支持存储多个不同类型值的元组。
有理数库 (<ratio>):支持编译时的有理数算术。
正则表达式库 (<regex>):强大的文本匹配工具。
3. 低级编程
POD扩展:放宽了POD(Plain Old Data)的要求,使其更灵活。通俗的讲,一个类或结构体通过二进制拷贝后还能保持其数据不变,那么它就是一个POD类型。
共用体改进:允许成员拥有构造函数和析构函数。
内存对齐:alignof运算符和alignas说明符支持更精确的内存控制。
constexpr:允许在编译时计算常量表达式,有助于嵌入式编程。
4. 杂项
扩展整型 (<cstdint>):支持C99中的扩展整型。
用户定义字面量:通过字面量运算符定义新的字面量表示法。
编译时断言 (static_assert):在编译阶段进行断言测试。
元编程支持:加强模板元编程能力。
5. 语言变化
标准委员会:ISO/IEC JTC1/SC22/WG21负责C++标准的发展。
库影响标准:STL和Boost库等社区努力推动了C++标准的发展。
Boost库:提供了大量实用库,许多已融入C++11。
6. 高效使用C++
面向对象编程:学习类设计、用例分析和CRC卡片等高级技术。
系统方法:如UML用于项目建模。
特定平台库:如Windows API、Apple Xcode等,简化平台编程。
7. 代码示例
Boost库使用示例:lexical_cast 用于类型转换。
#include <boost/lexical_cast.hpp>
#include <iostream>
#include <string>
int main() {
int i = boost::lexical_cast<int>("123");
std::string s = boost::lexical_cast<std::string>(123.456);
std::cout << "Int: " << i << ", String: " << s << std::endl;
// 注意:浮点数转换到字符串可能无法精确控制格式
double d = 123.456789;
std::string ds = boost::lexical_cast<std::string>(d);
std::cout << "Double to string: " << ds << std::endl;
return 0;
}