多态
一种事物,多种形态。换言之,对于同一个行为,不同的对象去完成就会产生不同的结果。
多态的构成条件
多态是继承体系中的一个行为,如果要在继承体系中构成多态,需要满足两个条件:
-
必须通过基类的指针or引用调用虚函数 -
被调用的函数必须是虚函数,并且派生类必须要对继承的基类的虚函数进行重写
虚函数
虚函数就是被 virtual 修饰的类成员函数 (这里的 virtual 和虚继承的 virtual 虽然是同一个关键字,但是作用不一样)。
重写
一般情况下,当派生类中有一个和基类完全相同的虚函数(函数名、返回值、参数完全相同),则说明子类的虚函数重写了基类的虚函数。
class Human
{
public:
virtual void print()
{
cout << "i am a human" << endl;
}
};
class Student : public Human
{
public:
virtual void print()
{
cout << "i am a student" << endl;
}
};
void ShowIdentity(Human &human)
{
human.print();
}
int main()
{
Human h;
Student s;
ShowIdentity(h);
ShowIdentity(s);
}
通常如果不满足函数名、返回值、参数完全相同,则不构成重写,即无法实现多态。但也有例外:
重定义(参数不同)
参数不同则会变成重定义:
class Base{
public:
virtual void Show(int n = 10)const{
std::cout << "Base:" << n << std::endl;
}
};
class Base1 : public Base{
public:
virtual void Show(int n = 20)const{
std::cout << "Base1:" << n << std::endl;
}
};
int main(){
Base* p1 = new Base1;
p1->Show();
return 0;
}
如果子类重写了缺省值,此时的子类的缺省值是无效的,使用的还是父类的缺省值。
因为多态是动态绑定,而缺省值是静态绑定。
- 对于
p1 ,他的静态类型即指针的类型——Base ,所以这里的缺省值是 Base 的缺省值。 - 而动态类型也就是指向的对象是
Base1 ,所以这里调用的虚函数则是 Base1 中的虚函数。 - 调用了
Base1 中的虚函数,Base 中的缺省值,因此输出 Base1:10 。
或者可以更简单的一句话描述,虚函数的重写只重写函数实现,不重写缺省值。
动态绑定和静态绑定
- 对象的静态类型:对象在声明时采用的类型。是在编译期确定的。(比如上面的
p1 ,Base 是静态类型,指向的对象的类型 Base1 是动态类型) - 对象的动态类型:目前所指对象的类型。是在运行期决定的。
对象的动态类型可以更改,但是静态类型无法更改。
- 静态绑定:绑定的是对象的静态类型,发生在编译期。
- 动态绑定:绑定的是对象的动态类型,发生在运行期。
协变(返回值不同)
当基类和派生类的返回值类型不同时,如果基类对象返回基类的 引用or指针 ,派生类对象返回的是派生类的 引用or指针 ,也能实现多态。这样实现多态的方式叫协变。
class Human
{
public:
virtual Human& print()
{
cout << "i am a human" << endl;
return *this;
}
};
class Student : public Human
{
public:
virtual Student& print()
{
cout << "i am a student" << endl;
return *this;
}
};
但如果返回类型不是对应类的 指针or引用 ,则不足以构成协变:
析构函数重写(函数名不同)
在特定条件下,函数名不同也能实现多态,最好的例子是析构函数,编译器为了让析构函数实现多态,会将它的名字处理成destructor ,也就是说,实际上析构函数的函数名也是“相同的”,其多态实现遵循重写的规定。
class Human
{
public:
~Human()
{
cout << "~Human()" << endl;
}
};
class Student : public Human
{
public:
~Student()
{
cout << "~Student()" << endl;
}
};
可以看到,如果不构成多态,那么指针是什么类型的,就会调用什么类型的析构函数。那么,如果派生类的析构函数中有资源释放的操作,而这里却没有释放掉那些资源,就会导致内存泄漏的问题。
所以为了防止这种情况,必须要将析构函数定义为虚函数。这也就是编译器将析构函数重命名为 destructor 的原因:
final和override
final 和 override 是 C++11 中提供给用户用来检测是否进行重写的两个关键字。
final
使用 final 修饰的基类虚函数不能被重写。
如果虚函数不想被派生类重写,就可以用 final 来修饰这个虚函数:
override
override 关键字是用来检测派生类虚函数是否构成重写的关键字。
在我们写代码的时候难免会出现些小错误,如 基类虚函数没有 virtual 或者 派生类虚函数名拼错 等问题,这些问题不会被编译器检查出来,发生错误时也很难一下子锁定,所以 C++ 增添了 override 这一层保险,当修饰的虚函数不构成重写时就会编译错误:
重载、重写、重定义
重载:
- 在同一作用域
- 函数名相同,参数的类型、顺序、数量不同。
- 重载不一定要求返回值相同:参数相同、返回值不同不构成重载;参数、返回值都不同则构成重载。(会发现返回值不同是否构成重载还是看参数相同与否……)
重写(覆盖):
- 作用域不同,一个在基类一个在派生类。
- 函数名,参数,返回值必须相同(协变和析构函数除外)
- 基类和派生类必须都是虚函数(派生类可以不加
virtual ,基类的虚函数属性可以继承,但是最好要加上 virtual )
考虑这样一个问题,下面有几个虚函数:
正确答案是 3 个,A 中的 fun1 ,B 中的 fun1 、fun2 。原因就如第三点所说,基类的虚函数属性可以继承 ,但是如果有 C类 继承了 B类 ,且也有一个 没有virtual关键字的 void fun1(); 函数 ,该函数并不是虚函数,因为 B类 的 fun1 并没有显式声明 virtual 属性。
而形如 fun2 这样的,子类是虚函数而父类没有 virtual 属性的,父类的 fun2 不是虚函数,虚函数不具备对称性。
重定义(隐藏):
- 作用域不同,一个在基类一个在派生类
- 函数名相同
- 派生类和基类同名函数如果不构成重写那就是重定义
抽象类
如果在虚函数的后面加上 =0 ,并且不进行实现,这样的虚函数就叫做纯虚函数。而包含纯虚函数的类,也叫做抽象类或者接口类。抽象类不能实例化出对象,因为他所具有的信息不足以描述一个对象,派生类继承后也只有在重写纯虚函数后才能实例化出对象。
抽象类就像是一个蓝图,为派生类描述好一个大概的架构,派生类必须实现完这些架构,至于要在这些架构上面做些什么,增加什么,就属于派生类自己的问题。
class Human
{
public:
virtual void print() = 0;
};
class Student : public Human
{
public:
virtual void print()
{
cout << "i am a student" << endl;
}
};
class Teacher : public Human
{
public:
virtual void print()
{
cout << "i am a teacher" << endl;
}
};
void ShowIdentity(Human& human)
{
human.print();
}
- 普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。
- 虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,所以如果不实现多态,不要把函数定义成虚函数。
多态的原理
虚函数
class Human
{
public:
virtual void print()
{
cout << "i am a human" << endl;
}
virtual void test1()
{
cout << "1test1" << endl;
}
void test2()
{
cout << "1test1" << endl;
}
int _age;
};
class Student : public Human
{
public:
virtual void print()
{
cout << "i am a student" << endl;
}
void test2()
{
cout << "2test2" << endl;
}
int _stuNum;
};
我们创建一个 Human 类对象 h ,观察它的大小,按理来说应该输出 4 ,因为它只有一个 int类型 的数据成员,但是结果却是 8 。
可以看到奇怪的是除了 _age 之外,还有个 void**(void*类型的指针,注意不是数组) 类型的 _vfptr ,这个 _vfptr 也被称为虚函数表指针,其指向了一个函数指针数组,这个函数指针数组也就是虚函数表,其中的每一个成员指针指向的都是虚函数,而不是虚函数的 test2 则没有被放入表中。
此时再创建一个 Student 类的对象 s ,进一步观察:
我们可以看到,如果派生类实现了某个虚函数的重写,那么在派生类的虚函数表中,重写的虚函数就会覆盖掉原有的函数,如Student::print 。而没有完成重写的 test1 则依旧保留着从基类继承下来的虚函数 Human::test1 。
总结
- 派生类会继承基类的虚函数表,如果派生类完成了重写,则会将重写的虚函数覆盖掉原有的函数。
- 指针或引用指向哪一个对象,就调用对象中虚函数表中对应位置的虚函数,来实现多态。
继续分析构成多态的另一个条件,为什么必须要指针或者引用才能构成多态?
- 如果将派生类对象赋值给基类对象,会因为对象切割,导致他的内存布局整个被修改,完全转换为基类对象的类型,虚函数表也与基类相同,所以不能实现多态。
- 如果用基类指针或者引用指向派生类对象,他们的内存布局是兼容的,不会像赋值一样改变派生类对象的内存结构,所以派生类对象的虚函数表得到了保留,所以他可以通过访问派生类对象的虚函数表来实现多态。
总结一下派生类虚函数表的生成过程:
- 首先派生类会将基类的虚函数表拷贝过来
- 如果派生类完成了对虚函数的重写,则用重写后的虚函数覆盖掉虚函数表中继承下来的基类虚函数
- 如果派生类自己又新增了虚函数,则添加在虚函数表的最后面
常见问题解析
内联函数可以是虚函数吗?
不可以,内联函数没有地址,无法放进虚函数表中。
静态成员函数可以是虚函数吗?
不可以,静态成员函数没有 this指针 ,无法访问虚函数表。
构造函数可以是虚函数吗?
不可以,虚函数表指针也是对象的成员之一,是在构造函数初始化列表初始化时才生成的
析构函数可以是虚函数吗?
可以,上面有写,最好把基类析构函数声明为虚函数,防止使用基类指针或者引用指向派生类对象时,派生类的析构函数没有调用,可能导致内存泄漏。
对象访问虚函数快还是普通函数快?
- 如果不构成多态,虚函数和普通函数的访问是一样快的,都是直接在编译时从符号表中找到函数的地址后调用。
- 如果构成多态,调用虚函数就得在运行期到虚函数表中查找,就会导致速度变慢,所以普通函数更快一些。
虚函数表
从上面的观察可以看出来,虚函数存于虚函数表中,那么虚函数表又存储在哪里呢?
虚函数表在编译阶段生成,存储于代码段。
详情可以看这篇博客。
注意:
- 同一个类的不同实例(对象)共用同一份虚函数表。
- 子类
特有的虚函数 会加在父类 虚函数表 中的 父类虚函数的后面 。 - 如果子类继承多个父类、且这些父类都有虚函数表,
子类特有的虚函数 加在 第一个父类的虚函数表 中。 - 如果子类继承多个父类、但只有部分父类有虚函数表,
子类特有的虚函数 加在 第一个有虚函数表的父类 的 虚函数表 中。 - 如果子类继承多个父类、且这些父类都没有虚函数表,子类会自己创建一个虚函数表来存储特有的虚函数。
|