目录
前言
本篇文章主要讲解C++的继承。继承是C++的三大特性之一,重要程度不必多说。本文配有代码示例进行细致讲解,内容丰富。
1. 继承的概念与定义
1.1 继承的概念
继承的概念:继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用。
1.2 继承的定义
1.2.1 定义格式
继承定义的格式如下,在派生类后面加上冒号,再添加继承方式和基类
下面是继承的代码,学生类和教师类都是派生类,人类是基类,派生类继承基类。
class Person
{
public:
void Print()
{
cout << "name:" << _name << endl;
cout << "age:" << _age << endl;
}
protected:
string _name = "peter"; // 姓名
int _age = 18; //年龄
};
class Student : public Person
{
protected:
int _stuid = 21; // 学号
};
class Teacher : public Person
{
protected:
int _jobid = 54; // 工号
};
int main()
{
Student s;
Teacher t;
s.Print();
t.Print();
return 0;
}
打开监视窗口,我们可以看到派生类中有基类的部分。
- 派生类继承基类的成员变量不是同一份,类里面的数据都是独立的。
- 派生类继承基类的普通成员函数使用一份的,因为普通函数不是存放在对象里面,是公用的。
- 派生类和基类的构造函数是独立的。
1.2.2 继承关系和访问限定符
下面的表格表明使用不同继承方式,在派生类会中变成什么样类型的访问。可以遵守下面几条规律
public继承 | protected继承 | private继承 | |
基类的public成员 | 派生类public成员 | 派生类protected成员 | 派生类private成员 |
基类的protected成员 | 派生类protected成员 | 派生类protected成员 | 派生类private成员 |
基类的private成员 | 派生类中不可见 | 派生类中不可见 | 派生类中不可见 |
- private成员访问限制最大,既类外无法访问,且派生类也不可以访问。因此,基类的private成员不管以什么方式继承,在派生类中都是不可见,即无法访问。
class Student : public Person
{
public:
void Func()
{
cout << _tele << endl;
}
protected:
int _stuid = 21; // 学号
};
class Teacher : public Person
{
protected:
int _jobid = 54; // 工号
};
下面是详细的报错情况。
- 基类的protected成员,类外不可以访问,但是子类中可以访问并使用。
- 除了基类中的private成员,基类的其他成员在子类的访问方式等于其中访问权限最小的,访问权限:public > protected > private。
- 使用关键字class时默认的继承方式是private,使用struct时默认继承方式是public,不过最好显示的写出继承方式。
- 在实际运用中一般都是使用public继承,几乎很少使用protected和private继承,也不提倡使用protected和private继承,因为protected和private继承下来的成员都只能在子类的类里面使用,实际中扩展维护性不强。
下面的代码中,A是父类,B是子类。正常来说A中的public成员变量可以在类外访问,但是B通过protected方式继承,使得父类中的public成员变量在子类变成protected级别的限定,所以b修改_public_member的值会报错。
class A
{
public:
int _public_member= 10;
protected:
int _protected_member = 20;
};
class B : protected A
{
public:
void Func()
{
cout << _protected_member << endl;
}
protected:
int _x = 30;
};
int main()
{
A a;
a._public_member = 100;
B b;
b.Func(); //间接访问
b._public_member = 0; //无法访问
return 0;
}
2. 子类和父类对象赋值转换
- 派生类对象可以赋值给基类的对象、基类的指针和基类的引用。这种jia也叫做切片或者切割,寓意吧派生类中的父类那部分切来赋值过去。
- 其中隐式类型转换中,会产生临时变量,临时变量具有常性,引用的时候需要在前加上const。而子类和父类对象赋值转换没有产生临时对象,是一种特殊的转换。
- 而基类的指针指向的是子类中的父类部分,基类的引用是子类中父类部分的别名。
- 基类对象不能赋值给派生类的对象。
- 基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用。但是必须是基类的指针是指向派生类对象时才是安全的。因为基类指针指向基类对象时,如果你要访问子类的成员变量,会超出基类的内存空间,访问到未知区域时就越界了。
int main()
{
Student s;
// 1.子类对象可以赋值给父类对象/指针/引用
Person p = s;
Person* ptr = &s;
Person& ref = s;
//比较一下父类指针/引用,与子类对象的地址
cout << &s << endl;
cout << ptr << endl;
cout << &ref << endl;
// 2.基类对象不能赋值给派生类对象
s = p; //error
// 3.基类的指针可以通过强制类型转换赋值给派生类的指针
Person* pp = &s;
Student* ps1 = (Student*)pp; //这种情况可以转换,因为原来指向的是子类
ps1->_stuid = 10;
//这种情况不可以转换,因为指向的父类对象,没有子类的内容,访问时会越界
pp = &p;
Student* ps2 = (Student*)pp;
ps2->_stuid = 10;
return 0;
}
当运行第1类代码,子类对象赋值给父类对象/指针/引用。运行结果如下,三个地址都是相同的,说明父类的内容一般放在子类内存空间一开始的位置。
当运行第2类代码时,运行结果如下。编译未通过,不支持这样的操作。
当运行第三类代码的第二种情况,直接运行中断报错,越界访问。
3. 继承中的作用域
我们现在学到的作用域有局部域,全局域,命名空间域和类域。其中只有局部域和全局域会影响生命周期。当你要使用一个变量时,编译器先在当前局部域查找,再到全局域查找。如果指定类域或者命名空间域,会优先到指定作用域查找,不会再到全局域查找。
- 在继承体系中基类和派生类都有独立的作用域,相当于一种“局部域”。子类的成员函数,使用变量时,会现在当前类域中查找变量,再到父类域查找变量,如果还是没有定义,就去全局域查找变量。此时,没有找到该变量的定义,就会报错。在类外使用成员函数,也是类似的查找方式。
- 子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义。如果不想被隐藏可以使用类域访问限定符::,指定在父类中访问。
int _x = 10;
class Person
{
protected:
string _name = "小李子"; // 姓名
int _num = 111; //身份证号
};
class Student : public Person
{
public:
void Print()
{
cout << " 全局变量:" << _x << endl; //全局域中的变量
cout << " 姓名:" << _name << endl; //父类域
cout << " 身份证号:" << Person::_num << endl;//指定父类域
cout << " 学号:" << _num << endl; //构成隐藏
}
protected:
int _num = 999; // 学号
};
int main()
{
Student s;
s.Print();
return 0;
}
运行结果如下:
- 成员函数构成隐藏的条件是只要函数名相同就构成隐藏,在实际中最好不要定义同名成员。
class A
{
public:
void fun()
{
cout << "func()" << endl;
}
};
class B : public A
{
public:
void fun(int i)
{
//A::fun();
cout << "func(int i)->" << i << endl;
}
};
void Test1()
{
B b;
b.fun(10);
}
void Test2()
{
B b;
b.fun();
}
int main()
{
Test1();
Test2();
return 0;
}
上面的代码中fun构成重载,还是隐藏?答案是构成隐藏。如果要构成重载,需要在同一个作用域下,同名函数不用参数类型。但是这两个函数分别在不同的类域下,只要是同名函数就构成隐藏。运行Test1函数,运行结果如下。
如果B类的fun函数中的第一行注释A::fun()放出来,再次运行,结果如下。说明如果指定类域就可以不受到隐藏的限制,调用父类同名函数。
那么运行Test2函数会怎么样呢,是运行父类fun函数或者子类fun函数,还是编译报错或者运行报错?结果如下,是编译报错。说明还是构成隐藏。
4. 派生类的默认成员函数
我们以Person为基类,Student为派生类。其中Person类中的构造函数,拷贝构造函数,赋值重载函数和析构函数已经实现,之后会在Student类中逐一实现。
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:
//...
protected:
int _num = 1;
string _addr = "长城";
};
4.1 构造函数
- 如果子类的构造函数不实现,编译器对生成默认构造函数。我们要把子类的内容分成三个部分,分别是父类成员,子类的内置成员和子类的自定义成员。
- 其中父类成员要看做一个整体,会调用父类的默认构造函数。子类的内置成员一般不处理,有些编译器会初始化。子类的自定义成员,会调用自己的默认构造函数。
void test1()
{
Student s;
}
运行结果如下,从中可以看出会自动调用父类的构造和析构函数。
如果要实现一个子类构造函数,并且还要初始化继承父类的成员变量,不能直接在初始化列表或函数体中进行初始化,只能在初始化列表中调用父类的有参构造函数。
class Student : public Person
{
public:
//不可以初始化父类的成员变量,这是不允许的
Student(const char* name, int num, const char* addr)
: _name(name)
, _num(num)
,_addr(addr)
{
cout << "Student()" << endl;
}
//只能在初始化列表中调用父类的构造函数
Student(const char* name = "", int num = 10, const char* addr = "")
:Person(name)
,_num(num)
,_addr(addr)
{
cout << "Student()" << endl;
}
protected:
int _num = 1;
string _addr = "";
};
void test2()
{
Student s("张三", 10, "北京");
}
使用正确的子类构造函数,运行test2函数。发现先调用父类构造,再调用子类构造。
- 派生类对象初始化先调用基类构造再调派生类构造。
如下面的代码,如果子类中不实现构造函数,那么运行起来会编译不通过,因为父类实现了有参构造,编译器就不会生成默认构造函数。
- 如果父类中没有默认构造函数,则必须在子类构造函数的初始化列表阶段显示调用。
class Person
{
public:
Person(const char* name)
: _name(name)
{
cout << "Person()" << endl;
}
//...
protected:
string _name; // 姓名
};
class Student : public Person
{
public:
Student(const char* name = "", int num, const char* addr = "")
:Person(name)
,_num(num)
,_addr(addr)
{
cout << "Student()" << endl;
}
protected:
int _num = 1;
string _addr = "";
};
4.2 析构函数
- 把子类的内容分成三个部分,分别是父类成员,子类的内置成员和子类的自定义成员。其中父类成员要看做一个整体。一般情况下,编译器会生成默认析构函数。父类成员会调用父类的默认构造函数。子类的内置成员不处理。子类的自定义成员,会调用自己的默认析构函数来清理资源。
如下面代码,实现子类析构函数。如果使用第一个析构函数,直接显示调用父类析构,会报错。
- 由于多态的原因,析构函数的名字会被统一处理为destructor(),此时父类和子类的析构函数构成隐藏。如果要调用父类析构函数,需要指定类域。
class Student : public Person
{
public:
~Student()
{
~Person();
cout << "~Student()" << endl;
}
~Student()
{
Person::~Person();
cout << "~Student()" << endl;
}
protected:
int _num = 1;
string _addr = "";
};
void test3()
{
Student s1;
}
运行结果如下,你会发现父类的析构函数执行了两次,这是为什么呢?
- 因为为了达到先析构子类对象再析构父类对象,在调用完子类析构后,编译器会自动调用父类析构。所以子类析构函数中不能显示调用父类析构函数。
4.3 拷贝构造函数
- 把子类的内容分成三个部分,分别是父类成员,子类的内置成员和子类的自定义成员。其中父类成员要看做一个整体。
- 一般情况下,编译器生成的拷贝构造。父类成员会调用父类的拷贝构造。子类的内置成员进行值拷贝(浅拷贝)。子类的自定义成员,会调用自己的拷贝构造函数。
- 如果子类中有指针类型成员,需要进行深拷贝,自己写拷贝构造函数。其中父类的成员必须调用父类的拷贝构造。
- 子类参数列表调用父类的拷贝构造函数时,可以直接传子类对象。这是我们所说的子类和父类对象的赋值转换,此时就派上用场了。
class Student : public Person
{
public:
Student(const Student& st)
:Person(st) //子类和父类的赋值兼容转换
,_num(st._num)
,_addr(st._addr)
{
cout << "Student(const Student& st)" << endl;
}
protected:
int _num = 1;
string _addr = "";
};
void test4()
{
Student s1("张三", 10, "北京");
Student s2(s1);
}
运行结果如下:
4.4 赋值重载函数
- 赋值重载函数跟拷贝函数类似。需要注意的是,如果需要调用父类赋值重载函数,必须指定类域。因为父类和子类的赋值重载函数构成隐藏,会无限递归调用下去,导致栈溢出。
class Student : public Person
{
public:
Student& operator=(const Student& st)
{
cout<<"Student& operator= (const Student& s)"<< endl;
if (this != &st)
{
Person::operator=(st);
_num = st._num;
_addr = st._addr;
}
return *this;
}
protected:
int _num = 1;
string _addr = "";
};
void test5()
{
Student s1("张三", 10, "北京");
Student s2;
s2 = s1;
}
运行结果如下:
总结
C++的继承内容讲解了一大半,其他内容会在下篇继承文章中继续讲解。如果熟练掌握其中的要点,还需要上手敲代码并调试,才会有更深的理解。
创作不易,希望这篇文章能给你带来启发和帮助,如果喜欢这篇文章,请留下你的三连,你的支持的我最大的动力!!!