C++ 继承:机制、特性与最佳实践全解析

时间:2024-12-10 08:16:09

???? 快来参与讨论????,点赞????、收藏⭐、分享????,共创活力社区。???? 


目录

一、继承的概念与定义

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

三、继承中的作用域

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

五、继承与友元

六、继承与静态成员

七、复杂的菱形继承及菱形虚拟继承

 八、继承的总结和反思


在 C++ 编程领域,继承是一项极为重要的特性,它为代码的复用和扩展提供了强大的支持,构建起了层次分明、逻辑严谨的类体系架构????。


一、继承的概念与定义

继承的核心思想在于,派生类能够获取基类的特性,并在此基础上进行拓展与创新✨。例如,我们定义一个基类(也称父类) Person

class Person {
public:
    void Print() {
        cout << "name:" << _name << endl;
        cout << "age:" << _age << endl;
    }
protected:
    string _name = "peter";  // 姓名
    int _age = 18;  // 年龄
};

从这个基类派生出 Student 类(也称子类)

class Student : public Person {
protected:
    int _stuid;  // 学号
};

通过这种继承关系,Student 类自动拥有了 Person 类的 _name 和 _age 成员变量以及 Print 函数,这就是继承带来的代码复用????。

继承的定义格式如下????:

 

在继承的定义中,涉及到继承关系和访问限定符。不同的访问限定符决定了基类成员在派生类中的可见性和访问权限。以 public 继承为例,基类的 public 成员在派生类中依然是 public,基类的 protected 成员在派生类中仍是 protected,而基类的 private 成员在派生类中不可见。比如: 

class Teacher : public Person {
protected:
    int _jobid;  // 工号
};

 在 Teacher 类中,Person 类的 public 成员 Print 可以在 Teacher 类的对象外部被调用(如果权限允许),protected 成员 _name 等只能在 Teacher 类及其派生类内部使用,private 成员则无法直接访问????‍。


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

基类与派生类对象之间存在特定的赋值转换规则。派生类对象可以赋值给基类的对象、指针或引用,这一过程形象地称为 “切片” 或 “切割”????。例如:

class Person {
protected:
    string _name;  // 姓名
    string _sex;  // 性别
    int _age;  // 年龄
};

class Student : public Person {
public:
    int _No;  // 学号
};

void Test() {
    Student sobj;
    // 1. 子类对象可以赋值给父类对象/指针/引用
    Person pobj = sobj;
    Person* pp = &sobj;
    Person& rp = sobj;

    // 2. 基类对象不能赋值给派生类对象
    // sobj = pobj;  // 错误,会导致编译错误

    // 3. 基类的指针可以通过强制类型转换赋值给派生类的指针,但需谨慎
    pp = &sobj;
    Student* ps1 = (Student*)pp;  // 这种情况转换时可以的,因为 pp 原本就指向 Student 对象
    ps1->_No = 10;

    pp = &pobj;
    Student* ps2 = (Student*)pp;  // 这种情况转换时虽然可以,但会存在越界访问的问题,因为 pp 指向的是 Person 对象,不是 Student 对象
    ps2->_No = 10;  // 可能会引发运行时错误
}

上述代码展示了这些赋值转换的具体情况。当进行切片操作时,派生类对象中属于基类的部分被提取出来赋值给基类对象、指针或引用,这是安全且符合逻辑的????。然而,将基类对象赋值给派生类对象是不被允许的,因为基类对象可能缺少派生类特有的成员。而对于基类指针到派生类指针的强制转换,只有当基类指针原本指向的是派生类对象时才是安全的,否则可能导致越界访问等严重问题????。


三、继承中的作用域

在继承体系中,基类和派生类各自拥有独立的作用域。当子类和父类存在同名成员时,子类成员会屏蔽父类对同名成员的直接访问,这种现象称为隐藏或重定义????。例如:

class Person {
protected:
    string _name = "小李子";  // 姓名
    int _num = 111;  // 身份证号
};

class Student : public Person {
public:
    void Print() {
        cout << "姓名:" << _name << endl;
        cout << "身份证号:" << Person::_num << endl;  // 通过基类名限定访问基类的同名成员
        cout << "学号:" << _num << endl;
    }
protected:
    int _num = 999;  // 学号,与 Person 类中的 _num 同名
};

void Test() {
    Student s1;
    s1.Print();
}

 Student 类的 Print 函数中,如果直接访问 _num,则访问的是 Student 类自身的 _num(学号)。若要访问基类 Person  _num(身份证号),需要使用 Person::_num 的方式进行显示访问????。

此外,对于成员函数的隐藏,只要函数名相同就会构成隐藏,即使函数参数列表不同。例如:

class A {
public:
    void fun() {
        cout << "func()" << endl;
    }
};

class B : public A {
public:
    void fun(int i) {
        A::fun();  // 调用基类的 fun 函数
        cout << "func(int i)->" << i << endl;
    }
};

void Test() {
    B b;
    b.fun(10);
}

 在 B 类中定义了 fun(int i) 函数,它隐藏了基类 A 中的 fun 函数。在 B 类的 fun(int i) 函数中,可以通过 A::fun() 来调用基类的 fun 函数????。

 


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

 

派生类的默认成员函数与基类密切相关。派生类的构造函数必须调用基类的构造函数来初始化基类部分的成员。如果基类没有默认构造函数,则必须在派生类构造函数的初始化列表中显式调用????。例如:

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:
    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;
    }

    ~Student() {
        cout << "~Student()" << endl;
    }
protected:
    int _num;  // 学号
};

void Test() {
    Student s1("jack", 18);
    Student s2(s1);
    Student s3("rose", 17);
    s1 = s3;
}

在上述代码中,Student 类的构造函数、拷贝构造函数和赋值运算符函数都正确地调用了基类对应的函数来处理基类成员的初始化和赋值操作????。并且,派生类的析构函数会在执行完自身的清理工作后自动调用基类的析构函数,以确保派生类对象先清理派生类成员,再清理基类成员,保证资源的正确释放????。

由于析构函数在某些场景下可能需要构成重写(这将在后续多态部分详细讲解),编译器会对析构函数名进行特殊处理,将其处理成 destrutor()。所以在父类析构函数未加 virtual 的情况下,子类析构函数和父类析构函数构成隐藏关系????。


五、继承与友元

友元关系在继承体系中具有独特的性质,即友元关系不能继承。这意味着基类的友元函数不能访问派生类的私有和保护成员????。例如:

class Student;

class Person {
public:
    friend void Display(const Person& p, const Student& s);
protected:
    string _name;  // 姓名
};

class Student : public Person {
protected:
    int _stuNum;  // 学号
};

void Display(const Person& p, const Student& s) {
    cout << p._name << endl;
    // cout << s._stuNum << endl;  // 错误,友元函数不能访问派生类的私有成员
}

void main() {
    Person p;
    Student s;
    Display(p, s);
}

 在上述代码中,Display 函数是 Person 类的友元函数,它可以访问 Person 类的私有成员 _name,但不能访问 Student 类的私有成员 _stuNum,即使 Student 类继承自 Person 类????‍。


六、继承与静态成员

当基类定义了静态成员时,整个继承体系中只有一个该静态成员的实例。无论派生出多少个子类,它们都共享这一静态成员????。例如:

class Person {
public:
    Person() { ++_count; }
protected:
    string _name;  // 姓名
public:
    static int _count;  // 统计人的个数
};

int Person::_count = 0;

class Student : public Person {
protected:
    int _stuNum;  // 学号
};

class Graduate : public Student {
protected:
    string _seminarCourse;  // 研究科目
};

void TestPerson() {
    Student s1;
    Student s2;
    Student s3;
    Graduate s4;
    cout << "人数 :" << Person::_count << endl;  // 输出 4,因为共创建了 4 个 Person 或其派生类对象
    Student::_count = 0;  // 可以通过派生类名访问静态成员,但实际上修改的是基类的静态成员
    cout << "人数 :" << Person::_count << endl;  // 输出 0
}

 在这个例子中,Person 类的静态成员 _count 用于统计创建的 Person 及其派生类对象的数量。无论是通过 Person 类还是 Student 类来访问 _count,都是同一个静态成员变量????。


七、复杂的菱形继承及菱形虚拟继承

在继承关系中,存在单继承、多继承等多种形式。其中,菱形继承是多继承的一种特殊情况,它可能会引发数据冗余和二义性问题????。例如:

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";  // 错误,存在二义性,不知道访问哪个父类的 _name
    a.Student::_name = "xxx";
    a.Teacher::_name = "yyy";  // 可以通过指定父类来访问,但数据冗余问题仍存在,因为有两份 _name
}

 在上述菱形继承的例子中,Assistant 类从 Student  Teacher 类继承,而 Student  Teacher 类又都继承自 Person 类,导致 Assistant 类中存在两份 Person 类的成员,造成数据冗余。并且在访问 _name 成员时,会出现二义性,因为编译器不知道应该使用 Student 继承的 _name 还是 Teacher 继承的 _name????。

为了解决菱形继承的这些问题,可以采用菱形虚拟继承。例如: 

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._name = "peter";  // 不再有二义性问题,且数据冗余问题得到解决
}

菱形虚拟继承的原理是,在虚拟继承的情况下,会在对象模型中引入虚基表指针。例如,在简化的菱形继承体系中:

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 {
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 被放置在对象组成的最下面,B  C 通过虚基表指针指向虚基表,虚基表中存储着偏移量,通过这些偏移量可以找到公共的A ,从而解决了数据冗余和二义性问题????。但需要注意的是,虚拟继承通常只在解决菱形继承问题时使用,不应在其他不必要的地方随意应用,因为它会增加一定的内存开销和运行时的复杂性????。


 八、继承的总结和反思

C++ 的继承机制虽然强大,但也带来了一些复杂性。多继承尤其是菱形继承会使代码的复杂度大幅增加,底层实现也变得十分复杂。这不仅会给开发人员带来理解和维护上的困难,还可能对程序的性能产生一定影响????。因此,在实际编程中,应尽量避免设计出多继承尤其是菱形继承的结构。

继承与组合是两种常见的代码复用方式。public 继承体现的是一种 “is - a” 的关系,即每个派生类对象都是一个基类对象。例如,BMW 类继承自 Car 类,意味着 BMW 是一种特殊的 Car。而组合则是一种 “has - a” 的关系,例如 Car 类中包含 Tire 类的对象,意味着每个 Car 对象都拥有一个 Tire 对象。

相比之下,组合具有更低的耦合度,因为组合类之间的依赖关系相对较弱,对象的内部细节对外部不可见,属于黑箱复用。这种方式使得代码的维护性更好,更易于扩展和修改。而继承在一定程度上破坏了基类的封装性,基类的改变可能会对派生类产生较大影响,导致派生类和基类之间的依赖关系较强,耦合度较高,属于白箱复用。

在实际编程中,应优先考虑使用对象组合来构建程序。只有在某些特定的关系确实适合用继承来表达,或者需要实现多态等特定功能时,才使用继承。这样可以在保证代码复用性的同时,最大程度地降低代码的复杂度,提高程序的可维护性和可扩展性????。


如果在学习过程中有任何疑问或建议,欢迎随时交流分享哦????! ????【A Charmer】