C++模板元编程系列-1.继承
随着对于编程的不断深入,笔者越发体会到自己的编程知识缺了一部分,那就是模板,对于模板的基础知识笔者是大概掌握的,比如函数模板,类模板,特化等基础知识,但是更进一步的类型萃取,模板元编程,混入等高阶模板的语法是完全无法理解的,因此在学习的途中写下这一系列的博客,要说明的是,本系列不会介绍模板的基础知识,最终的目标是掌握模板元编程这一高级编程技术。(注:笔者学习使用到的书籍有《C++新经典:模板与泛型编程》和《Effective C++》第三版》
1.继承
1.1继承中的权限问题
要理解模板元编程,首先就必须对面向对象有深刻的理解,因此在正式学习模板元编程前,先来总结回顾一下继承的知识。
首先是继承中的访问限定符,我们都知道,在C++面向对象的体系中有三种访问限定符,分别是:public,protected和private,而在继承中,子类继承父类也分为三种继承方式,分别是:public继承,protected继承和private继承,那么就要考虑一个问题,在父类中被继承到子类的内容会是什么权限的?子类能够继承父类的所有内容吗?考虑下方的例子:
class Base
{
public:
int _a = 1;
protected:
int _b = 2;
private:
int _c = 3;
};
class T1 : public Base
{};
class T2 : protected Base
{};
class T3 : protected Base
{};
在上方的基类Base中存在三种访问权限不同的成员变量,在下方三个子类中分别采用了不同的继承权限进行继承,下面在子类内部和子类外部进行访问权限的测试,T1测试代码如下:
class T1 : public Base
{
public:
void TestInT1()
{
std::cout << "In _a: " << _a << std::endl;
std::cout << "In _b: " << _b << std::endl;
std::cout << "In _c: " << _c << std::endl;
}
};
void TestOutT1()
{
T1 t1;
std::cout << "Out _a: " << t1._a << std::endl;
std::cout << "Out _b: " << t1._b << std::endl;
std::cout << "Out _c: " << t1._c << std::endl;
}
int main()
{
T1 t;
t.TestInT1();
TestOutT1();
return 0;
}
结果如下:
从错误信息可以看出,在T1类中无法访问到父类的私有成员变量_c,在类外,无法访问到父类中的保护成员变量_b和私有成员变量_c,可以得出当子类使用public继承时,子类中无法访问到父类的私有成员,子类外部只能访问到父类的共有成员,下面来看看T2的测试:
class T2 : protected Base
{
public:
void TestInT2()
{
std::cout << "In _a: " << _a << std::endl;
std::cout << "In _b: " << _b << std::endl;
std::cout << "In _c: " << _c << std::endl;
}
};
void TestOutT2()
{
T2 t2;
std::cout << "Out _a: " << t2._a << std::endl;
std::cout << "Out _b: " << t2._b << std::endl;
std::cout << "Out _c: " << t2._c << std::endl;
}
int main()
{
T2 t;
t.TestInT2();
TestOutT2();
return 0;
}
结果如下:
从错误信息可以看出,当子类使用protected继承时,在子类中无法访问到父类的私有成员,在子类外无法访问到父类的任何成员,最后来看看T3的测试:
class T3 : protected Base
{
public:
void TestInT3()
{
std::cout << "In _a: " << _a << std::endl;
std::cout << "In _b: " << _b << std::endl;
std::cout << "In _c: " << _c << std::endl;
}
};
void TestOutT3()
{
T3 t3;
std::cout << "Out _a: " << t3._a << std::endl;
std::cout << "Out _b: " << t3._b << std::endl;
std::cout << "Out _c: " << t3._c << std::endl;
}
int main()
{
T3 t;
t.TestInT3();
TestOutT3();
return 0;
}
结果如下:
可以得出:当子类使用private继承时,在子类内部可以访问到父类部分的共有成员和保护成员,无法访问到父类部分的私有成员,在子类外无法访问到父类的任何成员,最后让我们总结一下,首先对于父类的私有成员,子类无论采用哪种继承方式都无法访问,此时就产生了一个问题:在父类中的私有成员在物理上会被继承到子类中吗?首先对于一个继承了其它类的实例对象,其内存分布可以认为是这样的:
我们猜测,父类的私有成员是在父类部分中的,只不过无法在子类部分中访问到,可以利用子类public继承时,在子类外部能够访问到父类的pubilc成员来进行验证,代码如下:
class Base
{
public:
int _a = 1;
void Show()
{
std::cout << "_c: " << _c << std::endl;
}
protected:
int _b = 2;
private:
int _c = 3;
};
class T : public Base
{};
int main()
{
T t;
t.Show();
return 0;
}
如果在对象中能够访问到_c,那么就说明这个_c是存在于对象中的,只不过只能够在父类部分中使用,结果如下:
访问到了,说明父类中的私有成员是会存在于子类构建的实例中的,下面继续总结:
| 总结 |
|---|
| 1.当子类使用private继承时,在子类内部可以访问到父类部分的共有成员和保护成员,无法访问到父类部分的私有成员,在子类外无法访问到父类的任何成员 |
| 2当子类使用protected继承时,在子类内部可以访问到父类部分的共有成员和保护成员,无法访问到父类部分的私有成员,在子类外部无法访问到任何父类成员 |
| 3.当子类使用public继承时,在子类外部只能访问到父类的共有成员,在子类内部可以访问到父类的共有成员和保护成员,无法访问到私有成员 |
总结出来就是这三点,由于在实例中子类部分和父类部分是属于不同作用域的,其中子类部分属于子类作用域,父类部分属于父类作用域,因此可以这样进行建模来表示所有情况:
首先是左侧黑色框框的部分,这部分全部都是位于实例内部的,无论采用什么继承,都不影响子类部分访问父类部分的情况,都是只能访问到父类的共有成员和保护成员,无法访问到私有成员,然后再看左侧,此时在外部有两条访问路径,一条是访问父类部分,一条是访问子类部分,对于访问子类部分的路径,就和访问普通类对象是一样的,只能访问到共有成员,无法访问到私有和保护成员,对于访问父类部分的路径,可以将继承关系视作一个权限窗口,使用public继承时,窗口就是最大的,能够完整的看到父类部分中的public,protected和private,此时访问父类部分就和访问普通的类一样,只能访问到public成员,无法访问到protected成员和private成员,使用protected继承时,窗口是中等大小的,此时在外部就看不到父类部分中更大的public成员了,只能看到protected成员和private成员,由于本来就无法访问protected和private成员,又看不到public成员,因此就无法访问父类部分的任意成员了,最后是private继承,此时窗口是最小的,在外部看不到比private大的public和protected,只能够看到父类部分的private成员,那么自然就是无法访问的了。
我们还可以从安全性的角度来看待继承关系,你可能在教材中看到过这一句话:权限只能缩小,不能放大,这句话本质上是设计的安全性原则,权限越大越不安全,权限越小越安全,比如RW权限和R权限比,明显就是只拥有R权限的变量要更加安全,因为其不能被写修改,所谓的安全性原则是笔者自己总结的,简单来说就是:我们可以让危险的东西变安全,不能让安全的东西变危险,在有更安全选择的情况下,要选择更安全的,比如下方的代码:
int a = 10;
const int& b = a;
int& c = b;
此时由于使用了引用,a,b,c在底层都是同一个东西,使用const int&去接收int是可行的,因为变安全了,权限从RW变成了R,但是使用int&去接收const int&就是不可行的,因为变危险了,权限从R变成了RW。从这个视角,我们再来看上方的继承关系,此时可以认为在父类部分中的每一个权限部分都存在两种权限,一种是继承指定的权限,一种是自身权限,比如采用public继承时,父类部分中的权限就是(public,public),(public,protected)和(public,private),根据安全性原则,在有更安全选择的情况下,要选择更安全的,因此外部在访问时会优先选择权限更低的,比如(public,private)就会选择private,那么就无法访问了,又比如使用了protected继承,父类部分中的权限就是(protected,public),(protected,protected)和(protected,private),外部在访问时,看到(protected,public),优先选择了protected,因此就无法访问到public了。
1.2函数的继承
权限部分讲解完毕,下面来看看继承中有关函数接口的部分,同样可以分为三种情况:
class Base
{
public:
//非虚函数
void NoVirtual(int);
//虚函数
virtual void Virtual(int);
//纯虚函数
virtual void PureVirtual(int) = 0;
};
对于一个函数接口,我们可以把其拆分成两部分,一部分是函数声明,另一部分是函数实现,在上方的三种情况中,父类中的非虚函数的含义就是提供给子类函数声明和固定的函数实现,在设计原则上,不允许子类自身实现另一种函数实现,父类中虚函数的含义就是提供给子类函数声明和一份缺省的函数实现,子类可以根据自己的需求重写函数实现,但是必须使用基类的函数声明,也就是在重写时,子类中的函数必须与父类中的虚函数拥有相同的返回值,相同的函数名,相同的参数列表,不过在参数中可以使用协变,简单来说就是利用父类的指针与引用可以指向或引用子类的特性,因此如果在父类虚函数的返回值中返回的是一个基类的指针/引用,那么子类在重写时是可以返回父类返回基类的派生类的指针/引用的,还要注意的是,返回引用时左值引用和右值引用都可以构成协变,在参数列表中无法使用协变,也就是说在重写时,子类中函数的函数名与参数列表必须与父类保持一致,返回值在构成协变的情况下可以不同。如果子类不重写,那么就使用父类中提供的缺省实现,父类中纯虚函数的含义就是不提供函数实现,只规定函数声明,子类在继承后必须自己进行函数的实现,并且拥有纯虚函数的基类也叫做抽象基类,抽象基类不能被实例化,只能在被继承后成为子类实例的的一部分,下面来举个例子:
class Base
{
public:
void NoVirtual(int a)
{
std::cout << a << std::endl;
}
};
class T : public Base
{};
int main()
{
T t;
t.NoVirtual(10);
return 0;
}
在上方的父类中有一个非虚函数,下方的子类就继承了该函数的接口与实现,又由于此时是public继承并且父类中的函数也是public的,(public,public)得出的还是public,因此在子类实例化出来的对象中就可以访问到父类部分的函数,结果如下:
在设计原则上不允许子类自定义实现父类中的非虚函数,但是在语法上是可以的:
class Base
{
public:
//普通函数
void NoVirtual(int a)
{
std::cout << a << std::endl;
}
};
class T : public Base
{
public:
void NoVirtual(int a)
{
std::cout << "hello wrold" << std::endl;
}
};
int main()
{
T t;
t.NoVirtual(10);
return 0;
}
结果如下:
此时父类部分中的函数与子类部分中的同名函数构成隐藏关系,因为这两个函数完全就是位于不同的作用域,所以不构成函数重载,因为函数重载的前提条件就是函数位于同一作用域,可以证明一下:
class Base
{
public:
// 普通函数
void NoVirtual(int a)
{
std::cout << a << std::endl;
}
};
class T : public Base
{
public:
void NoVirtual(int a, int b)
{
std::cout << "hello wrold" << std::endl;
}
};
int main()
{
T t;
t.NoVirtual(1);
t.NoVirtual(2, 3);
return 0;
}
如果构成重载的话,那么应该会打印出1和hello wrold,实际结果如下:
重载,重写与隐藏是非常容易混淆的概念,笔者在此处简单的总结一下:
| 总结 |
|---|
| 1.重载:在同一作用域下,函数名相同但是参数列表不同的多个函数构成重载。参数列表不同包括参数的类型不同,参数的个数不同与参数的顺序不同,要注意的是1.不传参也可以调用的函数最多只能提供一个,2.返回值不同不构成重载,3.如果在同一作用域下的同名函数不构成重载,那么就会发生命名冲突 |
| 2.重写:在继承体系中,派生类重定义了基类的虚函数,使得在运行使能够通过具体的类型动态绑定到不同的函数实现。此时派生类函数声明中的函数名必须与基类相同,参数列表中参数的类型,顺序与个数必须与基类相同,在语法上允许提供不同的缺省值,但是在实际使用中最好不要提供,完全与基类从参数列表保持一致,返回值在不构成协变的情况下必须与基类的相同,在构成协变时,派生类的返回值可以是基类返回值类型的派生类的指针或引用 |
| 3.隐藏:在不同的作用域下,如果存在同名函数或变量,那么在不指明作用域时,编译器会按照特定的规则进行查找,在继承体系中,编译器优先查找子类部分,如果没有才继续查找父类部分,此时的查找仅查找符号,也就是函数名与变量名,在非继承体系中,遵循局部优先原则,在指定作用域时,编译器会直接去指定的作用域中查找,要注意的是::表示全局 |
下面回到继承中,目前已经得出,如果基类提供了非虚函数,那么子类会同时继承到该非虚函数的声明与实现,并且在设计原则上,子类不应该重定义该函数,在语法上,如果子类重定义了同名函数,那么构成隐藏。下面来看看基类提供虚函数的情况:
class Base
{
public:
virtual void fun(int a, int b)
{
std::cout << "000" << std::endl;
}
};
class T1 : public Base
{
public:
void fun(int a, int b) override
{
std::cout << "111" << std::endl;
}
};
class T2 : public Base
{
public:
void show()
{
fun(1, 2);
}
};
在子类中不写override也是可以的,但是写出来后语义会更加清晰,并且写出来后编译器会进行重写的检测,能够防止误操作。虚函数的继承是提供了函数声明与一份函数的缺省实现,在上方的T1类中就是重写了父类的fun函数,在T2中就是使用虚函数的缺省实现,要注意的是就算子类重写了虚函数,在子类中也是可以通过指定作用域来调用到父类中的虚函数的,重写机制也是动态多态的核心,利用虚函数重写后编译器生成的虚函数表,就可以在程序运行时根据不同的对象调用到不同的函数实现,再配合上基类指针或引用能够指向或引用子类对象的机制,就可以实现多个同名类调用同一个函数,在运行时展现出不同的效果,比如:
class Base
{
public:
virtual void fun()
{
std::cout << "Base" << std::endl;
}
};
class T1 : public Base
{
public:
void fun() override
{
std::cout << "T1" << std::endl;
}
};
class T2 : public Base
{
public:
void fun() override
{
std::cout << "T2" << std::endl;
}
};
int main()
{
T1 t1;
T2 t2;
Base t3;
Base& b1 = t1;
Base& b2 = t2;
Base& b3 = t3;
b1.fun();
b2.fun();
b3.fun();
return 0;
}

该机制的实现核心就在于虚函数表,下面来详细的介绍一下,可以认为虚函数表本质上就是一个函数指针数组,如果在继承体系中出现了虚函数,那么编译器就会自动生成这张表,在表中就会填入虚函数的函数地址,并且在继承链中从第一个使用虚函数的类开始,后续所有的类都会拥有自己的虚函数表,要注意的是虚函数是属于类本身的,不是属于类对象的,简单来说可以认为对于有虚函数表的类,编译器会在类内部自动添加上一个static 函数指针数组成员,根据这些规则,画出来的继承链就是这样的:
从图中可以看出,在整个继承树中当某一个类中写了虚函数时,后面所有继承该类的类包括该类本身都会拥有一张static 虚函数表,在图中就是从Class 4开始往下,所有直接或间接继承Class 4的类都会拥有自己的static 虚函数表,在图中也可以看出,对于继承了有虚函数的父类的子类,有四种不同的情况,即:
| 情况 |
|---|
| 1.在子类中重写了父类的虚函数 |
| 2.在子类中隐藏了父类的虚函数 |
| 3.在子类中添加了新的虚函数 |
| 4.在子类中没有对虚函数进行任何操作 |
首先在最开始,可以认为子类会直接拷贝父类的static 虚函数表,如果是情况4,那么子类就是直接使用这张拷贝过来的虚函数表,不进行任何修改,如果是情况1,那么编译器会使用子类重写后的新的函数的地址覆盖掉虚函数表中父类的同名虚函数的函数地址,如果是情况2,那么编译器不会修改拷贝下来的父类的虚函数表,如果是情况3,那么编译器会在虚函数表的后面加上新的条目并存储新的虚函数的地址。要注意的时,一但修改了虚函数表,那么后续的类继承到的虚函数表也会发生变化,因为子类拷贝的是父类的虚函数表,不是拷贝“爷爷类”的虚函数表。
对于有虚函数表的列的实例对象,编译器同样会做一些特殊处理,简单来说编译器会在对象的前方多加一个指针,指向该类对象对应类的虚函数表,也就是这样的:
在使用基类的指针/引用指向/引用子类对象时,在运行时就会使用偏移找到该对象中的vptr,然后再使用vptr找到vtable,最后就可以调用到虚函数表中的函数了,由于此时每个类都存在自己的static vtable,因此如果发生重写的话,不同的类对象在运行时就会通过虚函数表找到不同的函数实现,由此就实现了动态多态,也就是运行时多态。从底层原理可以看出,要实现动态多态是会有额外的开销的,额外空间开销就是类的vtable与对象的vptr,额外时间开销就是原本一次指针跳转就可以找到函数,在动态多态中要跳转两次,中间还有一次查表,一般而言,这些开销都是可以接收的,但是如果对性能的要求非常高,那么就要使用模板和静态多态来提高性能了,具体的方法就是本节要介绍的奇异的递归模板模式,不过现在先让我们把视角拉回继承。
对应虚函数的继承,还有很多细节没有讲解,比如协变,权限对虚函数继承的影响等,下面让我们一个个来看,先来看看下方的一段代码:
class Base
{
public:
virtual void fun()
{
std::cout << "Base" << std::endl;
}
};
class T1 : public Base
{
private:
void fun() override
{
std::cout << "T1" << std::endl;
}
};
唯一的区别就是在T1类中,fun函数的权限变为private的了,那么现在的问题是,下面的两个函数运行的结果是什么:
void test1()
{
T1 t;
Base& b = t;
b.fun();
}
void test2()
{
T1 t;
Base& b = t;
t.fun();
}
首先test2是会报错的,因为fun是private的,显然无法在类外部直接进行访问。但是在test1中并没有直接通过T1类对象进行访问,而是利用了基类引用来间接的访问,在基类Base中的fun函数是public的,但是最终要调用的函数是位于T1中的fun,是private的,能够调用吗?我们从程序变为可执行的程序的各个阶段来进行分析,也就是预处理,编译,汇编,连接四个阶段,首先是预处理,显然是可以通过的,然后是编译,此时在编译器的语法检查中就是使用Base类访问其中的fun函数,是public的,因此编译这关也能过,程序就被编译成了反汇编,然后是汇编,就是把反汇编指令变成机器码,没有语法检查,显然也是可以通过的,最后是连接,fun函数存在实际的定义,因此连接器连接成功,全部都通过了,在运行时就是通过vptr查vtable,此时触发运行时多态,定位到了T1中的fun函数,但是程序都运行起来了,函数都通过vtable找到了,CPU都嗷嗷待哺了,还会管什么private吗?显然是不会的,因此我们推测,test1可以正常的运行,下面来看看实际的结果:
首先是test2:
int main()
{
test2();
}
结果:
没有通过编译期间的语法检查,直接报错了,符合预期,下面来看看test1:
int main()
{
test1();
}
结果:
打印出了结果,调用到的也确实是T1中private的函数fun,符合预期,从这里可以看出,public,protected和private的权限限制是静态限制,也就是通过在编译期间的语法检查来限制,一但语法检查通过了,后面的流程中就不会再管public,protected和private了,那么我们或许还可以利用动态多态的原理,使用vptr和vtable直接绕过private的语法检查,具体代码如下:
int main()
{
T1 t;
T1* pt = &t;
//1.找到vptr
void** vptr = (void**)pt;
//2.类型转换
void(*(*vtable)[1])() = (void(*(*)[1])())(*vptr);
//3.访问虚函数
(*vtable)[0]();
return 0;
}
结果如下:
或许我们还可以强行更改vptr指向的vtable,让程序执行到不同的函数
代码如下:
void newfun()
{
std::cout << "hello wrold" << std::endl;
}
class T1 : public Base
{
public:
void fun() override
{
std::cout << "T1" << std::endl;
}
};
void cb(T1& t)
{
t.fun();
}
int main()
{
T1 t;
T1* pt = &t;
//1.找到vptr
void** vptr = (void**)pt;
//2.构建新的vtable
void** newvtalbe = new void*[1];
//3.注册新函数
newvtalbe[0] = (void*)newfun;
//4.覆盖原vtable
*vptr = newvtalbe;
cb(t);
delete newvtable;
return 0;
}
结果如下:
将调用逻辑单独放入函数体cb中是为了禁止编译器的优化,虽然在语法上规定多态函数是在运行时调用的,但是编译器会分析上下文,如果它发现调用的函数是可以确定的,那么在编译期间就会硬编码函数跳转地址,没有走虚函数表,因此篡改虚函数表就失效了,但是将调用逻辑封装到单独的函数中后,函数栈帧一定是运行时才会展开的,因此就防止了编译器在编译期间的硬编码优化对结果产生影响,如果不使用函数封装调用逻辑的话就会发生一些非常诡异的事情:
int main()
{
T1 t;
T1* pt = &t;
//1.找到vptr
void** vptr = (void**)pt;
//2.构建新的vtable
void** newvtalbe = new void*[1];
//3.注册新函数
newvtalbe[0] = (void*)newfun;
//4.覆盖原vtable
*vptr = newvtalbe;
t.fun();
pt->fun();
delete newvtable;
return 0;
}

此时pt是t的指针,但是调用同一个函数却出现了不同的结果,原因就是上方说到了编译器的硬编码优化,对于t.fun,没有指针解引用操作,编译器可以通过语法判断出调用的就是T1中的fun函数,因此直接就硬编码了,而pt->fun中出现了指针解引用操作,该操作是在运行时触发的,因此函数调用走的就是vtable,就成功调用到了新vatable中的函数。
或许还可以利用注册到vtable中的新函数来反向篡改对象中的内容,埋下一颗定时炸弹,当程序运行了一段时间后触发,直接反向的将整个类修改成另一个类,又或许可以不替换整个vtable,而是替换其中的某一个函数指针,让攻击变得更加隐秘,甚至还可以利用这种操作反向修改整个继承链,彻底的将一个程序替换成另一个程序,为了避免替换被通过对比指针值的形式发现,还可以利用每一个进程都有独立虚拟地址空间的特性,实现让同一个指针值指向不同的函数…再往下就是内核攻防了,笔者会专门写一篇blog来进行介绍,现在让我们继续研究继承。
现在已经研究了非虚函数与虚函数的继承,非虚函数会提供给子类固定的函数声明与函数实现,在设计原则上不允许子类中再定义同名函数,如果定义了的话,那么构成隐藏,调用函数的原则遵循作用域符号查找原则,而虚函数会提供给子类一份函数声明与函数缺省实现,子类可以重写虚函数,但是必须与父类的虚函数拥有相同的函数名,参数列表,返回值在不构成协变的情况下也必须相同,下面来看看协变,协变的核心就是利用基类能够指向或引用子类的特性,让返回值能够返回不同的类型:
class A
{
public:
virtual void Show()
{
std::cout << "A" << std::endl;
}
};
class B : public A
{
public:
void Show() override
{
std::cout << "B" << std::endl;
}
};
class Base
{
public:
virtual A& fun()
{
A* a = new A;
return *a;
}
};
class T : public Base
{
public:
B& fun() override
{
B* b = new B;
return *b;
}
};
int main()
{
Base b;
T t;
A& a1 = t.fun();
A& a2 = b.fun();
a1.Show();
a2.Show();
}
结果如下:
原理并不复杂,要注意的就是:如果想要构成协变的话,那么父类中虚函数的返回值必须是基类的指针或引用,不能返回派生类或是基类的拷贝,其中的关键就是父类指针或引用能够指向或引用子类对象,这就话透露出了一个问题,考虑下方代码:
class A
{
public:
virtual void fun()
{
std::cout << "A" << std::endl;
}
};
class B : public A
{
public:
void fun() override
{
std::cout << "B" << std::endl;
}
};
class C : public B
{
public:
void fun() override
{
std::cout << "C" << std::endl;
}
};
void Show(A& base)
{
base.fun();
}
int main()
{
A a;
B b;
C c;
Show(a);
Show(b);
Show(c);
return 0;
}
上方代码会不会崩溃,也就是说在继承链中如果基类与派生类的继承层级大于一,那么基类指针或引用是否还能够指向或引用派生类?结果如下:
显然是可以的,也就是说父类指针或引用能够指向或引用子类对象的完整含义应该是:在继承链中,上游类的指针或引用能够指向或引用直接或间接继承该类的所有下游类对象,如下图:
其中Class1能够指向或引用其下方的Class2到Class8,Class2能够指向或引用其下方的Class 4,Class 6,Class 7…,也就是说在协变的返回值中,如果基类虚函数的返回值是Class 1的指针或引用,那么子类的返回值就可以是Class 1到Class 8中任意一个类的指针或引用。
最后来看看基类中纯虚函数的情况,在C++中规定,拥有纯虚函数的类为抽象基类,抽象基类不能被实例化,并且继承抽象基类的派生类必须实现纯虚函数接口的函数实现,比如下方的代码:
class Base
{
public:
virtual int add(int a, int b) = 0;
};
class T : public Base
{
public:
//派生类必须提供纯虚函数的实现
int add(int a, int b) override
{
return a + b;
}
};
int main()
{
//Base b; //抽象基类不能被实例化
T t;
Base& b = t;
int c = b.add(1, 2);
return 0;
}
由于抽象基类不能被实例化,因此唯一使用抽象基类的方式就是让抽象基类的指针或引用指向或引用继承其的派生类,利用动态多态的方式调用派生类中的成员函数,但是要注意的是,利用多态调用派生类中的成员时,是无法调用到派生类自身特有的函数的,因为动态多态的关键在于虚函数表,当你使用基类指针或引用来操作派生类时,编译器看的就只有对象虚函数表中基类的函数,显然对于子类特有的函数,是不在这部分中的,比如下方的代码:
class Base
{
public:
virtual void fun1() = 0;
virtual void fun2()
{
std::cout << "Base fun2" << std::endl;
}
void fun3()
{
std::cout << "Base fun3" << std::endl;
}
};
class T : public Base
{
public:
void fun1() override
{
std::cout << "T fun1" << std::endl;
}
//派生类特有函数
void Show()
{
std::cout << "T show" << std::endl;
}
};
int main()
{
T t;
Base& b = t;
}
如果使用派生类对象t,那么就能够访问到派生类继承下来的所有基类函数与派生类自身的特有函数,如果使用基类的引用b去访问,那么就只能够访问到对象中基类部分的函数,无法访问到派生类特有的Show函数。
容易推出,纯虚函数的效果就是强制派生类继承其接口,但是派生类可以任意的进行实现,要说明的时,纯虚函数也是会入虚函数表的,只不过在被派生类实现前,其在虚函数表中对应的槽位填的是空指针。值得一提的是,基类中的纯虚函数也可以提供缺省的实现,这一语法在某些场景下有妙用。
1.3继承的常用设计方式
在上文介绍了继承的基本语法和底层原理,从本节开始介绍继承的最佳实践。
1.3.1public继承和private继承的含义
首先回顾一下public继承的效果:当子类使用public继承父类时,父类中所有成员的访问权限不变,在子类中能够访问到父类的public成员和protected成员,在子类外能够使用子类对象访问到子类部分和父类部分的public成员。考虑下方的代码:
class animal
{
public:
void eat()
{
std::cout << "吃东西" << std::endl;
}
};
class bird : public animal
{
public:
void fly()
{
std::cout << "飞起来" << std::endl;
}
};
int main()
{
animal ani;
bird bir;
ani.eat();
bir.eat();
return 0;
}
由public继承的语义我们得知,在主函数中的两个eat调用都能够成功,这里的潜在含义就是能够对父类对象进行的任何操作,对子类对象同样也可以,上述代码的含义就是动物都会吃东西,鸟也是一种动物,因此鸟也会吃东西,但是不是所有动物都可以飞行,因此fly是鸟类的特有方法,再考虑下方的代码:
void test1(animal& ani)
{
ani.eat();
//ani.fly() 错误,不是所有动物都可以飞行
}
void test2(bird& bir)
{
bir.eat();
bir.fly(); //正确,鸟既可以吃东西,又可以飞行
}
利用父类引用能够引用子类对象的语法,test1函数能够同时接收父类animal的类对象和子类bird的类对象,但是由于此时是使用animal的引用接收的,因此就算传入的是bird对象,也无法调用到其特有的fiy函数,因为编译器检查出在animal类中没有fly方法,就会直接在编译期间报错。在下方的代码接收的参数是子类bird的引用,显然无法传入父类对象,只能传入子类对象。这部分代码的潜台词就是:在public继承中,如果一个函数能够接受父类对象,那么它也一定能够接受子类对象,不过在函数中,无法调用到子类特有的操作。语义也非常明确,test1明确的写出了自己接收的对象是animal,显然不是所有animal都会飞行的,因此无法调用到fly函数就是在正常不过的了,在test2中明确的写出了自己接收的对象是bird,不接受其它种类的动物,显然bird是可以飞行的,因此调用到fly函数就是非常合理的了。从上面两个例子我们容易发现,在public继承中的一个最基本的主张就是:能够对基类对象进行的任何操作,对其派生类对象也一定可以进行,说人话就是基类中的任何成员,只要是public的,那么通过派生类对象就也一定要能够访问到。也就是所谓的is_a了,主张派生类是一种基类,比如上方代码的主张就是鸟是一种动物。总之我们在使用public继承时一定要遵循一点:通过基类能够访问到的成员,通过派生类也必须要能够访问到。is_a原则带来的另一个要求就是在设计继承链时,越靠近继承链上游的类必须越抽象,越具备通性,比如在上方的代码中,我们就不能在animal中实现fly方法,否则就会出现这种可笑的场景:
class animal
{
public:
void eat()
{
//.......
}
void fly()
{
//.......
}
};
class people : public animal
{
//.....
};
int main()
{
people p;
p.fly(); //能够成功调用,但是人怎么能够飞行呢?
return 0;
}
显然人能够飞行是不合理的,因此在设计继承链时,上游类的实现必须抽象出下游类的共性,上方的例子非常直观,但是在实际开发中要做到这一点是非常困难的,比如在设计线程池,内存池等组件时,是不会存在如此明显的语义的,此时要设计出优雅的继承链就只能靠经验的累积了,我们能够做到的就是在设计继承链时,以is_a原则为指导思想,保证能够作用于基类中的每一个操作都能够作用于派生类。
在实际开发中,隐藏关系会破坏public继承的is_a继承原则,考虑下方代码:
class animal
{
public:
virtual void eat()
{
//.....
}
virtual void eat(int a)
{
//.....
}
virtual void eat(int a, int b)
{
//......
}
};
class bird : public animal
{
public:
void eat() override
{
//...
}
};
派生类重写的基类中的虚函数,这很合理,此时在基类中的eat函数重载了,一共有三个eat,根据隐藏的语法我们得知,编译器的跨作用域查找规则是:先在子类部分中查找,如果找到了指定的符号的话,那么就不会再去父类部分中查找,在上方的类中就是这种情况:
int main()
{
bird b;
b.eat();
b.eat(1); //调用失败
b.eat(1, 2); //调用失败
return 0;
}
由于编译器在子类部分中找到了eat符号,因此它就不会去父类中找了,尽管我们程序员的含义是:eat,eat(int),eat(int, int)三者是不同的函数,但是编译器在跨作用域查找时,只看符号,不看类型(对于变量也是如此),此时就违背了is_a的继承关系,因为通过基类能够调用到三种eat,但是使用子类却只能调用到一种eat了,此时如果子类有需求的话,可以直接把三个eat都重写了,不过这并没有打破隐藏,只是让编译器同样不会去父类中查找,但是这种操作也可以完成我们的目标,如果子类没有特殊需要,就是想直接使用父类的缺省实现,那么可以使用using声明,代码如下:
#include <iostream>
class animal
{
public:
virtual void eat()
{
std::cout << "animal" << std::endl;
}
virtual void eat(int a)
{
std::cout << "animal int" << std::endl;
}
virtual void eat(int a, int b)
{
std::cout << "animal int int" << std::endl;
}
};
class bird : public animal
{
public:
using animal::eat;
void eat() override
{
std::cout << "bird" << std::endl;
}
};
int main()
{
bird b;
b.eat();
b.eat(1); //调用成功
b.eat(1, 2); //调用成功
return 0;
}
结果如下:
使用using声明就相当于告诉编译器:我还要使用父类部分的成员,你得去帮我看看。因此编译器就按照程序员的要求,在调用eat时首先去子类部分中匹配,如果匹配失败,那么就再去父类部分中匹配,此时就真的打破了隐藏了。要注意的是,上方的using必须放在public访问限定符中,否则编译器就会认为在父类部分中要using的成员都是private的,在外部就无法调用了。在语法上,隐藏是public继承中is_a关系的杀手,但是这还是比较好避免的,恶心的是在某些时候is_a会和我们的认知相悖,比如现在我们要设计一种几何类继承链,根据我们小学学到的知识,我们知道正方形是一种特殊的矩形,显然我们应该可以使用public继承的is_a关系来进行表示,代码如下:
#include <iostream>
class Rectangle
{
private:
int _width = 0;
int _length = 0;
};
class Square : public Rectangle
{
private:
int _width = 0;
int _length = 0;
};
根据我们学到的几何知识来看,这非常合理,但是考虑下方的情况:
#include <iostream>
class Rectangle
{
public:
void ChangeWidth()
{
_width += 10;
}
private:
int _width = 0;
int _length = 0;
};
class Square : public Rectangle
{
private:
int _width = 0;
int _length = 0;
};
显然对矩形调用ChangeWidth是非常合理的,因为矩形不要求长宽相同,但是对于正方形来说,显然不能单独的改变其中某一条边,因为正方形的长与宽必须是相同的,这意味着对于基类对象能够调用的函数在其派生类中无法调用了,不满足is_a关系。具体的情况多种多样,我们在设计继承链时只需要记住一点:public继承必须严格满足is_a,也就是说在使用public继承时,对基类能够进行的所有操作,对派生类也必须要能够进行。
下面来看看private继承的情况,同样的,先从回顾一下基本语法,当子类使用private继承父类时,在子类中能够访问到父类的public成员和protected成员,在子类外,无法访问到父类部分的任何成员,只能访问到子类部分的public成员。潜台词就是子类在使用private继承父类后,子类中就有了一个父类,因为父类部分能够进行的所有操作都是在子类内部完成了,要暴露出什么接口给外部完全由子类决定,这就是所谓的has_a,即子类拥有父类,has_a带了的另一个效果就是既然已经把父类的操作托管给子类了,那么父类中的成员就不应该public给外部,但是又由于父类的private成员子类无法访问,因此在使用priavte继承时,父类中的大部分成员都应该得是protectec的,考虑下方代码:
class Phone
{
protected:
void PlayGame()
{}
protected:
int _phone_number;
int _money;
std::string _brand;
std::vector<std::string> _apps;
};
class People : private Phone
{
public:
void Show()
{
}
};
上方代码的潜台词就是每个人都有一部手机,此时使用private继承就非常合理了。
到此处继承部分结束,其实还有大量的知识点与设计方案没有讲,不过本系列毕竟是讲模板的,因此就不多说了。
更多推荐

所有评论(0)