当前位置:首页 » 《随便一记》 » 正文

【C++】C++中的继承

17 人参与  2024年03月05日 08:51  分类 : 《随便一记》  评论

点击全文阅读


目录

介绍:

一,继承的访问权限

二,基类和派生类对象赋值转换

三,继承中的作用域

四,派生类的默认成员函数

1,构造函数

2,析构函数

3,拷贝构造和赋值运算符

五,继承中的友元与静态成员

1,继承与友元

2,继承与静态成员

六,复杂的菱形继承及菱形虚拟继承

七,继承与组合


介绍:

        继承机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用。

        这里被复用的类叫做基类或父类,复用后产生新的类叫做派生类或子类。

普通继承:

class Person
{
public:
    void Print()
    {
        cout << "name:" << _name << endl;
        cout << "age:" << _age << endl;
    }
protected:
    string _name = "peter"; 
    int _age = 18;  
};
class Student : public Person //子类Student继承父类Person的成员(即成员函数+成员变量),继承后都会变成子类的一部分,通过调试窗口即可见
{
protected:
    int _stuid; 
};
class Teacher : public Person  //同理,继承父类Person
{
protected:
    int _jobid; 
};
int main()
{
    Student s;  
    Teacher t;
    //由于继承后将会成为子类的一部分,这里可看作子类中的成员
    s.Print(); 
    t.Print();
    cout << sizeof(Student) << endl << sizeof(Teacher) << endl; //发现占用的内存变大,因为继承,子类中包含父类,内存变大
    return 0;
}

模板继承:

//模板继承
template <class T>
class B : public A<T>
{
    .......
};

        以上继承中,Person是父类(基类)。Student和Teacher是子类(派生类)。我们以Student为例,如下:


一,继承的访问权限

        下面,我们谈谈这里的继承方式中的访问限定符和访问权限的问题。先来观察基类访问权限与派生类的关系。

        当基类的访问权限为private类型时,除非在自己类中运用自己的成员函数可进行访问,派生类无论怎样都访问不了。

        当基类的访问权限为protected类型时,在派生类中可进行访问,但在类外不可进行访问。也就是说在类外时如同private权限。        

        当基类的访问权限为public类型时,无论在派生类中,还是在类外,都允许直接访问。

        继承方式中的权限限制的就是基类中的权限。基类的私有成员在子类都是不可见。基类的其他成员在子类的访问方式 == Min(成员在基类的访问限定符与继承方式),其中public > protected > private。即如下:

        当继承方式是public时,基类的private是private,public是public,protected是protected。

        当继承方式是protected,基类的private是private,public是protected,protected是protected

        当继承方式是private时,继承基类的所有权限都为private。

#include <iostream>
using namespace std;
class Person
{
public:
    void Print()
    {
        cout << "name:" << _name << endl;
        cout << "age:" << _age << endl;
    }
protected:
    string _name = "peter"; 
    int _age = 18;  
};
class Student : public Person
{
public:
    void func() //继承方式是public权限,访问时与基类Person中默认取最小权限 
    {
        cout << _name << endl;
        cout << _age << endl;
        Print();
    }
protected:
    int _stuid; 
};
int main()
{
    Student s;
    s.func();  //没有私有权限,都可被派生类访问
    return 0;
}

        需说明一点,于class而言,若继承方式的权限不写,默认为私有private;于struct而言,若继承方式的权限不写,默认为公有public,跟默认成员权限一样。

        补:在实际运用中一般使用都是public继承,几乎很少使用protetced/private继承,也不提倡使用protetced/private继承,因为protetced/private继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强。


二,基类和派生类对象赋值转换

        派生类对象可以赋值给基类的对象 / 基类的指针 / 基类的引用,但基类对象不能赋值给派生类对象。因为子类对象(派生类)中都有父类(基类)成员,所以,子类对象可以赋值给父类对象,但是子类的成员父类不一定拥有,所以父类不能赋值给子类。

#include <iostream>
using namespace std;
class Person  //父类(基类)
{
public:
    void Print()
    {
        cout << "name:" << _name << endl;
        cout << "age:" << _age << endl;
    }
protected:
    string _name = "peter"; 
    int _age = 18;  
};
class Student : public Person  //子类(派生类)
{
public:
    void func() 
    {
        cout << _name << endl;
        cout << _age << endl;
        Print();
    }
protected:
    int _stuid; 
};
int main()
{
    Student s;
    Person p = s;    //子类对象s赋值父类的对象p
    Person* pp = &s; //子类s的地址赋值给父类的指针pp
    Person& rp = s;  //子类对象s赋值给父类的引用rp
    
    //s = p; //报错,父类对象不能赋值给子类对象
    return 0;
}

        注意,这里重点说明一下,不同类型赋值会产生临时变量,赋予的数据也是临时变量,但这里的子类赋值于父类是不会产生临时变量。这里的子类对象赋值给父类对象如同切割一样,将子类中所包含父类的成员切割下来给父类对象,派生类部分被丢弃。如下图:

        这里要说明一下,当基类指针或引用指向派生类时,实际上指向的是派生类中基类对象,即基类指针或引用可以用来访问派生类对象中基类的成员。然而,基类指针不能用来直接访问派生类中新增的成员(即基类中没有的成员),也就是说此时访问的数据都是基类的数据。(当通过基类指针调用虚函数时,情况会有所不同,这里先不讨论多态的知识)我们看以下代码:

#include <iostream>
using namespace std;
class Base1 { 
public: 
    int _b1; 
};
class Base2 { 
public: 
    int _b2; 
};
class Derive : public Base1, public Base2
{
public:
    int _d;
};
int main() {
    Derive d;
    Base1* p1 = &d;
    Base2* p2 = &d;
    Derive* p3 = &d;
    //以下输出中p1 == p3 != p2
    //因为先继承Base1,所以在子类赋予父类进行切割时,会从开始位置往后切割,即p1和p3指向开头位置是一样的
    //Base2后被继承,所以在进行切割时,会从里面切割。导致地址与p1和p3不同
    cout << "p1: " << p1 << endl;
    cout << "p2: " << p2 << endl;
    cout << "p3: " << p3 << endl;
    return 0;
}

        父‘类(基类)向子类(派生类)的转换在一定条件下也可进行,但这方面设计到一定的东西,这里先不做过多研究,后文会详细说明。


三,继承中的作用域

        1. 在继承体系中基类和派生类都有独立的作用域。

        2. 由于子类和父类都有独立的作用域,所以子类和父类中可以有同名成员。当在子类中访问同名成员时,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义。若想访问父类中的同名成员时,在子类成员函数中使用 基类::基类成员 显示访问基类成员(成员:成员函数和普通成员)。 

        3. 需要注意的是隐藏关系是对于继承关系中子类与父类中同名成员的关系,只要重名即构成隐藏。如果是成员函数的隐藏,只需要函数名相同就构成隐藏,返回值和参数可以不同。隐藏的本质是子类隐藏父类的同名成员。

        4. 注意在实际中在继承体系里面最好不要定义同名的成员。

//A和B的fun函数是隐藏关系
//注意: 这里的fun不是重载关系,重载关系是在同一作用域下的关系,这里的基类与派生类是两个不同的作用域

//对于基类和派生类而言,只要满足成员名相同即构成隐藏关系,所以,成员如果是函数,函数名相同就构成隐藏关系
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;
    }
};

int main()
{
    B b;
    b.fun(1);
    //b.fun();//调用错误,因为父类的fun被隐藏了,这里调用的是派生类中的fun
    b.A::fun(); //调用基类A的fun,需指定作用域
    return 0;
}


四,派生类的默认成员函数

        派生类一共存在6个默认构造函数,这里我们只需要了解四个即可,即构造函数、析构函数、拷贝构造函数、赋值运算符重载。在继承关系中,父类的这些函数都是通过子类进行调用的。

1,构造函数

        对于构造函数,由于构造函数的初始化列表是按照初始化对象的声明顺序进行初始化,而子类是先继承父类,所以这里先对父类初始化,即先调用父类的构造函数,然后再对子类进行初始化,即后调用子类的构造函数。如以下:

#include <iostream>
using namespace std;

class Person
{
public:
    Person(const char* name = "peter") //普通构造函数
        : _name(name)
    {   }
protected:
    string _name;
};
class Student : public Person
{
public:
    Student(const char* name, int num)
        //:_name(name) 错误,这里不允许这样对父类成员初始化,必须调用父类的构造函数对父类成员初始化
        : Person(name) //调用父类构造函数,若不写,这里会自动先调用父类的匹配构造函数,然后再调用子类的构造函数
        //这里要注意,若不显示调用父类的构造函数,当没有与之默认匹配的构造函数时将会报错
        , _num(num)
    {   }
protected:
    int _num;
};
int main()
{
    Student s("兔子", 1); 
    return 0;
}

        父类的构造函数可直接手动调用,也可让系统自动调用吗,但这里建议手动调用,因为当自动调用时,若不存在匹配的构造函数时将会出错。如下:

#include <iostream>
using namespace std;
class Person
{
public:
    Person(const char* name) //这里的构造函数默认情况下不能调用,因为不匹配
        : _name(name)
    {        }
protected:
    string _name;
};
class Student : public Person
{
public:
    Student(const char* name, int num)  //报错,因为不存在默认的构造函数
        : _num(num)
    {        }
protected:
    int _num;
};
int main()
{
    Student s("兔子", 1);
    return 0;
}

2,析构函数

        析构函数系统默认会先调用子类的析构函数,然后调用父类的析构函数,这样做是为了避免安全隐患。因为子类包含父类,若父类动态指向一块内存空间时,若先析构父类时,此空间已经被释放,子类析构时会再次析构此空间。

        众所周知,构造函数不能手动调用,但是析构函数可以,所以说这里我们可强行先调用父类的析构函数。但是要注意的是,子类的析构函数与父类的析构函数构成隐藏关系(由于多态的原因,析构函数被特殊处理),不能在子类的析构函数中直接显示调用。这里跟隐藏关系调用的逻辑一样,需指名作用域。

        这里不建议手动调用析构函数,这里的规则跟析构函数的规则一样,系统会自动调用,也就是说手动析构完之后系统也会再次调用。

#include <iostream>
using namespace std;
class Person
{
public:
    Person(const char* name = "peter") 
        : _name(name)
    {
        cout << "Person()" << endl;
    }
    ~Person()
    {
        cout << "~Person()" << endl;
    }
protected:
    string _name;
};
class Student : public Person
{
public:
    Student(const char* name, int num)
        : Person(name)
        , _num(num)
    {
        cout << "Student()" << endl;
    }
    ~Student() 
    {
        Person::~Person();//若不显示调用父类的析构函数,会默认先调用本类(子类)的析构函数,然后再调用父类Person的析构函数
        cout << "~Student()" << endl; 
    }
protected:
    int _num;
};
int main()
{
    Student s("兔子", 1); 
    return 0;//发现系统结束时输出了两次父类析构函数中的内容
}

3,拷贝构造和赋值运算符

        调用父类的赋值运算符时需注意指定作用域,而拷贝构造是将子类对象拷贝构造给父类,如同子类赋值父类般,进行切割。

#include <iostream>
using namespace std;
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;
    }
protected:
    string _name; 
};
class Student : public Person
{
public:
    Student(const char* name, int num)
        : Person(name)
        , _num(num)
    {
        cout << "Student()" << endl;
    }

    Student(const Student& s)
        : Person(s) //这里的拷贝构造也是将子类所包含父类的成员切割给父类,如同将子类对象赋值给父类对象般。赋值运算符同理
        , _num(s._num)
    {
        cout << "Student(const Student& s)" << endl;
    }

    Student& operator = (const Student& s)
    {
        cout << "Student& operator= (const Student& s)" << endl;
        if (this != &s)
        {
            Person::operator=(s); //注意这里要指定作用域,因为是隐藏关系
            _num = s._num;
        }
        return *this;
    }
protected:
    int _num; 
};
int main()
{
    Student s("兔子", 1);
    Student s1(s);
    s1 = s; 
    return 0;
}

总:这里的所有知识点可理解为跟单独的类一样,父类看作是子类的成员,父类的所有基本功能都要靠子类所对应的基本功能来实现。


五,继承中的友元与静态成员

1,继承与友元

        这里这里友元关系不能继承,即基类的友元不能被子类继承,也就是说基类友元不能访问子类私有和保护成员(当权限公有时有没有友元都一样,直接可被访问)。要想访问子类成员,这里必须也在子类中声明。

#include <iostream>
using namespace std;
class Student; //声明Student类,因为下面Display要使用
class Person
{
public:
    friend void Display(const Person& p, const Student& s);
    string _name = "张三";
};

class Student : public Person
{
    //friend void Display(const Person& p, const Student& s);  这里在子类中也声明友元,可正常运行
//public:  权限是公有,在类外可直接访问,不需要友元
protected: //权限为私有或保护,由于友元不能被继承,所以不可访问数据
    int _stuNum = 5; 
};
void Display(const Person& p, const Student& s)
{
    cout << p._name << endl;
    cout << s._stuNum << endl;  //权限公有运行正常。权限私有或保护不可访问,运行错误
}
int main()
{
    Person p;
    Student s;
    Display(p, s);
    return 0;
}

2,继承与静态成员

        静态成员可以被继承。静态成员存储在静态区,不在类中,也就说当基类存在静态成员时,子类不会将其直接继承下来复制一份,而是跟基类一样,直接调用静态区里的静态成员,即整个继承体系里面只有一个这样的静态成员。无论基类有多少个子类,都只有一个static成员实例。 

#include <iostream>
using namespace std;
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()
{
    Person p;
    Student s;
    cout << Person::_count << endl;  //输出2
    cout << Student::_count << endl;  //输出2
}


六,复杂的菱形继承及菱形虚拟继承

单继承:一个子类只有一个直接父类时称这个继承关系为单继承。以上所有的实例都为单继承。

多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承。

菱形继承:菱形继承是多继承的一种特殊情况。

        菱形继承存在很多问题,结构比较复杂。我们先来观察以下代码样例。

#include <iostream>
using namespace std;
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; 
};

int main()
{
    Assistant a;
    //a._name = "pter";//这样会有二义性无法明确知道访问的是哪一个
    //以下为正确写法,需跟一般继承一样,指名作用域
    //这样虽暂时解决了二义性,但没有解决本质问题。有时我们不需要存储两份,这样会造成空间的浪费,也就是数据冗余问题无法解决

    a.Student::_name = "张三";
    a.Person::_name = "李四";
    return 0;
}

        菱形继承的问题:从上面的对象成员模型构造可以看出,菱形继承有数据冗余和二义性的问题。 在Assistant的对象中Person成员会有两份。存储结构如下图:

        虚拟继承可以解决菱形继承的二义性和数据冗余的问题。如上面的继承关系,在Student和 Teacher的继承Person时使用虚拟继承即可解决问题。

        虚拟继承的使用方法是在继承前加上关键字virtual,此时所有加上关键字virtual的类将会特殊处理。此时可以理解它们所继承的父类共用一块空间。具体使用方法如下:

#include <iostream>
using namespace std;
class Person
{
public:
    string _name;
};

class Student : virtual public Person //增加关键字virtual,即虚继承
{
protected:
    int _num; 
};

class Teacher : virtual public Person //同理,增加虚继承
{
protected:
    int _id;
};

class Assistant : public Student, public Teacher //加上此位置后,与此类同名成员会跟以上情况一样,被特殊处理。
{
public:
    string _majorCourse; 
    string _name;
};

int main()
{
    Assistant a;
    a.Student::_name = "张三";  //在Student、Teacher类中,所继承的父类成员做了特殊处理,这里基类名称为_name的成员为"张三",即a.Person::_name = "张三"
    a.Person::_name = "李四";  //同理,都被处理成"李四"

    a._name = "小张"; //只有Assistant类的_name被处理成小张,因为此类没有增加虚继承
    //注意:若Assistant类里没有_name,这里直接a._name会默认基类中的成员

    return 0;
}

       下面我们通过内部来分别观察使用虚拟继承和不使用虚拟继承的情况。代码样例如下:

#include <iostream>
using namespace std;
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;
    return 0;
}

        下图是菱形继承的内存对象成员模型:这里可以看到数据冗余 。

        当我们使用虚拟存储时,内部结构如下:

        上图是菱形虚拟继承的内存对象成员模型:这里可以分析出D对象中将A放到的了对象组成的最下面,这个A同时属于B和C,那么B和C如何去找到公共的A呢?这里是通过了B和C的两个指针,指向的一张表。这两个指针叫虚基表指针,这两个表叫虚基表。虚基表中存的偏移量。虚基表指针本身的地址加上偏移量可以找到A的地址,进而找到A。注意,内存中按照十六进制存放偏移量。原理如下图:

        通过以上逻辑图,我们再观察以下代码有关虚继承的问题。 

#include <iostream>
using namespace std;
class A
{
public:
    int _a;
};

class B : virtual public A  
{
public:
    int _b;
};

class C : virtual public A  
{
public:
    int _c;
};

class D : public B, public C  //先继承B,后继承C,即B在存储前面,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;
    cout << d._a << endl;  //输出2

    D dd;  //调试后发现dd里面的虚基表指针指向的地址跟d一样,但虚基表指针本身的地址不一样,即A中的数据不一样
    cout << dd._a << endl;  //随机值

    B b;  //调试后发现b里面的虚基表指针指向的地址与d和dd都不一样
    B bb; //只与b里面的虚基表指针指向的地址一样,但虚基表指针本身的地址不一样,即A中的数据不一样
    
    C c; //调试后发现c里面的虚基表指针指向的地址跟b、d、dd都不一样
    C cc; //只与c里面的虚基表指针指向的地址一样,但虚基表指针本身的地址不一样,即A中的数据不一样
    return 0;
}

        有些人可能会有疑问,为什么D中B和C部分要去找属于自己的A,而不是直接指向A?不妨先想想,在虚继承中,若存在子类赋值给父类的情况,这里就出现很多复杂情况了,这里只需了解即可。在整个C++体系中,除了输入流istream和输出流ostream运用了虚拟继承,其它很少使用虚拟继承。

        最后,说一下,继承是C++中的缺陷之一。有了多继承,就存在菱形继承。菱形继承由于有数据冗余和二义性的问题,所以就有了菱形虚拟继承,到了这里底层实现就很复杂。所以一般不建议设计出多继承,若设计出多继承切记一定不要设计出菱形继承。否则在复杂度及性能上都有问题。


七,继承与组合

        继承和组合都是类的复用,不同的是,继承可以说每个派生类对象都是一个基类对象。而组合是一种包含关系,即假设B组合了A,每个B对象中都有一个A对象。

class A
{
public:
    int _a;
};
//组合
class B
{
private:
    A _a;
};

        通过以上实例可发现组合为低耦合(即两个模块关系不大),继承是高耦合(即两个模块关系大)。

        耦合性平常也叫做可维护性。若代码的可维护性高,在以后对代码的调整或更新的时候会很方便,比如在此项目上继续添加东西或迭代更新。若代码的可维护性低,这很不利于更新或填补bug。因此,我们需要低耦合,即可以用组合,就用组合。若实在不行才考虑继承。


点击全文阅读


本文链接:http://zhangshiyu.com/post/73887.html

<< 上一篇 下一篇 >>

  • 评论(0)
  • 赞助本站

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

关于我们 | 我要投稿 | 免责申明

Copyright © 2020-2022 ZhangShiYu.com Rights Reserved.豫ICP备2022013469号-1