多态
多态性是许多面向对象语言提供的一种能力。在C++中,多态性总是要在调用成员函数时使用对象的指针或引用。
基类指针
基类指针是在运用多态性时必然使用到的。
作为指向基类的指针是可以指向派生类的,如下:
class A
{
public:
void show()
{
std::cout << 1 << std::endl;
}
//A的其他成员
};
class B : public A
{
public:
void show()
{
std::cout << 2 << std::endl;
}
//B的其他成员
};
int main()
{
A m_a;
B m_b;
A *pA = &m_b; //指向派生类的基类指针
}
我们同时在A和B中都声明了show函数,但是我们可以通过基类指针也可以通过对象实例来调用:
m_a.show();
m_b.show();
pA->show();
输出:
1
2
1
我们注意到第三行,也就是使用指向派生类的基类指针调用show函数时输出的是 1 这代表着我们调用的仍然是基类的show函数,这并不是我们的本意,所以要解决这个问题我们需要将基类的show函数声明为虚函数。
虚函数
class A
{
public:
virtual void show()
{
std::cout << 1 << std::endl;
}
//A的其他成员
};
把一个函数声明为基类中的虚函数,就是告诉编译器,在派生于这个基类的任何类中,函数调用都是动态绑定的。虚函数在基类声明时使用限定符virtual。
注:如果成员函数的定义在类定义的外部,就不能在函数定义中添加关键字 virtual,这会产生错误,只能在类定义内部的声明或定义中添加关键字 virtual。
在基类中声明为virtual的函数在从基类(直接或间接)派生的所有类中都是虚函数。在派生类中,无论是否把函数指定为virtual,它都是虚函数。
使用虚函数时的一些要求
1.对于“虚拟”执行的函数,其在任意派生类和基类中的定义都必须有相同的签名。
2.如果在派生类中,函数的名称和参数列表与基类中声明的虚函数相同,则返回类型也必须与虚函数一致。如果不一致,派生类函数就不会编译。
3.另一个限制是,虚函数不能是模板函数。
4.只有当派生类函数的名称与基类中虚函数的名称相同,并且函数签名的其余部分也完全匹配时,才能重写基类额虚函数;如果不匹配,则派生类中的函数是新函数,“隐藏”了基类中的函数。
5.所以可以注意到如果我们仅仅是为派生类B多添加一个const修饰符,那么虚函数机制不会起作用,读者可自己测试测试。
警告:不能对静态成员函数使用关键字 virtual。顾名思义,对静态函数的调用总是静态解析的。即使调用多态对象的静态成员函数,静态成员函数也仍然使用对象的静态类型进行解析。这给出了另一个理由来解释为什么在调用静态成员函数时,总是应该使用类名而不是对象名作为前缀。即,总是使用 MyClass::myStaticFunction(),而不是myObject.myStaticFunction()。这明确表达了不应该期待多态性。
使用override限定符
如果我们在派生类中定义 Show函数,那么将定义一个新的成员函数而不是使用虚函数;并且同上面强调的一样,如果在派生类多谢了一个 const限定符,那么虚函数机制也不会起作用。为了避免这两类错误的出现,我们可以在派生类中需要重写的虚函数后面添加 override限定符,它会检查此函数的基类中是否声明了相同签名的虚成员,如果没有,编译器就把这里的定义标记为错误。
class A
{
public:
virtual void show()
{
std::cout << 1 << std::endl;
}
};
class B : public A
{
public:
void show() const override //编译器标记show()错误并提示:使用"override"声明的成员函数不能重写基类成员
{
std::cout << 2 << std::endl;
}
private:
};
它的作用就很明显:提示代码的阅读者这里是虚函数,当然也可以通过在函数前添加 virtual关键字,但个人偏向使用override。
使用final限定符
有时候,我们想让函数重写在某一个派生类之后停止,那么我们可以添加final关键词:
class A
{
public:
virtual void show()
{
std::cout << 1 << std::endl;
}
};
class B : public A
{
public:
void show() override final //override与final并不会冲突(顺序不影响),同样virtual也不会,但是语义上virtual是允许虚函数重写,而final是禁止虚函数重写,所以这两不建议写到一起以避免误解,但override和final不存在理解困难
{
std::cout << 2 << std::endl;
}
};
class C : public B
{
public:
void show() //编译器提示错误:无法重写"final"函数"B::show"
{
std::cout << 3 << std::endl;
}
};
也可以在类后添加 final限定符,以阻止此类作为基类出现派生类:
class A final //在类名后添加final阻止以它作为基类出现派生类
{
public:
virtual void show()
{
std::cout << 1 << std::endl;
}
};
class B : public A //报错:不能将"final"类类型用作基类
{
public:
void show() override final
{
std::cout << 2 << std::endl;
}
};
注:final和 override都不是关键字,但不建议将他们用作变量名或其他签名,因为那会引来不必要的混乱。
提示:函数的访问修饰符决定了是否可以调用该函数,但是不影响是否可以重写该函数。结果是,可以重写给定基类的private virtual函数。事实上,常常推荐将虚函数声明为私有的。
虚函数中的默认实参值
在作为基类的虚函数中添加默认实参值,那么它的派生类中的虚函数中的实参值无论是否改变,使用基类指针调用该函数时的实参都将是基类中的实参值:
class A
{
public:
virtual void show(int i=10)
{
std::cout << i << std::endl;
}
};
class B : public A
{
public:
void show(int i=20) override
{
std::cout << i << std::endl;
}
};
int main()
{
A a_1;
a_1.show();
B b_1;
b_1.show();
A* pA = &b_1;
pA->show();
}
输出:
10
20
10
通过引用调用虚函数
class A
{
public:
virtual int show() const //记得添加 const限定符
{
return 20;
}
};
class B : public A
{
public:
int show() const override
{
return 30;
}
};
void Show(const A& Aa)
{
std::cout << Aa.show() << std::endl;
}
int main()
{
B b_1;
Show(b_1); //函数会自动判断调用相应类的虚函数
}
输出:
30
多态集合
观察以下代码:
class A
{
public:
A(const int value)
:a{value}
{}
virtual void show()
{
std::cout << a<< std::endl;
}
int getA()
{
return a;
}
private:
int a;
};
class B : public A
{
public:
B(int value1,int value2)
:A(value1),b(value2) //可如此书写构造函数
{}
void show() override //使用override提示这是虚函数
{
std::cout<<getA()<<","<<b<<std::endl; //b没有被输出
}
private:
int b;
};
int main()
{
std::vector<A> Text;
Text.emplace_back(A{ 23 });
Text.emplace_back(B{ 233,19 });
for (auto& text : Text)
text.show();
}
输出:
23
233 //派生类B中的 b值没有被输出
我们可以观察到派生类B中的 b值没有在调用 show函数时输出。这是因为发生了对象切片的现象,即,只会保留对应于基类的子对象,改向量没有空间用于存储完整的派生类对象。
而要解决这个问题只能使用指针或者引用,使用指针时强烈建议使用智能指针:
int main()
{
std::vector<std::shared_ptr<A>> Text;
Text.emplace_back(std::make_shared<A>(23));
Text.emplace_back(std::make_shared<B>(233,19));
for (const auto& text : Text)
text->show();
}
但是这又遇到了另一个问题,我们给类A和类B重写析构函数观察一下:
class A
{
public:
A(const int value)
:a{value}
{}
virtual void show()
{
std::cout << a<< std::endl;
}
int getA()
{
return a;
}
~A()
{
std::cout << "A Destoryed\n";
}
private:
int a;
};
class B : public A
{
public:
B(int value1,int value2)
:A(value1),b(value2)
{}
void show() override
{
std::cout<<getA()<<","<<b<<std::endl; //b没有被输出
}
~B()
{
std::cout << "B Destoryed\n";
}
private:
int b;
};
int main()
{
std::vector<std::unique_ptr<A>> Text;
Text.emplace_back(std::make_unique<A>(23));
Text.emplace_back(std::make_unique<B>(233,19));
for (const auto& text : Text)
text->show();
}
输出:
23
233,19
A Destoryed
A Destoryed
是的,我们期望的b被输出了,但是B类中的析构函数却调用了基类A的析构函数。造成这种现象的原因是析构函数是静态解析的而不是动态解析的。为了确保派生类调用正确的析构函数,需要为析构函数使用动态绑定。这就需要用到虚析构函数。
虚析构函数
使用虚析构函数只需要在基类析构函数前加 virtual就行,其他的与普通虚构函数相同,当析构函数没有什么用户需要自定义的行为时,仍然建议使用默认析构函数而不是使用空代码块:
virtual ~A() = default;
需要注意,编译器生成的析构函数不是虚析构函数,除非显式把它们声明成虚析构函数。
注:当期望(甚至只是可能)使用多态性时,类中必须有一个虚析构函数,用来确保能够正确地释放对象。这意味着当一个类至少有一个虚成员函数时,就必须使其析构函数成为虚析构函数。当非虚析构函数被声明为protected或private时,可以不遵守此指导原则,但那种情况相当少见。
动态强制转换
语法:
dynamic_cast<T>(TheTypeTo)
动态强制转换在运行期间进行。这个运算符只能应用于多态类型的指针和引用,即至少包含一个虚函数的类类型。原因是只有指向多态类类型的指针才包含dynamic_cast<>()运算符检查强制转换是否有效所需的信息,而static_cast<>()是没有的。
并且当 dynamic_cast<>()强转失败时会将指针指向nullptr。
dynamic_cast<>()也可以用于引用的转换。但是强制转换一旦失败,就会抛出std::bad_cast类型异常。但是可以想到的是将引用转换为指针再检查指针是否为nullptr:
double doThat(A& TextA)
{
if (A_1* TextA_1{ dynamic_cast<A_1*>(&TextA) }; TextA_1 != nullptr)
{
...
}
...
}
调用虚函数的基类版本
观察下列代码:
class Box
{
public:
Box(double x,double z,double y)
:m_length{x},m_height{z},m_width{y}
{}
virtual double volume()
{
return m_height * m_length * m_width;
}
private:
double m_length;
double m_height;
double m_width;
};
class SmallBox : public Box
{
public:
using Box::Box;
double volume() override
{
return Box::volume() * 0.8; //调用了基类的虚函数,可以减少代码量
}
};
上述两个类中,SmallBox作为Box的派生类在调用虚函数volume时我们可以通过调用基类的虚函数来实现,当变量过多时我们以此避免了继续重复书写m_length等代码的乘积。
当然,当我们需要计算体积的损失时就可以这么干:
std::unique_ptr<Box> BoxPtr{new SmallBox{ 2,2,2 }};
std::cout << "The difference between them is : " << BoxPtr->Box::volume() - BoxPtr->volume() << std::endl;
在通过基类指针进行调用时,不能使用类名限定符来强制选择特定的派生类函数,这很好理解,因为派生类不包含在基类中。表达 BoxPtr->SmallBox::volume();不会被编译,因为SmallBox::volume()不是Box类的成员。通过指针调用函数,要么调用该指针的类类型的成员函数,要么动态调用一个虚函数。所以上述计算体积差的代码可改为:
SmallBox box1{ 2,2,2 };
double difference{ box1.Box::volume() - box1.volume() }; //区别在于没有使用指针,避免了可能出现的不必要的错误
std::cout << difference;
在构造函数或析构函数调用虚函数
为了展示效果我们对Box和SmallBox类进行修改:
class Box
{
public:
Box(double x,double z,double y)
:m_length{x},m_height{z},m_width{y}
{
std::cout << volume() << std::endl; //在构造函数中使用虚函数 volume()
}
virtual double volume()
{
return m_height * m_length * m_width;
}
private:
double m_length;
double m_height;
double m_width;
};
class SmallBox : public Box
{
public:
SmallBox(double x, double z, double y)
:Box{ x,z,y }
{
std::cout << volume() << std::endl; //在构造函数中使用重写的 volume()
}
double volume() override
{
return Box::volume() * 0.8; //调用了基类的虚函数
}
};
int main()
{
Box box1(2, 2, 2);
SmallBox box2(2, 2, 2);
}
输出:
8
8
6.4
可以看到,当我们在SmallBox中调用析构函数时因为我们使用的是基类提供的构造函数,所以第一次输出的是8,也就是说在实际上构造时调用的基类的 volume()函数。这是因为析构函数和构造函数的调用是静态的,在派生类构造函数时总是先编译继承于基类的成员,所以在两者中使用虚函数是十分危险的行为,我们得到的可能不是我们希望得到的。最后,我们应避免在构造函数和析构函数中调用虚函数。
多态的成本
实际上,使用多态性是要消耗内存成本的,但大部分时候这些成本微不足道。一个拥有虚函数的类和它完全相同但函数不是虚函数的类占用更多的内存。
class Text_1
{
public:
virtual void Show()
{
std::cout << std::format("这是一个多态类");
}
};
class Text_2
{
public:
void Show()
{
std::cout << std::format("这不是一个多态类");
}
};
int main()
{
std::cout << std::format("The size of Text_1 is {}, Text_2 is {}.\n", sizeof(Text_1), sizeof(Text_2));
}
输出:
The size of Text_1 is 8, Text_2 is 1.
那么多消耗的内存用来储存什么了呢?
实际上,多态为类创建了一个指向虚函数的指针表,这个表通常被称为 vtable当通过指针调用虚函数时会执行下述行为:
(1).首先,使用指向 vtable的对象指针查找类的 vtable的开头。
(2).其次,在类的 vtable中,查找被调用函数所对应的数据项,这通常使用偏移量来实现。
(3).最后,通过 vtable中的函数指针间接地调用函数。这个间接调用要比直接调用非虚函数慢一些,因此虚函数的每次调用都会占用额外的系统开销。
确定动态类型
使用typeid()运算符可确定该对象的动态类型。typeid()的语义如下:
(1).如果操作数是一个类型,则 typeid()计算为代表该类型的type_info对象的引用。
(2).如果操作数是任何返回多态类型的引用的表达式,则计算该表达式,操作数将返回表达式的计算结果所引用的值的动态类型。
(3).如果操作数是其他任何表达式,则不计算该表达式,返回的结果是该表达式的静态类型。
typeid(T).name()
当使用typeid(T).name()时函数返回T的类型,但要注意,这里返回的类型是type_info,如下语句是不合法的,因为编译器不会对类型名执行任何隐式转换:
if (typeid(1).name() == int)
应该显式地写:
if (typeid(1).name() == typeid(int).name())