C++对象模型:站在对象模型的尖端
Template
模板可以编写泛型代码。模板不仅可以用来创建通用的容器和算法,还可以用于实现复杂的编译时计算,比如模板元编程。
尽管模板提供了极大的灵活性和性能优势,但它们也可能导致一系列的问题,以理解的编译错误信息、较长的编译时间、程序尺寸膨胀等。
1. 模板的声明
当声明一个模板类或模板成员函数时,实际上是在告诉编译器如何根据不同的类型参数生成具体的类或函数。
template <typename T>
class Box {T value;
public:Box(T v) : value(v) {}T getValue() { return value; }
};
Box是一个模板类,可以用来创建不同类型T的具体Box类。
2. 模板的实例化
当模板被实际使用时,编译器会根据提供的类型参数来实例化模板。
Box<int> myIntBox(10); // 创建一个int类型的Box
Box<double> myDoubleBox(7.8); // 创建一个double类型的Box
静态成员和枚举
模板类中的静态成员和枚举需要通过特定类型的模板实例来访问。
如果Box类有一个静态成员count
Box<int>::count = ½; // 正确
// Box::count = ½; // 错误,需要指定类型
如果Box类有一个枚举Color,也需要通过特定类型的模板实例来访问枚举值。
Box<int>::Color color = Box<int>::Color::Red; // 正确
// Box::Color color = Box::Color::Red; // 错误
指针和引用
声明一个指向模板类的指针不会导致模板的实例化,因为指针只是存储地址,并不包含模板类的数据。但是,如果创建了一个模板类的引用,或者直接创建了模板类的对象,那么编译器就需要实例化该模板。
Box<int> *p; //不会导致实例化
const Box<int>& ref = Box<int>(20); //会导致实例化
Box<int> obj(30); //会导致实例化
成员函数的实例化
只有当模板类的成员函数被实际调用时,它们才会被实例化。这意味着如果一个模板类有很多成员函数,但只使用了少数几个,那么只有那些被使用的函数会被编译器实例化,从而节省了编译时间和最终的二进制大小。
Template的错误报告
(1)词法和语法错误:编译器可以在处理模板声明时立即捕捉到词法(如非法字符 $)和语法(如连续的标识符 ex ex 和缺少分号)错误。
(2)类型相关错误:对于类型相关的错误,如默认参数的类型不匹配(第5行)或运算符未定义(第8行),这些错误只有在模板被实例化时才会被检测到。例如,当尝试实例化 Mumble<int> 或 Mumble<int*> 时,编译器会进行类型检查,并在那时报告错误。
(3)成员引用错误:对于成员引用错误,如引用了不存在的成员变量或成员函数,这些错误通常在模板实例化时被发现。如果编译器采用延迟解析策略,那么这些错误直到模板实例化时才会被报告。
编译器策略
(1)完全解析但不做类型检验:一些早期的编译器,如cfront,会对模板进行完全的语法解析,但不会进行类型检验,直到模板被实例化时才进行类型检查。
(2)延迟解析:另一种常见的策略是将模板声明转化为一系列词法记号(tokens),并在实际的模板实例化时再进行解析和类型检验。这种方法可以减少不必要的前期处理,但在模板实例化前不会发现类型相关的错误。
Template 中的名称决议法(Name Resolution within a Template)
名称解析在模板中的作用
在C++中模板名称解析涉及两个不同的作用域:模板定义的作用域和模板实例化的作用域。
类型无关的名称解析:如果名称的使用不依赖于模板参数,编译器会在模板定义的作用域中解析该名称。
类型相关的名称解析:如果名称的使用依赖于模板参数,编译器会在模板实例化的作用域中解析该名称。
成员函数的实例化行为
C++模板成员函数的实例化是一个复杂的过程,编译器需要解决几个问题:
1. 编译器如何找到函数定义?
(1)包含模板程序文本文件:类似于头文件的处理方式,将模板定义包含在每个需要使用的源文件中。
(2)文件命名规则:规定模板定义必须存在于特定命名的文件中,如果在 Point.h 中声明了模板函数,那么其定义必须在 Point.C 或 Point.cpp 中。cfront 编译器采用了这种策略。
(3)混合策略:一些编译器如 Edison Design Group 编译器支持上述两种策略。
2. 如何仅实例化程序中使用的成员函数?
(1)全实例化:生成所有成员函数的实例,即使有些函数从未被使用。Borland 编译器采取了这种策略,但提供了 #pragma 指令来控制实例化。
(2)按需实例化:通过模拟链接过程,仅生成实际需要的成员函数实例。cfront 编译器采用了这种方法,而 Edison Design Group 编译器支持两种策略。
3. 如何防止成员定义在多个目标文件中被多次实例化?
.....
异常处理(Exception Handling)
C++的异常处理由三个主要组成部分构成:throw表达式、catch子句和try块。当异常被抛出时,程序控制权会转移,并且会沿着调用堆栈回溯,直到找到匹配的catch子句。在此过程中,局部对象的析构函数会被调用,称为"堆栈展开"(stack unwinding)的过程。
异常处理的支持
当异常发生时,编译系统需要做:
(1)检验发生throw操作的函数:确定抛出异常的函数。
(2)决定throw操作是否发生在try区段中:判断异常是否在try块中抛出。
如果异常在try块中抛出,编译系统会将异常类型与每一个catch子句进行比较。如果找到匹配的catch子句,控制权转移到该catch子句。
如果throw不在try块中,或者没有匹配的catch子句,编译系统会:
(1)摧毁所有活动的局部对象。
摧毁所有局部对象意味着在当前函数调用栈帧中,所有具有析构函数的局部对象将被销毁。析构函数通常用于释放对象占用的资源,如关闭文件、释放内存、断开网络连接等。在异常处理过程中,确保这些资源被正确释放是非常重要的,以避免资源泄漏。
(2)继续到堆栈中的下一个函数,并重复上述步骤2-5。
确定throw是否发生在try区段中,一个函数可以分为几个区域:
try区段外,没有需要析构的活动局部对象。try区段外,但有一个或多个需要析构的活动局部对象。try区段内。编译器通过构建一个程序计数器范围表来标识这些区域。当异常抛出时,当前的程序计数器值与范围表进行比较,以确定异常是否在try块中。
异常类型的比较
对于每个抛出的异常,编译器会生成一个类型描述符,其中包含异常类型的编码信息。如果异常类型是派生类型,描述符中还会包含所有基类的类型信息,包括私有和保护基类。编译器还为每个catch子句生成一个类型描述符。运行时异常处理器会将抛出对象的类型描述符与每个catch子句的类型描述符进行比较,直到找到匹配的catch子句或堆栈被完全展开。
异常对象的生命周期
当异常对象在运行时被抛出时,会发生以下过程:
异常对象被创建并通常放在一个特殊的异常数据堆栈中。传递给catch子句的是异常对象的地址、类型描述符(或一个返回类型描述符的函数指针)以及可能的异常对象描述符。
catch子句中的异常对象
考虑以下catch子句
catch(exPoint p) {// do somethingthrow;
}
如果异常对象的类型是exVertex,派生自exPoint,那么这个catch子句会匹配。在这种情况下,p会发生以下变化:p会被初始化为异常对象的副本,就像函数参数一样。如果exPoint有复制构造函数和析构函数,它们会应用于p。由于p是一个对象而不是引用,复制时会进行切片(slicing),即只复制exPoint部分,丢弃exVertex特有的部分。此外,p的虚函数表指针(vptr)会被设置为exPoint的虚函数表。当再次抛出异常时,会创建一个新的临时对象,而不是使用原来的exVertex对象。原来的异常对象会被再次抛出,对p的修改会被丢弃。
如果catch子句是引用类型:
catch(exPoint &p) {// do somethingthrow;
}
在这种情况下,p是原始异常对象的引用,任何虚函数调用都会基于exVertex的实际类型,对p的修改会传递到下一个catch子句。
异常处理机制要求编译器跟踪每个函数的作用域,以便在抛出异常时能够找到匹配的catch子句来处理异常。编译器还必须提供查询异常对象类型的方法(RTTI),并且管理被抛出的对象,包括其创建、销毁和清理。假设有一个基类 BaseException 和一个派生类 DerivedException,并且我们希望在抛出DerivedException 时,能够用 BaseException 类型的 catch 子句来捕获它。
异常对象的类型识别(RTTI)
#include <iostream>
#include <typeinfo>
class BaseException {public:virtual ~BaseException() {}
};
class DerivedException : public BaseException {public:~DerivedException() override {}
};
void someFunction() {try {// 抛出一个 DerivedException 对象throw DerivedException();} catch (const BaseException& e) {// 使用 typeid 来检查异常的实际类型if (typeid(e) == typeid(DerivedException)) {std::cout << "Caught a DerivedException" << std::endl;} else {std::cout << "Caught a BaseException" << std::endl;}}
}
int main() {someFunction();return 0;
}
抛出一个 DerivedException 对象。由于 BaseException 是 DerivedException 的基类,我们可以使用 BaseException 类型的引用作为 catch 子句的参数来捕获这个异常。然后,我们使用 typeid 操作符来检查捕获到的异常对象的实际类型。如果异常对象是 DerivedException 类型,程序会输出 "Caught a DerivedException";否则,输出 "Caught a BaseException"。
当执行 throw DerivedException(); 语句时,一个 DerivedException 对象被创建。这个对象是一个临时对象,它被创建在堆栈上或者堆上,具体取决于编译器的实现。抛出的 DerivedException 对象需要被存储起来,直到找到合适的 catch 子句。在存储期间,对象的生命周期由异常处理机制来维护。
当异常对象被传递给 catch (const BaseException& e) 时,它并没有立即被销毁。只有当 catch 块执行完毕时,异常对象才会被销毁。如果在 catch 块内部再次抛出异常(例如 throw或者 throw e),那么原始的异常对象会被复制,并且新的副本会被传递给下一个匹配的 catch 子句。原始对象仍然会在当前 catch 块结束时被销毁。如果 DerivedException 对象包含需要特别清理的资源(比如文件句柄或网络连接),这些资源会在对象的析构函数中被释放。例如,如果我们给 DerivedException 添加一个析构函数来关闭文件句柄,那么当 DerivedException 对象被销毁时,文件句柄就会被关闭。
当一个异常被抛出时,异常处理机制需要确定异常对象的实际类型,这样才能找到匹配的catch子句。C++提供了typeid操作符和dynamic_cast,这些都是基于RTTI的特性。通过RTTI,编译器能够在运行时检查异常对象的类型,从而决定应该执行哪个catch块。
异常对象的生命周期管理
异常对象在抛出时被创建,在找到匹配的catch子句后被销毁。编译器需要负责确保异常对象在整个异常处理过程中得到正确的管理。
当throw表达式被执行时,一个新的异常对象会被创建,这个对象通常是一个临时对象。异常对象需要被存储,直到找到合适的catch子句。这个存储位置可能是堆栈或堆,取决于编译器的实现。一旦异常对象被传递给catch子句,它将在catch块结束时被销毁。如果catch子句中有throw语句来重新抛出异常,那么原来的异常对象会被复制,新的副本将被传递给下一个catch子句。如果异常对象包含了需要特殊清理的资源(如文件句柄或网络连接),那么在对象被销毁时,这些资源需要被正确地释放。这通常是通过对象的析构函数来完成的。
异常处理可能对程序大小和执行速度产生影响。编译器可以选择在编译时或运行时构建支持异常处理所需的数据结构。编译时构建可以提高执行速度但可能增加程序大小,运行时构建则相反。
- 在编译时构建的情况下,编译器会在编译阶段生成一个异常处理表,这个表包含了关于每个try-catch块的信息,比如它们在代码中的位置、哪些异常类型可以被捕获等。这样,当异常发生时,编译器可以非常快速地查找这个表来决定如何处理异常。
- 编译器会动态地构建异常处理信息,它可能会创建一个临时的数据结构,记录当前的调用堆栈和异常类型。然后,这个数据结构会被用来搜索匹配的catch块。如果找到了匹配的catch块,异常对象会被传递给它;如果没有找到匹配的catch块,则会继续向上搜索调用堆栈,直到找到一个匹配的catch块或到达顶层调用。
对于一些看似简单的函数,异常处理也带来了额外的复杂性。
当一个异常抛出时,函数可能需要确保某些资源被正确释放,如锁定的内存等。文章还提供了几种编写异常安全代码的技术,比如使用智能指针auto_ptr来自动管理动态分配的内存,或者封装资源获取和释放逻辑到类的构造函数和析构函数中。
// 原始的mumble函数,没有异常处理
Point* mumble() {Point *pt1, *pt2;pt1 = foo();if (!pt1) return 0;Point p; // 局部对象pt2 = foo();if (!pt2) return pt1;// ... 其他代码return pt2;
}
// 添加了异常处理的mumble函数
void mumble(void *arena) {Point *p = new Point;smLock(arena); // 锁定内存// ... 其他代码smUnlock(arena); // 解锁内存delete p;
}
// 使用try-catch块使mumble函数异常安全
void mumble(void *arena) {Point *p;p = new Point;try {smLock(arena);// ... 其他代码} catch (...) {smUnlock(arena);delete p;throw; // 重新抛出异常}smUnlock(arena);delete p;
}//使用智能指针和封装资源管理的mumble函数
void mumble(void *arena) {std::auto_ptr<Point> p(new Point);SMLock sm(arena); // 封装了锁的类// 不需要显式的解锁和删除
}
原始的 mumble 函数,没有异常处理。如果 foo() 抛出异常,那么 mumble 函数将直接终止,而不会释放任何已分配的资源。此外局部对象 p 会在函数退出时自动销毁,但如果在 p 的构造过程中抛出异常,函数会直接退出,不会执行后续的代码。
添加异常处理的mumble函数
void mumble(void *arena) {Point *p = new Point;smLock(arena); // 锁定内存// ... 其他代码smUnlock(arena); // 解锁内存delete p;
}
该函数创建了一个 Point 对象,锁定了一块内存(arena),执行一些其他代码,然后解锁内存并删除 Point 对象。
使用 try-catch 块来确保在抛出异常时,smUnlock 和 delete p 仍然会被执行。如果在 try 块中的代码抛出异常,catch 块会解锁内存并删除 Point 对象,然后重新抛出异常。如果 try 块中的代码没有抛出异常,smUnlock 和 delete p 会在 try 块结束后正常执行。
使用智能指针和封装资源管理的 mumble 函数
void mumble(void *arena) {std::auto_ptr<Point> p(new Point);SMLock sm(arena); // 封装了锁的类// 不需要显式的解锁和删除
}
auto_ptr 会在离开作用域时自动删除 Point 对象,即使在构造 Point 对象或执行其他代码时抛出异常。SMLock 类会在构造函数中锁定内存,在析构函数中解锁内存,这样即使在 try 块中的代码抛出异常,锁也会被正确地解锁。方法利用了RAII原则,确保资源在对象生命周期内得到正确管理。
执行期类型识别(Runtime Type Identification,RTTI)
运行时类型识别(RTTI)允许程序员查询对象的实际类型,以及将基类的指针或引用转换为派生类的指针或引用。
#include <typeinfo> // 用于 typeid 和 dynamic_cast
//基类
class type {
public:virtual ~type() {} // 虚析构函数,确保派生类能够正确销毁virtual void call() = 0; // 纯虚函数,使 type 成为抽象类
};
// 单一函数类型
class fct : public type {
public:void call() override {std::cout << "fct::call()" << std::endl;}
};
// 可以被重载的函数类型
class gen : public type {
public:void call() override {std::cout << "gen::call() - non-const" << std::endl;}void call() const override {std::cout << "gen::call() - const" << std::endl;}
};
int main() {// 创建 type* 指针,并让它指向 fct 对象type* typePtr1 = new fct();// 创建 type* 指针,并让它指向 gen 对象type* typePtr2 = new gen();// 尝试将 type* 转换为 fct*fct* fctPtr1 = dynamic_cast<fct*>(typePtr1);if (fctPtr1) {fctPtr1->call(); // 安全地调用 fct::call()} else {std::cout << "typePtr1 is not a fct" << std::endl;}// 尝试将 type* 转换为 fct*,但实际上是 genfct* fctPtr2 = dynamic_cast<fct*>(typePtr2);if (fctPtr2) {fctPtr2->call(); // 如果转换成功,调用 fct::call()} else {std::cout << "typePtr2 is not a fct, it's actually a gen" << std::endl;}// 清理资源delete typePtr1;delete typePtr2;return 0;
}
这里的type是所有类型的基类,fct代表单一函数类型,而gen代表可以被重载的函数类型。
当尝试将 type* 指针转换为 fct* 时,如果该指针实际上指向的是 gen 对象,那么使用 static_cast 将导致未定义行为,因为 gen 和 fct 的内存布局可能不同。使用 dynamic_cast 则会在运行时检查实际的对象类型,如果发现类型不匹配,它会返回 nullptr,从而避免了潜在的错误。
dynamic_cast 在运行时检查类型转换的有效性。它使用了 RTTI(运行时类型信息)来确定对象的真实类型。如果转换不安全,dynamic_cast 会返回 nullptr(对于指针)或抛出异常(对于引用),这使得程序可以在转换失败时采取适当的措施。dynamic_cast 只能用于含有至少一个虚函数的类之间(即多态类型)。这是因为 RTTI 只能应用于这些类。
为什么要将基类指针转换为派生类指针?
将基类指针转换为派生类指针通常是为了访问派生类特有的成员,这些成员在基类中是不可见或不存在的,基类指针只能访问基类中定义的公共或保护成员。这种转换通常发生在多态场景下,当有一个指向基类的指针,但实际上该指针指向的是派生类的对象。通过向下转型(downcast),可以利用派生类特有的功能。
在多态设计中,基类指针经常被用来指向派生类对象,以便于编写通用的代码。但在某些情况下,你可能需要知道具体的派生类类型,以便执行特定的操作。
有时候,直接使用派生类指针可以提高性能,尤其是在频繁调用派生类特有的方法时,直接访问可以避免虚函数调用带来的额外开销。
#include <iostream>
class Shape {
public:virtual ~Shape(){}virtual void draw() const = 0; // 纯虚函数,使得 Shape 成为抽象类
};
class Circle : public Shape {
private:double radius;
public:Circle(double r) : radius(r) {}void setRadius(double r) {radius = r;}void draw() const override {std::cout << "Drawing a circle with radius: " << radius << std::endl;}
};
class Rectangle : public Shape {
private:double width, height;
public:Rectangle(double w, double h) : width(w), height(h) {}void draw() const override {std::cout<<"Drawing a rectangle with width: "<< width<<" and height: "<< height << std::endl;}
};
int main() {Shape* shape1 = new Circle(5.0);Shape* shape2 = new Rectangle(8.0, 9.0);// 通过基类指针调用 draw 方法shape1->draw(); // 输出圆的信息shape2->draw(); // 输出矩形的信息// 如果需要设置圆的半径,就需要向下转型Circle* circle = dynamic_cast<Circle*>(shape1);if (circle) {circle->setRadius(½); // 设置新的半径circle->draw(); // 再次绘制,显示更新后的半径} else {std::cout << "shape1 is not a Circle" << std::endl;}// 清理资源delete shape1;delete shape2;return 0;
}
非法/合法的向下转换
向下转换是合法的,当且仅当基类指针或引用实际上指向的是派生类对象。当基类指针或引用实际指向的是派生类对象时,向下转换是合法的。例如,如果有一个 Base* 指针,但它实际上指向的是 Derived 对象,那么将其转换为 Derived* 是合法的。
在C++中,当一个派生类对象被创建时,它首先包含了所有基类的数据成员。这意味着派生类对象的内存布局开始于基类部分,然后是派生类特有的部分。因此,一个指向基类部分的指针实际上也是指向整个派生类对象的起始位置。
当创建一个 Derived 对象时,内存布局如下:
+-----------------+
| Base::baseData | <- Base* 指向这里
+-----------------+
| Derived::data |
+-----------------+
一个 Base* 指针实际上可以用来访问整个 Derived 对象,只是通过这个指针只能访问 Base 部分的成员。如果我们将 Base* 向下转换为 Derived*,就可以访问 Derived 特有的成员。
如果基类指针或引用实际指向的是基类对象本身,而不是派生类对象,那么将其转换为派生类指针或引用是非法的。这会导致未定义行为。
使用 static_cast 或 C 风格的强制类型转换:static_cast 和 C 风格的强制类型转换(如(Derived*)basePtr)不会进行运行时类型检查。如果转换不合法,它们不会提供任何保护,可能会导致未定义行为。
使用 dynamic_cast:在不确定基类指针或引用实际指向哪种派生类对象时,使用 dynamic_cast 进行向下转换。它会在运行时进行类型检查,确保转换的合法性。
dynamic_cast 的工作原理
当使用 dynamic_cast 将基类指针或引用转换为派生类指针或引用时,编译器会在运行时检查实际的对象类型是否匹配。如果转换是不可能的(尝试将基类指针转换为一个无关的派生类),dynamic_cast 会返回 nullptr 对于指针,或者抛出 std::bad_cast 异常对于引用。
为了实现 dynamic_cast,C++ 使用了 RTTI 机制。RTTI 允许程序在运行时获取对象的类型信息。每个包含虚函数的类(即多态类)都有一个类型信息对象 (type_info),并且这个对象的指针通常被存储在虚函数表(vtable)的第一个槽位。
dynamic_cast 的成本
(1)空间成本:每个具有虚函数的类实例都会有一个额外的指针(通常是 vptr),它指向虚函数表。虚函数表的第一个槽位存放的是指向 type_info 对象的指针。
(2)时间成本:在执行 dynamic_cast 时,需要访问 vptr 来找到 type_info,然后与目标类型的 type_info 进行比较。这是一个运行时操作,比 static_cast 更加昂贵。
dynamic_cast 与引用
引用与指针的一个关键区别在于引用必须总是引用一个有效的对象,不能像指针那样可以为空(nullptr)。因此,当 dynamic_cast 应用于引用时,它不能返回 nullptr 来表示转换失败。相反,如果转换不安全,dynamic_cast 会抛出一个 std::bad_cast 异常。
Base& baseRef = getSomeBaseReference(); // 假设这个函数返回一个 Base& 引用
try {Derived& derivedRef = dynamic_cast<Derived&>(baseRef);// 转换成功,可以安全地使用 derivedRef
} catch (const std::bad_cast& e) {// 转换失败,baseRef 不是 Derived 对象的引用
}
可以重新实现 simplify_conv_op 函数,使其接受一个引用作为参数,并使用 dynamic_cast 来尝试向下转换。如果转换成功,函数将继续执行;如果转换失败,将捕获 std::bad_cast 异常并处理错误情况。
TypeID运算符
typeid 运算符用于获取表达式的类型信息,它可以用于在运行时检查变量或表达式的类型。
typeid 运算符返回一个 const std::type_info 类型的引用,该引用提供了有关类型的详细信息。
std::type_info 是 C++ 标准库中定义的一个类,它提供了关于类型的名称和其他信息。
typeid 运算符的基本用法
typeid(expression):返回表达式 expression 的类型信息。
typeid(type):返回类型 type 的类型信息。
std::type_info 类
std::type_info 类提供了以下成员函数:
virtual ~type_info();:析构函数。
bool operator==(const type_info& rhs) const;:判断两个 type_info 对象是否相等。
bool operator!=(const type_info& rhs) const;:判断两个 type_info 对象是否不相等。
bool before(const type_info& rhs) const;:比较两个 type_info 对象,用于排序。
const char* name() const;:返回类型的名称。
typeid 与多态类和非多态类
typeid 可以用于多态类(包含虚函数的类),在这种情况下,type_info 对象是在运行时确定的。
typeid 也可以用于非多态类(不包含虚函数的类)和内置类型,这种情况下,type_info 对象是在编译时确定的。
#include <iostream>
#include <typeinfo>
class Base {
public:virtual ~Base() {}
};
// 派生类
class Derived : public Base {
public:void doSomething() {std::cout << "Doing something in Derived" << std::endl;}
};
// 模板类
template <typename T>
class Generic {public:T value;Generic(T val) : value(val) {}
};
// 用于简化类型转换的函数
void simplify_conv_op(const Base& rt) {if (typeid(rt) == typeid(Derived)) {// 向下转换为 Derived 类型Derived& rf = static_cast<Derived&>(rt);rf.doSomething();} else if (typeid(rt) == typeid(Generic<int>)) {// 向下转换为 Generic<int> 类型Generic<int>& rg = static_cast<Generic<int>&>(rt);std::cout << "Generic<int> value: " << rg.value << std::endl;} else {// 其他类型的处理std::cout << "Unknown type" << std::endl;}
}