1. 继承与继承方式
1. 父类与子类
- 在现实生活中很多事物与对象都有着重合性的特征,它们虽然是不同的事物但同时也拥有着一致的属性,依此可以将它们分为一类。老师与学生在概念上是不同的,但这只是在以职业角色为标准的划分上,而在生物学的角度上二者都是人类。
- 计算机语言是我们用来与计算机沟通的工具,我们通过计算机可以理解的语言将现实世界的概念与问题表述出来,让计算机来帮助我们处理解决。
- 解决问题的前提是描述问题,在面向对象的语言中,对于现实中的对象与概念采用了类与实例化的语法描述形式,这种方式是采用了将需要表述的一类对象的属性特征抽象出来,生成一个模板蓝图,在实例化使用时再赋予不同的具体数据用来表示不同的具体对象。
- 由于生活中许多事物属性上的重叠与冗余,因此如果直接对应进行一一定义,在形式与实现上这样的结构与方式是十分冗余与低效的。(蔬菜:白菜,茄子,土豆)
- 为了解决上述问题,C++采用了将多个重复性属性较多的类,其中的重复属性提取出来专门实现封装为一个类(父类,基类),再以继承的方式让后续的类(子类,派生类)继承这个类属性与方法。这样,在后续需要定义包含父类属性与方法的子类时,就无需再子类内部再实现一遍,而是可以通过继承的方式直接获得。
- 继承的本质上是一种复用,子类一定包含继承于父类的所有特征与方法,即子类自动包含有父类中的成员与方法。
2. 继承方式
- 我们在概念上理解了什么是继承与继承的使用场景与必要性,那么,接下来就进行继承语法上的学习使用与细节。
- C++中,基类中资源根据被访问限定符的不同与继承方式的不同,基类继承到派生类的资源是各有不同的。
- 类中的访问限定符:
public(公有)
,protected(保护)
,private(私有)
,不同的访问限定符也表示着类中资源可以被访问权限的不同。- 类的继承方法:
public(公有继承)
,protected(保护继承)
,private(私有继承)
定义方式:
[子类] : [继承方式] [父类]
示例:
class A
{
public:
int _a;
};
class B : public A
{
public:
int _b;
};
继承方式:
类成员\继承方法 | public继承 | protected继承 | private继承 |
---|---|---|---|
父类public成员 | public成员 | protected成员 | private成员 |
父类protected成员 | protected成员 | protected成员 | private成员 |
父类private成员 | 不可见 | 不可见 | 不可见 |
- <1>父类成员中的私有成员继承时,会被继承只是在子类中不可用。
<2> 继承方式与成员的访问限定符搭配,取权限较小的那一个。
<3> public可以被继承,类内类外都可以访问使用;protect可以被继承,只有类内可以访问使用;private不可见,只有类内可以使用
<4> 不难看出protect访问限定符是专门因为继承而出现的。- 继承方式中使用最多的是public继承与protect继承,private继承几乎不会使用。
2. 基类与派生类的赋值兼容转换
- 在变量赋值比较等操作中,当使用不同类型的变量赋值时,因为类型不同无法直接进行赋值,所以编译器都会通过类型转换(类型提升/截断)生成一份对应类型的中间变量(具有常性,无法引用),再进行赋值。
int main()
{
char i = 'a';
int j = 97;
if (i == j)
{
cout << "is equal" << endl;
}
return 0;
}
- 自定义类型的变量若拥有单参数的构造函数,那么其也支持类型转换。
- 父类对象与子类对象也属于不同的自定义,而它们之间支持一种特殊的 “赋值方式”,子类对象可以赋值给父类对象,我们称之为赋值的向上兼容。
- 这种赋值方式不同于通常情况下不同类型之间的赋值方式,其不会产生中间变量,而是将子类对象中属于父类的那一部分切片切割出来,然后将这一部分的内容直接赋值给父类对象。
- 因为不产生中间变量,所以可以直接使用引用的方式赋值父类对象,这样若对父类对象的成员做改变,同样也会影响子类对象。
int main()
{
B bb;
bb._a = 1;
bb._b = 2;
A& aa = bb;
aa._a = 10;
cout << bb._a << endl;
//输出:10
return 0;
}
3. 继承中的作用域
1. 子类中的父类成员
- 通过继承父类产生的子类对象,我们没有定义为其定义父类中的成员,但其默认拥有着父类的成员。那么,在这些成员是否在子类中存储定义,与子类自身定义的成员存在不同吗?
- 继承来的父类对象同样存储在子类对象的存储空间中,只是这些继承来的对象与子类对象属于不同的作用域,这也就意味着在子类对象中我们可以定义与父类对象同名的变量与方法等。
- 当子类中存在有与父类同名的成员时,我们对其进行调用会优先调用子类中的成员,想要对父类中的同名成员进行调用需要指定类域
'[子类对象].[父类类域][同名成员]
- 以上这种父类与子类中存在同名成员的情况,我们称之为这两个同名成员构成隐藏关系。
- 对于成员函数来说,父类与子类中只要函数名相同就构成隐藏,也就是说参数与返回值可以不同。
class A
{
public:
int _a;
void func()
{
cout << "hello world" << endl;
}
};
class B : public A
{
public:
//同名成员变量
int _a;
int _b;
//同名成员函数
int func(int num = 1)
{
return num * 10;
}
};
int main()
{
B bb;
bb._a = 10;
bb.A::_a = 20;
cout << bb._a << " " << bb.A::_a << endl;
return 0;
}
4. 派生类中的默认成员函数
- 既然谈论到类,那么我们就不得不提及类与对象中重要的组成部分,默认成员函数。我们知道,子类中的父类对象是继承来的,与子类自己定义的对象处在不同的作用域中,那么,子类的默认成员函数会如何处理它们呢。
1. 构造函数
- 子类对象无法初始化父类的成员。
- 子类对象的构造函数与父类的构造函数不是同一个,子类构造函数初始化子类的成员,父类构造函数初始化父类的成员。
- 子类对象的构造时,会首先自动调用父类的构造函数构造父类成员,而后再调用子类的构造函数。
- 当我们想要使用指定的值初始化父类的成员时,默认调用父类构造函数的方式就不可用了,我们需要进行显示调用,调用方式如下:
class Person
{
public:
const char* _name;
int _age;
Person(const char* name, int age)
{
_name = name;
_age = age;
}
};
class Student : public Person
{
public:
int _id;
//在初始化列表调用父类的构造
Student(const char* name, int age, int id)
:Person(name, age)
,_id(id)
{}
};
- 子类的构造方式为,先父后子,即先调用父类的构造函数再调用子类的构造函数,初始化列表是按照声明顺序调用对应构造与进行初始化的,父类必然声明定义于子类之前。
2. 拷贝构造
- 拷贝构造与构造的调用机制相同,父类成员调用父类的拷贝构造,子类成员调用子类的。
- 在调用父类的拷贝构造时,我们同时也要传递父类对象的参数,可是我们进行拷贝构造时,传递的参数只有子类对象。
- 我们可以直接调用父类拷贝构造,并传递子类的对象参数,其会自动发生赋值兼容转换。
class Person
{
public:
const char* _name;
int _age;
Person(const Person& p)
{
_name = p._name;
_age = p._age;
}
};
class Student : public Person
{
public:
int _id;
//类型切片
Student(const Student& st)
:Person(st)
,_id(st._id)
{}
};
3. operator=运算符重载
- 赋值运算的重载父类也与子类分离,需要注意的是,二者的赋值运算符构造隐藏,调用父类赋值运算符时需要指定类域。
class Person
{
public:
const char* _name;
int _age;
Person& operator=(const Person& p)
{
if (this != &p)
{
_name = p._name;
_age = p._age;
}
return *this;
}
};
class Student : public Person
{
public:
int _id;
Student& operator=(const Student& st)
{
if (this != &st)
{
Person::operator=(st);
_id = st._id;
}
return *this;
}
};
4. 析构
- 析构函数的调用方式与其他默认函数不同,因为子类可以访问从父类继承而来的成员,当我们进行父类成员的析构进行了资源清理之后,可能会在度访问父类成员,所以,默认上不允许我们主动调用父类的析构函数。
- 编译器会默认在调用完子类的析构函数之后,紧接的调用一次父类的析构函数。
- 由于多态的原因,父类与子类的析构函数都会被特殊处理为destructor的同名函数,构成隐藏,因此,显示调用时要指明类域。
class Person
{
public:
const char* _name;
int _age;
~Person()
{
cout << "~Person()" << endl;
}
};
class Student : public Person
{
public:
int _id;
~Student()
{
cout << "~Student()" << endl;
}
};
int main()
{
Student stu1("张三", 22, 1);
return 0;
}
5. 友元与静态成员
- 父类的友元并不是子类友元,友元关系不能被继承
- 父类的静态成员也是子类的静态成员,但子类并不会创建一个属于自身类的新的静态成员,而是调用父类的静态成员资源,拥有访问权与使用权
class A
{
public:
int _a;
static int count;
}
int A::count = 0;
class B : public A
{
public:
int _b;
}
int main()
{
cout << &A::count << endl;
cout << &B::count << endl;
return 0;
}
6. 单继承,多继承与菱形继承
- 单继承:当前类的直接父类只有一个
- 多继承:当前类的直接父类有多个
//多继承语法
[子类] : [继承方法][父类1],[继承方法][父类2]
class C : public A, public B
{}
- 菱形继承:多继承的前提下,多个父类都继承于同一个类
- 菱形继承不可避免地会存在重复于不必要的成员数据,会有数据冗余于二义性,虽然可以通过指定不同父类类域的方式调用不同父类中的同名资源,但这是空间上的浪费,也会导致结构上的不必要复杂。因此,在我们日常的使用中一定要避免出现菱形继承。
补充:虚继承与菱形继承的内部结构
- 当我们定义出现菱形继承时,可以通过virtual关键字优化掉子类中的重复数据成员部分,称之为虚继承,使用方式如下:
class A
{
public:
int _a;
};
class B : public virtual A
{
public:
int _b;
};
class C : public virtual A
{
public:
int _c;
};
class D : public B, public C
{
public:
int _d;
};
- 优化前存储方式(未使用virtual),按照声明顺序自上至下存储,同时依照内存对齐的规则。
- 优化后存储方式(使用virtual),对应存储重复父类对象的位置存储一个指针,指针指向偏移表,根据编译表中的偏移量再加上当前地址,就可以得到数据的存储地址。
- 此种存储方式使得我们进行切片操作时,无论怎么切片都可以正常获得被特殊处理的重复变量。
7. 组合与继承
- 继承:类与类之间的关系为父子,子类拥有着从父类继承而来的资源,类之间耦合度高。
- 组合:一个类作为另一个类的成员变量,外部类想要访问包含类的资源只能通过其进行访问,类之间耦合度低。
- 继承是一种白箱复用,即复用的资源是可见的,可以直接修改访问的;组合是一种黑箱复用,我们并不能直接获知其内部的资源,访问与修改也只能通过包含对应类提供对外接口实现。
- 在编程的过程中我们应该尽可能地追求代码的低耦合性,只有这样代码才能有良好的可维护性(迭代更新,修复bug)。
- 继承与组合的区别除开定义与使用方式的不同外,在根本上还有逻辑的区别:
<1> 继承(is-a):一个类在另一个类的基础上添加了额外的补充属性(Person - Student)
<2> 组合(has-a):一个类是另一个类的一部分(汽车 - 轮胎)
<3> 补充:在能使用组合的情况下,尽可能使用组合(stack - vector/deque/list)