MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

C++派生新类的三个步骤

2024-09-037.9k 阅读

一、理解类的继承与派生概念

在 C++ 中,类的继承是一种重要的特性,它允许我们基于已有的类创建新的类。通过继承,新类(称为派生类或子类)可以获得现有类(称为基类或父类)的成员(包括数据成员和成员函数),并且可以在其基础上进行扩展和修改。派生新类是构建复杂软件系统的关键步骤之一,它有助于代码的复用和软件架构的分层设计。

(一)继承的本质

继承本质上是一种“is - a”关系。例如,我们有一个“Animal”类,然后可以派生出“Dog”类,因为“Dog is an Animal”。在这种关系下,“Dog”类自动拥有“Animal”类的一些通用属性和行为,如年龄、进食方法等,同时“Dog”类可以添加自己特有的属性和行为,比如吠叫的声音。从代码角度看,继承使得派生类能够复用基类的代码,减少重复编写,提高代码的可维护性和可扩展性。

(二)继承的语法基础

在 C++ 中,定义派生类的基本语法如下:

class DerivedClass : access - specifier BaseClass {
    // 派生类新的成员声明
};

这里的 access - specifier 决定了基类成员在派生类中的访问权限,常见的有 publicprivateprotected。如果是 public 继承,基类的 public 成员在派生类中仍然是 public 的,protected 成员在派生类中仍然是 protected 的;如果是 private 继承,基类的所有成员在派生类中都变成 private 的;protected 继承则使基类的 publicprotected 成员在派生类中都变为 protected 的。

二、派生新类的第一步:确定基类与继承方式

(一)选择合适的基类

选择合适的基类是派生新类的首要任务。基类应该包含派生类所共有的属性和行为。例如,如果我们要创建一个“Student”类,并且考虑到学生也是人,具有人的一些基本属性,如姓名、年龄等,那么我们可以选择“Person”类作为基类。

// 基类 Person
class Person {
private:
    std::string name;
    int age;
public:
    Person(const std::string& n, int a) : name(n), age(a) {}
    std::string getName() const {
        return name;
    }
    int getAge() const {
        return age;
    }
};

在这个例子中,“Person”类封装了姓名和年龄这两个属性,以及获取姓名和年龄的方法。当我们要创建“Student”类时,“Person”类的这些成员对于描述学生的基本信息是有用的,所以“Person”类可以作为一个合适的基类。

(二)确定继承方式

继承方式决定了基类成员在派生类中的访问权限,这对于类的设计和安全性至关重要。

  1. public 继承
    • 特点:在 public 继承中,基类的 public 成员在派生类中仍然是 public 的,protected 成员在派生类中仍然是 protected 的。这意味着通过 public 继承,派生类对象可以像使用自己的成员一样使用基类的 public 成员,并且派生类的成员函数可以访问基类的 protected 成员。
    • 适用场景:当我们希望保持基类的接口在派生类中可见,并且派生类与基类之间具有严格的“is - a”关系时,通常使用 public 继承。例如,“Student”类 public 继承自“Person”类,因为学生完全具备人的公共属性和行为,并且外界应该能够像访问人的公共属性一样访问学生的公共属性。
// Student 类 public 继承自 Person 类
class Student : public Person {
private:
    std::string studentId;
public:
    Student(const std::string& n, int a, const std::string& id) : Person(n, a), studentId(id) {}
    std::string getStudentId() const {
        return studentId;
    }
};

在这个例子中,“Student”类通过 public 继承获得了“Person”类的 public 成员 getNamegetAge,外界可以通过“Student”类的对象直接调用这些函数。

  1. private 继承
    • 特点:在 private 继承中,基类的所有成员(publicprotectedprivate)在派生类中都变成 private 的。这意味着派生类的成员函数可以访问基类的 publicprotected 成员,但外界无法通过派生类对象直接访问基类的任何成员,即使它们在基类中是 public 的。
    • 适用场景:当我们希望在派生类中复用基类的代码,但又不想让派生类对外暴露基类的接口时,private 继承是一个不错的选择。例如,我们有一个“StringUtil”类用于处理字符串操作,现在我们要创建一个“PasswordManager”类,它内部需要使用一些字符串操作功能,但不希望外界将“PasswordManager”视为一个字符串处理类,此时可以使用 private 继承。
// StringUtil 基类
class StringUtil {
public:
    static std::string reverseString(const std::string& str) {
        std::string reversed = str;
        std::reverse(reversed.begin(), reversed.end());
        return reversed;
    }
};

// PasswordManager 类 private 继承自 StringUtil 类
class PasswordManager : private StringUtil {
private:
    std::string password;
public:
    PasswordManager(const std::string& p) : password(p) {}
    std::string getHashedPassword() const {
        // 这里简单示例,实际哈希算法更复杂
        std::string reversed = reverseString(password);
        return std::string("hashed_") + reversed;
    }
};

在这个例子中,“PasswordManager”类通过 private 继承获得了“StringUtil”类的 reverseString 函数,但外界无法直接调用“PasswordManager”对象的 reverseString 函数,只能调用“PasswordManager”自己暴露的 getHashedPassword 函数。

  1. protected 继承
    • 特点:在 protected 继承中,基类的 publicprotected 成员在派生类中都变为 protected 的。这意味着派生类的成员函数可以访问基类的这些成员,并且如果有进一步从该派生类派生的子类,子类也可以访问这些变为 protected 的成员,但外界不能通过派生类对象直接访问这些成员。
    • 适用场景:当我们希望派生类及其子类能够复用基类的部分接口,但不希望外界直接访问这些接口时,protected 继承比较合适。例如,我们有一个“Shape”类作为基类,派生出“Rectangle”类,“Rectangle”类可能会进一步派生出“Square”类,“Rectangle”类通过 protected 继承“Shape”类,这样“Square”类可以访问“Shape”类中变为 protected 的相关成员,同时外界无法直接通过“Rectangle”类对象访问这些成员。
// Shape 基类
class Shape {
protected:
    int x;
    int y;
public:
    Shape(int a, int b) : x(a), y(b) {}
    int getX() const {
        return x;
    }
    int getY() const {
        return y;
    }
};

// Rectangle 类 protected 继承自 Shape 类
class Rectangle : protected Shape {
protected:
    int width;
    int height;
public:
    Rectangle(int a, int b, int w, int h) : Shape(a, b), width(w), height(h) {}
    int getWidth() const {
        return width;
    }
    int getHeight() const {
        return height;
    }
};

// Square 类 public 继承自 Rectangle 类
class Square : public Rectangle {
public:
    Square(int a, int b, int s) : Rectangle(a, b, s, s) {}
};

在这个例子中,“Rectangle”类通过 protected 继承获得了“Shape”类的 xy 成员以及 getXgetY 函数,并且这些成员在“Rectangle”类中变为 protected 的。“Square”类作为“Rectangle”类的派生类,可以访问这些 protected 成员。

三、派生新类的第二步:添加新的成员

(一)添加数据成员

在确定了基类和继承方式后,我们需要为派生类添加特有的数据成员。这些数据成员用于描述派生类不同于基类的属性。

例如,在前面“Student”类继承“Person”类的例子中,“Student”类除了拥有“Person”类的姓名和年龄外,还需要有自己特有的学号属性。所以我们在“Student”类中添加了 studentId 数据成员。

class Student : public Person {
private:
    std::string studentId;
public:
    Student(const std::string& n, int a, const std::string& id) : Person(n, a), studentId(id) {}
    std::string getStudentId() const {
        return studentId;
    }
};

在这个例子中,studentId 数据成员用于唯一标识学生,是“Student”类特有的属性。

(二)添加成员函数

除了数据成员,我们还需要为派生类添加特有的成员函数。这些成员函数实现了派生类特有的行为。

  1. 普通成员函数
    • 例如,“Student”类可能有一个获取学生详细信息的函数,该函数不仅包含学生自己的学号,还包含从基类继承的姓名和年龄信息。
class Student : public Person {
private:
    std::string studentId;
public:
    Student(const std::string& n, int a, const std::string& id) : Person(n, a), studentId(id) {}
    std::string getStudentId() const {
        return studentId;
    }
    std::string getStudentInfo() const {
        std::ostringstream oss;
        oss << "Name: " << getName() << ", Age: " << getAge() << ", Student ID: " << studentId;
        return oss.str();
    }
};

在这个例子中,getStudentInfo 函数将学生的姓名、年龄和学号组合成一个字符串返回,这是“Student”类特有的行为。

  1. 重载基类成员函数
    • 有时,派生类可能需要以不同的方式实现基类的某个成员函数,这就涉及到函数重载。例如,假设“Person”类有一个 printInfo 函数用于打印人的基本信息,“Student”类可以重载这个函数来打印学生的详细信息。
class Person {
private:
    std::string name;
    int age;
public:
    Person(const std::string& n, int a) : name(n), age(a) {}
    std::string getName() const {
        return name;
    }
    int getAge() const {
        return age;
    }
    void printInfo() const {
        std::cout << "Name: " << name << ", Age: " << age << std::endl;
    }
};

class Student : public Person {
private:
    std::string studentId;
public:
    Student(const std::string& n, int a, const std::string& id) : Person(n, a), studentId(id) {}
    std::string getStudentId() const {
        return studentId;
    }
    void printInfo() const {
        std::cout << "Name: " << getName() << ", Age: " << getAge() << ", Student ID: " << studentId << std::endl;
    }
};

在这个例子中,“Student”类重载了“Person”类的 printInfo 函数,以适应学生信息的打印需求。当通过“Student”类对象调用 printInfo 函数时,会执行“Student”类中重载后的版本。

  1. 覆盖基类虚函数(多态相关)
    • 当基类中的函数被声明为虚函数时,派生类可以覆盖这个虚函数来实现多态行为。例如,我们有一个“Animal”类作为基类,其中有一个虚函数 makeSound,然后派生出“Dog”类和“Cat”类,它们分别覆盖 makeSound 函数来实现不同的叫声。
class Animal {
public:
    virtual void makeSound() const {
        std::cout << "Some generic animal sound" << std::endl;
    }
};

class Dog : public Animal {
public:
    void makeSound() const override {
        std::cout << "Woof!" << std::endl;
    }
};

class Cat : public Animal {
public:
    void makeSound() const override {
        std::cout << "Meow!" << std::endl;
    }
};

在这个例子中,“Dog”类和“Cat”类通过覆盖“Animal”类的虚函数 makeSound,实现了各自特有的叫声。当使用基类指针或引用指向派生类对象时,调用 makeSound 函数会根据对象的实际类型(即动态类型)来决定执行哪个派生类的版本,从而实现多态。

四、派生新类的第三步:处理构造函数与析构函数

(一)派生类的构造函数

  1. 构造函数的调用顺序
    • 当创建派生类对象时,首先调用基类的构造函数,然后再调用派生类自己的构造函数。这是因为在初始化派生类对象之前,需要先初始化从基类继承来的部分。例如:
class Base {
public:
    Base() {
        std::cout << "Base constructor called" << std::endl;
    }
};

class Derived : public Base {
public:
    Derived() {
        std::cout << "Derived constructor called" << std::endl;
    }
};

int main() {
    Derived d;
    return 0;
}

在这个例子中,当创建 Derived 类对象 d 时,会先输出“Base constructor called”,然后输出“Derived constructor called”。

  1. 向基类构造函数传递参数
    • 如果基类的构造函数需要参数,派生类的构造函数需要在初始化列表中向基类构造函数传递合适的参数。例如,在“Student”类继承“Person”类的例子中,“Person”类的构造函数需要姓名和年龄作为参数,“Student”类的构造函数在初始化列表中调用“Person”类的构造函数并传递相应参数。
class Person {
private:
    std::string name;
    int age;
public:
    Person(const std::string& n, int a) : name(n), age(a) {
        std::cout << "Person constructor called with name: " << name << ", age: " << age << std::endl;
    }
};

class Student : public Person {
private:
    std::string studentId;
public:
    Student(const std::string& n, int a, const std::string& id) : Person(n, a), studentId(id) {
        std::cout << "Student constructor called with student ID: " << studentId << std::endl;
    }
};

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

在这个例子中,“Student”类的构造函数通过 Person(n, a) 在初始化列表中调用“Person”类的构造函数,并传递姓名和年龄参数,然后再初始化自己的 studentId 成员。

(二)派生类的析构函数

  1. 析构函数的调用顺序
    • 与构造函数的调用顺序相反,当派生类对象被销毁时,首先调用派生类自己的析构函数,然后再调用基类的析构函数。这是因为需要先清理派生类特有的资源,然后再清理从基类继承来的部分。例如:
class Base {
public:
    ~Base() {
        std::cout << "Base destructor called" << std::endl;
    }
};

class Derived : public Base {
public:
    ~Derived() {
        std::cout << "Derived destructor called" << std::endl;
    }
};

int main() {
    {
        Derived d;
    }
    return 0;
}

在这个例子中,当 Derived 类对象 d 超出作用域被销毁时,会先输出“Derived destructor called”,然后输出“Base destructor called”。

  1. 析构函数的作用
    • 派生类的析构函数主要用于释放派生类对象在生命周期内分配的资源。例如,如果派生类在构造函数中动态分配了内存,那么在析构函数中需要释放这些内存,以避免内存泄漏。例如:
class Derived {
private:
    int* data;
public:
    Derived() {
        data = new int[10];
        std::cout << "Derived constructor called" << std::endl;
    }
    ~Derived() {
        delete[] data;
        std::cout << "Derived destructor called" << std::endl;
    }
};

在这个例子中,“Derived”类的构造函数分配了一个包含 10 个整数的数组,析构函数释放了这个数组所占用的内存,确保资源的正确回收。

通过以上三个步骤,即确定基类与继承方式、添加新的成员、处理构造函数与析构函数,我们就可以在 C++ 中成功地派生新类。这三个步骤相互关联,每一步都对派生类的正确设计和功能实现起着重要作用。在实际编程中,需要根据具体的需求和软件架构来合理运用这些步骤,以构建出高效、可维护的软件系统。