C++之多态及原理

本文详细介绍了C++中的多态性,包括编译时和运行时多态,重点讲解了虚函数、运算符重载和模板的使用。阐述了虚函数的定义、虚表、虚析构函数及其代价,同时探讨了运算符重载的规则和实现,以及函数和类模板的使用。此外,还解析了相关面试题,如重载、重写(覆盖)和隐藏的区别,以及final和override关键字的应用。
摘要由CSDN通过智能技术生成

1.多态

1.1 多态定义

  • 定义:通俗来说,多态就是一个接口多种方法。

1.2 多态分类

编译时多态:在编译过程中静态确定同名操作的具体操作对象。包含重载多态,强制多态,参数多态三种情况。其中强制多态是指将一个类型加以变化以符合一个函数或者操作的要求,参数多态与类模板相关联,在使用的时候必须赋予实际的类型才可以实例化, 重载多态指普通函数以及类的成员函数的重载都属于重载多态。
运行时多态:在运行过程中动态确定同名操作的具体操作对象。包括包含多态一种情况,包含多态类族中定义不同类中的同名成员函数的多态行为,主要是通过虚函数来实现。

2.运算符重载

2.1 运算符重载定义

  • 定义:运算符重载是对已有的运算符赋予多重含义,使运算符作用于不同类型的数据时导致不同的行为,实质是函数重载。

2.2 运算符重载规则

  • C++中的已有运算符除了少数之外,全部可以重载。不能重载的运算符有类属关系运算符“.”、成员指针运算符“.*”、作用域分辨符“::”、三目运算符“?:”,其中前两个保证了C++中访问功能没有被改变,作用域分辨符的操作数是类型,而不是普通的表达式,也不具有重载的特征。
  • 重载之后运算符的优先级结合性都不会改变。
  • 运算符重载是针对新类型数据的实际需要,对原有运算符进行适当的改造。一般来讲,重载的功能应当和原有的功能相类似,不能改变原运算符的操作对象的个数,同时至少要有一个操作对象是自定义类型。
  • 不能重载为类的友元函数的双目运算符= () [] ->。若运算符所需的操作数(尤其是第一个操作数),希望有隐式类型转换,则只能选用友元函数。
  • 运算符函数名称规则operator 运算符。例如,+运算符重载函数名应该为operator+

2.3 运算符重载为成员函数

  • 参数的个数:如果是双目运算符,左操作数是对象本身的数据,由this指针指出,右操作数则需要通过运算符重载函数的参数表来传递;如果是单目运算符,操作数由对象的this指针给出,就不再需要任何参数。因此来说,函数的参数个数比原来的操作数要少一(后置“++”,“–”除外);
  • 重载形式1:对于双目运算符B,若表达式为op1 oper op2,其中op1是A类的对象,应当把oper重载为A类的成员函数,该函数只有一个形参,形参类型是op2所属类型。经过重载之后,表达式op1 oper op2就相当于函数调用op1.oper(op2)。例如:
//声明
Complex operator+ (const Complex &c2) const;
//定义
Complex Complex::operator+ (const Complex &c2) const{
	return Complex(real + c2.real, imag + c2.imag);
}
  • 重载形式2:对于前置单目运算符oper,如-(负号)等,如果想要重载为类的成员函数,用来实现表达式oper op1,其中op1为A类的对象,则oper应当重载为A类的成员函数,函数没有形参。经过重载之后,表达式oper op1相当于函数调用op1.oper()。例如:
//声明
Complex &operator++();
//定义
Complex &Complex::operator++() {
	this->x += 1.0;
	this->y += 1.0;
	return *this;
}
  • 重载形式3:后置运算符“++”和“–”,如果要将他们重载为类的成员函数,用来实现表达式op1++或者op1–,其中op1为A类的对象,那么运算符就应当重载A类的成员函数,这时函数要带有一个整形(int)形参。重载之后,表达式op1++和op1–就相当于函数调用op.operator++(0)和op.operator--(0)。注意这里的int类型参数在运算不起任何作用,只是用于区别后置++、–与前置++、–
//声明
Complex &operator++(int flag);
//定义
Complex& Complex::operator++(int flag) {
	Complex old = *this;
	++(*this); //调用前置“++”运算符;
	return old;
}

2.4 运算符重载为非成员函数

  • 定义:操作数需要通过函数的参数表来传递,在形参表从左到右的顺序就是运算符操作数的顺序。如果需要访问运算符参数对象的私有成员,可以将该函数声明为类的友元函数
  • 重载形式1:对于双目运算符oper,如果需要实现op1 oper op2,其中op1 和 op2 中只要有一个具有自定义类型,就可以将oper重载为非成员函数,函数的形参为op1和op2。经过重载之后,表达式op1 B op2就相当于函数调用operator oper(op1,op2)
//声明
friend Complex operator+ (const Complex &c1, const Complex &c2);
//定义
Complex operator+ (const Complex &c1, const Complex &c2) {
	return Complex(c1.real + c2.real, c1.imag + c2.imag);
}
  • 重载形式2:对于前置运算符oper,如“一”(负号)等,如果要实现表达式 oper op,其中op具有自定义类型,就可以将oper重载为非成员函数,函数的形参为op。见过重载之后,表达式oper op相当于函数调用operator oper(op)。例如:
//声明
friend Complex &operator++(Complex &c1);
//定义
Complex &operator++(Complex &c1) {
	c1.x += 1.0;
	c1.y += 1.0;
	return c1;
}
  • 重载形式3:对于后置运算符++和- -,如果要实现表达式 op++ 或者 op - -,其中op具有自定义类型,就可以将运算符重载为非成员函数.这时函数的形参有两个,一个是op,一个是int类型形参。第二个参数是用于与区别前置运算,重载之后,表达式 op++和op–就当于函数调用operator ++(op,0)和operator --(op,0)
//声明
friend Complex &operator++ (Complex &a,int flag);
//定义
Complex &operator++ (Complex &a, int flag) {
	Complex old = a;
	++a;    //调用前置“++”运算符;
	return a;
}
  • 必须使用非成员函数的重载情况

(1)要重载的操作符的第一个操作数是不可以更改类型。例如“<<”运算符的第一个操作数的类型为ostream,是标准库的类型,无法向其中添加成员函数。
(2)以非成员函数形式重载,支持更灵活的类型转换

3.模板

3.1 函数模板

  • 语法template <typename type> ret-type func-name(parameter list){// 函数的主体},例如:
template <typename T>
const T &Max (const T &a , const T & b) {
	return a > b ? a : b;
}

3.2 类模板

  • 语法template <class type> class class-name { }。例如:
template <class T>
class Stack { 
	public: 
		void push(const T &val) {
	    	data.push_back(val);
		} 
	    const T pop() {
	    	T val = data.back();
			data.pop_back();
			return val;
		}                
	    const T& top() const {
	    	return data.back();
		}           
	    bool empty() const {
	    	return data.size() == 0;
		}     
    private: 
    	vector<T> data;         
};

4.虚函数

4.1 一般虚函数成员

  • 声明virtual 函数类型 函数名(形参表){} ,实际上就是在类的定义中使用virtual关键字来限定成员函数,virtual只能出现在类的函数原型声明时,而不是在函数成员实现的时候。

4.2 纯虚函数与抽象类

  • 纯虚函数定义:声明为纯虚函数之后,基类中就可以不再给出函数的实现部分。语法为virtual void funtion1()=0;
  • 抽象类定义:抽象类是带有纯虚函数的类。建立抽象类,就是为了通过多态地使用其中的成员函数。抽象类处于类的上层,一个抽象类自身无法实例化,也就是说我们无法定义一个抽象类的对象。只有通过继承机制,生成抽象类的非抽象派生类,然后再实例化。
  • 抽象类作用:通过抽象类为类族建立一个公共的接口,使它们能够更有效地发挥多态特性。接口的完整实现即纯虚函数的函数体,由派生类定义
  • 抽象类的派生类:若抽象类的派生类没有给出全部纯虚函数的实现,这时的派生类仍然是一个抽象类。相反,若抽象类的派生类全部给出全部纯虚函数的实现,这个派生类就不是抽象类,可以定义自己的对象。
  • 抽象类与指针(引用):抽象类不能实例化即不能定义一个抽象类的对象,但是可以定义一个抽象类的指针和引用。通过指针和引用,就可以指向并访问派生类的对象,进而访问派生类的成员,这种访问是具有多态性。

4.3 虚析构函数

  • 定义和语法:C++中,不能声明虚构造函数,但是可以声明虚析构函数;语法形式为 vitrual ~类名();
  • 作用:避免内存泄漏,虚析构函数使得在删除指向派生类对象的基类指针时,也可以调用派生类的析构函数来实现释放派生类中堆内存的目的,从而防止内存泄漏。
  • 总结:在基类中的析构函数没有声明为虚函数,而且基类的指针指向派生类的对象情况下,delete基类的指针,只会调用基类的析构函数,不会调用派生类的析构函数,从而导致内存泄漏。但是如果析构函数声明为虚函数,delete基类的指针时,先调用派生类的析构函数,再调用基类中的析构函数

4.4 纯虚析构函数

  • 纯虚析构函数:析构函数可以是纯虚函数,含有纯虚函数的类是抽象类,此时不能被实例化。但派生类中可以根据自身需求重新改写基类中的纯虚函数
  • 建议:纯虚析构函数一定得定义,因为每一个派生类析构函数会被编译器加以扩张,以静态调用的方式调用其每一个虚基类以及上一层基类的析构函数。因此,缺乏任何一个基类析构函数的定义,就会导致链接失败,最好不要把虚析构函数定义为纯虚析构函数。

4.5 运行时多态需要满足的三个条件

  • 类之间需要满足赋值兼容规则
  • 要声明虚函数
  • 由成员函数通过指针(引用)访问虚函数。如果使用对象名来访问虚函数,则绑定发生在编译过程中(即静态绑定),无法实现多态。

4.6 虚函数的代价

  • 带有虚函数的类,每一个类会产生一个虚表,用来存储指向虚成员函数的指针,增大类的存储空间;
  • 带有虚函数的类的每一个对象,都会有有一个指向虚表的指针,会增加对象的空间大小;
  • 不能是内联的函数,因为内联函数在编译阶段进行替代,而虚函数在运行阶段才能确定到底是采用哪种函数。

5.虚表和虚表指针

5.1 虚表和虚表指针定义

  • 虚表:虚函数表的缩写,类中含有virtual关键字修饰的函数时,编译器会自动生成虚表。
  • 虚表指针:在含有虚函数的类实例化对象时,对象地址的前四个字节存储虚表指针。

5.2 虚表和虚表指针在内存的分布

  • 基类的虚表分布
  • 派生类虚表的分布

    在这里插入图片描述

5.3 实现多态的过程和原理

  • 编译器在发现父类中有虚函数时,自动生成一份虚表,该表是一维数组,虚表里保存虚函数的入口地址。另外虚表在Linux/Unix中存放在可执行文件的只读数据段中(rodata)。
  • 编译器会在每个对象中保存一个vptr(即虚表指针),指向对象所属类的虚表。在构造时,根据对象的类型去初始化虚表指针vptr,从而让vptr指向正确的虚表。因此在调用虚函数时,能找到正确的函数。
  • 子类定义对象时,程序运行会自动调用构造函数,在构造函数中创建虚表并对虚表初始化。当有重写时,虚表中存储的是子类的函数地址;当子类对基类的虚函数没有重写时,虚表中存储的是父类的虚函数地址;当子类中有自己的虚函数时,在虚表中将此虚函数地址添加在后面
  • 这样指向子类的父类指针在运行时,就可以根据子类对虚函数重写情况动态调用,从而实现多态性。

6.相关面试题

1.重载、重写(覆盖)和隐藏区别

  • 重载(overload):是指在同一范围定义中的同名成员函数才存在重载关系。主要特点是函数名相同,参数类型和数目有所不同,仅仅依靠返回值不同不能构成重载。重载和函数成员是否是虚函数无关。举个例子:
class A{
    ...
    virtual int fun();
    void fun(int);
    void fun(double, double);
    static int fun(char);
    ...
}
  • 重写/覆盖(override):指的是在派生类中覆盖基类中的同名函数,重写就是重写函数体,要求基类函数必须是虚函数且:

a)与基类的虚函数有相同的参数个数。
b)与基类的虚函数有相同的参数类型。
c)与基类的虚函数有相同的返回值类型。
例如:

//父类
class A{
public:
    virtual int fun(int a){}
}
//子类
class B : public A{
public:
    //重写,一般加override可以确保是重写父类的函数;
    //这时编译器可以帮助我们检查是够符合重写;
    virtual int fun(int a) override{}
}
  • 重载与重写的区别:重写是父类和子类之间的垂直关系,重载是不同函数之间的水平关系;重写要求参数列表和返回值相同,重载则要求参数列表不同,返回值不要求;重写调用方法根据对象类型决定,重载根据实参与形参决定。
  • 隐藏(hide):指的是某些情况下,派生类中的函数屏蔽基类中的同名函数,包括以下情况:

(1)两个函数参数不同,无论基类函数是不是虚函数都会被隐藏。和重载的区别在于两个函数不在同一个类中。举个例子:

//父类
class A{
public:
    virtual void fun(int a){
		cout << "A中的fun函数" << endl;
	}
};
//子类
class B : public A{
public:
    //隐藏父类的fun函数
   virtual void fun(char* a){
	   cout << "A中的fun函数" << endl;
   }
};
int main(){
    B b;
    b.fun(2);     //报错,调用的是B中的fun函数,参数类型不对;
    b.A::fun(2); //调用A中fun函数
    return 0;
}

(2)两个函数参数相同,但是基类函数不是虚函数。和重写的区别在于基类函数是否是虚函数。举个例子:

//父类
class A{
public:
    void fun(int a){
		cout << "A中的fun函数" << endl;
	}
};
//子类
class B : public A{
public:
    //隐藏父类的fun函数
    void fun(int a){
		cout << "B中的fun函数" << endl;
	}
};
int main(){
    B b;
    b.fun(2);     //调用的是B中的fun函数
    b.A::fun(2);  //调用A中fun函数
    return 0;
}

2.final和override关键字

  • final关键字:当不希望某个类被继承,或不希望某个虚函数被重写,可以在类名或者虚函数后添加final关键字。如果添加final关键字后被继承或重写,编译器会报错。
  • override关键字:当在父类中使用虚函数时候,可能需要在某个子类中对这个虚函数进行重写。此时用override指定子类的这个函数是重写父类的,如果父类中没有找到相关虚函数,编译器会报错。

3.类如何实现只能静态分配和只能动态分配

  • 前者是把new、delete运算符重载为private属性。后者是把构造、析构函数设为protected属性,再用子类来动态创建。
  • 建立类的对象有两种方式

1)静态建立,静态建立一个类对象,就是由编译器为对象在栈空间中分配内存;
2)动态建立A *p = new A();,动态建立一个类对象,就是使用new运算符为对象在堆空间中分配内存。这个过程分为两步,第一步执行operator new()函数,在堆中搜索一块内存并进行分配;第二步调用类构造函数构造对象;

  • 只有使用new运算符,对象才会被建立在堆上。因此只要限制new运算符就可以实现类对象只能建立在栈上,也就是将new运算符设为私有。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值