C++继承(上)

C++继承的概念、特性与默认成员函数

目录

一、继承的概念及定义

1. 继承的概念

2. 继承的定义

2.1 定义格式 

2.2 继承关系和访问限定符 

2.3 继承基类成员访问方式的变化

 

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

三、继承中的作用域

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


一、继承的概念及定义

1. 继承的概念

        继承是面向对象编程的三大特性之一,它允许我们在保留原有类(基类)特性的基础上进行扩展,增加新功能,从而产生新的类(派生类)。想象一下生物学的物种分类体系,就像"猫科动物"派生出"家猫"、"老虎"一样,代码中的继承可以复用已有功能。

        例如,有一个 “Person” 类,它包含了人的基本信息如姓名和年龄,并有打印信息的成员函数。我们可以在其基础上派生出 “Student” 和 “Teacher” 类,这两个派生类就复用了 “Person” 类的成员变量和成员函数。通过继承,我们能构建出具有层次结构的类体系,从简单到复杂逐步扩展功能。

🌵代码示例

#include <iostream>
#include <string>
using namespace std;

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

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

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

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

        在这个例子中,“Student” 和 “Teacher” 类继承了 “Person” 类的 “Print()” 函数以及 “_name” 和 “_age” 成员变量,从而复用了其功能和数据。继承后父类的Person的成员(成员函数+成员变量)都会变成子类的一部分。这里体现出了 Student和Teacher复用了Person的成员。下面我们使用监视窗口查看StudentTeacher对象,可以看到变量的复用。调用Print可以看到成员函数的复用。        

2. 继承的定义

2.1 定义格式 

在 C++ 中,继承的定义形式为 :

        上面我们看到Person父类,也称作基类Student子类,也称作派生类。其中,继承方式(访问限定符)决定了基类成员在派生类中的访问权限。

2.2 继承关系和访问限定符 

2.3 继承基类成员访问方式的变化

        以 “Person” 类作为基类,“Teacher” 类作为派生类为例,不同继承方式(public、protected、private)会影响基类成员在派生类中的访问级别。例如,基类的 public 成员在 public 继承下是派生类的 public 成员,在 protected 继承下是派生类的 protected 成员,在 private 继承下是派生类的 private 成员基类的 protected 成员在各种继承方式下都会变成派生类相应访问级别(与继承方式同级别)的成员而基类的 private 成员在派生类中无论何种继承方式都不可见


总结:

1. 基类private成员的不可见性

        基类private成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它。

class Base1 
{
private:
    int a;  // 私有成员
public:
    int b;  // 公有成员
protected:
    int c; // 保护成员
};

class Derived1 : public Base1
{
public:
    void test() 
    {
        b = 1;  // √ 可访问public成员(继承后仍为public)
        c = 2;  // √ 可访问protected成员(继承后仍为protected)

        //基类private成员a存在于派生类对象内存中,但派生类内外都无法直接访问
        a = 3;  // × 编译错误:'a' 是私有成员
    }
};

int main() 
{
    Derived1 d;
    d.b = 10;    // √
    d.c = 20; // × 编译错误:protected成员不可外部访问
    d.a = 30; // × 编译错误:private成员不可见
}

2. protected成员的作用

        基类private成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为protected。可以看出保护成员限定符是因继承才出现的。

class Base2 
{
protected:
    int value;  // 保护成员
public:
    void setValue(int v) 
    { 
        value = v; 
    }
};

class Derived2 : public Base2 
{
public:
    void modifyValue() 
    {
        value *= 2;  // √ 派生类可直接访问protected成员
    }
};

int main() 
{
    Derived2 d;
    d.setValue(5);   // √ 通过基类public接口操作
    d.value = 10; // × 编译错误:不可直接访问protected成员
}

3. 访问权限计算规则

        基类的私有成员在子类都是不可见。基类的其他成员在子类的访问方式 == Min(成员在基类的访问限定符,继承方式),public > protected > private

class Base3 
{
public:
    int x;
protected:
    int y;
private:
    int z;
};

// protected继承:min(public, protected) → protected
class Derived3 : protected Base3 
{ 
public:
    void test() 
    {
        x = 1;  // √ 继承后变为protected
        y = 2;  // √ 保持protected
        z = 3;  // × 编译错误
    }
};

// private继承:min(public, private) → private
class Derived4 : private Base3
{ 
public:
    void test() 
    {
        x = 1;  // √ 继承后变为private
        y = 2;  // √ 继承后变为private
    }
};

int main() 
{
    Derived3 d3;
    d3.x = 10;  // × 继承后x为protected
    
    Derived4 d4;
    d4.x = 10;  // × 继承后x为private
}

❓ 存在的疑问:
"为什么test()能访问基类的private继承成员?这不算破坏封装性吗?"

🎈 回答:
▸ 通过private继承,xy已成为Derived4自身的private成员
▸ 类的成员函数本就可以自由访问该类的任何成员(包括private)
▸ 这实际上是 白盒复用 的一种表现,不同于组合关系的黑盒复用

4. 默认继承方式差异

        使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过最好显示的写出继承方式。

class Base 
{ 
public: 
    int m; 
};

// 默认public继承
struct StructDerived : Base 
{};  

// 默认private继承
class ClassDerived : Base 
{};    

int main() 
{
    StructDerived s;
    s.m = 10;  // √ struct默认public继承
    
    ClassDerived c;
    c.m = 20;  // × class默认private继承
}

5. 非public继承的局限性

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

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

  • 派生类对象 可以赋值给 基类的对象 / 基类的指针 / 基类的引用。这里有个形象的说法叫切片 或者 切割。即只保留基类部分的数据,派生类特有的数据会被丢弃。

  • 基类对象不能直接赋值给派生类对象,因为派生类对象包含更多或更复杂的成员,基类对象无法提供这些额外的信息。

  • 基类的指针或引用可以通过强制类型转换赋值给派生类的指针或引用,但前提是基类指针实际指向的是派生类对象,否则可能会导致越界访问等错误。

#include <iostream>
#include <string>
using namespace std;

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

// 派生类 Student,继承自 Person
class Student : public Person
{
public :
    int _No ; // 学号
};

void Test ()
{
    Student sobj ; // 创建 Student 对象

    // 1. 子类对象可以赋值给父类对象 / 指针 / 引用
    Person pobj = sobj ; // 子类对象赋值给父类对象,发生切片,丢失子类特有数据(_No)
    Person* pp = &sobj; // 父类指针指向子类对象
    Person& rp = sobj; // 父类引用绑定到子类对象

    // 2. 基类对象不能赋值给派生类对象
    // sobj = pobj; // 这行代码会报错,因为基类对象无法直接赋值给派生类对象

    // 3. 基类的指针可以通过强制类型转换赋值给派生类的指针
    pp = &sobj; // 父类指针指向子类对象
    Student* ps1 = (Student*)pp; // 强制类型转换,安全,因为 pp 实际指向 Student 对象
    ps1->_No = 10; // 访问并修改 Student 对象的成员变量 _No

    pp = &pobj; // 父类指针指向父类对象
    Student* ps2 = (Student*)pp; // 强制类型转换,不安全,因为 pp 实际指向 Person 对象
    // ps2->_No = 10; // 此时访问 ps2->_No 会引发越界访问问题,因为 pobj 并不是 Student 对象
}

int main() 
{
    Test();
    return 0;
}

三、继承中的作用域

  • 在继承体系中,基类和派生类各自拥有独立的作用域。

  • 如果子类和父类中有同名成员,子类中的成员会屏蔽父类中同名成员的直接访问,这种情况称为隐藏。例如,子类有一个与父类同名的变量或函数,当我们试图在子类对象中访问该名称时,默认会访问子类的成员,若想访问父类的同名成员,需要使用 “基类名 :: 成员名” 的方式进行显式访问。

  • 对于成员函数来说,只要函数名相同,无论参数是否一致,都会构成隐藏。这和函数重载不同,重载要求在同一作用域内且参数列表不同

以下是一个关于成员变量隐藏的示例代码:

#include <iostream>
using namespace std;

// 父类
class Person
{
protected:
    int _num = 111;
};

// 子类
class Student : public Person
{
public:
    void fun()
    {
        cout << _num << endl; // 访问的是子类的 _num 成员变量
    }
protected:
    int _num = 999; // 子类的 _num 成员变量隐藏了父类的 _num 成员变量
};

int main()
{
    Student s;
    s.fun(); // 输出 999,因为访问的是子类的 _num 成员变量
    return 0;
}

        在这个例子中,Student 类的 _num 成员变量与 Person 类的 _num 成员变量同名。在 Student 类的 fun 函数中直接访问 _num 时,访问的是子类自己的 _num 成员变量。如果要访问父类中的 _num 成员变量,可以使用作用域限定符来显式指定:

void fun()
{
    cout << Person::_num << endl; // 指定访问父类中的 _num 成员变量
}

        对于成员函数来说,同样存在隐藏现象。如果子类和父类中有同名的成员函数,子类的成员函数会隐藏父类中的同名成员函数。 

#include <iostream>
using namespace std;

// 父类
class Person
{
public:
    void fun(int x)
    {
        cout << x << endl;
    }
};

// 子类
class Student : public Person
{
public:
    void fun(double x)
    {
        cout << x << endl;
    }
};

int main()
{
    Student s;
    s.fun(3.14);       // 直接调用子类中的成员函数 fun
    s.Person::fun(20); // 指定调用父类中的成员函数 fun
    return 0;
}

        在上面的代码中,Student 类的 fun(double x) 成员函数隐藏了 Person 类的 fun(int x) 成员函数。当调用 s.fun(3.14) 时,调用的是子类的 fun 函数。如果要调用父类的 fun 函数,则需要使用作用域限定符 Person:: 来显式指定。

        需要注意的是,成员函数的隐藏只需要函数名相同即可构成隐藏,即使参数列表不同。而函数重载要求两个函数在同一作用域内,并且参数列表不同。在继承体系中,父类和子类的成员函数不在同一作用域,因此不会构成重载。

        为了避免混淆和潜在的错误,在实际的继承体系设计中,应尽量避免在子类和父类中定义同名的成员变量和成员函数。如果确实需要同名成员,应清楚理解隐藏机制,并在需要访问被隐藏的父类成员时正确使用作用域限定符。

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

        6个默认成员函数,“默认”的意思就是指我们不写,编译器会给我们自动生成一个。

派生类成员函数的特点:

  • 构造函数 :派生类的构造函数被调用时,会自动先调用基类的构造函数来初始化继承自基类的成员。如果基类没有默认构造函数,派生类的构造函数必须在初始化列表中显式调用基类的构造函数。例如,在 Student 类的构造函数中,通过 Person(name) 显式调用基类 Person 的构造函数来初始化从 Person 类继承来的成员 _name

  • 拷贝构造函数 :派生类的拷贝构造函数需要先调用基类的拷贝构造函数来完成基类部分的拷贝构造,然后再对派生类自己新增的成员进行拷贝构造。比如 Student 类的拷贝构造函数通过 Person(s) 调用基类 Person 的拷贝构造函数,确保基类的成员 _name 被正确拷贝。

  • 赋值运算符重载函数 :派生类的赋值运算符重载函数必须先调用基类的赋值运算符重载函数来完成基类成员的赋值,再对派生类的成员进行赋值。Student 类的 operator= 函数中,通过 Person::operator=(s) 调用基类的赋值运算符重载函数,确保基类的 _name 成员被正确赋值后,再对派生类的 _id 成员进行赋值。

  • 析构函数 :派生类的析构函数在被调用后,会自动调用基类的析构函数来清理基类的成员。这意味着在销毁派生类对象时,先执行派生类的析构函数中的代码,清理派生类自己新增的成员,然后再自动调用基类的析构函数来清理继承自基类的成员。例如,当 Student 类的对象被销毁时,先执行 ~Student() 中的代码(如果有),然后自动调用 ~Person() 来清理 _name 成员。

  • 初始化和析构顺序 :派生类对象初始化时,先调用基类的构造函数,再调用派生类的构造函数。析构时,先调用派生类的析构函数,再调用基类的析构函数。这种顺序确保了对象的构造和析构过程符合逻辑,先构造基类部分,再构造派生类部分,销毁时则相反,先销毁派生类部分,再销毁基类部分。

编写派生类默认成员函数的注意事项:

  • 赋值运算符重载函数的隐藏问题 :由于派生类和基类的赋值运算符重载函数函数名相同,构成隐藏关系。因此,在派生类中调用基类的赋值运算符重载函数时,需要使用作用域限定符(Person::operator=(s))来显式指定调用基类的函数。

  • 析构函数的隐藏及调用 :由于多态等原因,编译器会统一对析构函数名进行特殊处理,导致派生类和基类的析构函数也构成隐藏关系。如果需要在某个地方调用基类的析构函数,必须使用作用域限定符。不过,通常情况下,我们不需要手动调用基类的析构函数,因为编译器会在派生类的析构函数执行完毕后自动调用基类的析构函数。若手动调用基类的析构函数,可能会导致基类被多次析构,引发问题。

  • 拷贝构造函数和赋值运算符重载函数中的切片行为 :在派生类的拷贝构造函数和赋值运算符重载函数中,当将派生类对象传递给基类的拷贝构造函数或赋值运算符重载函数时,会发生切片行为,即只将派生类对象的基类部分(即从基类继承来的成员)传递给基类的函数,派生类特有的成员不会被传递。这是因为基类的拷贝构造函数和赋值运算符重载函数只能处理基类类型的对象或引用。

        需要注意的是,基类的构造函数、拷贝构造函数、赋值运算符重载函数可以在派生类中通过显式调用的方式使用,但基类的析构函数在派生类的析构函数执行完毕后会由编译器自动调用,我们通常不需要手动调用基类的构造函数,否则可能导致基类被多次构造或析构的问题

        以下是一个综合示例,展示派生类中默认成员函数的调用过程。在这个例子中,当我们创建 Student 对象时,首先调用 Person 类的构造函数初始化继承的 _name 成员,然后再调用 Student 类的构造函数初始化 _id 成员。拷贝构造和赋值运算符重载函数也遵循类似的顺序,先处理基类部分,再处理派生类部分。析构时,先执行 Student 类的析构函数,再自动调用 Person 类的析构函数。

#include <iostream>
#include <string>
using namespace std;

// 基类
class Person
{
public:
    // 构造函数
    Person(const string& 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;
    }
private:
    string _name; // 姓名
};

// 派生类
class Student : public Person
{
public:
    // 构造函数
    Student(const string& name, int id)
        : Person(name) // 调用基类的构造函数初始化基类的那一部分成员
        , _id(id) // 初始化派生类的成员
    {
        cout << "Student()" << endl;
    }
    // 拷贝构造函数
    Student(const Student& s)
        : Person(s) // 调用基类的拷贝构造函数完成基类成员的拷贝构造
        , _id(s._id) // 拷贝构造派生类的成员
    {
        cout << "Student(const Student& s)" << endl;
    }
    // 赋值运算符重载函数
    Student& operator=(const Student& s)
    {
        cout << "Student& operator=(const Student& s)" << endl;
        if (this != &s)
        {
            Person::operator=(s); // 调用基类的 operator= 完成基类成员的赋值
            _id = s._id; // 完成派生类成员的赋值
        }
        return *this;
    }
    // 析构函数
    ~Student()
    {
        cout << "~Student()" << endl;
        // 派生类的析构函数会在被调用完成后自动调用基类的析构函数
    }
private:
    int _id; // 学号
};

int main()
{
    Student s1("Alice", 101); // 调用 Student 的构造函数
    Student s2(s1); // 调用 Student 的拷贝构造函数
    Student s3("Bob", 102);
    s3 = s1; // 调用 Student 的赋值运算符重载函数
    return 0;
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

南风与鱼

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值