C++静态联编和动态联编

C++静态联编和动态联编

一、简介

程序调用函数时,将使用哪个可执行代码块呢?编译器负责回答这个问题。将源代码中的函数调用解释为执行特定的函数代码块被称为函数名联编

在C语言中,这非常简单,因为每个函数名都对应一个不同的函数。在C++中,由于函数重载的缘故,这项任务更复杂。

编译器必须查看函数参数及函数名才能确定使用哪个函数。然而,C/C++编译器可以在编译过程完成这种联编。

静态联编: 在编译过程中进行联编被称为静态联编,也称为早期联编。

动态联编: 虚函数的使用使函数名联编这项工作变的困难。使用哪一个函数是不能再编译时确定的,因为编译器不知道用户将选择哪种类型的对象。所以,编译器必须生成能够在程序运行时选择正确的虚方法的代码,这被称为动态联编,也称为晚期联编

二、指针与引用类型的兼容性

2.1 通常使用方式

在C++中,动态联编与通过指针和引用调用方法相关,从某种程度上说,这是由继承控制的。公有继承建立is-a关系的一种方法是如何处理指向对象的指针和引用。通常,C++不允许将一种类型的地址赋给另一种类型的指针,也不允许一种类型的引用指向另一种类型。举例如下:

double x = 3.2;
int *pi = &x;		// 无效赋值,不匹配的指针类型
long &rl = x;		// 无效赋值,不匹配引用类型
2.2 继承使用方式

指向基类的引用或指针可以引用派生类对象,而不必进行显示类型转换。举例如下:

BrassPlus tom("Test", 1234, 1234.56);
Brass *pb = &tom;		// 正确使用
Brass &rb = tom;		// 正确使用
2.3 继承类型转换

向上强制转换: 将派生类引用或指针转换为基类引用或指针被称为向上强制转换,这种公有继承不需要进行显示类型转换,该规则是is-a关系的一部分。

向下强制转换: 将基类指针引用或指针转换为派生类引用或指针,被称为向下强制转换,如果不使用显示类型转换,则向下强制转换是不允许的,原因是is-a关系通常是不可逆的。

2.4 向上类型转换说明

对于使用基类引用或指针作为参数的函数调用,将进行向上强制转换。举例如下:

// 假设示例中的每个函数的代码块都调用参数的ViewAcct()函数
void fr(Brass &rb);		// 调用rb.ViewAcct()
void fp(Brass *pb);		// 调用pb->ViewAcct()
void fv(Brass b);		// 调用b.ViewAcct()

int main()
{
    Brass b("Test1", 1234, 1234.56);
    BrassPlus bp("Test2", 5678, 5678.89);
    
    fr(b);				// 调用Brass::ViewAcct()
    fr(bp);				// 调用BrassPlus::ViewAcct()
    
    fp(&b);				// 调用Brass::ViewAcct()
    fp(&bp);			// 调用BrassPlus::ViewAcct()
    
    fv(b);				// 调用Brass::ViewAcct()
    fv(bp);				// 调用Brass::ViewAcct()
}

上述示例代码详解:

  • 按值传递导致只将BrassPlus对象的Brass部分传递给fv()函数。

  • 引用和指针发生的隐式向上转换导致fr()函数和fp()函数,分别为Brass对象和BrassPlus对象使用Brass::ViewAcct()和BrassPlus::ViewAcct()。

  • 隐式向上强制转换使基类指针或引用可以指向基类对象或派生类对象,因此需要动态联编。C++使用虚成员函数来满足这种需求

三、虚成员函数和动态联编

根据如下代码示例,回顾使用引用或指针调用方法的过程:

Brass *b;
BrassPlus bp;
b = &bp;
b->ViewAcct();

如果在基类中没有将ViewAcct()函数声明为虚函数,则b->ViewAcct()将根据指针类型(Brass*)调Brass::ViewAcct()。

指针类型在编译时已知,因此编译器在编译时,可以将ViewAcct()关联到Brass::ViewAcct()。总之,编译器对非虚方法使用静态联编

如果在基类中将ViewAcct()函数声明为虚函数,则b->ViewAcct()根据对象类型(BrassPlus)调用BrassPlus::ViewAcct()。

对象类型只有在运行程序时才能确定。所以编译器生成的代码将在程序执行时,根据对象类型将ViewAcct()关联到Brass::ViewAcct()或BrassPlus::ViewAcct()。总之,编译器对虚方法使用动态联编

3.1 为什么有两种类型的联编

如果动态联编能够让开发人员能够重新定义方法,而静态联编在这方面很差,为什么不摒弃静态联编呢?

解:原因有两个,效率和概念模型

效率: 为使程序能够在运行阶段进行决策,必须采取一些方法来跟踪基类指针或引用指向的对象类型,这增加了额外开销。例如,如果类不用作基类,则不需要动态联编。如果派生类不重新定义基类的任何方法,也不需要使用动态联编。

概念模型: 在设计类时,可能包含一些不在派生类重新定义的成员函数。不将函数设置为虚函数,有两方面的好处:首先效率更高;其次,指出不要重新定义该函数。仅将那些预期将被重新定义的方法声明为虚方法。

注: 如果要在派生类中重新定义基类的方法,则将它设置为虚方法;否则,设置为非虚方法。

3.2 为什么默认为静态联编

基类方法不声明为虚方法,或派生类不重新定义基类任何方法,则使用静态联编更合理,效率也更高。由于静态联编的效率更高,因此被设置为C++的默认选择。

3.3 虚函数的工作原理

编译器处理虚函数的方法是:给每个对象添加一个隐藏成员。隐藏成员中保存了一个指向函数地址数组的指针。这种数组称为虚函数表

虚函数表: 虚函数表中存储了为类对象进行声明的虚函数的地址。例如,基类对象包含一个指针,该指针指向基类中所有虚函数的地址表。派生类对象将包含一个指向独立地址表的指针。具体如下:

  • 如果派生类提供了虚函数的新定义,该虚函数表将保存新函数的地址;
  • 如果派生类没有重新定义虚函数,该虚函数表将保存函数原始版本的地址。
  • 如果派生类定义了新的虚函数,则该函数的地址也将被添加到虚函数表。
  • 无论类中包含的虚函数是1个或者多个,都只需要在类对象添加1个地址成员,只是表的大小不同而已。
3.3.1 虚函数调用过程

调用虚函数时,程序将查看存储在对象中的隐藏成员保存的虚函数表地址,然后转向相应的虚函数地址表。具体调用过程说明如下:

  • 如果使用类声明中定义的第一个虚函数,则程序将使用虚函数表中的第一个函数地址
  • 如果使用类声明中定义的第三个虚函数,则程序将使用地址为虚函数表中第三个元素的函数
3.4 使用虚函数成本

使用虚函数时,在内存和执行速度方面有一定的成本,包括:

  • 每个对象都将增大,增大量为存储地址的空间
  • 对于每个类,编译器都将创建一个虚函数地址表(数组)
  • 对于每个函数调用,都需要执行一项额外的操作,即到虚函数表中查找地址

虽然非虚函数的效率比虚函数稍高,但不具备动态联编功能。

四、虚函数注意事项

虚函数的一些要点如下:

(1)在基类方法的声明中使用关键字virtual,可使该方法在基类以及所有的派生类(包括从派生类派生出来的类)中是虚的。

(2)如果使用指向对象的引用或指针来调用虚方法,程序将使用为对象类型定义的方法,而不是使用为引用或指针类型定义的方法。这称为动态联编或晚期联编。这种行为非常重要,因为这样基类指针或引用可以指向派生类对象。

(3)如果定义的类将被用作基类,则应将哪些要在派生类中重新定义的类方法声明为虚方法。

4.1 构造函数

构造函数不能是虚函数。创建派生类对象时,将调用派生类的构造函数,而不是基类的构造函数,然后,派生类的构造函数将使用基类的一个构造函数,这种顺序不同于继承机制。因此,派生类不继承基类构造函数,所以将类构造函数声明为虚的没什么意义。

4.2 析构函数

析构函数应当是虚函数,除非类不用作基类。例如,假设Employee是基类,Singer是派生类,并添加一个char*成员,该成员指向由new分配的内存。当Singer对象过期时,必须调用~Singer()析构函数来释放内存。示例代码如下:

Employee *pe = new Singer;
delete pe;		// 使用~Employee()或~Singer()

如果使用默认的静态联编(析构函数为非虚函数),delete语句将调用~Employee()析构函数。这将释放由Singer对象中的Employee部分指向的内存(应为delete pe;调用的是Employee类的析构函数),但不会释放新的类成员指向的内存。

如果析构函数是虚函数,则delete pe;将先调用Singer()析构函数释放由Singer指向的内存,然后调用Employee()析构函数来释放由Employee组件指向的内存。

注: 通常应给基类提供一个虚析构函数,即使它并不需要析构函数。

4.3 友元函数

友元函数不能是虚函数,因为友元函数不是类成员函数,而只有成员函数才能使虚函数。如果由于这个原因引起设计问题,可以通过让友元函数使用虚成员函数来解决。

4.4 没有重新定义

如果派生类没有重新定义函数,将使用该函数的基类版本。如果派生类位于派生链中,则将使用最新的虚函数版本。

4.5 重新定义将隐藏方法

假设有如下示例代码:

// 基类
class Base
{
public:
	virtual void showperks(int a) const;    
};

// 派生类
class Derived : public Base
{
public:
    virtual void showperks() const;
};

// 调用
Derived der;
der.showperks();		// 有效的调用
der.showperks(5);		// 无效的调用

新定义将showperks()定义为一个不接受任何参数的函数。重新定义不会生成函数的两个重载版本,而是隐藏了接受一个int参数的基类版本。总之,重新定义继承的方法并不是重载。

如果在派生类中重新定义函数,将不只是使用参数列表相同的函数覆盖基类声明,无论参数列表是否相同,该操作将隐藏所有的同名基类方法

这引出两条经验规则:

(1)如果重新定义继承的方法,应确保与原来的原型完全相同,但如果返回类型是基类引用或指针,则可以修改为指向派生类的引用或指针。这种特性被称为返回类型协变(covariance of return type),因为允许返回类型随类类型的变化而变化。示例代码如下:

// 基类
class Base
{
public:
    virtual Base& build(int a);
};

// 派生类
class Derived : public Base
{
public:
    // 返回类型协变的派生类方法
    virtual Derived& build(int a);
};

注: 这种例外只适用于返回值,而不适用于参数。

(2)如果基类中声明的函数被重载了,则应在派生类中重新定义所有的基类重载函数。示例代码如下:

// 基类
class Base
{
public:
    // 3个showperks()重载函数
    virtual void showperks(int a) const;
    virtual void showperks(double b) const;
    virtual void showperks() const;
};

// 派生类
class Derived : public Base
{
public:
    // 3个重新定义的showperks()重载函数
    virtual void showperks(int a) const;
    virtual void showperks(double b) const;
    virtual void showperks() const;
};

注: 如果只重新定义一个版本,则另外两个版本将被隐藏,派生类对象将无法使用它们。如果重新定义的基类中重载的某个函数不需要对函数行为进行修改,则派生类新定义的函数可只调用被新定义的基类函数,示例代码如下:

void Derived::showperks() const
{
    // 不更改showperks()行为,只需调用基类的相同版本函数即可
    Base::showperks();
}
  • 4
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值