面向对象程序设计的核心思想是数据抽象、继承、动态绑定。
通过使用数据抽象,将类的接口与实现分离
使用继承,定义相似的类型并对其相似关系建模
使用动态绑定,可以在一定程度上忽略相似类型的区别,而以统一的方式使用它们的对象
多态
多态的概念:通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态
就比如生活中,我们需要买火车票,不同的人扮演的角色不同,买的票也不同,比如学生买学生票,小孩买未成年人票,大人买全票(举个例子)
多态的定义及实现
在继承中构成多态还有两个条件:
- 必须通过基类的指针或者引用调用虚函数
- 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写
虚函数
在C++语言中,基类将类型相关的函数与派生类不做改变直接继承的函数区分对待。对于某些函数,基类希望它的派生类各自定义其适合自身的版本,此时基类就将这些函数声明为虚函数(virtual function)。
class Person
{
public:
virtual void Print(int a) const;//在函数前面加virtual,声明其为虚函数
};
虚函数重写
虚函数的重写(覆盖):派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了基类的虚函数
比如:
class Person {
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person {
public:
virtual void BuyTicket() { cout << "买票-半价" << endl; }
};
注意:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时
(void BuyTicket() { cout << “买票-半价” << endl; })
虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用
派生类必须在其内部对所有重新定义的虚函数进行声明,派生类可以在这样的函数之前加上virtual关键字,不是非得这么做,在C++11新标准中允许派生类显示的注明它将使用哪个成员函数改写基类的虚函数,具体措施就是在形参列表后加上override关键字
虚函数重写的两个例外:
- 协变(基类与派生类虚函数返回值类型不同)
派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变
class A {};
class B : public A {};
class Person {
public:
virtual A* f() { return new A; }
};
class Student : public Person {
public:
virtual B* f() { return new B; }
};
- 析构函数的重写(基类与派生类析构函数的名字不同)
如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。虽然函数名不相同,看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor
class Person {
public:
virtual ~Person() { cout << "~Person()" << endl; }
};
class Student : public Person {
public:
virtual ~Student() { cout << "~Student()" << endl; }
};
只有派生类Student的析构函数重写了Person的析构函数,下面的delete对象调用析构函数,才能构成多态
动态绑定与静态绑定
- 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载
- 动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。
通过使用动态绑定,我们能用同一段代码分别处理Person与Student对象。
观察下面代码:
class Person
{
public:
virtual void Print()
{
cout << _No<<endl;
}
int _No=1;
};
class Student : public Person
{
public:
void Print() override
{
cout << _age<<endl;
}
int _age=100;
};
void Test(Person& obj)
{
obj.Print();
}
Student继承与Person,Person里面的函数Print为虚函数,Student为这个继承来的虚函数重新定义了。
运行下面代码:
Person obj_per;
Student obj_stu;
Test(obj_per);
Test(obj_stu);
运行结果:
我们发现,传入的对象类型不同,打印的结果也不同。因为函数Test传入的形参obj是基类Person的一个引用(构成多态),我们既能使用基类Person的对象调用它,也能使用派生类Student的对象调用它(我们可以看这一篇关于)。因为Print是使用引用类型调用的,所以实际传入Test函数的类型决定到底该执行Print哪个版本。
Test(obj_per);
传入的是基类Person的对象,所以调用基类版本
Test(obj_stu);
传入的是派生类Student的对象,所以调用派生类的版本
如果我们不使用引用作为Test的形参:
void Test(Person obj)
{
obj.Print();
}
运行结果:
调用的都是基类的Print函数,这个不是动态绑定
在上述过程中函数的运行版本由由实参决定,即在运行时选择函数的版本,所以动态绑定有时又被称为运行时绑定。
在C++语言中,当我们使用基类的引用(或指针)调用一个虚函数时将发生动态绑定。
override 和 final (C++11)
C++对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数名字母次序写反而无法构成重载,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来debug会得不偿失,因此:C++11提供了override和final两个关键字,可以帮助用户检测是否重写
- final:修饰虚函数,表示该虚函数不能再被重写
class Person
{
public:
virtual void Print() final
{
cout << _No << endl;
}
int _No = 1;
};
class Student : public Person
{
public:
virtual void Print()//不能继承
{
cout << _age << endl;
}
int _age = 100;
};
结果:
- override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错
class Person
{
public:
virtual void Print()
{
cout << _No << endl;
}
int _No = 1;
};
class Student : public Person
{
public:
virtual void Print() override
{
cout << _age << endl;
}
int _age = 100;
};
如果没有重写就报错:
如果我们上面图片中的override去掉,能够编译通过,但是基类Person与派生类Student里面的Print函数不构成动态绑定,而是构成隐藏关系,因为同名函数,不在同一作用域,派生类会隐藏掉基类里面的同名函数。
重载、覆盖(重写)、隐藏(重定义)的对比:
成员函数与继承
在C++语言中,基类必须将它的两个成员函数区分开来:一种是基类希望其派生类进行覆盖的函数;另一种是基类希望派生类直接继承而不要改变的函数。
对于前者,基类将其定义为虚函数,当我们使用指针或引用调用虚函数时,该调用将被动态绑定。根据指针或引用的类型不同,该调用可能执行基类的版本,也可能执行某个派生类的版本。
任何构造函数之外的非静态函数都可以是虚函数。关键字virtual只能出现在类内部的声明语句之前而不能用于类外部的函数定义。如果基类把一个函数声明为虚函数,则该函数在派生类中隐式的也是虚函数。
成员函数如果没被声明为虚函数,则其解析过程发生在编译时而非运行时。
派生类中的虚函数
派生类经常(但不总是)覆盖它继承的虚函数。如果派生类没有覆盖其基类中的某个虚函数,则该虚函数的行为类似于其他的普通成员,派生类会直接继承其在基类的版本。
派生类可以在它覆盖的函数前使用virtual关键字,但不是非得这么做。C++11新标准允许派生类显示地注明它使用某个成员函数覆盖它继承的虚函数,具体的做法是:在形参后面、或者在const成员函数的const关键词后面、或者在引用成员函数的引用限定符后面添加一个关键词override。
一个派生类的函数如果覆盖了某个继承而来的虚函数,则它的形参类型返回值函数名必须与它被覆盖的函数完全一致。
虚函数与默认实参
和其他的函数一样,虚函数也可以拥有默认实参,如果某次函数调用使用了默认实参,则该实参值由本次调用的静态类型决定。
如果我们通过基类的引用或指针调用函数,则使用基类中定义的默认实参,即使运行的是派生类中的函数版本也是如此。此时,传入派生类函数的将是基类函数定义的默认实参。如果派生类函数依赖不同的实参,则程序结束将与我们预期不符。
如果虚函数使用默认实参,则基类和派生类中定义的默认实参最好一致
一道关于默认实参的面试题:最终打印的结果是什么?
class A
{
public:
virtual void func(int val = 1) { std::cout << "A->" << val << std::endl; }
virtual void test() { func(); }
};
class B : public A
{
public:
void func(int val = 0) { std::cout << "B->" << val << std::endl; }
};
int main(int argc, char* argv[])
{
B* p = new B;
p->test();
return 0;
}
我们可能会认为,这里B继承A的test函数,那么test调用函数的this指针也会改变,其实不会。是基类的函数,哪怕被继承使用仍然是基类的this指针,不会因为继承this指针类型发生改变。
我们通过下面代码看看this指针的类型问题:我们创建一个派生类的对象,它的初始化是基类部分的由基类构造函数初始化,派生类部分由派生类部分初始化。我们使用typeid(this).name()
来看看this指针的类型
class Person
{
public:
Person()
{
cout << typeid(this).name() << endl;
cout << this << endl;
_name = "sb";
_sex = "nan";
_age = 18;
cout << _name << " " << _sex << " " << _age << endl;
}
protected:
string _name; // 姓名
string _sex; // 性别
int _age; // 年龄
};
class Student : public Person
{
public:
Student()
{
cout << typeid(this).name() << endl;
cout << this << endl;
_No = 123;
cout << _No << endl;
}
public:
int _No; // 学号
};
int main()
{
Student s;
return 0;
}
使用的是同一个this指针,但是在基类与派生类的类型是不相同的。在派生类的构造函数中调用基类的构造函数时,编译器会自动将派生类对象的 this 指针转换为基类类型的指针,并传递给基类的构造函数。
比如下面代码:
class A
{
public:
void test() { cout << _name << endl; }
//p访问的是基类部分,转化为基类this指针只能指向基类的成员,也就是10的
//_name其实是this->_name
int _name = 10;
};
class B : public A
{
public:
int _name = 100;
};
int main(int argc, char* argv[])
{
B* p = new B;
p->test();
return 0;
}
运行结果:
基类和派生类有相同成员变量_name,派生类会隐藏基类的_name,但是我们使用B的对象访问从A继承来的test函数运行结果是10,因为,this指针还是基类的this指针,它只有访问基类的成员权限,没有访问派生类的权限。
如何回避虚函数的机制
在某些情况,我们希望对虚函数的调用不要进行动态绑定,而是强迫其执行虚函数的某个特定的版本。使用作用域运算符可以实现这一目的。
比如下面代码:
class Person {
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person {
public:
virtual void BuyTicket() { cout << "买票-半价" << endl; }
};
运行:
Student obj_stu;
Person* obj= &obj_stu;
obj->Person::BuyTicket();
如果按照动态绑定来,obj的静态类型是Person* 但是它的动态类型是Student*,由动态类型决定它使用虚函数哪一个版本。但是我们使用了Person::,所以强制执行这个类作用域里面的,也就是Person类中的:
通常情况下,只有成员函数(或友元)中的代码才需要使用作用域运算符来回避虚函数机制
什么时候我们需要回避虚函数的默认机制?通常是当一个派生类的虚函数调用它覆盖的基类虚函数版本时。在此情况下,基类的版本通常完成继承层次中所有类型都要做的共同任务,而派生类中定义的版本需要执行一些与派生类本身密切相关的操作。
如果一个派生类虚函数需要调用它的基类版本,但是没有使用作用域运算符,则在运行时该调用将被解析为对派生类版本自身的调用,从而导致无限递归。
#include <iostream>
class Base {
public:
virtual void func() {
std::cout << "Base::func()" << std::endl;
}
};
class Derived : public Base {
public:
virtual void func() {
// 错误示例:没有使用作用域运算符来调用基类的虚函数
func(); // 递归调用自身
}
};
int main() {
Derived derived;
derived.func();
return 0;
}
派生类 Derived 的虚函数 func 内部调用了自身而没有使用作用域运算符。因此,每次调用 func 时,都会再次执行派生类的 func 函数,导致无限递归,最终导致栈溢出。
为避免这种情况,应该使用作用域运算符来明确指定基类的虚函数调用,如 Base::func()
C++多态性:
面向对象程序设计(OOP)的核心思想是多态性。我们把具有继承关系的多个类型称为多态类型,因为我们能使用这些类型的“多种形式”而无需在意它们的差异。引用或指针的静态类型与动态类型不同这一事实正是C++语言支持多态性的根本存在。
当我们使用基类的引用或指针调用基类中定义的一个函数时,我们并不知道真正作用的对象是什么类型,如果该函数是虚函数,直到运行时才知道到底执行哪一个版本,判断的依据正是该引用或指针所绑定的对象的真实类型。
另一方面,对非虚函数的调用在编译时进行绑定。类似的,通过对象进行的函数(虚函数或非虚函数)调用也在编译时绑定。对象的类型是确定不变的,我们无论什么时候都不可能令对象的动态类型与静态类型不一致。因此,通过对象进行的函数调用将在编译时绑定到该对象所属类中的函数版本上。
当且仅当对通过指针或引用调用虚函数时,才会在运行时解析该调用,也只有在这种情况下对象的动态类型与静态类型可能会不一致
如有错误或者不清楚的地方欢迎私信或者评论指出🚀🚀