在过往的文章中介绍过Java的继承,我们这里比较学习C++的继承。
继承是出现是基于对代码复用的需求,在我们写代码时,会发现两个类之间存在大量的代码重复的情况,这个时候继承就排上了用场。继承可以在保持原有类的特性的基础上进行扩展,增加新的字段和方法形成一个新的类。
所以继承本质上就是类之间的复用,在继承的关系下父类会成为子类的一部分,即子类是由自身的成员和父类继承下来的成员组成。
在java中使用关键字extends来表示类之间的继承关系。子类会将父类中的成员变量和成员方法继承下来,可以视为子类中就具有这些成员变量和成员方法,可以对其进行访问。
而在C++中是在类名之后通过冒号来说明继承关系的。
Java的继承:class 子类 extends 父类{}
class A{}
//B是子类,A是父类
class B extends A{}C++的继承:class 子类(派生类):<继承方式> 父类(基类)
class A
{};
struct B : A
{};
java的访问限定符分为private(同包同类)、default(同包子类)、protected(不同包子类)、public(不同包非子类)。通过以上四个限定符,限制了成员在类、子类、包三种场景下的访问规则。
C++和Java的访问限定不同。C++中,继承后的访问限定符由父类访问权限+继承方式两方面决定,共9类。其中父类的private成员无论什么继承方式在子类中都不可见,其余的6类根据public > protected > private的关系,选择父类访问权限、继承方式二者中较小的一个视为该成员的访问限定符。如protected的成员在public继承后,取较小就是protected,所以可以认为是子类的protected成员。
class A
{
public:
int a_public;
protected:
int a_protected;
private:
int a_private;
};
class A_PUBLIC : public A
{
//实际上有的成员:
//public: int a_public
//protected: int a_protected
//不可见:a_private
void func()
{
a_public;
a_protected;
//a_private; //error
}
};
class A_PROTECTED : protected A
{
//实际上有的成员:
//protected: int a_public
//protected: int a_protected
//不可见:a_private
};
class A_PRIVATE : private A
{
//实际上有的成员:
//private: int a_public
//private: int a_protected
//不可见:a_private
};
①可见不可见的含义是是否可被访问。不可见指不可被访问,但是不代表不存在,实际上private成员是存在的,只不过在子类中不可访问。所以可以理解为:继承是所有成员都被继承了,而访问限定符private决定了能否在子类中访问到这些继承的成员。
②protected的成员只在自己类中和子类中可被访问。
③public的成员在自己类、子类和类外均可被访问。
④可以发现public继承后除了private外,public仍是子类的public,protected仍是子类的protected。所以一般都会使用public继承,而很少使用protected和private继承。
⑤一般建议建议显式写出继承方式。当不写出继承方式时:class默认继承方式是private;struct默认继承方式是public。
C++的赋值兼容转换实际和Java的向上转型是同一种操作,都是把派生类对象赋值给基类对象。
Java的向上转型,总结出的情况为:①直接赋值。②作为方法的参数,将子类对象实参传给父类对象形参。③作为返回值,将子类对象以父类对象的形式返回。
C++中的赋值兼容转换则有所不同。C++中的赋值兼容转换,由于类型的不同,一般有三种:子类对象可以赋值给父类对象、指针、引用。赋值兼容转换实际上就是切片或切割:对于父类对象而言,赋值兼容转换实际上将子类中父类部分切割出去,产生了一个新的父类对象;而对于父类指针、引用,他们并没有新创建空间,而是让父类的指针、引用指向了子类中继承父类的那一部分,相当于切割,因此修改子类对象会影响父类指针、引用指向的值,同样的通过他们也可以修改子类对象的值。
class Person { public: string _name = "Jone"; int age = 13; }; class Student :public Person { public: double score = 98.2; }; void func() { Student st; Person per = st; Person* pper = &st; Person& rper = st; cout << st._name << ',' << per._name << ',' << pper->_name << ',' << rper._name << endl; st._name = "Alice"; cout << st._name << ',' << per._name << ',' << pper->_name << ',' << rper._name << endl; st._name = "Tom"; cout << st._name << ',' << per._name << ',' << pper->_name << ',' << rper._name << endl; }
output:
Jone,Jone,Jone,Jone
Alice,Jone,Alice,Alice
Tom,Jone,Tom,Tom
对于C++中的父类赋值给子类的情况我们暂且认为不允许,等以后详细讨论。
在C++中,对于作用域的问题,我们曾经提到过,包括全局域、局部域、命名空间域、类域等。在继承中,虽然基类和派生类存在继承关系,但是作为不同的类,它们具有独立的作用域。
对于同名的函数或变量,会优先访问子类作用域中的函数和变量,这样就构成了隐藏(重定义) 的关系。如果想要访问父类成员,则需要使用::作用域限定符来标识作用域。对于函数的隐藏关系,只需要子类父类函数名相同即可认为是隐藏。这里需要指出,重载会考虑参数是否相同,但是重载只会发生在同一作用域下的函数之间,父类和子类同名函数一定是隐藏。
class A { public: void Print() { cout << "A::Print()" << endl; } int num = 10; double d = 80.8; }; class B : public A { public: void Print() { cout << "B::Print()" << endl; } int num = 20; }; void func() { //父类和子类具有独立的作用域 //对于同名的函数或变量,会优先访问子类作用域中的函数和变量,这样就构成了 隐藏(重定义) 的关系 //如果想要访问父类成员,则需要使用::作用域限定符来标识作用域 B b; b.Print(); cout << b.num << endl; cout << b.d << endl; b.A::Print(); cout << b.A::num << endl; cout << b.A::d << endl; } //对于函数而言,只需要子类父类函数名相同即可认为是隐藏 //这里需要指出,重载会考虑参数是否相同,但是重载只会发生在同一作用域下的函数之间,父类和子类同名函数一定是隐藏 class C { public: void func1() {} }; class D : public C { public: void func1(int a) {} };
output:
B::Print()
20
80.8
A::Print()
10
80.8
对应的在java中也存在子类优先原则:当子类和父类的成员名相同时,优先访问子类成员。对于C++中类域指定访问的方法,java中对应的引入了关键字super,super可以在子类方法中访问到父类的成员。
对于构造函数,无论父类还是子类在不写时,编译器都会自动生成一个默认构造。默认构造对内置类型不做处理、对自定义类型会调用它的默认构造函数、对子类中父类的那一部分会调用父类的默认构造函数。
子类如果不显式调用父类的构造函数,则编译器会使用父类的默认构造。所以父类如果没有默认构造,就代表着子类必须自己写构造函数,并且要求子类必须显式调用父类的构造函数。
构造函数初始化顺序是声明顺序而不是初始化列表的顺序,所以无论父类对象初始化在什么位置,都会优先调用父类的构造函数,在父类部分初始化结束后再初始化子类成员。
Student(string name, int age)
:_age(age)
, Person(name)
{}
对应地,java在子类构造方法第一行有着默认隐含的super()来调用父类的构造。这个隐含的super()只能调用无参构造,当父类不存在无参构造时就需要自己手动传参调用。
对于拷贝构造函数,编译器生成的拷贝构造函数,对内置类型按字节完成拷贝、对自定义类型调用它的拷贝构造、对子类中父类的那一部分会调用父类的拷贝构造。拷贝构造终究属于构造函数,当子类拷贝构造中不显式调用父类的拷贝构造时,编译器会调用父类的默认构造。所以为了可以正常拷贝,我们需要在子类的拷贝构造中显式调用父类的拷贝构造。另外,由于拷贝构造也属于构造函数,所以我们在调用父类的构造函数时也可以调用非拷贝构造的函数。
Student(const Student& s)
:_age(s._age)
,Person(s) //兼容复制转换,父类引用=子类对象,实际传的是子类对象中父类那一部分的引用
{}
编译器生成的赋值运算符重载函数,对内置类型按字节完成拷贝、对自定义类型调用它的赋值运算符重载、对子类中父类的那一部分会调用父类的赋值运算符重载。所以如果子类自己实现赋值运算符重载函数,需要显式调用父类的赋值运算符重载。
Student& operator=(Student& s)
{
if (this != &s)
{
_age = s._age;
Person::operator=(s); //同样采取兼容复制转换的方法进行传参
}
return *this;
}
编译器生成的析构函数,对内置类型不处理,系统自动回收,对自定义类型调用它的析构函数,对子类中父类放入那一部分调用父类的析构函数。当自己实现析构函数时,因为构造顺序是先父类后子类,所以析构顺序是先子类后父类,为了避免出现先父后子的析构顺序,父类的析构函数不需要显式写出,会自动调用。
~Student()
{}
int _age;
先来回顾一下友元的概念。以友元函数为例,对于一个类外函数可能会遇到需要在访问类中private成员的情况,如果为了可以被访问就将访问限定符改为public这就会违背类和对象“封装”的初心。所以为了应对这种特殊需求,C++为我们提供了在类外访问私有成员的方法:友元。只需要在类中将这个函数声明为友元函数,则可以在类外对类中的private成员进行访问。
继承体系中的友元强调一点:友元关系不可以被继承。即函数是父类的友元,但并不是子类的友元,所以可以访问父类私有成员而不可以访问子类私有成员。
class B; //声明
class A
{
public:
friend void funca(A& a, B& b);
private:
int _a = 10;
public:
static double d;
};
double A::d = 1.97;
class B : public A
{
private:
int _b = 20;
};
void funca(A& a, B& b)
{
cout << a._a << endl;
//cout << b._b << endl; //友元关系不可以被继承,error
在类中可以使用static来修饰成员变量或者成员函数,使之成为静态成员变量或者静态成员函数。由static修饰的静态成员不再属于某一个对象,而是属于类的,为该类的所有对象共享。
对于基类和派生类:父类定义的static成员,在整个继承体系中被共用,即无论如何继承,只有一个static成员。
class B; //声明
class A
{
public:
friend void funca(A& a, B& b);
private:
int _a = 10;
public:
static double d;
};
double A::d = 1.97;
class B : public A
{
private:
int _b = 20;
};
void func()
{
B b;
A a;
funca(a, b);
cout << a.d << ' ' << b.d << endl;
}
单继承就是一个派生类只继承一个基类,而多继承是一个派生类继承多个基类。
class A
{};
class B
{};
//单继承
class C : public A
{};
//多继承
class D : public A, public B
{};
对于多继承可能会出现菱形继承的问题:即继承的父类有着共同的“祖先”,同宗同源。在菱形继承下,子类会存在两份共同父类的成员,即为二义性或称为数据冗余问题。
为了解决这个问题需要追溯到二义性源头,如A类因为继承了B、C类而具有二义性,B、C又是最初分别通过D、E继承自F的,那么D和E就需要虚拟继承,即在继承方法前加入virtual。
class F
{};
class D : virtual public F
{};
class E : virtual public F
{};
class B : public D
{};
class C : public E
{};
class A : public B, public C
{};
所以在继承时,应该尽量避免菱形继承。
相较于继承,组合是一个类似的概念。
继承:把一个类的细节(成员)完全转移到子类中,实际上内部需要同时考虑父类子类的所有成员,因为继承就是类的合二为一。
组合:一个类的对象作为另一个类的成员,这样子对象就可以作为一个整体出现并使用,耦合度低。
在java的学习中,我们已经接触过了多态。多态字面意思来看就是多种形态,即当去执行某个行为,会因为对象的不同而产生不同的效果。体现在代码层面就是根据对象的性质不同,对同一个方法进行调用时,得到了不同的执行结果。
我们曾经介绍过java在多态中有三个条件:(1)存在继承关系,并且发生向上转型;(2)子类对父类的方法进行重写;(3)通过父类对象的引用去调用这个重写的方法。在满足了如上三个条件后,就会发生动态绑定,而动态绑定则是多态的基础。
而对于C++而言,实际上条件差不多:(1)使用基类的指针或引用调用虚函数;②被调用的函数必须是虚函数,且被派生类重写。
先看一个例子:
class A
{
public:
virtual void func()
{
cout << "A::func()" << endl;
}
};
class B : public A
{
public:
virtual void func()
{
cout << "B::func()" << endl;
}
};
class C : public A
{
public:
virtual void func()
{
cout << "C::func()" << endl;
}
};
void Func(A& a)
{
a.func();
}
void func()
{
A a;
B b;
C c;
Func(a);
Func(b);
Func(c);
}
这个例子中,B、C类继承了A类。他们都具有成员函数func,并且func函数是虚函数,所以当通过引用或指针调用func的时候就满足了多态的条件。可以发现,我们为Func函数传递不同的对象,Func函数以基类的引用接收参数,然后调用虚函数func产生了多态现象。
实现多态必须有重写,而被重写的函数一定要是虚函数,所以先来认识一下什么是虚函数。虚函数简单来说就是被关键字virtual修饰的成员函数。这个关键字我们在避免菱形继承的时候也使用过。
当子类中存在的一个与父类完全相同的虚函数,则与父类中的该虚函数之间构成重写。重写的完全相同要求返回类型、函数名、参数列表完全相同。
①协变——基类与派生类的虚函数返回类型不同。
这个规则的出现主要是为了迎合函数返回类型是自身相关类型的情况。
class A1
{
virtual A1* fun()
{
return new A1;
}
};
class B1 : public A1
{
virtual B1* fun()
{
return new B1;
}
};
②析构函数的重写。
析构函数看似函数名不同,但实际上经过编译器处理,最后析构函数的函数名都会被处理成destructor。所以重写析构函数,由于其没有返回值,只需要保证其参数完全相同并且是虚函数则构成重写。
class A2
{
public:
virtual ~A2()
{
cout << "~A2()" << endl;
}
};
class B2 : public A2
{
public:
virtual ~B2()
{
cout << "~B2()" << endl;
}
};
对于析构函数,继承体系下,析构函数被定义为虚函数是很有必要的。因为实际代码中存在赋值兼容转换的情况,设计为虚函数重写后可以根据对象类型完成多态,分类处理。如果不设计为虚函数,则不构成重写,那么在销毁对象时就不会多态调用析构函数,那么如果子类赋值兼容转换给了父类的指针,在析构时会调用父类析构,此时子类那一部分就造成内存泄漏了。
③子类重写的虚函数不加virtual是可以的,但是不建议。如果声明和定义分离,virtual关键字只在声明时加上,在类外实现时不能加。
①override修饰的虚函数会检查是否重写了父类的某个虚函数,如果没有重写则报错。
②final修饰的虚函数不可以再被重写。
class A3
{
public:
virtual void func1() final
{}
virtual void func2(int a=1)
{}
};
class B3 : public A3
{
public:
//virtual void func1()
//{}
virtual void func2(int b=2) override
{}
//重写只重写函数体,而不重写函数名、参数列表等,所以B3中的函数体和A3不同,但是B3的func2的参数列表仍为int a=1,缺省值为1而非2
};
需要注意到重写的小细节。重写只重写了函数体,而不会重写参数列表,换言之,子类重写的函数,函数体是子类中的,但是参数列表(如缺省值等)是来源于父类的,这部分不会重写。
③只有成员函数可以作为虚函数,如友元函数等类外函数不可以是虚函数。
④静态成员函数就不能设置为虚函数,即static和virtual不可以同时使用。这是因为多态的调用需要通过调用对象找到虚表,从而确定函数地址(即虚函数拿到this后去找函数地址);而静态函数由于没有this指针,所以不知道是谁调用的自己,从而也就无法实现多态的功能了。
重载要求只发生在同一作用域下,要求函数名相同,参数列表不同。重载可以实现不同类型参数调用不同函数体的功能。
隐藏(重定义)的两个函数分别位于父类和子类,要求函数名相同。隐藏解决子类和父类的同名成员冲突问题,隐藏父类成员,使用子类成员。
重写(覆盖)是一种特殊的隐藏,函数都为虚函数,分别位于父类和子类,要求函数名、参数、返回类型相同。重写帮助实现多态,重写的函数根据调用的对象来决定使用父类或者是子类的函数。
因此可以看出基类和派生类同名函数,如果不是重写关系,就是隐藏关系。
在虚函数声明的后面标识“=0”,则表示该函数是一个纯虚函数(纯虚函数允许存在函数体)。包含纯虚函数的类称为抽象类,抽象类不可以被实例化。
所以一般概念层的类,这样的类不存在对象,就把它写成一个抽象类。抽象类的作用一般就是继承给其他类,然后由其他类实现其中的纯虚函数。
和java中的抽象类存在不同,java中的抽象类由abstract标识,而c++中则是根据成员函数有无纯虚函数来判断。
java中抽象类被继承后要实现所有抽象方法,c++也是如此,因为如果不重写纯虚函数,那么就会认为该类中也含有纯虚函数,因此认为是一个抽象类,从而无法实例化。
class A1
{
public:
virtual void func() = 0;
};
class A2 : public A1
{
public:
void func() {}
};
多态的具体表现是:对象调用同一个函数,由于对象的不同从而调用不同的函数。那么调用同样名字的函数,编译器怎么做到如此灵活变通的呢。答案是,在对象的空间中,包含着成员变量和一个指针_vfptr。
class A
{
public:
virtual void Func1() {}
virtual void Func2() {}
virtual void Func3() {}
private:
int a;
};
class B : public A
{
public:
virtual void Func1() {}
virtual void Func2() {}
private:
int b;
};
void func()
{
A a;
B b;
cout << sizeof(A) << endl;
}
对于上述的代码,我们发现打印出的A的大小为8(32位环境)。这就说明除了整型成员a之外,还有一个占四个字节的指针。
既然发现了这个_vfptr的存在,我们就要详细说一下它到底是什么东西。_vfptr又叫做虚函数表指针,指向一个函数指针数组,也就是虚函数表(虚表),这个数组中存放着各个虚函数的地址。为了实现多态,所以父类和子类对象的成员的虚函数不完全相同,所以子类对象和父类对象有着自己独自的虚表。
相当于对象如果想要调用自己的虚函数,就需要首先访问对象自己空间内的虚函数表指针,通过这个指针访问到了虚函数表,这个虚表(数组)由于所属的类不同,从而内容不完全相同,在此之后通过虚函数表取得了将要调用的虚函数的地址。
以上述代码为例,A和B类对象的虚表中,Func1和Func2被重写了,所以他们对应的虚表中的地址不一样,在多态调用的时候根据对象的不同,会访问属于各自不同的虚表,从而找到代码段中的不同函数。而Func3没有被重写,所以在a和b对象的虚表中,Func3地址相同。
多态只可以通过指针和引用实现,这是因为多态在调用函数时并不确定调用的是继承体系中哪一个对象的函数,从而需要在运行时确定调用的函数位置,因而其指令中函数地址是在运行时动态绑定的。
而指针和引用实际上是切片,也就是并未创建出新的对象,只是指针引用指向了基类部分,是披着基类类型壳子的子类,所以在调用重写的函数时,指针和引用很清楚自己真正是什么类型,从而正确完成多态。
而对象,就是非常确定的存在,基类对象就是基类,不可能是子类或其他什么,这也印证了子类赋值兼容转换给基类,基类会创建新空间。一个确定的对象去调用重写的函数,自然只会调用自己的,从而不会发生多态。所以对象调用函数是静态绑定,在编译时就已经确定。
void func()
{
A a;
B b;
A a1 = b;
A* pa = &b;
A& ra = b;
}
使用如上代码测试:
通过调试观察到,对象的赋值兼容转换相当于把父类部分拷贝出去创建了一个新的父类对象,因此a和a1对象都是父类对象_vfptr相同。而切片和引用则是指向了父类部分,他们是明白自己属于子类对象的,可以看到他们的_vfptr和子类对象b相同。