【C++篇】继承之韵:解构编程奥义,感悟面向对象的至高法则

2024-10-09 20:08:35 浏览数 (1)

C 继承详解:初阶理解与实战应用

前言

C 继承机制是面向对象编程的重要组成部分,能够帮助开发者实现代码的复用和扩展。通过继承,开发者可以基于已有的类创建新的类,从而避免重复代码编写,提升开发效率。然而,继承的使用并不总是那么简单,特别是在涉及到复杂继承关系时,容易导致一些新手难以理解的困惑。本篇文章将通过细致入微的分析,帮助大家从初阶的角度理解 C 中继承的基本原理,并结合实际的代码示例,逐步深入剖析继承中的难点和注意事项。

第一章:继承的基本概念与定义

1.1 继承的概念

在C 中,继承(Inheritance) 是面向对象程序设计中的一种机制,它允许程序员在已有类(即基类或父类)的基础上,扩展或修改功能,从而形成新的类(即派生类或子类)。这种机制能够复用已有的代码,并且通过层次化的类结构,展示了面向对象编程由简单到复杂的认知过程。

举个例子,假设有一个基类 Person,定义了基本的个人信息,如姓名和年龄。现在需要创建一个 Student 类,除了拥有基本的个人信息外,还需要增加学号。通过继承,Student 类可以复用 Person 类中的代码,而不必重新编写这些属性。

代码语言:javascript复制
class Person {
public:
    void Print() {
        cout << "name:" << _name << endl;
        cout << "age:" << _age << endl;
    }

protected:
    string _name = "peter";  // 姓名
    int _age = 18;           // 年龄
};

// Student类继承自Person类
class Student : public Person {
protected:
    int _stuid;  // 学号
};

在以上代码中,Student 类继承了 Person 类的成员函数和成员变量,这意味着 Student 类中包含了 _name_age 两个属性,以及 Print() 函数。通过继承,我们实现了代码的复用。

1.2 继承的定义

继承在 C 中的定义主要通过以下格式实现:

代码语言:javascript复制
class 子类名 : 继承方式 基类名 {
    // 子类的成员
};

其中,继承方式 可以是 publicprotectedprivate,它们决定了基类的成员在派生类中的访问权限。

  • public 继承:基类的 public 成员在派生类中保持 publicprotected 成员保持 protected
  • protected 继承:基类的 public 成员在派生类中变为 protectedprotected 成员保持 protected
  • private 继承:基类的 publicprotected 成员在派生类中均变为 private

示例代码:

代码语言:javascript复制
class Teacher : public Person {
protected:
    int _jobid;  // 工号
};

int main() {
    Student s;
    Teacher t;
    s.Print();
    t.Print();
    return 0;
}

在这个示例中,StudentTeacher 都继承了 Person 类的 Print() 函数,通过 s.Print()t.Print() 可以分别输出 StudentTeacher 对象的姓名和年龄。


第二章:继承中的访问权限

2.1 基类成员在派生类中的访问权限

基类的 publicprotectedprivate 成员在派生类中的访问权限取决于继承方式。下面是不同继承方式下的访问权限表:

类成员

public 继承

protected 继承

private 继承

基类的 public 成员

public

protected

private

基类的 protected 成员

protected

protected

private

基类的 private 成员

不可见

不可见

不可见

从表中可以看出,基类的 private 成员在派生类中始终不可见(不可访问),无论采用何种继承方式。然而,基类的 protected 成员和 public 成员则根据继承方式在派生类中具有不同的访问级别。

注意:如果需要基类的某个成员在派生类中可访问但不希望类外部访问,则可以将其设置为 protected,这样可以更好地控制访问权限。

2.2 基类与派生类对象的赋值转换

在C 中,基类和派生类对象的赋值转换是一个比较常见的操作场景。通常情况下,派生类对象可以赋值给基类对象,或者通过基类的指针或引用来操作派生类对象。这种转换机制使得C 在继承结构中实现了多态和代码复用。但需要注意的是,基类对象不能直接赋值给派生类对象。

2.2.1 派生类对象赋值给基类对象

派生类对象包含了基类的成员,因此派生类对象赋值给基类对象时,实际上是将派生类中属于基类的那一部分赋值给基类对象。这种操作称为切片(Slicing),即派生类对象中的基类部分被切割下来,赋值给基类对象。

示例代码如下:

代码语言:javascript复制
class Person {
public:
    string _name;
protected:
    int _age;
};

class Student : public Person {
public:
    int _stuid;
};

int main() {
    Student s;
    s._name = "John";
    s._stuid = 1001;
    
    Person p = s;  // 切片操作,将派生类对象赋值给基类对象
    cout << "Name: " << p._name << endl;  // 输出 "John"
    // cout << p._stuid;  // 错误:基类对象无法访问派生类的成员
    return 0;
}

在上面的代码中,Student 对象 s 被赋值给 Person 对象 p。但是由于 Person 类没有 stuid 成员,p 无法访问 Student 类中的 _stuid 成员。因此,这里发生了切片操作,p 只保留了 Student 类中 Person 类的那部分内容。

2.2.2 基类指针和引用的转换

派生类对象可以赋值给基类的指针或引用,这是实现多态的重要前提条件。通过基类指针或引用,程序可以在运行时动态绑定到派生类的成员函数。这种方式允许我们在不需要修改代码的情况下扩展程序的功能。

代码语言:javascript复制
class Person {
public:
    virtual void Print() {
        cout << "Person: " << _name << endl;
    }
protected:
    string _name = "Alice";
};

class Student : public Person {
public:
    void Print() override {
        cout << "Student: " << _name << ", ID: " << _stuid << endl;
    }
private:
    int _stuid = 123;
};

void PrintPersonInfo(Person& p) {
    p.Print();  // 基类引用调用虚函数,实现多态
}

int main() {
    Student s;
    PrintPersonInfo(s);  // 输出 "Student: Alice, ID: 123"
    return 0;
}

在这个例子中,我们通过基类 Person 的引用调用 Student 类中的 Print() 函数,实现了运行时多态。派生类对象 s 被传递给基类引用 p,并正确调用了 Student 类的重写函数 Print()

2.2.3 强制类型转换的使用

在某些特殊情况下,基类指针或引用可能需要转换为派生类的指针或引用。C 提供了 dynamic_caststatic_cast 等多种类型转换方式。在继承关系中,使用 dynamic_cast 进行安全的类型转换尤为重要,特别是在处理多态时。

代码语言:javascript复制
Person* pp = new Student();  // 基类指针指向派生类对象
Student* sp = dynamic_cast<Student*>(pp);  // 安全的向下转换
if (sp) {
    sp->Print();
} else {
    cout << "Type conversion failed!" << endl;
}

dynamic_cast 在运行时进行类型检查,确保转换是安全的。如果转换失败,将返回 nullptr,从而避免越界访问的风险。

第三章:继承中的作用域与成员访问

3.1 作用域的独立性与同名成员的隐藏

在继承关系中,基类与派生类各自拥有独立的作用域。如果派生类中定义了与基类成员同名的变量或函数,基类的同名成员将被隐藏,这种现象称为隐藏(Hiding)。也叫重定义同名成员在派生类中会覆盖基类中的成员,导致基类成员无法被直接访问。

示例代码:

代码语言:javascript复制
class Person {
protected:
    int _num = 111;  // 身份证号
};

class Student : public Person {
public:
    Student(int num) : _num(num) {}  // 派生类中的_num覆盖了基类中的_num

    void Print() {
        cout << "身份证号: " << Person::_num << endl;  // 访问基类中的_num
        cout << "学号: " << _num << endl;  // 访问派生类中的_num
    }

protected:
    int _num;  // 学号
};

int main() {
    Student s(999);
    s.Print();  // 输出身份证号和学号
    return 0;
}

在这个例子中,Student 类中定义了一个 _num 变量,它隐藏了基类 Person 中的同名变量。为了访问基类的 _num,我们使用了 Person::_num 来显式地指定访问基类中的成员。这样可以避免由于成员同名而导致的混淆。

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

3.1.1 函数的隐藏

同名成员函数也会构成隐藏,只要函数名称相同,即使参数列表不同,也会发生隐藏。这种行为和函数重载不同。在派生类中,如果我们希望访问基类中的同名函数,必须显式调用基类的函数。

代码语言:javascript复制
class A {
public:
    void fun() {
        cout << "A::fun()" << endl;
    }
};

class B : public A {
public:
    void fun(int i) {  // 隐藏了基类的fun()
        cout << "B::fun(int i) -> " << i << endl;
    }
};

int main() {
    B b;
    b.fun(10);  // 调用B::fun(int i)
    b.A::fun();  // 显式调用基类的fun()
    return 0;
}

在此代码中,派生类 B 中的 fun(int i) 函数隐藏了基类 A 中的 fun() 函数。如果我们希望调用基类的 fun() 函数,必须通过 b.A::fun() 来显式调用。这与函数重载不同,函数隐藏仅要求函数名相同,而不考虑参数列表。并且函数重载说的是同一作用域,而这里基类和派生类时两个作用域


3.2 派生类的默认成员函数

在 C 中,当我们不显式定义类的构造函数、拷贝构造函数、赋值运算符和析构函数时,编译器会自动为我们生成这些函数。这些自动生成的函数在派生类中也会涉及到对基类成员的操作,因此在继承体系中了解这些默认成员函数的调用规则非常重要。

3.2.1 构造函数的调用顺序

在派生类对象的构造过程中,基类的构造函数会优先于派生类的构造函数被调用。如果基类没有默认构造函数,则派生类的构造函数必须在初始化列表中显式调用基类的构造函数。

代码语言:javascript复制
class Person {
public:
    Person(const string& name) : _name(name) {
        cout << "Person constructor called!" << endl;
    }

protected:
    string _name;
};

class Student : public Person {
public:
    Student(const string& name, int stuid) : Person(name), _stuid(stuid) {
        cout << "Student constructor called!" << endl;
    }

private:
    int _stuid;
};

int main() {
    Student s("Alice", 12345);
    return 0;
}

输出

代码语言:javascript复制
Person constructor called!
Student constructor called!

在这个例子中,Student 类的构造函数首先调用了 Person 类的构造函数来初始化基类部分。随后才执行派生类 Student 的构造函数。这种调用顺序确保基类的成员在派生类构造之前就已经被正确初始化。

3.2.2 拷贝构造函数与赋值运算符的调用

当派生类对象被拷贝时,基类的拷贝构造函数会先被调用,然后才是派生类的拷贝构造函数。同样,赋值运算符的调用顺序也遵循这一规则:基类的赋值运算符会先于派生类的赋值运算符被调用。

代码语言:javascript复制
class Person {
public:
    Person(const string& name) : _name(name) {}
    
    // 拷贝构造函数
    Person(const Person& p) {
        _name = p._name;
        cout << "Person copy constructor called!" << endl;
    }

    // 赋值运算符
    Person& operator=(const Person& p) {
        _name = p._name;
        cout << "Person assignment operator called!" << endl;
        return *this;
    }

protected:
    string _name;
};

class Student : public Person {
public:
    Student(const string& name, int stuid) : Person(name), _stuid(stuid) {}

    // 拷贝构造函数
    Student(const Student& s) : Person(s) {
        _stuid = s._stuid;
        cout << "Student copy constructor called!" << endl;
    }

    // 赋值运算符
    Student& operator=(const Student& s) {
        Person::operator=(s);  // 先调用基类的赋值运算符
        _stuid = s._stuid;
        cout << "Student assignment operator called!" << endl;
        return *this;
    }

private:
    int _stuid;
};

int main() {
    Student s1("Alice", 12345);
    Student s2 = s1;  // 拷贝构造函数
    Student s3("Bob", 54321);
    s3 = s1;  // 赋值运算符
    return 0;
}

输出

代码语言:javascript复制
Person copy constructor called!
Student copy constructor called!
Person assignment operator called!
Student assignment operator called!

在拷贝构造和赋值操作过程中,基类部分总是优先于派生类部分进行初始化或赋值操作。为了保证派生类对象的完整性,派生类的拷贝构造函数和赋值运算符必须调用基类的相应函数,确保基类成员正确处理。

3.2.3 析构函数的调用顺序

与构造函数的调用顺序相反,析构函数的调用顺序是先调用派生类的析构函数,然后再调用基类的析构函数。这确保了派生类的资源先被释放,然后基类的资源才能安全地释放。

代码语言:javascript复制
class Person {
public:
    Person(const string& name) : _name(name) {}

    ~Person() {
        cout << "Person destructor called!" << endl;
    }

protected:
    string _name;
};

class Student : public Person {
public:
    Student(const string& name, int stuid) : Person(name), _stuid(stuid) {}

    ~Student() {
        cout << "Student destructor called!" << endl;
    }

private:
    int _stuid;
};

int main() {
    Student s("Alice", 12345);
    return 0;
}

输出

代码语言:javascript复制
Student destructor called!
Person destructor called!

可以看到,当 Student 对象 s 析构时,首先调用了 Student 的析构函数,随后调用了 Person 的析构函数。这种析构顺序确保派生类资源(如成员变量 _stuid)被先行清理,而基类的资源(如 _name)则在派生类资源清理后再进行释放。

3.2.4 虚析构函数

在继承体系中,若希望基类指针指向派生类对象,并通过该指针安全地释放对象,基类的析构函数应当定义为虚函数。否则,仅会调用基类的析构函数,导致派生类资源没有正确释放,从而引发内存泄漏。

代码语言:javascript复制
class Person {
public:
    Person(const string& name) : _name(name) {}
    virtual ~Person() {
        cout << "Person destructor called!" << endl;
    }

protected:
    string _name;
};

class Student : public Person {
public:
    Student(const string& name, int stuid) : Person(name), _stuid(stuid) {}

    ~Student() {
        cout << "Student destructor called!" << endl;
    }

private:
    int _stuid;
};

int main() {
    Person* p = new Student("Alice", 12345);
    delete p;  // 安全删除,先调用派生类的析构函数
    return 0;
}

输出

代码语言:javascript复制
Student destructor called!
Person destructor called!

通过将基类的析构函数声明为 virtual,当通过基类指针删除派生类对象时,派生类的析构函数将首先被调用,从而确保所有派生类的资源被正确释放。

总结

通过本篇文章的学习,我们深入了解了 C 中继承的基本概念、继承方式对成员访问的影响、对象赋值转换的机制,以及如何处理同名成员的隐藏问题。我们还讨论了派生类默认成员函数的调用顺序和析构函数的正确使用方式。

继承机制使得我们能够有效地复用代码,同时为程序设计提供了层次结构。但在实际开发中,继承的设计需要谨慎,避免出现复杂的层次结构。在下一篇文章中,我们将进一步探讨 虚拟继承 的使用,解决多继承中常见的问题,敬请期待!

以上就是关于【C 篇】继承之韵:解构编程奥义,领略面向对象的至高法则的内容啦,各位大佬有什么问题欢迎在评论区指正,或者私信我也是可以的啦,您的支持是我创作的最大动力!❤️

0 人点赞