C 语言

关注公众号 jb51net

关闭
首页 > 软件编程 > C 语言 > C++多态(Visual Studio 2019)

C++之初识多态(Visual Studio 2019)的使用

作者:云雷屯176

文章主要解释了C++中的多态概念、实现细节及一些相关特性,如虚函数、override和final关键字、纯虚函数和抽象类,通过代码示例展示了多态原理,包括虚函数表和动态绑定的概念,并探讨了多继承下虚函数表的行为

此文章关于多态的代码全部是使用Visua Studio2019 (x86) 实现的,C++多态在不同编译器中的实现细节可能不同,所以部分情况下相同代码运行结果可能不同,在此声明。

多态的概念

多态,顾名思义,即多种形态。具体来说就是对于某个行为,不同的对象去完成该行为会产生不同的状态。

就像 “叫” 这个行为,猫来完成时其叫声是 “喵喵喵” ,而狗则是 “汪汪汪”。

还有坐公交车,老人和小孩坐车是免票,而其他人则是全价票。

同样的一个动作,不同的对象来执行,其实现的方法不同。

多态的定义与实现

多态是建立在继承基础上的,在不同的继承关系下的类对象,调用同一函数,产生不同的行为。

在C++中,继承体系下构成多态需要满足以下两个条件:

虚函数

虚函数:即被 virtual 修饰的类的成员函数。

class A {
public:
    virtual int add(int a, int b) {
        return a + b;
    }
}

使用virtual修饰虚函数时,只需要修饰类内部修饰即可。

class A {
public:
	virtual void print_1();
};

virtual void A::print_1() {
	cout << "1 : class A" << endl;
}

 

虚函数的重写

虚函数的重写指的是:派生类中有一个与基类完全相同的虚函数 (即派生类的虚函数与基类虚函数的返回值类型,参数列表,函数名完全相同),称子类的虚函数重写了基类的虚函数

class A {
public:
	virtual void print_1() {
		cout << "1 : class A" << endl;
	}
};

class B : public A {
public:
	virtual void print_1() {
		cout << "1 : class B" << endl;
	}
};

void func(A& a) {
	a.print_1();
}

int main() {
	A a;
	B b;
	func(a);
	func(b);
	return 0;
}

PS.子类重写父类虚函数时子类虚函数可以不加virtual关键字,这样也可以构成重写——继承后基类的虚函数被继承下来了,在派生类之中依旧保持虚函数属性。但是这种写法并不规范,并不建议使用。

虚函数重写的两个例外

1.协变

class A {
public:
	virtual A& print_1() {
		cout << "1 : class A" << endl;
		return *this;
	}
};

class B : public A {
public:
	virtual B& print_1() {
		cout << "1 : class B" << endl;
		return *this;
	}
};

需要注意的是,只有返回对应类型的指针或引用才会构成协变,单纯返回派生类或基类类型并不构成协变。

class A {
public:
	virtual A print_1() {
		cout << "1 : class A" << endl;
		return *this;
	}
};

class B : public A {
public:
	virtual B print_1() {
		cout << "1 : class B" << endl;
		return *this;
	}
};

2.析构函数的重写

class A {
public:
	virtual ~A() {
		cout << "A" << endl;
	}
};

class B : public A {
public:
	virtual ~B() {
		cout << "B" << endl;
	}
};


int main() {
	A* a = new B;
	delete a;
	return 0;
}

 

析构函数的重写最常见的应用便是基类指针指向派生类对象的情况下,如果我们所使用的派生类对象是从堆上 new 来的,那么,在没有重写析构函数的情况下,在基类对象 delete 时,只会调用基类的析构函数释放基类的空间,而派生类对象所占用的空间则不会被释放,就会导致内存泄漏。

C++11 override & final

1. override

class A {
public:

	virtual void print() {
		cout << "a" << endl;
	}
};

class B : public A {
public:
	virtual void print() override {
		cout << "b" << endl;
	}
};

当override修饰的函数并非重写的基类虚函数时,会报错:

class A {
public:

	virtual void print() {
		cout << "a" << endl;
	}
};

class B : public A {
public:
	virtual void not_print() override {
		cout << "b" << endl;
	}
};

2.final

class A {
public:
	int a;
	virtual void print() final{
		cout << "a" << endl;
	}
};

class B : public A {
public:
	int b;
	void a_print() {
		cout << "b" << endl;
	}
};

class C {
public:
	int a;
	void print() {
		cout << "a" << endl;
	}
};

class D : public C {
public:
	int b;
	void a_print() {
		cout << "b" << endl;
	}
};

int main() {
	B b;
	D d;
	b.b = 3;
	b.a = 2;
	d.a = 2;
	d.b = 3;
	while (0);
	return 0;
}

 

重载&重写&重定义

重载

将语义或功能相似的函数用同一个名字表示。

条件:

重写 (覆盖)

覆盖是C++中多态的基础,使派生类可以重新定义基类的虚函数。

条件:

重定义 (同名隐藏)

在继承体系中,子类与父类中有同名成员,子类可以屏蔽父类指针对子类中父类成员的直接访问。(但是可以使用 :: 访问父类成员)

条件:

class A {
	void print() {
		cout << "a" << endl;
	}
};

class B : public A {
public:
	int b;
	void print() {
		cout << "b" << endl;
	}
};

int main() {
	A a;
	B b;
	a.print();
	b.print();
	while (0);
	return 0;
}

 

纯虚函数及抽象类

在有些时候,基类的某些行为在没有被派生类重写之前会非常不明确,或者说,不合常理,不好定义。比如我们先前举过的例子——动物的 “叫” 这个行为,在动物这个基类中我们很难定义它具体是一种什么样的行为,表达方式是怎样的。我们是无法对这个行为做出具体的定义的,无法提供实现。但是作为一种 “共性” 我们又想要在基类中声明它。

于是乎,在虚函数的基础上,C++又提出了一种新的函数——纯虚函数。

class A {
public:
	virtual void print() = 0;
};

class B : public A {
public:
	int b;
	virtual void print() {
		cout << "b" << endl;
	}
};

int main() {
	B b;
	b.print();
	while (0);
	return 0;
}

接口继承与实现继承

普通函数的继承是一种实现继承,派生类继承了基类的函数,可以使用函数,这种继承继承的是函数的实现。

虚函数的继承则是一种接口继承,派生类继承的只是虚函数的接口,目的是为了重写,从而达成多态,继承的是一个接口。

所以,如果不实现多态,就不要把函数设置为虚函数。

多态的原理

class A {
public:
	int _a1;
	int _a2;

	virtual void study() {
		cout << "study" << endl;
	}

	A()
		:_a1(1)
		,_a2(2)
	{}
};


int main() {
	A a;
	return 0;
}

以上代码中,类A的实例化对象a在实例化后,其内存如上图所示,明显在我们所定义的变量a1, a2之前,还有着一串神奇的数字——00 75 9b 34。(据说有些编译器这玩意可能在后边)

单从表面上来看,它应该是一个指针。而实质上,它的确是一个指针。

在VS调试模式下的局部变量窗口中,我们可以很清楚的看到,这玩意还不是一个简单的指针,它是一个二级指针,指针名为 __vfptr (v——virtual f——function)我们通常称其为虚函数表指针。

 进一步将这个虚函数表指针展开来看

很容易看到,这个虚函数表指针指向的对象是一个函数指针,而函数指针指向的对象则是我们代码中所定义的虚函数——A::study(void)

一个含有虚函数的类中都至少有一个虚函数表指针,因为虚函数的地址要被存放到虚函数表中。

虚函数表也简称虚表

下面,我们将之放入继承体系之中来分析。

class A {
public:
	int _a1;
	int _a2;

	virtual void func1() {
		cout << "A::func1" << endl;
	}

	virtual void func2() {
		cout << "A::func2" << endl;
	}

	virtual void func3() {
		cout << "A::func3" << endl;
	}

	A()
		:_a1(1)
		,_a2(2)
	{}
};

class B : public A{
public:
	int _b1;
	int _b2;

	virtual void func1() {
		cout << "B::func1" << endl;
	}

	virtual void func2() {
		cout << "B::func2" << endl;
	}

	B()
		:_b1(3)
		,_b2(4)
	{}
};

int main() {
	A a;
	B b;
	return 0;
}

通过以上调试图片,可以看出

1.基类中有一个虚函数表指针,虚函数表中存储着三个基类虚函数的地址。

2.派生类对象中也有一个虚函数表指针,且该虚函数表由两部分构成——派生类重写的虚函数以及从基类中继承的虚函数。

3.基类对象与派生类对象的虚函数表中的内容是不同的,由于我们在派生类代码中重写了虚函数func1以及func2,所以派生类的虚函数表中对应位置存储的是被派生类重写后的B::func1(void)以及B::func2(void)。所以虚函数的重写也被称为覆盖,覆盖指的是虚表中虚函数的覆盖,派生类重写的虚函数地址覆盖了基类的虚函数地址。重写是语法层面的称呼,覆盖是原理层面的称呼。

4.虚函数表本质是一个存放虚函数指针的指针数组,一般情况下这个数组最后会放一个nullptr。

5.派生类虚表生成的过程如下:首先,将基类中虚表内容拷贝一份到派生类之中;而后,如果派生类重写了某个虚函数,用派生类自身的虚函数的地址覆盖虚函数表中基类虚函数的地址;最后,派生类将派生类之中新增的虚函数增加到虚函数表的最后。

6.虚函数表中只存放继承或派生类自身定义的虚函数,非虚函数不会被存放。

虚函数存在哪里?虚表存在哪里?

多态的原理

分析了这么多,多态的原理究竟是什么?这里就需要用到我们之前屡试不爽的方法——通过基类指针/引用调用函数:

void Func(A* tmp) {
	tmp->func1();
}

int main() {
	A a;
	B b;
	Func(&a);
	Func(&b);
	return 0;
}

通过对以上代码进行反汇编,我们可以看到在使用基类引用调用虚函数时的操作如下: 

mov    eax,dword ptr [tmp]

tmp中存储的是基类对象的指针,将tmp中的值移动到eax寄存器中。

mov    edx,dword ptr [eax]

 将eax的值指向的内容存储到edx中,即将基类对象前4个字节(虚表指针)移动到edx中。

mov    ecx,dword ptr [tmp]

取tmp指针存储的值到ecx寄存器中 

mov    eax,dword ptr [edx]

取edx寄存器中的值指向的内容,相当于取了虚表的前四个字节,即虚表中存储的第一个虚函数的地址。如果我们此处调用的是fun2或者func3,则会在edx基础上偏移一定字节。 

call    eax

调用eax寄存器中存储的指针指向的函数。

动态绑定与静态绑定

静态绑定又被称为前期绑定早绑定,在程序编译期间便已经确定了程序的行为,也被称为静态多态

动态绑定又被称为后期绑定晚绑定,在程序运行期间,根据具体拿到的类型来确定程序的行为,调用具体的函数,也称为动态多态。 

多继承下的虚函数表

我们上文所主要讲解使用的是单继承情况下的虚函数表。这里我们就简单研究下如果在多继承体系下,虚函数表又会有怎样的不同。

是将多个基类的虚函数表直接合为一个,还是分别存储与派生类对象中分属于不同基类的存储空间里。

class Base1 {
public:
	virtual void func1() { cout << "Base1::func1" << endl; }
	virtual void func2() { cout << "Base1::func2" << endl; }
private:
	int b1;
};
class Base2 {
public:
	virtual void func1() { cout << "Base2::func1" << endl; }
	virtual void func2() { cout << "Base2::func2" << endl; }
private:
	int b2;
};
class Derive : public Base1, public Base2 {
public:
	virtual void func1() { cout << "Derive::func1" << endl; }
	virtual void func3() { cout << "Derive::func3" << endl; }
private:
	int d1;
};
typedef void(*VFPTR) ();
void PrintVTable(VFPTR vTable[])
{
	cout << " 虚表地址>" << vTable << endl;
	for (int i = 0; vTable[i] != nullptr; ++i)
	{
		printf(" 第%d个虚函数地址 :0X%x,->", i, vTable[i]);
		VFPTR f = vTable[i];
		f();
	}
	cout << endl;
}
int main()
{
	Derive d;
	VFPTR* vTableb1 = (VFPTR*)(*(int*)&d);
	PrintVTable(vTableb1);
	VFPTR* vTableb2 = (VFPTR*)(*(int*)((char*)&d + sizeof(Base1)));
	PrintVTable(vTableb2);
	return 0;
}

可能是之前对VS的编译器做过太多设置的修改,这个程序在Debug模式下运行会发生崩溃,崩溃原因是虚函数表末尾并没有按照我们预期的那样缀上一个nullptr。

但是在release模式下可以正常地运行,并得到一下结果: 

通过观察图示结果我们可以很直观的看到,多继承情况下,派生类重写的虚函数会放在第二个继承基类部分的虚函数表中,而未重写虚函数则会在第一个继承基类部分的虚函数表中。

同时,在多个基类中有相同的函数(函数名,返回值,参数列表)时,默认会重写先继承的基类中的对应函数。

总结

以上为个人经验,希望能给大家一个参考,也希望大家多多支持脚本之家。

您可能感兴趣的文章:
阅读全文