1.概念
继承(inheritance)机制是面向对象程序设计 使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称 派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是 函数复用,把公共的部分单独写成一个函数,让程序去调用这个函数;而继承的本质可以认为是 类设计层次的复用。
面向对象三大特性:封装、继承和多态。有时还会加上抽象。
2.示例
class Person
{
public:void Print(){cout << "name:" << _name << endl;cout << "age:" << _age << endl;}
protected:string _name = "peter"; // 姓名int _age = 18; //年龄
};继承上面的类: : + 类名 ,public是一种继承方式
class Student : public Person
{
protected:int _stuid; // 学号
};class Teacher : public Person
{
protected:int _jobid; // 工号
};int main()
{Student s;Teacher t;s.Print();t.Print();return 0;
}
3.定义
3.1定义格式
3.2继承关系和访问限定符
3.3继承基类成员访问方式的变化
类成员 \ 继承方式 | public继承 | protected继承 | private继承 |
基类的public成员 | 派生类的public成员 | 派生类的protected成员 | 派生类的private成员 |
基类的protected 成员 | 派生类的protected成员 | 派生类的protected成员 | 派生类的private成员 |
基类的private成 员 | 在派生类中不可见 | 在派生类中不可见 | 在派生类中不可见 |
3.3.1总结
一、
基类private成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在子类里面还是子类外面都不能去访问它。所以父类的一些成员,如果不想要给子类使用,就可以设置为私有,但实际上父类成员也很少定义为私有,因为这样就不符合复用的规则了。
二、基类的protected成员被继承下来后, 在子类里面可以访问,在子类外面不可以访问。基类private成员在派生类中是 不能被访问,如果基类成员 不想在类外直接被访问,但需要在派生类中能访问,就定义为 protected。由此也可以看出 保护成员限定符是 因继承才出现并有意义的。三、观察上面的表格,我们进行总结会发现, 基类的私有成员在子类都是不可见的。而基类其它成员在子类的 访问方式 = Min(成员在基类的访问限定符,继承方式),取权限小的那个,已知:public > protected > private。四、使用 关键字class时 默认的继承方式是private,使用 struct时默认的继承方式是 public,不过最好还是显式地写出继承方式。class Student : Person//默认是私有继承 { protected:int _stuid; };struct Student : Person//默认是公有继承 { protected:int _stuid; };
五、在实际运用中一般使用都是 public继承,几乎很少使用 protetced/private继承,也不提倡使用 protetced/private继承,因为protetced/private继承下来的成员都只能在派生类的类里面使用,甚至可能直接就是 不可见的,不符合复用,实际中扩展维护性不强。
4.基类和派生类间的对象赋值转换
4.1引入
已知,不同类型的对象在赋值时,如果是相近类型,即意义相似的类型时,可以隐式类型转换
隐式类型转换要产生临时变量
int main()
{double d = 3.14;int i = d;//这就是相近类型,都是表示数据大小//只是说一个是int类型、不带精度,一个是double类型、带有精度//int& r = d;//这里报错不是因为类型不匹配//而是因为r引用的不是d,是中间生成的临时变量,但临时变量具有常性const int& r = d;//所以要加上const//int和int*也是相近类型,int表示数据大小,int*是地址,地址本质是一个编号return 0;
}
4.2父子类的赋值兼容规则
class Person
{
protected:string _name; // 姓名string _sex; //性别int _age; // 年龄
};class Student : public Person
{
public:int _No; // 学号
};int main()
{Student s;Person p = s;//父类和子类,在public继承的情况下,它们是一个is-a的关系//即子类对象就是一个特殊的父类对象//所以按照之前的理解,这里也应该会产生临时对象Person& rp = s;//但这里却没有报错,也就是说没有产生临时对象,这是为什么?//难道自定义类型的临时对象没有常性吗?///string str = "hello";//这里也是隐式类型的转换,单参数的构造函数支持隐式类型转换//先构造,再拷贝构造//string& rstr = "hello";//但实际上,自定义类型的临时对象也是有常性的const string& rstr = "hello";return 0;
}
那么 Person& rp = s; 这里为什么不会报错呢?
其实这里是一个特殊处理,它并不产生临时对象。
5.继承中的作用域
5.1引入
//命名空间定义的是一个域,类定义的也是一个域
class Person
{
protected:string _name = "小李子"; int _num = 111;
};class Student : public Person
{
public:void Print(){}
protected:int _num = 999; //父类中已经有了一个叫_num的变量//那子类这里还可以定义名为_num的变量吗?//答案是可以的,父类和子类各自拥有独立的作用域//在同一个作用域中,是不能定义同名变量的//但它们是属于不同的域//所以,父类的_num虽然是继承下来了//但还是认为父类和子类各有一个_num成员
};
5.2新的问题
class Person
{
protected:string _name = "小李子"; int _num = 111;
};class Student : public Person
{
public:void Print(){cout << " 学号:" << _num << endl;//这里访问的是哪个类的_num?//显然是子类的。//访问时要去查找_num,遵循就近原则cout << " 身份证号:" << Person::_num << endl;//这样去显式地指定作用域,就可以访问父类的_num了}
protected:int _num = 999; };
5.3新概念:隐藏
父类和子类可以有同名成员,因为他们是独立作用域
默认情况下,直接访问的是子类的成员,子类的同名成员隐藏(重定义)了父类同名成员
5.3.1练习
// A:两个fun构成函数重载
// B:两个fun构成隐藏
// C:编译报错
// D:运行报错
class A
{
public:void fun(){cout << "func()" << endl;}
};class B : public A
{
public:void fun(int i){cout << "func(int i)->" << i << endl;}
};void Test()
{B b;b.fun(10);
};int main()
{Test();//答案是B,函数重载要求在同一作用域//就比如STL中,不同容器的同名函数,是不会报错的return 0;
}
注意:在继承中,同名的成员函数,函数名相同就构成隐藏,与参数、返回值无关。
void Test()
{B b;b.fun(10);//b.fun();//注意:这样是调用不到父类的fun函数的//子类的函数隐藏了父类的,这样调用就会去调用子类的//而子类的是带参的,会认为忘记传了参数,然后报错b.A::fun();//指定作用域就可以调用父类的函数了
};
总结:建议尽量不要去写同名成员
6.派生类的成员函数
6.1默认构造函数
class Person
{
public:Person(const char* name = "peter"): _name(name){cout << "Person()" << endl;}Person(const Person& p): _name(p._name){cout << "Person(const Person& p)" << endl;}Person& operator=(const Person& p){cout << "Person operator=(const Person& p)" << endl;if (this != &p)_name = p._name;return *this;}~Person(){cout << "~Person()" << endl;}
protected:string _name; // 姓名
};class Student : public Person
{
public:int _id;
};int main()
{Student s;//Student不写构造、析构函数时,//默认生成的构造、析构函数会去自动调用父类的构造、析构函数return 0;
}
6.2显式书写的构造函数
错误写法:
class Student : public Person
{
public:Student(const char* name,int id):Person(name)//要像调用匿名对象时一样去调用,_id(id){//Person();//Person::Person();//Person("hello");//写在函数体内的写法,通通是匿名对象}int _id;
};int main()
{Student s("jx",198);//显式写构造函数时,不能像之前的类那样去初始化//C++有规定,派生类的构造函数//要分成两个部分来看待//要初始化父类的成员,就要把父类当成一个完整的对象去初始化//而不是单个成员依次去初始化//所以要去复用父类的成员函数//规定:要去初始化父类成员,就要去显式调用父类的构造函数//日常情况下,构造函数除了定位new是不能显式调用的return 0;
}
6.3默认的拷贝构造
class Student : public Person
{
public:int _id;
};int main()
{Student s("jx",198);Student s1(s);//拷贝构造同理,不显式写拷贝构造时//Student的默认拷贝构造会去调用父类的拷贝构造return 0;
}
可以认为现在将成员分为了内置类型、自定义类型和父类成员
内置类型进行值拷贝,自定义类型调用自己的函数,父类去调用父类的函数
6.4显式书写的拷贝构造
class Student : public Person
{
public:Student(const char* name,int id):Person(name),_id(id){cout << "Student(const char* name,int id)" << endl;}//Student(const Student& s)//此时会去调用父类的构造函数而非拷贝构造//{// cout << "Student(const Student& s)" << endl;//}Student(const Student& s)//此时才会调用父类的拷贝构造:Person(s)//父类的拷贝构造,要传父类的对象//如何把子类对象s中父类的部分拿出来?//由父子类的赋值兼容规则可知,直接传s就可以,_id(s._id){cout << "Student(const Student& s)" << endl;}int _id;
};int main()
{Student s("jx",198);Student s1(s);return 0;
}
6.5默认赋值重载
class Student : public Person
{
public:int _id;
};int main()
{Student s("jx",198);Student s1(s);Student s2("lx", 200);s = s2;//赋值重载同理,不显式写赋值重载时//Student的默认赋值重载会去调用父类的赋值重载return 0;
}
6.6显式书写的赋值重载
class Student : public Person
{
public:Student& operator=(const Student& s){if (this != &s){Person::operator=(s);//注意要指定类域,否则会死循环导致栈溢出_id = s._id;}cout << "Student& operator=(const Student& s)" << endl;return *this;}int _id;
};int main()
{Student s("jx",198);Student s1(s);Student s2("lx", 200);s = s2;return 0;
}
6.7显式书写的析构函数
class Student : public Person
{
public:~Student(){//~Person();//析构函数不能这样显式调用//这里是特例,~Student()和父类的~Person()构成了隐藏关系// 由于多态的原因,析构函数会被统一处理成destructor// 所以父子类的析构函数构成隐藏关系Person::~Person();//想要访问,就需要指定类域cout << "~Student()" << endl;}int _id;
};int main()
{Student s("jx",198);Student s2("lx", 200);s = s2;return 0;
}
原因:
6.7.1正确写法
class Student : public Person
{
public:~Student(){//Person::~Person();cout << "~Student()" << endl;}
//之前的写法没有报错是因为没有对资源进行释放,
//否则会多调用两次析构函数,就会出问题int _id;
};int main()
{Student s("jx",198);Student s2("lx", 200);s = s2;return 0;
}
6.8补充问题
如何实现一个不能被继承的类,或者说这个类被继承了也没有意义?
6.8.1C++ 98实现方式
//C++98 考虑把父类的构造函数私有化即可
//这样的话,这个类继承就几乎没有意义了
//间接使得父类不能被继承
class A
{
private:A(){}
protected:
//这里也可以私有化,这样连成员变量也是不可见的int _a;int _b;
};class B :public A
{};int main()
{//B bb;//此时不管调用默认的还是显式书写的,都定义不出对象return 0;
}
6.8.2C++ 11实现方式
//C++11 认为这个方法不够直观,这里其实还是继承了父类,只是定义不出对象
//所以新增了一个更直观的不能被继承的方式
//新定义一个关键字 final,用来修饰父类,就直接不能被继承了class A final
{
public:A(){}
protected:int _a;int _b;
};class B :public A
{};int main()
{B bb;return 0;
}
此时,在继承时就会报错
6.9总结
1. 派生类的构造函数 必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的 初始化列表阶段显式调用。2. 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。3. 派生类的operator=必须要调用基类的operator=完成基类的赋值。4. 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。5. 派生类对象初始化先调用基类构造再调派生类构造。6. 派生类对象析构清理先调用派生类析构再调基类的析构。7. 因为后续一些场景析构函数需要构成重载,重载的条件之一就是函数名相同。那么编译器会对析构函数名进行特殊处理,处理成destrutor(),所以父类析构函数不加virtual的情况下,子类析构函数和父类析构函数构成隐藏关系。
7.继承与友元
友元关系不能继承,也就是说基类友元不能访问子类私有和保护成员。
class Student;//这里是声明,注意添加
class Person
{
public:friend void Display(const Person& p, const Student& s);
protected:string _name;
};class Student : public Person
{
//friend void Display(const Person& p, const Student& s);
//除非在派生类也加上友元声明
public:int _a;
protected:int _stuNum;
};void Display(const Person& p, const Student& s)
{cout << p._name << endl;cout << s._a << endl;cout << s._stuNum << endl;//这里会报错
}int main()
{Person p;Student s;Display(p, s);return 0;
}
8.继承与静态成员
问题:父类的静态成员会不会被继承下来?
先观察,子类可不可以访问父类的静态成员?
答案是可以。
class Person
{
public:Person() { ++_count; }
protected:string _name; // 姓名
public:static int _count; // 统计人的个数。
};int Person::_count = 0;class Student : public Person
{
protected:int _stuNum; // 学号
};int main()
{cout << Person::_count << endl;cout << Student::_count << endl;//编译不报错,可以通过子类来访问return 0;
}
那么,父、子类的静态成员是各自独有的吗?
显然不是。
int main()
{cout << &Student::_count << endl;cout << &Person::_count << endl;//此时发现二者地址相同//说明二者的静态成员是同一个,都是父类的//而不是二者各自独有的return 0;
}
8.1总结
一、
可以认为静态成员没有被继承下来
因为在子类中没有产生独立的一个_count二、
也可以认为静态成员被继承下来了
但是继承的只有它的使用权
与成员函数类似,继承了使用权,所以可以调用成员函数三、
静态成员不属于某个对象,它不存储在对象中
静态成员属于整个类,它存储在静态区,突破类域就可以访问
8.2应用:统计父子类所创建的对象个数
class Person
{
public:Person() { ++_count; }//这里会对_count进行++
protected:string _name; // 姓名
public:static int _count; // 统计人的个数。
};Student func()
{Student st;return st;
}int main()
{Person p;Student s;func();cout << Student::_count << endl;//任何派生类都必须调用基类的构造函数进行初始化//所以_count就是基类和所有派生类创建的对象个数return 0;
}
9.复杂的菱形继承
按照所继承的父类个数,继承可以分为单继承和多继承。
9.1单继承
9.2多继承
9.3菱形继承
class Person
{
public:string _name; // 姓名
};class Student : public Person
{
protected:int _num; //学号
};class Teacher : public Person
{
protected:int _id; // 职工编号
};class Assistant : public Student, public Teacher
{
protected:string _majorCourse; // 主修课程
};void Test()
{// 这样写会有二义性,存在歧义,导致无法明确地知道访问的是哪一个Assistant a;//a._name = "peter";//这里会报错:"Assistant::_name" 不明确// 书写时显式指定访问哪个父类的成员,就可以解决二义性问题a.Student::_name = "小刘";//作为学生,叫小刘a.Teacher::_name = "老刘";//作为老师,叫老刘// 但是数据冗余问题依旧无法解决,导致空间的浪费// Person对象越大,浪费的空间也就越大
}int main()
{Test();return 0;
}
所以,为了解决菱形继承所带来的二义性和数据冗余的问题,C++引入了虚拟继承。
10.菱形虚拟继承
10.1书写格式
class Person
{
public:string _name; // 姓名
};class Student : virtual public Person
{
protected:int _num; //学号
};class Teacher : virtual public Person
{
protected:int _id; // 职工编号
};class Assistant : public Student, public Teacher
{
protected:string _majorCourse; // 主修课程
};void Test()
{Assistant a;//此时就不存在二义性问题了,a中只有一个Persona.Student::_name = "小刘";a.Teacher::_name = "老刘";a._name = "刘梦";//此时访问的_name都是同一个
}int main()
{Test();return 0;
}
10.2补充
实践中,不建议使用菱形继承。
10.3底层原理
虚拟继承能够解决数据冗余和二义性的底层原理是什么?
class A
{
public:int _a;
};// class B : public A
class B : virtual public A
{
public:int _b;
};// class C : public A
class C : virtual public A
{
public:int _c;
};class D : public B, public C
{
public:int _d;
};int main()
{D d;d.B::_a = 1;d.C::_a = 2;d._b = 3;d._c = 4;d._d = 5;d._a = 0;return 0;
}
10.3.1不加virtual的继承
10.3.2虚拟继承
可以观察到,偏移量表中存储的不只有偏移量,还有其它的信息。
10.3.3存储地址的优点
验证:
class A
{
public:int _a[100];
};
//...
int main()
{cout << sizeof(D) << endl;//B、C不加virtual的情况下是812//B、C加virtual的情况下是420//由此可知,A越大,节省的空间就越多return 0;
}
10.4实例
10.5总结
1. C++语法较为复杂,其实多继承就是一个体现。有了多继承,就可能存在菱形继承,因为有了菱形继承,所以设计了菱形虚拟继承,它的底层实现十分复杂。所以建议一定不要设计出菱形继承,否则在复杂度及性能上都会有问题。2. 多继承可以认为是C++的缺陷之一,很多后来的编程语言都没有多继承,比如Java。
11.继承与组合
public继承是一种is-a的关系。也就是说每个派生类对象都是一个基类对象。而组合是一种has-a的关系。假设B组合了A,则每个B对象中都有一个A对象。
11.1共同点
共同点:都可以复用
//继承
class A
{
public:void func(){cout << "void func()" << endl;}
protected:int _a;
};class B :public A
{
public:void f(){func();_a++;}
protected:int _b;
};//组合
class C
{
public:void func(){cout << "void func()" << endl;}
protected:int _c;
};class D
{
public:void f(){c.func();//c._a++;}
protected:C c;int _d;
};int main()
{cout << sizeof(B) << endl;cout << sizeof(D) << endl;//都是8return 0;
}
11.2不同点
组合的权限相对要小
//继承
class A
{
public:void func(){cout << "void func()" << endl;}
protected:int _a;
};class B :public A
{
public://继承的权限更大//父类的公有可以使用,父类的保护也可以使用void f(){func();_a++;}
protected:int _b;
};//组合
class C
{
public:void func(){cout << "void func()" << endl;}
protected:int _c;
};class D
{
public://组合的权限要小一些//组合类的公有可以使用,组合类的保护不能使用void f(){c.func();//c._a++;}
protected:C c;int _d;
};int main()
{B bb;bb.func();//继承这里可以直接调用父类的公有函数D dd;//dd.func();//但组合这里不能直接调用组合类的公有函数return 0;
}
11.3总结
一、优先使用对象组合,而不是类继承 。二、继承允许使用者根据 基类的实现 来 定义派生类的实现 。这种 通过生成派生类的复用 通常被称为 白箱复用(white-box reuse) 。术语“白箱”是相对可视性而言:在继承方式中,基类的内部细节对子类可见 。继承一定程度上,破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高。三、在软件工程中,黑盒测试通常指不清楚内部实现,内部的细节是隐藏的,只知道上层可以看到的,然后根据上层可以看到的功能,去进行测试
而白盒测试是指清楚底层设计。软件工程、软件维护讲究:低耦合,高内聚, 耦合度就是指关联、关系。
通俗理解为每个类、模块内部功能设计要尽可能地完善,而类与类、模块与模块之间的关联度则是越低越好。比如有一个A类,它有100成员,其中10个公有的、90个保护的,此时有一个B类,
那么B类最好是去组合A类而不是继承。
1.因为如果是组合,那么对A类进行更改、维护时就只需要注意不去更改公有成员即可,保护成员是可以进行更改的,不会影响B类,
2.但是如果是继承的话,那么保护成员的更改都会影响到B类。
四、对象组合是 类继承之外的另一种复用选择 。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为 黑箱复用(black-box reuse) ,因为对象的内部细节是不可见的,对象只以“黑箱”的形式出现。组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被封装。五、实际使用中,尽量多去使用组合。组合的耦合度低,代码维护性更好。当然继承也有它的优势,有些关系适合继承那就使用继承,另外,要实现多态,也必须使用继承。类之间的关系可以使用继承,但适合组合的情况下,最好使用组合。
12.经典笔试面试题
13.练习题
1.