C++对象模型:Function 语意学
Member 的各种调用方式
Nonstatic Member Function
使用C++时,成员函数和非成员函数在性能上应该是等价的。当设计类时,我们不应该因为担心效率问题而避免使用成员函数。
实现:编译器会将成员函数转换为一个带有额外this指针参数的非成员函数,这使得成员函数可以直接访问对象的数据成员。
成员函数到非成员函数的转换
签名修改:首先,成员函数的签名会被修改,在最前面添加一个隐式的this指针作为参数。如果成员函数是const的,那么this指针也会是const。
非const成员函数:
float Point3d::magnitude() { ... }
转换后:
float Point3d::magnitude(Point3d *const this) { ... }
const成员函数:
float Point3d::magnitude() const { ... }
转换后:
float Point3d::magnitude(const Point3d *const this) { ... }
通过this指针存取数据成员:接下来,所有对非静态数据成员的直接访问都会被替换为通过this指针的间接访问。 例如:
return sqrt(_x*_x +_y*_y + _z*_z);
会被转换为:
return sqrt(this->_x * this->_x + this->_y * this->_y + this->_z * this->_z);
名称修饰:最后,成员函数的名称会被进行“名称修饰”(name mangling),以确保它在整个程序中的唯一性。这样可以支持重载和其他语言特性。 假设原始的成员函数是:
float Point3d::magnitude() const;
编译器可能会生成如下形式的外部函数:
extern "C" float __Z9magnitudeRK7Point3d(const Point3d *const this);
调用转换:对于每个成员函数调用,编译器会生成相应的代码来传递当前对象的地址作为this指针。
对象直接调用:
obj.magnitude();
变为
__Z9magnitudeRK7Point3d(&obj);
指针调用:
ptr->magnitude();
变为:
__Z9magnitudeRK7Point3d(ptr);
优化例子
考虑一个归一化向量的成员函数:
Point3d Point3d::normalize() const {float mag = magnitude();return Point3d(_x / mag, _y / mag, _z / mag);
}
这个函数可能被转换成类似下面的形式(假设已经进行了NRV优化)
void normalize__7Point3dFv(register const Point3d *const this, Point3d &result) {register float mag = this->magnitude(); // 使用了转换后的magnitude函数new (&result) Point3d(this->_x / mag, this->_y / mag, this->_z / mag); // 直接构造
}
这里,&result代表了返回值的位置,new (&result)是放置新对象的原地构造(placement new)。这样做避免了默认构造函数的开销,并且直接创建了归一化后的Point3d对象。
名称的特殊处理(Name Mangling)
名称修饰(name mangling)将函数和成员变量的名称转换为唯一的内部表示形式。这样做为了支持重载、类成员访问以及跨模块链接时的类型安全。
解决同名问题:当一个基类和派生类中存在同名成员时,编译器需要一种方式来区分它们。
支持函数重载:即使两个函数具有相同的名字,只要它们的参数列表不同,编译器也需要能够生成不同的内部名称。
确保类型安全链接:通过将函数签名编码进名称中,可以防止链接时由于类型不匹配导致的错误。
考虑以下类定义:
class Bar {
public:int ival;
};
class Foo:public Bar {
public:int ival; // 与Bar::ival同名
};
Foo对象包含了一个Bar的实例和一个自己的ival。
为了区分这两个ival,编译器可能会对它们进行名称修饰如:
Bar::ival 可能被修饰为 ival__3Bar,Foo::ival 可能被修饰为 ival__3Foo。这使每个成员都有一个唯一的名字,避免了命名冲突。
对于成员函数,尤其是重载的成员函数,名称修饰更加复杂。因为除了类名外,还需要包括函数的参数类型信息。
class Point {
public:void x(float newX);float x();
};
编译器可能将这些函数修饰为:
void x__5PointFf(float newX); (5是Point的长度,Ff表示有一个float参数)
float x__5PointFv(); (Fv表示没有参数)
这确保即使函数名字相同,只要参数列表不同,就会有不同的内部名称。
类型安全链接
名称修饰有助于在链接阶段进行有限的形式类型检查。如果有一个print函数定义如下:
void print(const Point3d& p) { ... }
而用户意外地声明并调用它为:
// 错误的声明
void print(const Point3d* p);
由于名称修饰的不同,链接器会发现无法解析这个函数调用,从而报错。这称为“类型安全链接”(type-safe linkage)。这种机制只能检测函数签名(即名称、参数个数和类型)的错误,而不能检测返回类型的错误。
Virtual Member Functions(虚拟成员函数)
C++中虚拟成员函数是实现多态的关键机制。当一个成员函数被声明为virtual时,它可以在派生类中被重写,并且通过基类指针或引用调用时,会根据实际对象的类型来决定调用哪个版本的函数。
假设normalize()是一个虚拟成员函数,那么以下的调用:
ptr->normalize();
会被内部转换为:
(*ptr->vptr[1])(ptr);
vptr 是编译器生成的一个指向虚函数表的指针。每个包含或继承了至少一个虚拟函数的对象都会有一个这样的指针。1 是虚函数表中的索引值,对应于normalize()函数的位置。
第二个 ptr 表示 this 指针,即当前对象的地址。
显式调用虚函数以避免虚函数机制开销
如果在同一个类的方法中调用另一个虚函数,并且已经知道具体的类型,可以直接调用该函数,而不是通过虚函数机制。如在 Point3d::normalize() 中调用 magnitude() 时,直接调用 Point3d::magnitude() 会更高效,因为它避免了查找vtable的过程。
register float mag = Point3d::magnitude();
内联虚函数
如果虚函数被声明为内联,编译器可以直接展开函数体,从而进一步提高性能。如果 magnitude() 是内联的,那么在 normalize() 中调用它时,编译器可以直接将 magnitude() 的代码嵌入到 normalize() 中,避免了虚函数机制的开销。
对象直接调用虚函数
对于直接通过对象调用虚函数的情况,编译器可以进行优化,直接调用具体类型的函数,而不是通过虚函数表。如对于 obj.normalize();,编译器可以直接调用 Point3d::normalize(),而不需要通过 vptr 查找vtable。
normalize__7Point3dFv(dobj);
假设我们有以下类定义:
class Point3d {
public:float _x, _y, _z;virtual float magnitude() const {return sqrt(_x * _x + _y * _y + _z * _z);}virtual void normalize() const {float mag = magnitude();// 归一化逻辑}
};
通过基类指针调用
Point3d* ptr = new Point3d();
ptr->normalize();
显式调用虚函数
在 normalize() 中调用 magnitude() 时,直接调用 Point3d::magnitude() 会更高效:
void Point3d::normalize() const {float mag = Point3d::magnitude(); // 直接调用,避免虚函数机制// 归一化逻辑
}
内联虚函数
如果 magnitude() 被声明为内联函数:
inline float Point3d::magnitude() const {return sqrt(_x * _x + _y * _y + _z * _z);
}
在 normalize() 中调用时,编译器可以直接展开 magnitude() 的代码,进一步提高效率
void Point3d::normalize() const {float mag = sqrt(_x * _x + _y * _y + _z * _z); // 直接展开// 归一化逻辑
}
Static Member Functions(静态成员函数)
静态成员函数
在C++中,静态成员函数(static member functions)是一种特殊的成员函数,它们不依赖于类的任何特定实例。静态成员函数的主要特性是没有 this 指针,因此不能直接访问类的非静态成员。以下是对静态成员函数的详细解释和内部转换机制。
调用静态成员函数
假设 Point3d::normalize() 是一个静态成员函数,那么以下两种调用方式:
obj.normalize();
ptr->normalize();
静态成员函数没有 this 指针,不能直接访问类的非静态成员。不能被声明为 const、volatile 或 virtual。静态成员函数可以通过类名直接调用,也可以通过类的对象调用,但通常是直接通过类名调用。
表达式求值:
如果静态成员函数是通过某个表达式获得的类对象调用,该表达式仍然会被评估。
if (foo().object_count() > 1)
会被转换为:
(void) foo(); // 评估表达式以保存副作用
if (Point3d::object_count() > 1) ...
内部转换
静态成员函数在编译器内部会被转换为一个普通的非成员函数。例如:
unsigned int Point3d::object_count() {return _object_count;
}
会被 cfront 编译器转换为:
unsigned int object_count__5Point3dSFv() {return _object_count__5Point3d;
}
其中 SFv 表示这是一个静态成员函数,拥有一个空的参数列表。
取地址
取静态成员函数的地址时,得到的是其在内存中的位置。由于静态成员函数没有 this 指针,所以其地址的类型是一个普通函数指针,而不是指向类成员函数的指针。
auto func = &Point3d::object_count;
func 的类型将是:
unsigned int (*)();
而不是unsigned int (Point3d::*)();
virtual Member Functions(虚成员函数)
虚拟成员函数是C++中实现多态,为了支持虚拟函数,编译器需要在运行时确定对象的实际类型,并调用正确的函数版本。
实现模型
每个类有一个虚函数表,其中包含该类中所有虚函数的地址。每个对象有一个指向虚函数表的指针。通过这个机制,可以在运行时动态地选择和调用正确的虚函数。
执行期类型判断:
为了支持多态,必须能够在运行时确定对象的实际类型。
ptr->z();
编译器需要在运行时知道 ptr 指向的对象类型,以便调用正确的 z() 函数实例。最直接但成本最高的方法是将必要的信息附加到指针上。这会导致空间开销增加,并且破坏与C语言的兼容性。
更好的方法是将这些信息存储在对象本身。然而并不是所有的类都需要这些信息。
简单的C结构体如 struct date { int m, d, y; }; 并不需要额外的信息。
为了识别哪些类需要额外的运行时信息,最合理的方法是检查类是否包含任何虚函数。只要类有一个虚函数,它就需要这些额外的运行时信息。class 和 struct 不能帮助我们区分,它们在语法上是等价的,因此唯一适当的方法是查看类是否有虚函数。
存储的信息:对于每个多态对象,需要存储两个信息:
类型标识:一个字符串或数字,表示类的类型。
虚函数表指针:指向一个表格,该表格包含程序中的虚函数的运行时地址。
虚函数表的构建:虚函数表在编译时构建,其大小和内容在运行时不会改变。编译器完全控制虚函数表的构建和访问。每个类只有一个虚函数表,包含所有活跃的虚函数实例的地址。
纯虚函数的空间占位符,通常是 pure_virtual_called() 函数,用于处理纯虚函数的调用。
虚函数调用:每个虚函数被分配一个固定的索引值,这个索引在整个继承体系中保持一致。
例如,在 Point 类体系中:
#include <iostream>class Point {
public:// 虚析构函数,确保派生类对象能够正确释放资源virtual ~Point() {}// 纯虚函数,使得Point成为一个抽象类virtual Point* mult(float) = 0;// 获取x坐标float x() const { return _x; }// 默认的y坐标为0,可以被派生类重写virtual float y() const { return 0.0f; }// 默认的z坐标为0,可以被派生类重写virtual float z() const { return 0.0f; }protected:// 构造函数,默认x坐标为0Point(float x = 0.0f) : _x(x) {}// x坐标私有变量float _x;
};// 2D点类,继承自Point
class Point2d : public Point {
public:// 构造函数,默认x和y坐标为0Point2d(float x = 0.0f, float y = 0.0f) : Point(x), _y(y) {}// 析构函数~Point2d() override {}// 重写mult方法Point2d* mult(float scalar) override {return new Point2d(_x * scalar, _y * scalar);}// 重写y方法float y() const override { return _y; }
protected:// y坐标私有变量float _y;
};// 3D点类,继承自Point2d
class Point3d : public Point2d {
public:// 构造函数,默认x、y和z坐标为0Point3d(float x = 0.0f, float y = 0.0f, float z = 0.0f) : Point2d(x, y), _z(z) {}// 析构函数~Point3d() override {}// 重写mult方法Point3d* mult(float scalar) override {return new Point3d(_x * scalar, _y * scalar, _z * scalar);}// 重写z方法float z() const override { return _z; }
protected:// z坐标私有变量float _z;
};
虚析构函数被分配槽位1,mult() 被分配槽位2,但由于它是纯虚函数,实际存放的是pure_virtual_called()的地址,y() 被分配槽位3,z() 被分配槽位4,x()不是虚函数,因此没有槽位。
调用转换:编译器将虚函数调用转换为对虚函数表的访问。例如,ptr->z(); 被转换为:
(*ptr->vptr[4])(ptr);
其中 vptr 是指向虚函数表的指针,4 是 z() 在虚函数表中的索引。
单一继承
在单一继承的情况下,虚函数机制表现良好,既有效率又容易建模。每个派生类会扩展基类的虚函数表,并添加自己的虚函数。
多重继承
在多重继承的情况下,事情变得复杂一些。每个基类都有自己的虚函数表,派生类需要维护多个虚函数表指针。为了支持多态,编译器需要确保通过任何基类指针都能正确地调用派生类的虚函数。
//基类 Basel
class Basel {
public:// 构造函数Basel() : data_Basel(0.0f) {}// 虚析构函数virtual ~Basel() {}// 纯虚函数,使得Basel成为一个抽象类virtual void speakClearly() = 0;// 克隆方法virtual Basel* clone() const = 0;
protected:// 数据成员float data_Basel;
};
// 另一个基类 Base2
class Base2 {
public:// 构造函数Base2() : data_Base2(0.0f) {}// 虚析构函数virtual ~Base2() {}// 纯虚函数,使得Base2成为一个抽象类virtual void mumble() = 0;// 克隆方法virtual Base2* clone() const = 0;
protected:// 数据成员float data_Base2;
};
// 派生类 Derived,继承自两个基类
class Derived : public Basel, public Base2 {
public:// 构造函数Derived() : data_Derived(0.0f) {}// 虚析构函数virtual ~Derived() {}// 实现 Basel 的纯虚函数void speakClearly() override {std::cout << "Derived speaking clearly" << std::endl;}// 实现 Base2 的纯虚函数void mumble() override {std::cout << "Derived mumbling" << std::endl;}// 实现克隆方法Derived* clone() const override {return new Derived(*this);}
protected:// 数据成员float data_Derived;
};
当一个派生类从多个基类继承时,基类中包含虚函数,编译器需要指针调整。当通过基类指针删除派生类对象时,必须调用正确的虚析构函数。如果Base2是Derived的一个基类,并且pbase2指向Derived对象,那么通过delete pbase2,删除对象时,编译器需要确保调用的是Derived的析构函数,而不是Base2的析构函数。
为了支持这一点,编译器会在Base2的虚表(vtable)中放置一个指向Derived析构函数的指针。这样,即使pbase2是一个Base2*类型的指针,它也会调用Derived的析构函数,从而正确地清理Derived对象。
当通过Base2*指针调用mumble()方法时,如果mumble()是虚函数,那么编译器会使用Base2的虚表来找到实际要调用的方法。但是,由于Derived是从Base2继承而来的,它的内存布局可能与Base2不同,因此this指针需要调整到正确的偏移位置,以便指向Derived对象中的Base2子对象。
虚拟基类指针调整:在虚函数表中存储一个偏移量,用于调整this指针。这样,即使通过基类指针调用虚函数,也能找到正确的派生类方法。
隐藏的调整代码:编译器生成一些额外的代码,在调用虚函数之前调整this指针。这种调整通常是通过添加或减去一个固定的偏移量来完成的。
Bjarne Stroustrup 在 cfront 中的做法
Bjarne Stroustrup 在早期的 C++ 编译器 cfront 中采取了一种方法,即扩展虚表(vtable),使其不仅包含虚函数的地址,还包含了一个偏移量。这样,每个虚表条目变成了一个结构体,包含函数地址(faddr)和偏移量(offset)。
例如:
原本的虚表条目可能是这样的:void (Base2::*fptr)()。
扩展后的虚表条目则是这样的:struct { void (*faddr)(); int offset; }。
当通过Base2*指针调用虚函数时,编译器会执行以下操作:从pbase2->vptr[1]获取结构体。使用pbase2 + vptr[1].offset来调整this指针。调用(*vptr[1].faddr)(pbase2 + vptr[1].offset);
这样做以确保即使通过基类指针调用虚函数,也能正确地调整this指针,从而调用派生类中的相应方法。
缺点:
所有的虚函数调用都需要额外的开销,即使不需要调整this指针,这包括了额外内存访问(读取偏移量)和额外的加法操作(调整this指针),此外每个虚表条目的大小也增加了,导致更多的内存占用。通常采用更优化的方法来处理这种情况,比如只对需要调整this指针的虚函数进行特殊处理,而不是对所有虚函数都做这种调整。这样可以在不牺牲性能的情况下支持多重继承和虚函数。
Thunk 技术
Thunk 是一小段汇编代码,它用于在调用虚函数之前调整this指针。
(1)调整 this 指针:根据基类子对象在派生类中的偏移量,调整this指针。
(2)跳转到虚函数:将控制权转移到实际的虚函数。
假设有一个派生类 Derived 继承自两个基类 Base1 和 Base2,并且通过 Base2* 指针删除 Derived 对象。相关的 thunk 可能看起来像这样:
// 虚拟 C++ 代码
pbase2_dtor_thunk:this += sizeof(Base1); // 调整 this 指针Derived::~Derived(this); // 跳转到 Derived 的析构函数
编译器实现
Bjarne Stroustrup 在早期的 cfront 编译器中没有使用 thunk 技术,主要是因为 cfront 使用 C 作为中间语言,而 C 语言无法高效地生成高效的 thunk 代码。现代编译器通常可以直接生成高效的 thunk 代码,从而避免了额外的空间和性能开销。
Virtual Table Slots
为了支持多重继承,每个基类都有自己的虚表(vtable)。每个虚表槽(slot)可以包含两种类型的地址:
直接指向虚函数:如果不需要调整 this 指针。
指向一个 thunk:如果需要调整 this 指针。
Base1 *pbase1 = new Derived;
Base2 *pbase2 = new Derived;
delete pbasel; // 不需要调整 this 指针
delete pbase2; // 需要调整 this 指针
在这种情况下,Derived 类会有两个虚表:
主要虚表:与 Base1 共享,不需要调整 this 指针。
次要虚表:与 Base2 关联,需要调整 this 指针。
为什么要调整this指针?
多个虚表
对于每个虚表,Derived 对象中有对应的 vptr。这些 vptr 在构造函数中初始化。例如,Derived 对象可能有如下布局:vptr1 指向主要虚表 vtbl_Derived。vptr2 指向次要虚表 vtbl__Base2__Derived。
主要虚表:
vtbl_Derived Derived 对象通过 Base1* 或 Derived* 指针访问时使用。直接指向虚函数或指向不需要调整 this 指针的 thunk。
次要虚表
vtbl__Base2__Derived。当 Derived 对象通过 Base2* 指针访问时使用。直接指向虚函数或指向需要调整 this 指针的 thunk。
构造函数中的初始化:在构造函数中,编译器会生成代码来初始化这些 vptr。
Derived::Derived() {// 初始化 vptr1 指向主要虚表vptr1 = &vtbl_Derived;// 初始化 vptr2 指向次要虚表vptr2 = &vtbl__Base2__Derived;
}
虚拟继承
在虚拟继承的情况下,虚函数机制变得更加复杂。虚基类共享同一个虚函数表,派生类需要处理复杂的虚函数表布局,以确保正确的虚函数被调用。
class Point2d {
public://构造函数,默认参数为0.0Point2d(float x = 0.0, float y = 0.0) : x_(x), y_(y) {}//虚析构函数virtual ~Point2d() {}//纯虚函数mumble,需要在派生类中实现virtual void mumble() = 0;//纯虚函数z,需要在派生类中实现virtual float z() const = 0;
protected:// 保护成员变量x_和y_float x_, y_;
};
//Point3d继承自Point2d,使用虚继承
class Point3d : public virtual Point2d {
public://构造函数,默认参数为0.0Point3d(float x=0.0,float y=0.0, float z=0.0):Point2d(x, y), z_(z) {}// 析构函数~Point3d() override {}// 实现基类中的纯虚函数zfloat z() const override {return z_; // 返回z坐标值}// 如果需要的话,这里也可以实现mumble()方法
private:// 私有成员变量z_float z_;
};
虚拟继承与非虚拟继承的区别:
在非虚拟的单一继承中,派生类对象的内存布局是直接包含基类子对象,派生类对象可以直接转换为基类对象指针,而不需要调整this指针。虚拟继承是为了处理多重继承中的菱形问题(即一个类从多个基类派生,这些基类又共享一个共同的基类)。在虚拟继承的情况下,编译器确保派生类只保留一份共同基类的实例。这会导致派生类对象和基类对象之间的内存布局有所不同(?)。
调整this指针:当使用虚拟继承时,由于基类可能不是位于派生类对象的开始位置,因此当通过基类指针访问派生类成员时,编译器需要对this指针进行调整。
如果虚拟基类也包含虚函数和非静态数据成员,那么内存布局会变得更加复杂。每个派生类都需要维护指向虚拟基类的指针,并且这些指针的位置以及如何通过它们找到实际的数据成员变得非常棘手。
编译器需要计算正确的偏移量来定位这些数据成员。
建议避免的做法:不要在虚拟基类中声明非静态数据成员。简化内存布局,减少编译器的工作负担,并降低程序出错的可能性。如果确实需要数据成员,可以在派生类中定义,或者考虑重新设计类层次结构以避免使用虚拟继承。
函数的效能
(1)非成员函数的性能
非成员函数的调用效率很高,它们不涉及对象的状态和虚拟机制的开销。由于实现简单,编译器可以进行优化。
(2)内联函数
未优化的内联函数提高了约25%的效率,而优化后的内联函数表现极为出色。编译器可以将不变的表达式移出循环,从而减少不必要的计算,这一优化提升了性能。
(3)虚拟函数的开销
虚拟函数调用相较于非虚拟函数,存在额外的开销。这是由于虚拟函数的实现需要通过虚拟表(vtable)和虚拟指针(vptr)进行查找。每次虚拟函数的调用都会涉及到指针的调整和查找,从而导致性能下降。
(4)继承的影响
在单继承的情况下,每增加一层继承,虚拟函数的执行时间明显增加。这是因为每层继承都可能导致额外的构造函数调用,特别是涉及到虚拟函数的构造,这会导致对this
指针的设置。
(5)构造函数的额外开销
由于每个构造函数都可能包含对this
指针的测试,有多个继承层次时,这种开销会积累并显著影响性能。虽然现代编译器已经优化了内存管理,但仍然保留了一些过时的兼容性检查,导致性能下降。
(6)局部对象的构造开销
使用局部对象会导致频繁调用构造函数,即使该对象未被使用。通过消除局部对象的使用,避免了构造函数的调用,可以进一步提高性能。
指向 Member Function 的指针
- 非静态数据成员的地址:当获取一个非静态数据成员的地址时,得到的结果实际上是该成员在类布局中的位置,加上一个偏移量(通常是1)。这个地址表明这个成员在对象中的相对位置,但并不指向一个具体的对象实例。因此,这个地址是不完整的,因为它不能被直接使用来访问该数据成员。需要将其与具体对象的地址结合起来,才能访问该成员。
- 非静态成员函数的地址:对于非静态成员函数,获取其地址会得到它在内存中的真实地址,但同样这个地址是“不完整”的。虽然可以通过这个地址直接调用函数,但非静态成员函数在调用时需要一个隐式的参数
this
,它指向调用该函数的对象实例。这意味着,尽管你获得了函数的地址,但在实际调用时仍需绑定到一个对象上才能生效。
this
指针的作用:所有非静态成员函数在调用时都会自动传入this
指针。因此,当通过获取函数地址来调用它时,需要将this
指针传递给函数,以确保函数能够正确访问和操作该对象的成员。
指向成员函数指针的声明与使用
声明语法
return_type(class_name::*pointer_name)(argument_list);
return_type
成员函数的返回类型。class_name
函数所属的类。pointer_name
指针名称。argument_list
参数列表。
定义与初始化
double (Point::*coord)() = &Point::x; // 或者Point::y
可以通过赋值来改变指针指向的成员函数
coord = &Point::y;
调用成员函数
调用指向的成员函数时,需要使用对象实例(或指针)
使用对象
(origin.*coord)(); //通过对象调用
使用指针
(ptr->*coord)(); //通过指针调用
编译器转化
上述调用会被编译器转化为:
(coord)(&origin); // 对于对象调用
(coord)(ptr); // 对于指针调用
静态成员函数是属于类本身而不是某个特定对象的。它们可以被类直接调用,而不需要创建对象实例。由于静态成员函数不与任何对象实例关联,它们没有this
指针。在调用静态成员函数时,不需要一个对象的上下文。由于没有this
指针,静态成员函数的类型可以被视为普通函数指针。
支持“指向 Virtual Member Functions”的指针
虚拟机制仍然能在使用 "指向 member function指针" 的情况下运行。
class Point {
public:virtual ~Point(); // 虚拟析构函数float x(); // 非虚拟成员函数float y(); // 非虚拟成员函数virtual float z(); // 虚拟成员函数
};
// 指向成员函数的指针,能够指向 Point 类中的成员函数
float (Point::*pmf)() = &Point::z; // 指向虚拟函数 z 的指针
// 创建一个 Point3d 对象(假设它是 Point 的派生类)
Point* ptr = new Point3d;
// 直接调用虚拟成员函数
ptr->z(); // 调用的是 Point3d::z()
// 通过指向成员函数的指针间接调用虚拟成员函数
(ptr->*pmf)(); // 仍然调用 Point3d::z()
float(Point::*pmf)()=Point::z;
Point *ptr = new Point3d;
编译器在处理指向虚拟成员函数的指针时,可能会将调用转化为以下形式:
// 假设pmf指向虚拟函数z的索引
(*ptr->vptr[(int)pmf])(ptr); //调用虚拟函数
对于非虚拟成员函数(如 x()
和 y()
),获取地址的方法如下:
float (Point::*pmf1)() = &Point::x; // 指向非虚拟函数 x 的指针
float (Point::*pmf2)() = &Point::y; // 指向非虚拟函数 y 的指针
编译器如何处理指向成员函数的指针
为了能够在一个指针中存储虚拟函数的索引和非虚拟函数的地址,编译器需要一些技巧。例如,cfront 2.0(第一个C++编译器)使用了一个普通指针,并通过某种方式(如位运算)区分该指针是指向内存地址还是虚拟表索引。通过将指针转为整数,并与127进行位运算,来判断是虚拟函数调用还是非虚拟函数调用。如果该值小于128,则认为是指向非虚拟函数的地址;如果大于128,则将其视为虚拟函数的索引。
if (((int)pmf) & 127) {//非虚拟调用(*pmf)(ptr);
}else{ //虚拟调用(*ptr->vptr[(int)pmf])(ptr);
}
假设有一个类Point
,定义了虚拟成员函数z()
和非虚拟成员函数x()
。pmf
指向Point::z()
,在调用(ptr->*pmf)()
时,编译器会查找ptr
的虚拟表,根据pmf
中的索引找到对应的虚拟函数地址并调用。如果指向的是x()
,则直接调用其地址。
在多重继承之下,指向 Member Functions 的指针
指向成员函数的指针:指向成员函数的指针不仅需要指向函数本身的地址,还需要能够处理虚拟函数和多重继承的情况。
结构体 mptr
:
设计了一个结构体 mptr
来表示指向成员函数的指针
// 结构体定义
structmptr {int delta;//this指针的偏移量int index;
// 虚拟表索引union {ptrtofunc faddr; // 非虚拟函数地址int v_offset;//虚拟函数的虚拟表偏移};
};
int delta
表示 this
指针的偏移量,处理可能的多重继承情况。int index
:表示在虚拟表中的索引。union
包含两个成员,用于存储函数地址或虚拟表偏移。ptrtofunc faddr
:存储非虚拟函数的地址。int v_offset
存储虚拟函数的虚拟表偏移。在调用成员函数时,编译器需要根据 mptr
结构中的信息来决定是调用虚拟函数还是非虚拟函数:
(ptr->*pmf)();
// 转换为以下代码:
(pmf.index < 0)?// 非虚拟调用(*pmf.faddr)(ptr)://虚拟调用 (*ptr->vptr[pmf.index])(ptr);
inline函数
Point
类的加法运算符的定义,使用inline
函数来提高效率。
class Point {// 私有数据成员int _x, _y;
public://构造函数Point(int x = 0, int y = 0) : _x(x), _y(y) {}//获取x坐标inline int x() const{ return _x; }//获取y坐标inline int y() const{ return _y; }// 加法运算符重载friend Point operator+(const Point& lhs, const Point& rhs) {Point new_pt;new_pt._x = lhs.x() + rhs.x();new_pt._y = lhs.y() + rhs.y();return new_pt;}
};
x()
和y()
函数被声明为inline
直接在调用点展开代码,可以避免函数调用的开销。仍然可以控制对私有数据成员的访问,保持数据封装的原则。虽然inline
函数提供了性能优势,但并不是所有函数都能强制为inline
。以下是几个关键点:
- 编译器的决定:使用
inline
只是一个建议,编译器可以选择不展开函数,尤其是在函数复杂度过高或不适合展开时。在调用时,如果传递的参数或临时对象使得inline
扩展成本高于正常调用,编译器同样会选择不扩展。 - 链接问题:如果
inline
函数未被展开,可能会生成多个相同的函数实例,导致代码膨胀。虽然链接器会尝试去重,但不是所有链接器都能有效地处理。
形式参数
在调用inline
函数时,形式参数会被实际参数替代。然而如果实际参数是一个有副作用的表达式,比如函数调用或修改变量的操作,可能会导致多次求值,从而引入不必要的复杂性。
inline int min(int i,int j){return i < j ? i : j;
}
这是一个简单的inline
函数,用于返回两个整数中的较小者。
inline int bar(){int minval;int val1 = 1024;int val2 = 2048;
//(1)minval = min(val1, val2);
//(2)minval = min(1024, 2048);
//(3)minval = min(foo(), bar() + 1);return minval;
}
在这个bar
函数中,我们有三次对min
函数的调用,分别用 (1)
、(2)
和 (3)
标记。
(1) min(val1, val2)
这里,val1
和val2
是局部变量。
替换后,会变成:
minval = val1 < val2 ? val1 : val2;
由于没有副作用,直接进行简单替换。
(2) min(1024, 2048)
这里的参数是常量。替换后会变成:
minval = 1024;
编译器可以直接将结果替换为常量,因为常量不需要求值。
(3) min(foo(), bar() + 1)
在这个调用中,foo()
和 bar() + 1
都是函数调用,它们可能有副作用。例如,如果foo()
修改了某个全局变量,或者bar()
的调用会造成状态改变。为了避免重复求值,编译器会引入临时变量来存储这两个表达式的结果。扩展后可能变成:
int t1 = foo();
int t2 = bar() + 1;
minval = t1 < t2 ? t1 : t2;
通过引入临时变量t1
和t2
,确保每个表达式只求值一次,避免副作用。
多次调用 foo()
的情况通常是指在表达式中重复使用相同的函数,比如:
min(foo(), foo() + 1);
在这个例子中,foo()
被调用了两次:第一次调用 foo()
会使 globalVar
增加 1。第二次调用 foo()
又会使 globalVar
再次增加 1。如果 foo()
是这样的一个函数,每次调用都会修改 globalVar
的值,那么最终 globalVar
的值会因为被调用两次而发生两次变化,导致结果不一致。
局部变量
C++ 中使用 inline
函数时引入局部变量的影响,以及可能出现的问题和编译器的处理方式。
定义带局部变量的 inline
函数。首先,我们有一个带局部变量的 inline
函数 min
inline int min(int i, int j){int minval = (i < j) ? i : j;return minval;
}
在调用这个 inline
函数时,假设有以下代码:
int local_var;
int minval;
minval=min(val1, val2);
在这个调用中,min
函数将被编译器扩展为:
int local_var;
int minval;
// inline 函数扩展
minval = ((int minval = (val1 < val2) ? val1 : val2), minval);
唯一性:每个 inline
函数中的局部变量必须有一个独一无二的名称。如果该 inline
函数被多次扩展,每次扩展都需要自己的局部变量。这是为了确保每次调用的局部变量不会相互干扰。
封闭作用域:局部变量在函数调用的一个封闭区段中存在,有自己的作用域。因此,即使在多个地方调用 inline
函数,它们的局部变量也不会相互冲突。
如果 inline
函数的参数有副作用,比如:
minval = min(val1, val2) + min(foo(), foo() + 1);
在这种情况下,可能会产生许多临时变量来存储这些中间结果。编译器的扩展可能变成
int t1; // 存储 foo() 的返回值int t2; // 存储 foo() + 1 的返回值
minval = (t1 = val1 < val2 ? val1 : val2) + ((t2 = foo()), t1 < t2 ? t1 : t2);
如果一个 inline
函数被调用太多次,尤其是其中有副作用的参数,可能导致程序大小迅速增加。这是因为每次调用都会生成新的扩展代码,尤其是在复杂的 inline
函数中,局部变量会导致更多的临时对象。如果 inline
函数中还包含其他的 inline
函数调用,可能会导致更复杂的调用链,这样的链条可能无法被编译器有效地扩展。