C++模板元编程系列-2.类型转换与奇异的递归模板模式
从本节开始正式介绍模板相关的高级设计和语法,要注意的是奇异的递归模板模式与混入都和继承有很强的联系。
1.奇异的递归模板模式1
我们都知道,在继承中派生类可以调用到基类的public成员和protected成员,但是在常规继承中,基类是不可能反过来调用到派生类中的任何派生类特有的成员的,不过在使用了奇异的递归模板模式的继承后,在基类中就可以调用到派生类特有的成员了,先来看看具体代码长什么样子:
template<class T>
class Base
{
public:
void fun()
{
T& derived_class = static_cast<T&>(*this);
derived_class.show();
}
};
class Derived1 : public Base<Derived1>
{
public:
void show()
{
std::cout << "派生类特有方法被基类调用了" << std::endl;
}
};
int main()
{
Derived1 d1;
Base<Derived1>& b = d1;
b.fun();
return 0;
}
运行结果如下:
上方代码的细节非常多,首先在标准继承的视角中,上方的继承体系是public继承,并且只有非虚函数,那么对于在函数外部构造的派生类对象,如果是使用基类的引用进行函数调用的话,那么能够调用到的就只有对象中基类部分的public函数,也就是fun,上方的代码完全符合这一点,因此编译通过。关键是从模板的角度来看,首先基类是一个类模板,并且派生类在public继承基类时,将自身传递给了基类的模板,因此在基类中的T就变为了派生类Derived1,基类模板实例化出来后就会是这样子的:
class Base
{
public:
void fun()
{
Derived1& derived_class = static_cast<Derived1&>(*this);
derived_class.show();
}
};
显然,一切的关键就在于fun函数中的第一句类型转换,当我们使用派生类构建出对象时,基类中的T就变为了Derived1,而基类中的*this解引用出来的就是对象中的基类部分,也就是Base部分。在此处就必须先搞清楚C++中基类与派生类之间类型转换的规则。
2.C++中自定义类型的类型转换
从转换目标上看分为:从基类转换成派生类和从派生类转换成基类,从转换操作上看分为:拷贝转换,引用转换与指针转换,从转换的时机看分为:编译期静态转换和运行时动态转换,情况总和就是这这样的:
在讨论前,先来说说编译期转换和运行期转换的区别,首先编译期转换就是在编译器编译好代码后,类型就已经被转换了,在程序运行的时候只需要按照编译器转换出的类型进行执行,在C语言中的()强制类型转换和C++中的static_cast,const_cast,reinterpret_cast都是编译期转换,而运行期转换就是在程序运行起来后,执行到需要类型转换的地方才动态的进行类型转换,这意味着运行期的类型转换会在一定程度上拖慢程序运行的效率,并且只有C++中的dynamic_cast提供了运行期类型转换的功能。然后来说说拷贝转换,引用转换,指针转换的区别:在继承体系中拷贝转换涉及到了切片的操作,简单来说就是在基类拷贝派生类对象时,会将对象中的基类部分切出来,然后调用基类的拷贝构造函数来构造出新的基类对象,引用转换就是使用派生类或基类对象引用继承链中不同的派生类或基类对象,从结果上来说,它们最终使用的都是内存中的同一个对象,而指针转换和引用转换是类似的,只不过多了一层指向关系。下面来一个个的讨论上方的所有情况并深度分析static_cast, const_cast,reinterpret_cast的区别,首先看(基类转派生类-编译期转换-拷贝转换)的情况:
在将基类对象类型转换成派生类对象时,内存中存储的只有基类对象的内容,没有派生类对象特有的内容,因此容易得出,基类对象是无法拷贝转换成派生类对象了,因为基类对象无论怎么切片,都切不出子类特有的部分,如下方图示:
但是在实际进行拷贝转换时得分为两种情况,情况1是派生类没有实现相应的构造函数,情况2是派生类实现了相应的构造函数,我们先看情况1,代码如下:
class Base
{
public:
void Bshow()
{
std::cout << "Base b: " << _b << std::endl;
}
int _b = 10;
};
//没有实现相应的构造函数
class Derived1 : public Base
{
public:
void D1show()
{
std::cout << "Drivate1 d: " << _d1 << std::endl;
}
int _d1 = 30;
};
int main()
{
Base b; //基类对象
Derived1 d1_1 = b; //无法转换
const Derived1 d1_2 = b; //无法转换
Derived1 d1_3 = static_cast<Derived1>(b); //无法转换
Derived1 d1_4 = reinterpret_cast<Derived1>(b); //无法转换
Derived1 d1_5 = (Derived1)b; //无法转换
return 0;
}
显然无论是隐式类型转换还是显示类型转换都是失败了,这也符合我们的预期,因为编译器是无法拷贝出不存在的东西的。下面来看看实现了相应构造函数的情况:
class Base
{
public:
void Bshow()
{
std::cout << "Base b: " << _b << std::endl;
}
int _b = 10;
};
//实现了相应的构造函数
class Derived2 : public Base
{
public:
Derived2(const Base& b)
:Base(b)
{}
void D2show()
{
std::cout << "Drivate1 d: " << _d1 << std::endl;
}
int _d1 = 30;
};
int main()
{
Base b; //基类对象
Derived2 d2_1 = b; //成功
const Derived2 d2_2 = b; //成功(const对象无法调用非const成员函数)
Derived2 d2_3 = static_cast<Derived2>(b); //成功
Derived2 d2_4 = reinterpret_cast<Derived2>(b); //失败
Derived2 d2_5 = (Derived2)b; //成功
d2_1.Bshow();
d2_1.D2show();
d2_3.Bshow();
d2_3.D2show();
d2_5.Bshow();
d2_5.D2show();
return 0;
}

出人意料的是reinterpret_cast失败了,其它的类型转换最终都成功调用到了构造函数,要说明的是此处其实不算是严格的类型转换,而是构造出了一个新对象,没有出现UB行为,关键就在于reinterpret_cast失败的原因,笔者原先还以为reinterpret_cast的效果和C风格的()类型转换是类似的,现在看来并非如此,可以认为reinterpret_cast的效果就是强制让编译器以另一种类型视角看待一个内存块,不会调用构造,就是单纯的改变编译器看待内存块的视角,这是一个非常危险的行为,因此C++标准为reinterpret_cast添加的严格的限制,具体来说就是:1.能让指针类型类型之间互相转换,2.能让引用类型之间互相转换,3.能让整形和指针类型之间互相转换,比如:
struct A
{
int a;
int b;
double c;
};
int main()
{
int a = 10;
char& b = reinterpret_cast<char&>(a);
short* c = reinterpret_cast<short*>(a);
A& d = reinterpret_cast<A&>(a);
std::cout << d.a << std::endl;
std::cout << d.b << std::endl;
return 0;
}

编译不会报错,并且可以正常运行,不过这种代码显然非常诡异,是UB的,一般来说,如果你要频繁的使用到reinterpret_cast,那么多半是你的代码出问题了。然后是对于C风格的()类型转换在C++中也明确的规定,具体来说C++会按照顺序依次尝试为()使用不同的类型转换,顺序一般是:static_cast---->const_cast------>static_cast+const_cast----->reinterpret_cast---->reinterpret_cast+const_cast,如果在其中某一步转换成功了,那么就不会继续往后走,显然在上方的代码中()是调用到了static_cast,而C++中又明文规定static_cast在拷贝转换时如果存在合适的构造函数,那么会调用构造函数,因此()和staitc_cast都成功的调用到了构造,行为是正确的。
最后来总结一下(基类转派生类-编译期转换-拷贝转换)的情况:如果派生类没有实现接收基类对象的构造函数,那么绝对无法进行类型转换,如果派生类实现了接收基类对象的构造函数,那么隐式类型转换和static_cast会调用构造函数,可以成功的转换(准确的说此时应该是构造而不是转换)。
下面来看看(基类转派生类-编译期转换-引用转换)的情况,我们先来好好的理解一下引用,对于引用,我们在教材上学到到基本定义是:引用是对象或函数的别名,老师可能会和你说:引用一个变量后,对原变量和引用变量取地址,取出的地址一定是相同的,就像下方的代码:
int main()
{
int a = 10;
int& ra = a;
std::cout << "pa: " << &a << std::endl;
std::cout << "par: " << &ra << std::endl;
return 0;
}

嗯,非常好,和我们学到的是一样的,两个变量名在底层本质上是同一块内存块的抽象名称,但是再看看下方的代码:
int main()
{
int a = 10;
const float& ra = a;
std::cout << "pa: " << &a << std::endl;
std::cout << "par: " << &ra << std::endl;
return 0;
}

哦豁,地址不同了,是定义错了吗?其实是因为在C++标准中对引用的定义还有后半部分,即引用绑定规则,这才是引用中最精髓的一部分,笔者也不知道为什么国内的教材都有意或无意的避开了这个规则,或许是因为考试不考吧…呵呵,笔者是自己在测试途中偶然发现这个现象的,然后去看了C++官方文档才知道了原来引用还有这个规则,下面来详细讲解一下该规则,我们注意到,上方两段代码中最核心的区别就在于第二段代码中的const float& ra = a,关键就在于类型转换,简单来说当编译器发现有一个float类型引用了int类型的变量时,编译器不会也不能直接将一个4字节大小的内存块既视为int类型,又视为float类型的,因此在底层走的是隐式类型转换,而隐式类型转换就会诞产生临时变量,也就是纯右值,因此必须使用const引用来接收该类型转换后产生的纯右值,在接收后,右值的生命周期被延长,严格意义上来说已经变为了左值(因为可以取地址了),可以认为是这样子的:
此时ra和a在底层对应的已经是不同的内存块了,因此取出来的地址自然就是不同的,也就是说如果在引用时涉及到了类型转换,那么对于非继承体系的类型,在底层会开辟一块新的内存块,然后将原内存块中的值拷贝到新内存块中,最后再将const引用的变量名绑定到新的内存块上,那么容易推知,此时如果我们修改a,是不会影响到ra的:
int main()
{
int a = 10;
const float& ra = a;
a -= 10;
std::cout << "ra: " << ra << std::endl;
std::cout << "a: " << a << std::endl;
return 0;
}

此时看似是引用,但是在底层走的是拷贝,下面让我们回到继承体系中,看看(基类转派生类-编译期转换-引用转换)到底是什么情况,首先在内存中已经有的是基类的一个对象,如果我们使用派生类引用它的话,那么此时如果派生类实现了一个能够接受基类对象引用的构造函数,那么就可以走构造,如果没有,那么就走类型转换,从内容来看,单单的基类对象无论怎么转换都是不可能转换出子类特有部分的内容的,因此此时要么是报错,要么出现UB(未定义行为),先来看看没有实现构造函数的情况:
class Base
{
public:
void Bshow()
{
std::cout << "Base b: " << _b << std::endl;
}
int _b = 10;
};
//没有实现相应的构造函数
class Derived1 : public Base
{
public:
void D1show()
{
std::cout << "Drivate1 d: " << _d1 << std::endl;
}
int _d1 = 30;
};
int main()
{
Base b; //基类对象
Derived1& d1_1 = b; //编译报错
const Derived1& d1_2 = b; //编译报错
Derived1& d1_3 = static_cast<Derived1&>(b); //编译通过且能够正常运行
Derived1& d1_4 = reinterpret_cast<Derived1&>(b); //编译通过且能够正常运行
Derived1& d1_5 = (Derived1&)b; //编译通过且能够正常运行
d1_3.Bshow();
d1_3.D1show(); //随机值
d1_4.Bshow();
d1_4.D1show(); //随机值
d1_5.Bshow();
d1_5.D1show(); //随机数
return 0;
}

神奇的事情发生了,编译器表现出了不同的行为,对于隐式类型转换,编译器直接报错,但是对于显示类型转换,编译器一律放行,并且也可以访问到派生类特有部分的内容,只不过全部都是随机值,并且还注意到随机值都是相等的,那么容易推测出这几个引用在底层引用的都是同一个内存块,来看看实际情况:
std::cout << "pb: " << &b << std::endl;
std::cout << "pd1_3: " << &d1_3 << std::endl;
std::cout << "pd1_4: " << &d1_4 << std::endl;
std::cout << "pd1_5: " << &d1_5 << std::endl;

结果如我们所料,那么还容易推测出,如果修改了其中的一个对象,所有对象都会发生变化。在派生类没有实现相应的构造函数时,基类引用转换为派生类既出现了报错,又出现了UB,我们来为没听说过UB的读者简单的科普一下:UB是未定义行为的简称,当UB发生时,会出现下列四种情况:
| 情况 |
|---|
| 1.程序正常运行,但出现程序员意料之外的奇怪结果 |
| 2.程序崩溃 |
| 3.在不同的编译器下产生不同的结果 |
| 4.在不同的平台下产生不同的结果 |
在实际项目的开发中,UB往往比报错要更加恐怖,想象一下,在一段程序中出现了内存泄漏,如果有完善的抛异常机制,那么在内存泄漏的一瞬间,程序员就会得知程序出问题了,然后很快就可以解决,几乎没有任何损失,这就是报错的好处,因为错误被发现了才能够被解决,但是如果没有报错,而是UB的话,那么这个程序可能每次泄漏一点点内存,在运行了几个月后,服务器的内存被泄漏光了,整个程序全面崩溃,如果该程序是上了线的,那么损失就非常恐怖了,而且修复也会非常困难。下面让我们来详细的分析一下上方的代码,首先是隐式类型转换的报错,这和C++中对于继承体系类型转换的硬性规定有关,即:隐式引用类型转换是单向向上兼容的,说人话就是基类的引用可以引用派生类对象,但是不能反过来让派生类引用基类对象(直接报错),从内存的实际来看,在基类对象中压根就没有派生类特有部分的内容,因此这个规定是非常合理的,下面来看看UB的原因,这和C++中类型转换的设计理念有关,C++认为,如果程序员显示的写明了类型转换,那么这就意味着程序员很清楚自己在做什么,而且引用本身不涉及内存的开辟,所以编译器就不会做太多的干涉,此处具体的干涉情况涉及到了C++中不同类型转换的区别,笔者会在后文详细介绍。
显示类型转换的灵活性是C/C++中一个非常强大的机制,但是对于程序员的要求非常高,一个不小心就是UB行为,就比如上方的程序,首先两个对象属于同一个继承体系,那么它们就不是毫不相干的类型,其次程序员显示的写名了要类型转换,那么编译器就直接放行。可以认为隐式类型转换出了问题是编译器和C++委员会要负责,而显示类型转换出了问题是程序员自己负责,因此编译器对于显示类型转换放的就比较宽了,上方的程序最终在内存中体现的结果就是:
如果派生类部分能够访问,那么就会得出随机值,如果不能访问,那么程序直接崩溃,下面来看看派生类实现了相应构造函数的情况:
class Base
{
public:
void Bshow() const
{
std::cout << "Base b: " << _b << std::endl;
}
int _b = 10;
};
//实现了相应的构造函数
class Drivate2 : public Base
{
public:
Drivate2(const Base& b)
:Base(b)
{
}
void D2show() const
{
std::cout << "Drivate2 d2: " << _d2 << std::endl;
}
int _d2 = 50;
};
int main()
{
//基类对象
Base b;
Drivate2& d2_1 = b; //编译报错
const Drivate2& d2_2 = b; //通过
Drivate2& d2_3 = static_cast<Drivate2&>(b); //通过
Drivate2& d2_4 = reinterpret_cast<Drivate2&>(b); //通过
Drivate2& d2_5 = (Drivate2&)b; //通过
d2_2.Bshow();
d2_2.D2show();
d2_3.Bshow();
d2_3.D2show();
d2_4.Bshow();
d2_4.D2show();
d2_5.Bshow();
d2_5.D2show();
return 0;
}

结果比较出人意料,在隐式类型转换时,非const引用情况下转换失败,const引用转换成功而且调用到了构造函数,这和内置类型的引用转换是类似的,在底层开辟一个新的内存块,然后走了拷贝,但是要注意的时,此时不是严格意义上的类型转换,因为调用了构造函数,走的是构造流程,具体来说就是先创建出派生类的临时对象,该临时对象是调用构造函数构造出来的,然后由于使用了const引用延长了临时对象的生命周期,因此临时对象就被拷贝到了新的内存块中,拥有了实际地址,这也是非const引用报错的原因,即:非const引用不能引用临时对象,因为临时对象具有常性。这个非常简单,关键是显示类型转换的行为有些出乎意料,容易发现没有调用构造函数,而是出现了UB行为,还记得在讨论拷贝转换时如果派生类实现了相应的构造函数,那么static_cast是会调用构造函数,但是在引用时显然并非如此,没有调用构造函数,出现了UB行为,最后总结一下(基类转派生类-编译期转换-引用转换)的行为就是:如果派生类没有实现接收基类对象的构造函数,那么隐式类型转换全部编译报错,显示类型转换全部UB,如果派生类实现了接收基类对象的构造函数,那么隐式类型转换中的const引用转换成功调用到了构造函数,在底层走的实际上是拷贝,而显示类型转换全部UB。在此处我们谈论的仅是类型转换,在实际开发中如果派生类实现了接收基类对象的构造函数,那么我们一般会显示的调用构造函数:
Base b;
Drivate2 d1 = Drivate2(b); //拷贝
const Drivate2& d2 = Drivate2(b); //引用
这样子就绝对不会UB了,而且语义明确,不会走复杂的类型转换流程,就是单纯的调用构造函数,在引用时由于函数返回的是临时对象,因此需要加上const(底层实际上还是拷贝),这才是最佳实践。下面来看看(基类转派生类-编译期转换-指针转换)的情况,同样的我们先看没有实现构造函数的情况:
class Base
{
public:
void Bshow()
{
std::cout << "Base b: " << _b << std::endl;
}
int _b = 10;
};
//没有实现相应的构造函数
class Derived1 : public Base
{
public:
void D1show()
{
std::cout << "Drivate1 d: " << _d1 << std::endl;
}
int _d1 = 30;
};
int main()
{
Base b; //基类对象
Derived1* d1_1 = &b; //编译报错
const Derived1* d1_2 = &b; //编译报错
Derived1* d1_3 = static_cast<Derived1*>(&b); //编译通过且能够正常运行
Derived1* d1_4 = reinterpret_cast<Derived1*>(&b); //编译通过且能够正常运行
Derived1* d1_5 = (Derived1*)&b; //编译通过且能够正常运行
d1_3->Bshow();
d1_3->D1show(); //随机值
d1_4->Bshow();
d1_4->D1show(); //随机值
d1_5->Bshow();
d1_5->D1show(); //随机数
return 0;
}

和引用的情况一模一样,这里就不多解释了,下面来看看实现了相应的构造函数的情况:
class Base
{
public:
void Bshow() const
{
std::cout << "Base b: " << _b << std::endl;
}
int _b = 10;
};
//实现了相应的构造函数
class Drivate2 : public Base
{
public:
Drivate2(const Base* b)
:Base(*b)
{
}
void D2show() const
{
std::cout << "Drivate2 d2: " << _d2 << std::endl;
}
int _d2 = 50;
};
int main()
{
//基类对象
Base b;
//Drivate2* d2_1 = &b; //编译报错
const Drivate2 d2_2 = &b; //通过
Drivate2* d2_3 = static_cast<Drivate2*>(&b); //通过
Drivate2* d2_4 = reinterpret_cast<Drivate2*>(&b); //通过
Drivate2* d2_5 = (Drivate2*)(&b); //通过
d2_2.Bshow();
d2_2.D2show();
d2_3->Bshow();
d2_3->D2show();
d2_4->Bshow();
d2_4->D2show();
d2_5->Bshow();
d2_5->D2show();
return 0;
}

情况同样与引用类似,因为引用在底层实际上就是使用指针实现的,那么总结出来(基类转派生类-编译期转换-指针转换)的结论就是:如果派生类没有实现相应的构造函数,那么隐式类型转换全部报错,显示类型转换全部UB,如果派生类实现了相应的构造函数,那么隐式类型转换中的const转换成功,显示类型转换全部UB。当然最佳实践依旧是显示的调用构造函数,而不是走类型转换这一套。下面来总结一下(基类转派生类-编译期转换)的所有情况:
| 基类转派生类-编译期转换 |
|---|
| 1.拷贝转换:如果派生类没有实现接收基类对象的构造函数,那么无论是隐式类型转换还是显示类型转换都会报错,因为编译器无法拷贝出不存在的派生类特有部分;如果派生类实现了接收基类对象的构造函数,那么隐式类型转换会调用到构造函数,显示类型转换中的()和static_cast也都会调用到构造函数,类型转换成功(实际上走的是构造),要注意的是reinterpret_cast受限于类型转换规定,此时会报错 |
| 2.引用转换:如果派生类没有实现接收基类对象的构造函数,那么隐式类型转换全部报错,显示类型转换全部UB;如果派生类实现了接收基类对象的构造函数,那么隐式类型转换中的const&成功调用到构造函数,显示类型转换全部UB |
| 3.指针转换:如果派生类没有实现接收基类对象的构造函数,那么隐式类型转换全部报错,显示类型转换全部UB,如果派生类实现了接收基类对象的构造函数,那么隐式类型转换中的const转换能成功调用到构造函数,显示类型转换全部UB |
| 最佳实践:能显示的调用构造函数就显示调用构造函数,不要走容易出现UB的复杂的类型转换流程 |
下面来看看(基类转派生类-运行期转换)的情况,首先得补充一些前置知识,在上文已经说明,只有C++中的dynamic_cast提供了运行时转换的功能,其底层的关键就在于:类对象中记录的指向虚函数表的指针,类中的虚函数表,虚函数表中的type_info指针和多态继承体系中编译器自动创建的type_info对象,首先在本系列的上一节就说明过:当继承体系中出现虚函数或纯虚函数时,那么从出现虚函数的类开始(包括该类)往后的所有直接或间接的派生类中都会存在一份虚函数表,虚函数表最简化的模型就是static void*,然后存在虚函数表的类创建出来的对象中都会自动一个指向本类虚函数表的指针,也就是说同类创建出来的对象共享同一份虚函数表,即:
注:上图中的虚函数泛指虚函数和纯虚函数,然后就是运行时类型转换的关键:虚函数表中的type_info指针和type_info对象,简单来说就是在一个继承体系中,如果出现了虚函数或纯虚函数,那么编译器除了会为类创建vtable和为对象添加vptr外,还会给每个类创建一个type_info对象,在该对象中记录着本类的信息,比如类名称,类型唯一标识等,如下图:
实际上肯定会更加复杂,但是大框架就是这样的了,注意到没有实现虚函数或非虚函数并且还没有继承拥有虚函数表的类,编译器是不会为其创建虚函数表的,也没有type_info,这意味着在无虚函数表时,dynamic_cast是无法进行类型转换的,也就是说继承体系中是虚函数或纯虚函数是dynamic_cast进行运行时动态类型转换的必要条件。dynamic_cast具体的转换流程就是在运行时先通过对象的vptr找到类对应的vtable,然后再通过在vtable中嵌入的指针找到该类的type_info,最后再利用type_info对象中的base_list去遍历整个继承体系中所有拥有type_info的类,直到转换目标类的类型信息匹配上为止,显然dynamic_cast类型转换的时间复杂度是O(n),继承体系越复杂,dynamic_cast就越慢,在实际开发中,一般只会在Debug阶段使用dynamic_cast,在Release中会把能够替换的dynamic_cast替换成static_cast。下面来看看动态转换的实际情况,先来讨论(基类转派生类-运行时转换-拷贝转换),同样的,先看派生类没有实现相应构造函数的情况,代码如下:
#include <iostream>
class Base
{
public:
virtual void fun()
{
std::cout << "Base: " << _b << std::endl;
}
int _b = 10;
};
class Drivate1 : public Base
{
public:
void fun() override
{
std::cout << "Drivate1: " << _d << std::endl;
}
int _d = 20;
};
int main()
{
Base b; //基类对象
Drivate1 d1 = dynamic_cast<Drivate1>(b); //报错
return 0;
}
意料之中的失败了,此时只存在基类部分,派生类又没有实现相应的构造函数,那么编译器当然就无法拷贝不存在的东西了,下面来看看实现了相应构造函数的情况:
class Base
{
public:
virtual void fun()
{
std::cout << "Base: " << _b << std::endl;
}
int _b = 10;
};
class Drivate1 : public Base
{
public:
Drivate1(const Base& b)
:Base(b)
{}
void fun() override
{
std::cout << "Drivate1: " << _d << std::endl;
}
int _d = 20;
};
int main()
{
Base b; //基类对象
Drivate1 d1 = dynamic_cast<Drivate1>(b); //失败
return 0;
}
由于dynamic_cast是在运行时转换的,查看的是type_info,那么当然就调用不了构造函数,所以也报错了,最终的结论就是:在(基类转派生类-运行时转换-拷贝转换)时,无论派生类有没有实现接收基类对象为参数的构造函数,dynamic_cast的转换都会失败。下面来看看(基类转派生类-运行时转换-引用转换)的情况,首先是派生类没有实现相应的构造函数:
#include <iostream>
class Base
{
public:
virtual void fun()
{
std::cout << "Base: " << _b << std::endl;
}
int _b = 10;
};
class Drivate1 : public Base
{
public:
void fun() override
{
std::cout << "Drivate1: " << _d << std::endl;
}
int _d = 20;
};
int main()
{
Base b; //基类对象
Drivate1& d1 = dynamic_cast<Drivate1&>(b); //编译通过
b.fun();
d1.fun();
return 0;
}

编译通过了,但是在运行时抛出了异常,这其实才是我们想要的行为,而不是出现在编译期引用转换时的UB行为,抛出异常的原因很简单,首先从内存的角度来看,内存中只有基类部分,派生类特有部分是未定义的,那么当然就不能引用了,其次从dynamic_cast的底层实现来看,type_info中的指针是只会从派生类的type_info指向基类的type_info,不能反向查找,编译器就是通过这个特性检查出了程序的问题并抛出了异常,下面来看看实现了相应构造函数的情况:
class Drivate1 : public Base
{
public:
Drivate1(const Base& b)
:Base(b)
{}
void fun() override
{
std::cout << "Drivate1: " << _d << std::endl;
}
int _d = 20;
};
//其它代码不变

同样抛出了异常,我们发现dynamic_cast似乎无论如何都不会调用构造函数,其底层的实现就是完完全全的靠type_info的,总结出来的结论就是:在(基类转派生类-运行时转换-引用转换)时,无论派生类有没有实现接收基类对象的构造函数,dyname_cast转换编译都会通过,但是运行时都会抛出异常。下面来看看(基类转派生类-运行时转换-指针转换的情况),我们推测该情况和引用是一模一样的,验证如下:
class Base
{
public:
virtual void fun()
{
std::cout << "Base: " << _b << std::endl;
}
int _b = 10;
};
class Drivate1 : public Base
{
public:
void fun() override
{
std::cout << "Drivate1: " << _d << std::endl;
}
int _d = 20;
};
int main()
{
Base b; //基类对象
Drivate1* d1 = dynamic_cast<Drivate1*>(&b); //编译通过
b.fun();
d1->fun();
return 0;
}

判断错了,尽管都在运行时报错了,但是报错信息不同,上方的报错一般都是因为程序对空指针解引用了,而且我们还发现Base被打印了出来,这意味着程序是运行到d1->fun()时才报错的,那么有理由推断d1是空指针,验证如下:
int main()
{
Base b; //基类对象
Drivate1* d1 = dynamic_cast<Drivate1*>(&b); //编译通过
b.fun();
if(d1 == nullptr)
{
std::cout << "nullptr" << std::endl;
}
return 0;
}

结果如我们所料,由此可以推测出dynamic_cast的行为是:在引用转换时,如果转换失败,那么直接在dynamic_cast调用处截断程序并抛出异常,在指针转换时,如果转换失败,那么返回空指针,这意味着在实际操作时,如果采用了指针转换,那么我们就得进行判空,下面来看看派生类实现了相应构造函数的情况:
class Drivate1 : public Base
{
public:
Drivate1(const Base& b)
:Base(b)
{}
void fun() override
{
std::cout << "Drivate1: " << _d << std::endl;
}
int _d = 20;
};
//其它代码不变

显然转换失败了,我们几乎可以确定了,dynamic_cast在类型转换时不会调用构造函数,因此派生类是否实现构造函数都不会改变dynamic_cast的行为,最终得出的结论就是:在(基类转派生类-运行时转换-指针转换)时,无论派生类有没有实现接收基类对象的构造函数,dynamic_cast都会转换失败并返回空指针。
在上方介绍的是纯基类对象转派生类的情况,其实还存在一种情况,即基类对象是这样的:
Base b = Drivate(); //基类对象
const Base& b = Drivate(); //基类对象
const Base
此时上方的那个走的是切片的拷贝,和纯基类的行为一模一样,下方那个走的是基类引用引用派生类对象,硬要说的话在底层其实是属于派生类对象的,因此笔者会在介绍派生类转换基类时再详细分析,下面来总结一下(基类转派生类-运行时转换)的所有情况:
| 情况 |
|---|
| 1.拷贝转换:无论派生类有没有实现接收基类对象的构造函数,都会编译报错 |
| 2.引用转换:无论派生类有没有实现接收基类对象的构造函数,编译都会通过,但是在运行时会在dynamic_cast调用处抛出异常 |
| 3.指针转换:无论派生类有没有实现接收基类对象的构造函数,编译都会通过,但是在运行时dynamic_cast会返回空指针 |
| 4.结论:基类对象无论如何都无法通过dynamic_cast在运行时转换成派生类,写了就是错 |
基类转派生类的所有情况就分析完了,不过在实际开发中,几乎是不会出现使用类型转将基类对象换成派生类对象的情况的,就算有一般也都是显示的调用构造函数,不走类型转换,因为基类转派生类涉及到了"从无到有"的过程,这显然是构造的职责,强行让类型转换来做就会让代码变得非常诡异,因此最佳实践就是:当出现要从基类转派生类的需求时,显示的调用构造函数吧,如果派生类没有相应的构造函数,那么就实现一个,能不走类型转换就不要走类型转换。下面来看看派生类对象转基类对象的情况:
首先是(派生类转基类-编译期转换-拷贝转换)的情况,测试代码如下:
class Base
{
public:
void BaseShow()
{
std::cout << "Base: " << _b << std::endl;
}
int _b = 10;
};
class Drivate : public Base
{
public:
void DrivateShow()
{
std::cout << "Drivate1: " << _d << std::endl;
}
int _d = 20;
};
int main()
{
Drivate d; //派生类对象
Base b1 = d; //成功
Base b2 = static_cast<Base>(d); //成功
Base b3 = reinterpret_cast<Base>(d); //编译报错
Base b4 = (Base)d; //成功
d.BaseShow();
d.DrivateShow();
b1.BaseShow(); //无法访问到派生类特有部分
b2.BaseShow();
b4.BaseShow();
std::cout << "ptr_d: " << &d << std::endl;
std::cout << "ptr_b1: " << &b1 << std::endl;
std::cout << "ptr_b2: " << &b2 << std::endl;
std::cout << "ptr_b4: " << &b4 << std::endl;
return 0;
}

首先派生类对象中是含有基类部分的,因此在派生类转基类时就不是从无到有,而是切片,在所有的转换中就只有reinterpret_cast失败了,这和reinterpret_cast的转换规则有关,即能转换的只能是:整形,引用和指针。从结果上来看也容易发现没有出现UB行为,并且确实是进行的拷贝,此时的行为就是将派生类对象中的基类部分拷贝出来赋值给新的基类对象,硬要说的话在底层走的是编译器为基类实现的默认拷贝赋值函数,也就是说如果想禁止掉这种切片的转换方式,就只需要加上这一句代码:
Base(const Base&) = delete;
加上后派生类就无法拷贝转换出基类了,总结一下就是:(派生类转基类-编译期转换-拷贝转换)时,在底层使用了切片,此时如果基类有实现拷贝构造函数的话,那么(),static_cast都会成功,而reinterpret_cast受限于转换类型的限制会失败,下面来看看(派生类转基类-编译期转换-引用转换)的情况,这显然就是使用基类引用来引用派生类对象,在语法上完全可行,相当于从基类视角看待派生类对象,这也是实现动态多态的基本原理,下面来看看实际情况:
class Base
{
public:
void BaseShow()
{
std::cout << "Base: " << _b << std::endl;
}
// Base() = default;
// Base(const Base&) = delete;
int _b = 10;
};
class Drivate : public Base
{
public:
void DrivateShow()
{
std::cout << "Drivate1: " << _d << std::endl;
}
int _d = 20;
};
int main()
{
Drivate d; //派生类对象
Base& b1 = d; //成功
Base& b2 = static_cast<Base&>(d); //成功
Base& b3 = reinterpret_cast<Base&>(d); //成功
Base& b4 = (Base&)d; //成功
d.BaseShow();
d.DrivateShow();
b1.BaseShow(); //无法访问到派生类特有部分
b2.BaseShow();
b3.BaseShow();
b4.BaseShow();
std::cout << "ptr_d: " << &d << std::endl;
std::cout << "ptr_b1: " << &b1 << std::endl;
std::cout << "ptr_b2: " << &b2 << std::endl;
std::cout << "ptr_b3: " << &b3 << std::endl;
std::cout << "ptr_b4: " << &b4 << std::endl;
return 0;
}

所有转换全部成功,并且就算把基类的拷贝构造禁掉也不影响,因为在底层是改变了看待派生类对象的视角,相当于只看了派生类对象中的基类部分,不涉及任何的拷贝行为,唯一要注意的就当转换为基类视角看待派生类对象时,就无法使用派生类特有的部分了,总结一下就是:(派生类转基类-编译期转换-引用转换)时,所有静态转换都会成功,无论基类有没有禁掉拷贝构造函数,下面来看看(派生类转基类-编译期转换-指针转换)的情况,该情况从理论上来看和引用转换的情况应该是一样的,验证如下:
class Base
{
public:
void BaseShow()
{
std::cout << "Base: " << _b << std::endl;
}
// Base() = default;
// Base(const Base&) = delete;
int _b = 10;
};
class Drivate : public Base
{
public:
void DrivateShow()
{
std::cout << "Drivate1: " << _d << std::endl;
}
int _d = 20;
};
int main()
{
Drivate d; //派生类对象
Base* b1 = &d; //成功
Base* b2 = static_cast<Base*>(&d); //成功
Base* b3 = reinterpret_cast<Base*>(&d); //成功
Base* b4 = (Base*)&d; //成功
d.BaseShow();
d.DrivateShow();
b1->BaseShow(); //无法访问到派生类特有部分
b2->BaseShow();
b3->BaseShow();
b4->BaseShow();
std::cout << "ptr_d: " << &d << std::endl;
std::cout << "ptr_b1: " << b1 << std::endl;
std::cout << "ptr_b2: " << b2 << std::endl;
std::cout << "ptr_b3: " << b3 << std::endl;
std::cout << "ptr_b4: " << b4 << std::endl;
return 0;
}

确实是一样的,因此(派生类转基类-编译期转换)的总结如下:
| 总结 |
|---|
| 1.拷贝转换:如果基类没有禁掉拷贝构造函数,那么()和static_cast都会转换成功,reinterpret_cast转换失败 |
| 2.引用转换:无论基类有没有禁掉拷贝构造,(),static_cast和reinterpret都会转换成功 |
| 3.指针转换:和引用转换情况相同 |
很明显的,(派生类转基类-编译期转换)比(基类转派生类-编译器转换)要容易不少,主要是因为没有涉及到从无到有的过程,下面来看看(派生类转基类-运行时转换)的情况,首先是(派生类转基类-运行时转换-拷贝转换),由于dynamic_cast只能接收指针和引用,因此转换绝对是失败的,而且是编译期间报错,验证如下:
#include <iostream>
class Base
{
public:
virtual void fun()
{
std::cout << "Base: " << _b << std::endl;
}
int _b = 10;
};
class Drivate : public Base
{
public:
void fun() override
{
std::cout << "Drivate: " << _d << std::endl;
}
int _d = 20;
};
int main()
{
Drivate d; //派生类对象
Base b = dynamic_cast<Base>(d); //失败
return 0;
}
意料之中的失败,并且确实是编译期报错,总结一下就是:dynamic_cast只接收引用或指针,因此在(派生类转基类-运行时转换-拷贝转换)时会在编译期报语法错误,下面来看看(派生类转基类-运行时转换-引用转换)的情况,我们可以推测,此时由于派生类中的type_info是有指向基类的type_info的,因此转换会成功:
class Base
{
public:
virtual void fun()
{
std::cout << "Base: " << _b << std::endl;
}
int _b = 10;
};
class Drivate : public Base
{
public:
void fun() override
{
std::cout << "Drivate: " << _d << std::endl;
}
int _d = 20;
};
int main()
{
Drivate d; //派生类对象
Base& b = dynamic_cast<Base&>(d); //成功
b.fun(); //动态多态
std::cout << "pd: " << &d << std::endl;
std::cout << "pb: " << &b << std::endl;
return 0;
}

转换成功并且表现出了动态多态的行为,容易发现这和静态转换时的效果是相同的,但是由于走了运行时的转换,因此效率会低一些,来看看下方的一段代码:
class Base
{
public:
virtual void fun()
{
std::cout << "Base: " << _b << std::endl;
}
int _b = 10;
};
class Drivate : public Base
{
public:
void fun() override
{
std::cout << "Drivate: " << _d << std::endl;
}
int _d = 20;
};
int main()
{
Drivate d; //派生类对象
Base& b1 = dynamic_cast<Drivate&>(d); //1
Base& b2 = dynamic_cast<Base&>(d); //2
Base& b3 = static_cast<Drivate&>(d); //3
Base& b4 = static_cast<Base&>(d); //4
d.fun();
b1.fun();
b2.fun();
b3.fun();
b4.fun();
std::cout << "pd: " << &d << std::endl;
std::cout << "pb1: " << &b1 << std::endl;
std::cout << "pb2: " << &b2 << std::endl;
std::cout << "pb3: " << &b3 << std::endl;
std::cout << "pb4: " << &b4 << std::endl;
return 0;
}
问题就是上方代码的运行结果是什么?首先在内存的视角中,在内存中一定是存在一个派生类对象d的,然后就是两处dynamic_cast的转换,首先在1中,dynamic_cast的<>内部填了Drivate&,那么在运行过程中dynamic_cast查看type_info时,从d对象对应类型的type_info开始查找,首先查找的就是d类型本身的type_info,也就是Drivate的type_info,直接就匹配上了,因此不会抛异常,也就是说第1处的dynamic_cast就相当于是使用基类引用派生类对象,然后是2处的dynamic_cast,在运行时编译器通过d对应类型的type_info向上查找,由于当基类中存在type_info时,派生类的type_info的base_list中是存在指向基类type_info的指针的,因此成功匹配,不会抛出异常,也就是说1,2处类型转换的效果就等同于使用基类引用引用派生类对象,d,b1,b2三个变量的地址相同,然后是使用static_cast的情况,此时不涉及运行时类型转换,就是相当于告诉编译器以另一个类型视角看待指定的内存块抽象,首先在3处的static_cast中就相当于是视角不变,转换后还是以Drivate的视角看待d,因此编译通过,效果同样相当于以基类引用引用派生类对象,在4处的static_cast我们在上文分析过该情况,就是(派生类转基类-编译期转换-引用转换)的情况,依据结论,编译会通过,并且效果同样等同于基类引用引用派生类对象,因此最终4个类型转换的效果都等同于基类引用引用派生类对象,地址全部相同,函数调用全部表现出多态行为调用到Drivate中的fun函数,结果如下:
从该例中容易发现,在某些情况下static_cast和dynamic_cast起到的效果完全相同,主要就是当在使用基类指针或引用来指向与引用派生类对象时,如果想要把基类的指针或引用向下转回派生类对象以此访问到派生类特有的成员时,dynamic_cast和static_cast的效果是相同的,也就是下方这种场景:
void test(Base& b)
{
//希望调用到派生类特有的函数
dynamic_cast<Drivate&>(b).show(); //成功
static_cast<Drivate&>(b).show(); //成功
// reinterpret_cast<Drivate&>(b).show(); //成功
// ((Drivate&)b).show(); //成功
}
int main()
{
Drivate d; //派生类对象
test(d);
return 0;
}

在实际开发中这种需求是很常见的,即一个函数接收继承链上层类型的引用,利用基类指针能够引用派生类的规则来让该函数能够处理多种不同的类型,此时在函数中就会出现将基类指针向下转换以此调用到派生类特有函数的需求,一般而言我们会在实际开发的Debug模式下使用dynamic_cast(继承体中得有虚函数),因为dynamic_cast在运行时有类型检查,不会出现UB行为,在上文我们也测试过了,static_cast在将基类引用向下转型时是非常容易UB的,比如:
void test(Base& b)
{
//希望调用到派生类特有的函数
static_cast<Drivate&>(b).show(); //编译通过
}
int main()
{
Base d; //基类对象,内存中不存在派生类特有部分
test(d);
return 0;
}

UB了,该情况属于(基类转派生类-编译期转换-引用转换),此时显示类型转换一定会UB,但是在使用dynamic_cast时就会在运行时抛出异常:
void test(Base& b)
{
//希望调用到派生类特有的函数
dynamic_cast<Drivate&>(b).show(); //编译通过
}

在开发阶段,程序员需要定位程序的bug,而不是难以发现的UB,因此会使用dynamic_cast,在发布时,由于程序员已经确定在dynamic_cast处的程序不会出现问题了,因此就会把其替换为效率更高的dynamic_cast,以此让用户的体验更加流畅,总结一下:在(派生类转基类-运行时转换-引用转换)时,如果基类中定义了虚函数或虚函数表,那么转换成功,更常见的情况是内存中存储的是派生类对象,语言层使用基类对象进行引用,那么此时可以使用dynamic_cast将基类引用安全的向下转换到派生类类型以此访问派生类特有成员,此时static_cast也可以达成相同的效果并且效率更高,但是由于没有运行时的类型检查,因此容易出现UB行为。最后来看看(派生类转基类-运行时转换-指针转换的情况),此时我们容易推断出应该和引用转换的行为是类似的,代码如验证如下:
int main()
{
Drivate d; //派生类对象
Base* b = dynamic_cast<Base*>(&d); //转换成功
b->fun();
return 0;
}

转换成功并且运行正常,下面来看看内存中是派生类对象,在语言层使用基类指针进行指向的情况:
void test(Base* b)
{
//希望调用到派生类特有的函数
dynamic_cast<Drivate*>(b)->show(); //编译通过
}
int main()
{
Drivate d; //派生类对象
Base* b = &d;
test(b);
return 0;
}

意料之中的成功转换并且调用到了派生类特有的函数,并且还可以推测出如果使用指针转换失败,那么dynamic_cast会返回空指针,而不是抛出异常:
void test(Base* b)
{
//希望调用到派生类特有的函数
dynamic_cast<Drivate*>(b)->show(); //编译通过
}
int main()
{
Base b; //派生类对象
test(&b);
return 0;
}

而static_cast在基类对象确实的指向派生类的指针时可以正常的进行转换,如果基类对象的指针就是指向基类对象的,那么就会因为对象中没有派生类特有部分而出现UB行为:
void test(Base* b)
{
//希望调用到派生类特有的函数
static_cast<Drivate*>(b)->show(); //编译通过
}
int main()
{
Drivate d;
Base* b = &d; //派生类对象
test(b);
return 0;
}

此时基类对象是指向派生类对象的指针,在内存中实际存在着派生类特有部分,因此转换成功并且正常运行。
void test(Base* b)
{
//希望调用到派生类特有的函数
static_cast<Drivate*>(b)->show(); //编译通过
}
int main()
{
Base d;
Base* b = &d; //派生类对象
test(b);
return 0;
}

此时内存中不存在派生类特有部分,因此出现UB行为,最终的总结就是:在(派生类转基类-运行时转换-指针转换)时,如果基类实现了虚函数或纯虚函数,那么dynamic_cast可以转换成功,更常见的是使用基类指针指向派生类对象,然后再使用dynamic_cast安全的将基类指针向下转换到派生类类型,以此调用到派生类特有成员,此时使用static_cast等静态类型转换接口也可以成功的将基类指针向下转换成派生类类型,并且效率更高,不过由于没有运行时的类型检查,因此可能会出现UB行为。最后总结一下(派生类转基类-运行时转换的情况):
| 总结 |
|---|
| 1.拷贝转换:dynamic_cast只能接收引用类型或指针类型,因此转换失败,在编译期报语法错误 |
| 2.引用转换:如果基类实现了虚函数或纯虚函数,那么dynamic_cast转换成功,更常见的用法是当基类引用引用了派生类对象时,可以使用dynamic_cast安全的向下转换回派生类对象,以此访问到派生类特有成员,此时static_cast等静态转换也可以做到,并且效率更高,但是由于没有运行时类型检查,以此容易出现UB行为 |
| 3.指针转换:和引用转换的情况类似,只不过dyanmic_cast在运行时转换失败的话会返回空指针,而不是抛出异常 |
自定义类型转换的12钟情况就全部分析完了,在实际的开发中一定是会遇到更加复杂的情况的,比如菱形继承,多继承等情况,但是最多也就是上方这些基本情况的组合,最终我们在看待自定义类型转换时应该得站在内存的视角,关键在于内存中是否存在要转换类型的特有部分,如果存在,那么一般来说类型转换都是比较安全且合法的,如果不存在,那么最好就不要走类型转换,从无到有是构造的职责,如果强行使用类型转换的话那么大概率会报错或UB。
3.奇异的递归模板模式2
下面回到奇异的递归模板模式中,再来分析下方的代码:
template<class T>
class Base
{
public:
void test()
{
T& d = static_cast<T&>(*this);
d.fun();
}
};
class Drivate : public Base<Drivate>
{
public:
void fun()
{
std::cout << "Drivate: " << _d << std::endl;
}
int _d = 10;
};
int main()
{
Drivate d;
d.test();
return 0;
}
上方代码的流程就是先创建出派生类对象d,然后调用基类部分的test函数,在test函数中,首先就是一个类型转换,我们把T替换为派生类类型,得出实际的类型转换为:
Drivate& d = static_cast<Drivate&>(*this);
在基类中的*this就相当于是基类对象,也就是说是派生类对象中基类部分的基类对象,从底层的内存来看,该对象就是基类的引用引用派生类对象,那么这个类型转换实际上就是将基类引用往下转换为派生类对象,以此达到调用派生类特有成员的需求,使用的是static_cast,那么如果内存中存在派生类特有部分,转换就会成功,如果不存在派生类特有部分,那么就会UB,显然此处在内存中的是一个完整的派生类对象,存在派生类特有部分,因此会转换成功并且正常运行:
结果如我们所料,并且还容易推知,如果要让程序更加安全,杜绝UB行为的话,可以将static_cast替换为dynamic_cast,即:
T& d = dynamic_cast<T&>(*this);

嗯…推断错误了,原因非常简单,因为继承体系中没有虚函数或纯虚函数,因此编译器就没有为类型创建vtable和type_info,加上去就可以了:
template<class T>
class Base
{
public:
void test()
{
T& d = dynamic_cast<T&>(*this);
d.fun();
}
virtual void vir()
{
;
}
};
class Drivate : public Base<Drivate>
{
public:
void fun()
{
std::cout << "Drivate: " << _d << std::endl;
}
int _d = 10;
};
int main()
{
Drivate d;
d.test();
return 0;
}

虚函数中甚至可以什么都没有,只需要函数声明和空定义就可以了,编译器只要检测到了虚函数就会自动创建出vtable和type_info, 那么dynamic_cast就可以使用了,不过一般来说,奇异的递归模板模式的效果就是使用静态多态模拟出动态动态,以此提高程序的运行效率并节省创建vtable和type_info的内存开销,以此如果让其配合虚函数和dynamic_cast使用就会让代码变得很奇怪,既没有节省内存开销,又使用了静态多态,因此一般来说是不会这么操作的,在实际开发中,我们一般会配合上友元+禁用构造函数的方式来让奇异的递归模板模式模拟出编译期语法检查的效果(下文详细介绍),容易发现,奇异的递归模板模式在底层实际上就是用的类型转换,从语义上来看,该模式的效果就是让继承链上游的类根据程序员指定的信息变得更加具体了,比如下方的代码:
template<class T>
class animal
{
public:
void movefun()
{
T& ani = static_cast<T&>(*this);
ani.move();
}
};
class bird : public animal<bird>
{
public:
void move()
{
std::cout << "fly" << std::endl;
}
};
int main()
{
bird b;
b.movefun();
return 0;
}

这段代码的关键依旧在于类型转换,我们将T替换为bird,得出实际类型转换为:
bird& ani = static_cast<bird&>(*this);
ani.move();
显然上方的代码看着像动态多态,但是实际上没有使用动态多态,因为动态多态的标准定义是使用基类的指针或引用调用被派生类重写的虚函数,在上方的类型转换后,调用函数的变为了派生类bird的引用,不构成动态多态(连虚函数都没有,怎么会构成动态多态呢?),就仅仅只是一次普通的函数调用,相当于是在基类的movefun函数中使用派生类的引用调用派生类的move,本质上是使用模板模拟出了动态多态行为,从底层来看是直接使用bird的引用调用bird的move,这段代码的语义就像是每一个动物都会移动,鸟是一种动物(is_a),但是鸟的移动方式是飞行,我明确的告诉你该动物是鸟,因此该动物的移动方式统一都会是飞行,显然基类在语义上变得具体了,准确来说是编译器通过程序员提供的信息来使用类型推导让基类变得具体了,并没有违反public的is_a继承原则,而且由于实际上走的是是静态多态,在函数调用在编译期间确定,因此效率会比动态多态高,但是静态多态显然是需要配合上编译期类型检查才是完整且安全的,在上方的代码中可能会出现这种问题:
template<class T>
class animal
{
public:
void movefun()
{
T& ani = static_cast<T&>(*this);
ani.move();
}
};
class bird : public animal<bird>
{
public:
void move()
{
std::cout << "fly" << std::endl;
}
};
class cat : public animal<bird>
{
public:
void move()
{
std::cout << "walk" << std::endl;
}
};
int main()
{
cat c;
c.movefun();
return 0;
}

从语法上看,上方的代码是没有问题的,关键依旧在于基类中的那一处类型转换,我们带入具体的类型,得出实际转为:
bird& ani = static_cast<bird&>(*this);
ani.move();
语法没错,但是语义非常奇怪,猫的移动方式怎么能是飞行呢?这显然不符合我们的常识,一般来说在使用奇异的递归模板模式中,我们在派生类的继承处填入基类模板时,都会填入该派生类自身作为模板,因为怎么做的语义最明确,比如说派生类鸟,那么就填入鸟类作为基类模板,明确告诉编译器:我知道这个动物是鸟,你尽管实例化吧。如果填入了不同的类的话,比如下方的猫类,在继承时填入了鸟作为基类模板,那么编译器就认为基类表示的动物是鸟,但是在我们程序员看来派生类对应的又是猫,语义就冲突了,不过在某些时候该操作又能够发挥出神奇的效果,比如可以让一个派生类拥有另一个派生,在对语义的要求没有那么高时,该操作是可行的,并且是一种强大的复用代码的手段,此时就相当于抛弃语义概念,只复用代码功能,就像这样:
class plane : public animal<bird>
{
public:
};
int main()
{
plane p;
p.movefun();
return 0;
}

虽然把飞机当成一种鸟非常诡异,但是你不能否认飞机的移动方式和鸟确实是一样的,这就是所谓的抛弃概念,复用使用功能,在上方的代码中的体现就是飞机复用了鸟的move函数,该操作也称为鸭子模式,即一个动物如果看起来像鸭子,叫起来也像鸭子,那么该动物就是鸭子,核心就是强调功能,弱化语义,当然具体情况多种多样,随机应变才是关键。但是如果明确的提出对语义有很高的要求(比如飞机绝对不能是鸟),那么我们可以使用"友元+禁用构造函数"的方法来让编译器实现逻辑上的语义检查:
template<class T>
class animal
{
public:
void movefun()
{
T& ani = static_cast<T&>(*this);
ani.move();
}
private:
friend T;
animal(){};
};
具体操作非常简单,只需要让模板T成为基类的友元(不一定必须放在private下,friend在类中任意位置声明的效果都是一样的)并且将基类的构造函数显示的实现在private中就可以了,此时由于我们显示的实现了构造函数,因此编译器不会再生成默认的,其次由于构造函数实现在了private中,因此外部无法调用到,然后由于声明了模板T作为了基类的友元,因此模板T对应的类型能够调用到基类的构造函数,最后在派生类构造的时候,必须使用基类的构造函数,这四个规则的约束就起到了这样的效果:
class bird : public animal<bird>
{
public:
void move()
{
std::cout << "fly" << std::endl;
}
};
class cat : public animal<bird>
{
public:
void move()
{
std::cout << "walk" << std::endl;
}
};
int main()
{
bird b; //成功
b.movefun();
cat c; //失败(编译报错)
c.movefun();
return 0;
}
此时在bird类中向基类的模板传入自身,因此bird成为基类的友元,bird类中能够调用到基类的构造函数,因此b能够创建,而在cat类中,向基类传入了bird模板,那么就是bird成为了基类的友元,cat类中就无法调用基类私有化的构造函数,因此c无法创建,编译报错。也就是说"友元+基类私有化构造函数"起到的效果就是让派生类必须注入自身类型作为基类的模板,否则就会编译报错,这就保证了语义的明确性,当然是否使用还是得看具体场景的,像上方飞机复用鸟中move的例子中使用该方法就不合适了。
本小节到此处就结束了,容易发现难的其实不是奇异的递归模板模式,而是看似简单的类型转换,又或者说奇异的递归模板模式就是基于类型转换实现的,在下一节中笔者将以一个实际案例来分析奇异的递归模板模式的具体效果并介绍混入的知识,希望本节能够让读者有所收获。
更多推荐


所有评论(0)