第18课-C++继承:探索面向对象编程的复用之道

一、引言

C++ 作为一种强大的编程语言,继承机制在面向对象编程中扮演着至关重要的角色。它允许开发者基于已有的类创建新的类,从而实现代码的复用和功能的扩展。然而,继承的概念和使用方法并非一目了然,特别是在处理复杂的继承关系时,需要对其原理和规则有深入的理解。本文将详细阐述 C++ 中继承的各个方面,包括基本概念、访问权限、作用域以及默认成员函数等,通过实际的代码示例帮助读者更好地掌握这一重要特性。

武磊中国足球的希望

二、继承的基本概念与定义

(一)概念

在 C++ 中,继承是一种面向对象程序设计的机制,它使程序员能够在已有类(基类或父类)的基础上创建新的类(派生类或子类),并对其功能进行扩展或修改。这种机制有效地复用了已有代码,同时通过构建层次化的类结构,展现了面向对象编程从简单到复杂的演变过程。
例如,假设有一个基类Vehicle,它定义了一些基本的车辆属性,如车轮数量和颜色。现在要创建一个Car类,除了继承这些基本属性外,还需要增加一些汽车特有的属性,如座位数量。通过继承,Car类可以复用Vehicle类的代码,无需重新编写已有的属性定义。

  • cpp
class Vehicle {
public:
    void printInfo() {
        cout << "Wheel number: " << _wheelNumber << endl;
        cout << "Color: " << _color << endl;
    }

protected:
    int _wheelNumber = 4;
    string _color = "black";
};

// Car类继承自Vehicle类
class Car : public Vehicle {
protected:
    int _seatNumber;
};

在上述代码中,Car类继承了Vehicle类的成员函数和成员变量,这意味着Car类包含了_wheelNumber_color两个属性以及printInfo()函数。通过继承,实现了代码的复用。

(二)定义

C++ 中继承的定义格式如下:

  • cpp
class 子类名 : 继承方式 基类名 {
    // 子类的成员
};

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

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

以下是一个示例代码:

  • cpp
class Truck : public Vehicle {
protected:
    int _loadCapacity;
};

int main() {
    Car c;
    Truck t;
    c.printInfo();
    t.printInfo();
    return 0;
}

在这个示例中,CarTruck都继承了Vehicle类的printInfo()函数,通过c.printInfo()t.printInfo()可以分别输出CarTruck对象的车辆信息。

三、继承中的访问权限

(一)基类成员在派生类中的访问权限

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

类成员 public继承 protected继承 private继承
基类的public成员 public protected private
基类的protected成员 protected protected private
基类的private成员 不可见 不可见 不可见

从表中可以看出,基类的private成员在派生类中始终不可见(不可访问),无论采用何种继承方式。然而,基类的protected成员和public成员则根据继承方式在派生类中具有不同的访问级别。
需要注意的是,如果需要基类的某个成员在派生类中可访问但不希望类外部访问,则可以将其设置为protected,这样可以更好地控制访问权限。

(二)基类与派生类对象的赋值转换

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

1. 派生类对象赋值给基类对象

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

以下是一个示例代码:

  • cpp
class Animal {
public:
    string _name;
protected:
    int _age;
} ;

class Dog : public Animal {
public:
    int _breedId;
};

int main() {
    Dog d;
    d._name = "Buddy";
    d._breedId = 1;

    Animal a = d;   // 切片操作,将派生类对象赋值给基类对象
    cout << "Name: " << a._name << endl;   // 输出 "Buddy"
    // cout << a._breedId;  // 错误:基类对象无法访问派生类的成员
    return 0;
}

在上述代码中,Dog对象d被赋值给Animal对象a。由于Animal类没有_breedId成员,a无法访问Dog类中的_breedId成员。因此,这里发生了切片操作,a只保留了Dog类中Animal类的那部分内容

2. 基类指针和引用的转换

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

以下是一个示例代码:

  • cpp
class Shape {
public:
    virtual void draw() {
        cout << "Drawing a shape" << endl;
    }
protected:
    string _name = "Shape";
} ;

class Circle : public Shape {
public:
    void draw() override {
        cout << "Drawing a circle" << endl;
    }
private:
    int _radius = 5;
} ;

void drawShape(Shape& s) {
    s.draw();   // 基类引用调用虚函数,实现多态
}

int main() {
    Circle c;
    drawShape(c);   // 输出 "Drawing a circle"
    return 0;
}

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

3. 强制类型转换的使用

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

以下是一个示例代码:

cpp

Shape* sp = new Circle();   // 基类指针指向派生类对象
Circle* cp = dynamic_cast<Circle*>(sp);   // 安全的向下转换
if (cp) {
    cp->draw();
} else {
    cout << "Type conversion failed!" << endl;
}

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

四、继承中的作用域与成员访问

(一)作用域的独立性与同名成员的隐藏

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

以下是一个示例代码:

  • cpp
class Parent {
protected:
    int _id = 111;   // 身份证号
} ;

class Child : public Parent {
public:
    Child(int id) : _id(id) { }   // 派生类中的_id覆盖了基类中的_id

    void print() {
        cout << "身份证号: " << Parent::_id << endl;   // 访问基类中的_id
        cout << "孩子编号: " << _id << endl;   // 访问派生类中的_id
    }

protected:
    int _id;   // 孩子编号
} ;

int main() {
    Child c(999);
    c.print();   // 输出身份证号和孩子编号
    return 0;
}

在这个例子中,Child类中定义了一个_id变量,它隐藏了基类Parent中的同名变量。为了访问基类的_id,我们使用了Parent::_id来显式地指定访问基数类中的成员。这样可以避免由于成员同名而导致的混淆。
需要注意的是,在实际的继承体系中,最好不要定义同名的成员

(二)函数的隐藏

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

以下是一个示例代码:

  • cpp
class Base {
public:
    void function() {
        cout << "Base::function()" << endl;
    }
} ;

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

int main() {
    Derived d;
    d.function(10);   // 调用Derived::function(int i)
    d.Base::function();   // 显式调用基类的function()
    return 0;
}

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

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

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

(一)构造函数的调用顺序

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

以下是一个示例代码:

  • cpp
class BaseClass {
public:
    BaseClass(const string& name) : _name(name) {
        cout << "BaseClass constructor called!" << endl;
    }

protected:
    string _name;
} ;

class DerivedClass : public BaseClass {
public:
    DerivedClass(const string& name, int value) : BaseClass(name), _value(value) {
        cout << "DerivedClass constructor called!" << endl;
    }

private:
    int _value;
} ;

int main() {
    DerivedClass d("Alice", 12345);
    return 0;
}
  • 输出:
  • cpp
BaseClass constructor called!
DerivedClass constructor called!

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

(二)拷贝构造函数与赋值运算符的调用

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

以下是一个示例代码:

  • cpp
class OriginalClass {
public:
    OriginalClass(const string& name) : _name(name) { }

    // 拷贝构造函数
    OriginalClass(const OriginalClass& o) {
        _name = o._name;
        cout << "OriginalClass copy constructor called!" << endl;
    }

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

protected:
    string _name;
} ;

class NewClass : public OriginalClass {
public:
    NewClass(const string& name, int value) : OriginalClass(name), _value(value) { }

    // 拷贝构造函数
    NewClass(const NewClass& n) : OriginalClass(n) {
    _value = n._value;
    cout << "NewClass copy constructor called!" << endl;
    }

    // 出版设赋值运算符
    NewClass& operator=(const NewClass& n) {
        OriginalClass::operator=(n);   // 先调用基类的赋值运算符
        _value = n._value;
        cout  += "NewClass assignment operator called!" << endl;
        return *this;
    }

private:
    int _value;
} ;

int main() {
    NewClass n1("Alice", 12345);
    NewClass n2 = n1;   // 拷贝构造函数
    NewClass n3("Bob", 54321);
    n3 = n1;   // 赋值运算符
    return 0;
}
  • 输出:
  • cpp
OriginalClass copy constructor called!
NewClass copy constructor called!
OriginalClass assignment operator called!
NewClass assignment operator called!

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

(三)析构函数的召唤顺序

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

以下是一个示例代码:

  • cpp
class BaseObject {
public:
    BaseObject(const string& name) : _name(name) { }

    ~BaseObject() {
        cout << "BaseObject destructor called!" << endl;
    }//,x,

protected:
    string _name;
} ;

class DerivedObject : public BaseObject {
public:
    DerivedObject(const string& name, int value) : BaseObject(name), _value(value) { }

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

private:
    int _value;
} ;

int main() {
    DerivedObject d("Alice", 12345);
    return 0;
}
  • 输出:
  • cpp
DerivedObject destructor called!
BaseObject destructor called!

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

(四)虚析构函数

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

以下是一个示例代码:

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

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

猜你喜欢

转载自blog.csdn.net/2401_83603768/article/details/142876010