时至今日,编程教材推荐面向对象编程,作为使软件更清晰及模块化的一种手段。所谓的对象是结构体及类的实例。面向对象编程形式对程序性能有积极与消极的影响。
积极的影响有:
消极影响有:
面向对象编程的积极影响还是消极影响占优,不能一概而论。至少,可以这样说,类与成员函数的使用代价不高。如果对程序的逻辑结构与清晰性有益,可以使用面向对象编程风格,只要在程序最关键部分避免过多的函数调用。使用结构体(没有成员函数)对性能没有消极影响。
在创建一个类或结构体的实例时,类或结构体的数据成员以它们声明的次序连续储存。把数据组织成类或结构体没有性能上的损失。访问类或结构体数据成员所需的时间与访问一个简单变量一样。
大多数编译器将对齐数据成员来取整地址,以优化访问,如下表所示。
类型 | 大小,字节 | 对齐,字节 |
---|---|---|
bool | 1 | 1 |
char, signed or unsigned | 1 | 1 |
short int, signed or unsigned | 2 | 2 |
int, signed or unsigned | 4 | 4 |
64-bit integer, signed or unsigned | 8 | 8 |
pointer or reference, 32-bit mode | 4 | 4 |
pointer or reference, 64-bit mode | 8 | 8 |
float | 4 | 4 |
double | 8 | 8 |
long double | 8, 10, 12 or 16 | 8 or 16 |
表7.2. 数据成员的对齐 |
在具有不同大小成员的结构体或类中,这个对齐会导致的内存空洞。例如:
// Example 7.39a
struct S1 {
short int a; // 2 bytes. first byte at 0, last byte at 1
// 6 unused bytes
double b; // 8 bytes. first byte at 8, last byte at 15
int d; // 4 bytes. first byte at 16, last byte at 19
// 4 unused bytes
};
S1 ArrayOfStructures[100];
这里,在a与b之间有6个未使用字节,因为b必须从被8整除的地址开始。在末尾也有4个未使用字节。原因是数组中S1的下一个实例必须从被8整除的地址开始,以将其b成员对齐到8。将最小的成员放到最后,未使用字节数可以减少到2:
// Example 7.39b
struct S1 {
double b; // 8 bytes. first byte at 0, last byte at 7
int d; // 4 bytes. first byte at 8, last byte at 11
short int a; // 2 bytes. first byte at 12, last byte at 13
// 2 unused bytes
};
S1 ArrayOfStructures[100];
这个重排使结构体减小了8字节,数组减小了800字节。
结构体与类对象通常可以通过重排数据成员变得更小。如果类有至少一个虚成员函数。在第一个数据成员前或最后一个数据成员后有一个虚表指针。这个指针在32位系统中为4字节,在64位系统中为8字节。如果你对结构体或其每个成员的多大有疑问,那么你可以使用sizeof操作符进行一些实验。由sizeof操作符返回的值包括在对象末尾的所有未使用字节。
如果数据成员相对于结构体或类开头的偏移小于128,访问它的代码会更紧凑,因为这个偏移可以表示为一个8位有符号数。如果相对于结构体或类开头的偏移是128字节或更大,那么偏移必须被表示为一个32位数(指令集没有8位与32位之间的偏移)。例如:
// Example 7.40
class S2 {
public:
int a[100]; // 400 bytes. first byte at 0, last byte at 399
int b; // 4 bytes. first byte at 400, last byte at 403
int ReadB() {return b;}
};
这里b的偏移是400。任何通过指针或成员函数,比如ReadB,访问b的代码需要将这个偏移编码为一个32位数。如果交换a与b,那么可以通过编码为一个8位有符号数的偏移,或完全不使用偏移,访问两者。这使得代码更紧凑,因而代码缓存的使用更有效率。因此,建议在结构体或类声明中,大数组与其他大对象最后出现,最常使用的数据成员最先出现。如果不可能在前128个字节里包含所有的数据成员,将最常用的成员放在前128个字节。
每次声明或创建一个类的新对象时,将产生数据成员的新实例。不过每个成员函数仅有一个实例。不拷贝函数代码,因为相同的代码适用于该类的所有实例。
调用成员函数与调用带有结构体指针或引用的简单函数一样快。例如:
// Example 7.41
class S3 {
public:
int a;
int b;
int Sum1() {return a + b;}
};
int Sum2(S3 * p) {return p->a + p->b;}
int Sum3(S3 & r) {return r.a + r.b;}
三个函数Sum1,Sum2与Sum3都做相同的事情,它们效率相同。如果你查看编译器产生的代码,你将注意到某些编译器对这三个函数产生完全相同的代码。Sum1有一个隐含的this,它与Sum2及Sum3中的p与r作用相同。无论你是希望把函数做成该类的一个成员,还是给它该类或结构体的指针或引用,这纯粹是程序风格的问题。某些编译器,通过在寄存器中传递this,而不是在栈上,使在32位Windows中,Sum1比Sum2与Sum3的效率稍高。
static成员函数不能访问任何非静态数据成员或非静态成员函数。静态成员函数比非静态函数更快,因为它不需要this指针。如果成员函数不需要任何非静态访问,你可以通过使它们成为静态,使它们更快。
虚函数用于实现多态类。多态类的每个实例有一个指针,指向虚表。虚表里存放指向不同版的本虚函数的指针。这个所谓的虚表用于在运行时找出虚函数的正确版本,多态是面向对象编程效率不如非面向对象编程的主要原因之一。如果可以避免虚函数,那么就可以获得面向对象编程的大多数好处,而无需付出性能代价。
调用虚成员函数的时间比调用非虚成员函数要多几个时钟周期,假设函数调用语句总是调用该虚函数的同一个版本。如果版本改变,你可能会得到10 ~ 20时钟周期的误预测惩罚。虚函数调用的延迟与误预测的规则与switch语句相同。
在一个已知类型的对象上调用虚函数时,可以绕过分派机制,不过你不能总是依赖编译器绕过这个分派机制,即使在这样做是显而易见的。
仅在编译时刻不知道调用多态成员函数的哪个版本时,才需要运行时多态。如果在程序的关键部分使用虚函数,那么可以考虑不使用多态或通过编译时多态的情况下,是否有可能获得期望的功能。
有时,通过模板而不是虚函数,获得期望的多态效果是可能的。模板参数应该是一个包含有多个版本函数的类。这个方法更快,因为模板参数总是在编译时解析的,而不是在运行时。不幸的是,这种方法的语法是如此杂乱,它不一定值得。
运行时类型识别对需要对所有的类对象添加额外的信息,是低效的。如果编译器有用于RTTI的一个选项,关闭它,使用其他方式实现。
派生类的一个对象实现的方式与包含父类与子类成员的简单类的一个对象相同。父类与子类成员的访问一样快。通常,你可以假定使用继承几乎没有性能损失。
出于以下原因,代码缓存和数据缓存可能有少许性能降低:
多继承中,在通过其中一个基类指针访问派生类对象时,会提高成员指针及虚函数的复杂性。通过把对象放到派生类中,可以避免多继承:
// Example 7.42a. Multiple inheritance
class B1; class B2;
class D : public B1, public B2 {
public:
int c;
};
替换为:
// Example 7.42b. Alternative to multiple inheritance
class B1; class B2;
class D : public B1 {
public:
B2 b2;
int c;
};
通常,应该只在对程序逻辑结构有益时使用继承。
构造函数在内部被实现为一个返回该对象引用的成员函数。新对象的内存分配不一定由构造函数本身来完成。因此,构造函数与其他成员函数一样高效。这适用于缺省构造函数、拷贝构造函数,以及其他构造函数。
类不需要构造函数。如果对象不需要初始化,不需要缺省构造函数。如果对象拷贝可以通过拷贝所有数据成员完成,则不需要拷贝构造函数。简单的构造函数可以被内联,以提升性能。
一旦对象通过赋值、作为函数参数、或作为函数返回值被拷贝时,一个拷贝构造函数被调用。如果涉及内存或其他资源的分配,拷贝构造函数会很耗时。有各种方法避免内存块的拷贝的性能损耗,例如:
析构函数与成员函数一样高效。如果不必要,不要定义析构函数。虚析构函数与虚成员函数效率相同。