第十三章:类继承 | C++ Primer Plus 重点带看

基类成员的初始化

通过使用成员初始化列表,调用基类的构造函数完成。然后再进入派生类的构造函数体,完成派生对象的创建。

// 语法:在派生类构造函数初始化列表中调用基类构造函数
// 初始化列表的顺序没有语法要求,但推荐按  基类->成员声明顺序  写。
Derived::Derived(参数列表) 
: Base(基类参数),  // 调用基类构造函数
  成员1(值1),    //成员按声明顺序
  成员2(值2) 
{
    // 构造函数体
}

派生类构造函数的 3 个要点

  1. 首先创建基类对象;
  2. 应通过成员初始化列表的方式将基类信息传给基类构造函数;
  3. 应初始化派生类新增的数据成员。

析构顺序

与创建顺序相反,先执行派生类的析构函数,再执行基类的析构函数。

类声明位置

一般将基类和派生类的声明放在同一个头文件中。

派生类与基类之间的特殊关系

  1. 派生类对象可以直接使用基类的公有方法;
  2. 基类指针、基类引用可以在不显式转换的情况下指向或引用派生类对像。但基类指针、引用只能访问基类中有的成员反过来不行)。这种转换是隐式的,也是安全的,被称为向上强制转换,并且是可传递的;
  3. 也可以用派生类对象初始化基类对象。会调用基类的拷贝构造函数,只复制基类部分。
class Base {
public:
    void baseFunc() { }
};

class Derived : public Base {
public:
    void derivedFunc() { }
};

Derived d;
Base* p = &d;

p->baseFunc();      // ✅
p->derivedFunc();   // ❌ 编译错误:p 是 Base*,不知道 Derived::derivedFunc


Derived d;
Base b = d;  // 调用基类拷贝构造,只复制 Base 部分

公有继承:is-a 关系

"派生类是基类的一个特例",这是公有继承使用的核心思想,目的是为了建立类型层次,并实现对外可见的完整多态。只在基类的基础上增加属性,但不会删除减少基类的属性。

私有继承大部分情况是为了复用基类的代码,并不是为了建立类型层次,几乎不怎么用(因为用组合会更清晰)。

保护继承是一种对外隐藏的 is-a 关系,只有在派生类内部才知道这种关系(只有内部能实现多态)。

class Base {
public:
    virtual void foo() { std::cout << "Base\n"; }
};

class Derived : protected Base {
public:
    void callBase() {
        Base* p = this;  // ✅ 在 Derived 内部可以
        p->foo();
    }
};

class GrandChild : public Derived {
public:
    void callBase2() {
        Base* p = this;  // ✅ 在孙类内部也可以
        p->foo();
    }
};

// 外部
Derived d;
Base* p = &d;  // ❌ 错误:外部看不到这个继承关系

多态

多种形态,即同一个方法的行为随上下文而异。

多态公有继承的实现机制

只有一种:虚函数 + 运行时绑定。也叫函数重写,运行时才会查表决定到底调用哪个实际类型版本,才是多态。

在派生类中重新定义基类的方法(非 virtual)。这是函数隐藏(父类把子类的方法隐藏),编译时就决定了调用基类版本,不是多态。

virtual

如果没有使用 virtual,程序将根据引用或指针类型选择方法;如果使用 virtual,程序将根据引用或指针指向的实际对象类型选择方法。(方法在基类中声明为 virtual 后,所有派生类(包括派生的派生)中将自动也成为虚方法,也就是说可以只在基类写 virtual,而派生类不用,但一般是都写上)

作用域解析运算符 :: 调用基类方法

当派生类定义了与基类同名的成员时,基类版本会被隐藏。此时可用 Base::method() 显式调用基类版本。通常用于派生类扩展基类功能时,先调用基类实现,再添加自己的逻辑。

基类应使用虚析构函数

为基类提供虚析构函数,确保 delete 基类指针时能正确调用派生类析构函数,避免内存泄漏。

  • 非虚析构函数:静态绑定,delete 时只调用基类析构函数。
  • 虚析构函数:动态绑定,先调用派生类析构函数,再自动调用基类析构函数。

静态与动态绑定(联编)

绑定指调用函数时,确定执行哪个版本的过程

  • 静态绑定:编译时即确定调用哪个函数,生成直接调用指令。
  • 动态绑定:运行时通过 vtable 确定函数地址。

非虚方法一定是静态绑定,因为编译器在编译时就确定了函数地址(由指针类型决定,不由 p 指向的对象决定)。虚方法通过基类指针或引用调用时,必须使用动态绑定;如果直接调用对象(如 obj.method()),编译器可以优化为静态绑定。

虚函数指针、虚函数表

虚表指针(vptr)存储在每个有虚函数的对象中,指向该类的虚函数表(vtable)。vtable 是类的属性,所有同类对象共享一份;vptr 是对象的属性,每个对象独立存储。

查表过程:从指针取出对象地址 → 访问 vptr → 查类的 vtable → 跳转到函数地址 → 调用函数执行

class Base {
public:
    virtual void foo() { std::cout << "Base\n"; }
};

class Derived : public Base {
public:
    void foo() override { std::cout << "Derived\n"; }
};

Base* p = new Derived();
p->foo();  // 输出 Derived

p->foo()
    ↓
┌─────────────────┐
│ p 是 Base*      │  知道类型是 Base,sizeof(Base)...
└─────────────────┘
    ↓
┌─────────────────┐
│ 从 p 取出对象地址 │
└─────────────────┘
    ↓
┌─────────────────┐
│ 访问对象的 vptr  │  对象内存布局:vptr 在最前面
└─────────────────┘
    ↓
┌─────────────────┐
│ vptr → vtable   │  运行时才知道指向哪个 vtable
└─────────────────┘
    ↓
┌─────────────────┐
│ vtable[0] → foo │  运行时才知道 foo 在哪
└─────────────────┘
    ↓
调用 foo()

构造函数不能为虚函数原因

虚函数依赖 vptr 查表机制,但 vptr 在构造函数中才被设置,构造时对象还未完全成型,无法使用虚函数机制。此外,创建对象时编译器已知具体类型,不需要动态绑定。

派生类不继承基类的构造函数。

友元不能是虚函数

因为友元不是成员(没有 this 指针),只有成员才能是虚函数。

┌─────────────────────────────────────────────────────┐
│                                                     │
│   虚函数机制的前提:必须是类的成员                     │
│                                                     │
│   ├── 虚函数通过 vptr/vtable 实现                   │
│   ├── vptr 存在于每个对象中                         │
│   └── 只有成员函数才隐含 this 指针,才能访问 vptr    │
│                                                     │
│   友元函数:                                         │
│   ├── 不是类的成员                                   │
│   ├── 没有 this 指针                                 │
│   ├── 不能访问 vptr                                 │
│   └── 因此无法实现动态绑定                           │
│                                                     │
└─────────────────────────────────────────────────────┘

函数类型

能否 virtual

原因

普通成员函数

可以

有 this,可访问 vptr

静态成员函数

❌ 不行

无 this 指针

构造函数

❌ 不行

vptr 尚未设置

析构函数

✅ 可以

对象仍存在

友元函数

❌ 不行

不是成员,无 this

方法隐藏

派生类重新定义方法将会隐藏基类的同名方法(不管函数特征标如何),而不会是函数重载。(返回类型协变除外)。要想调用被隐藏的方法可以通过两个途径:1、使用作用域解析运算符(::)来指定基类的函数;2、在派生类中使用 using 声明,将基类的函数引入派生类作用域,从而形成重载。

方法隐藏(非多态,静态绑定)和方法重写(多态,动态绑定)的区别

方法隐藏:派生类定义与基类同名的函数时,基类的所有同名函数(不论参数列表,不论是否 virtual)都会被隐藏。

方法重写:派生类用 virtual 重写基类的虚函数,未被重写的虚函数仍然正常继承(只重写一个不会发生隐藏,依然会继承下来,根本原因是虚函数表对应的基类版本继承了下来)。

注意二者都可以通过解析运算符实现基类同名函数的显示调用。

#include <iostream>
using namespace std;

class Base
{
  public:
    // 基类有多个重载的虚函数
    virtual void process()
    { // 虚函数1
        cout << "Base::process()\n";
    }

    virtual void process(int x)
    { // 虚函数2,重载
        cout << "Base::process(int) with " << x << "\n";
    }

    virtual void process(double x)
    { // 虚函数3,重载
        cout << "Base::process(double) with " << x << "\n";
    }
};

class Derived : public Base
{
  public:
    // 派生类只重写了一个
    void process() override
    { // 只重写无参版本
        cout << "Derived::process()\n";
    }
    // 注意:没有重写 process(int) 和 process(double)
};

int main()
{
    Derived d;

    // ✅ 可以调用,派生类有这个方法
    d.process(); // 输出: Derived::process()

    // ❌ 编译错误!被隐藏了
    // d.process(10);     // 错误: Base::process(int) 被隐藏
    // d.process(3.14);   // 错误: Base::process(double) 被隐藏

    // ✅ 但可以通过基类指针/引用调用
    Base &b = d;
    b.process(10);   // 输出: Base::process(int) with 10
    b.process(3.14); // 输出: Base::process(double) with 3.14

    // ✅ 或者通过作用域解析运算符
    d.Base::process(10);   // 输出: Base::process(int) with 10
    d.Base::process(3.14); // 输出: Base::process(double) with 3.14

    return 0;
}

protected

与 private 相似,在类外也是只能用公有方法访问 protected 数据成员和调用 protected 方法。与 privated 区别是类内使用:派生类可以直接访问 protected 成员和方法(对外隐藏,对派生类开放)。

抽象基类

只要包含纯虚函数(= 0)就是抽象基类,并且抽象基类不能创建对象。如果派生类不实现纯虚函数定义,那么将继续成为抽象类。(必须实现所有纯虚函数才能脱离抽象状态,否则仍是抽象类)抽象基类包含纯虚函数,不能实例化对象。

抽象基类通常用于定义接口契约,让不同派生类提供统一行为。

纯虚函数实现

抽象类也可以对纯虚函数进行定义,定义的方式和普通函数一样,默认也是内联的,可以在类外定义,也可以直接在类内定义(注意:部分编译器不支持)。

纯虚函数提供定义用于为派生类提供默认实现,派生类可以选择覆盖或继承使用(将不在是抽象类了)。

class Base {
public:
    virtual void foo() = 0;
    virtual void bar() = 0 {  // 类内定义
        std::cout << "inline definition\n";
    }
};
// 类外定义
void Base::foo() { /* ... */ }

基类和派生类内使用动态内存分配时的注意事项

派生类的析构函数拷贝构造函数赋值运算符都必须使用相应的基类方法完成。否则可能造成浅拷贝或重复释放等其他内存问题。

  • 析构函数是自动完成的,不需要手动处理;
  • 拷贝构造函数需要通过初始化成员列表调用基类拷贝构造函数完成的(否则会隐式调用默认构造函数,造成错误);
  • 赋值运算符必须通过显示调用基类赋值运算符 Base::operator=(传入的 BasePlus 引用) 完成。
class Base {
protected:
    int* data;
public:
    Base(int v = 0) : data(new int(v)) { }
    Base(const Base& other) : data(new int(*other.data)) { }
    virtual ~Base() { delete data; }
};

class Derived : public Base {
private:
    double* arr;
public:
    Derived(int a = 0, int b = 0) : Base(a), arr(new double(b)) { }
    Derived(const Derived& other) : Base(other), arr(new double(*other.arr)) { }
    Derived& operator=(const Derived& other) {
        if (this != &other) {
            Base::operator=(other);
            delete arr;
            arr = new double(*other.arr);
        }
        return *this;
    }
    ~Derived() { delete arr; }
};

派生类使用基类中的友元

因为友元函数不是成员函数,不可以被继承、也不可以使用作用域运算符调用。

派生类如果需要使用基类的友元函数,有两种方式:一是定义自己的友元函数,内部将派生类引用强制转换为基类引用来调用基类友元;二是直接定义新的友元函数,内部直接访问所需的成员。

class Derived : public Base {
private:
    double extra;
public:
    Derived(int d = 0, double e = 0) : Base(d), extra(e) { }
    friend std::ostream& operator<<(std::ostream& os, const Derived& d) {
        os << static_cast<const Base&>(d);  // 强制转为基类引用
        os << ", extra: " << d.extra;
        return os;
    }
};
// 另一种写法
class Derived : public Base {
    // ...
    friend std::ostream& operator<<(std::ostream& os, const Derived& d) {
        os << "Base: " << d.data;      // 直接访问基类私有成员
        os << ", extra: " << d.extra;  // 访问自己的成员
        return os;
    }
};

调用拷贝构造的四种情形

  1. 用同类对象初始化一个新对象;
  2. 将对象按值传递给函数(这个临时对象既要调用拷贝构造函数,又要在函数调用结束之后调用析构函数,效率非常低,所以对于对象一般传递引用);
  3. 函数按值返回对象(可能编译器已经优化了,不会在返回处再调用一次构造函数);
  4. 编译器生成的临时对象。

赋值运算符调用的情形

如果语句创建新的对象,则使用构造函数;如果语句修改已有对象的值,则是赋值

四种函数不能被继承

构造函数;友元函数;析构函数;赋值运算符(实际上会继承,但是有问题,见下面代码)。

class Base {
public:
    // 基类赋值运算符
    Base& operator=(const Base& other) {
        cout << "Base::operator=" << endl;
        return *this;
    }
};

class Derived : public Base {
    // 没有定义自己的operator=
};


int main() {
    Derived d1, d2;
    
    // 这是合法的!编译器会生成一个Derived::operator=
    d1 = d2;  // ✅ 编译通过
    
    // 但编译器生成的Derived::operator=会:
    // 1. 调用Base::operator= 处理基类部分
    // 2. 对派生类成员进行逐成员赋值
    // 3. 返回Derived&
}
为什么说"不能继承"?
1、派生类不会自动获得一个能正确处理深拷贝的operator=
2、基类的operator=参数是const Base&,不能直接用于Derived对象间的赋值
3、需要派生类显式定义自己的operator=
4、即使使用合成的,也可能有资源管理问题

对象切片

对象切片是指将派生类对象赋值给基类对象时,只复制基类部分,派生类特有的数据和行为被丢弃。切片只发生在对象赋值/按值传递时,因为需要将数据复制到目标对象的存储空间中。指针和引用不会切片,因为它们只是指向/引用原对象,不进行数据复制。避免切片的方法是使用引用或指针传递派生类对象。

操作

行为

Base b = d;

调用 Base(const Derived&) 或赋值运算符,需要复制数据,派生类部分无法放入基类空间。

Base* p = &d;

只是记录地址,不复制数据,指向的是完整对象。

Base& r = d;

只是别名,不复制数据,引用的是完整对象。

自动向上强制转换

向上强制转换是指将派生类指针/引用隐式转换为基类指针/引用,是安全的隐式转换。

区分赋值转型赋值给基类对象会发生切片,丢失派生类部分;而指针/引用转型只是别名/地址记录,不复制数据,完整对象保持不变。

C++ Primer Plus 文章被收录于专栏

C++ Primer Plus 精读|从入门到面试,重点内容全程带看。 本专栏以《C++ Primer Plus》为蓝本,逐章提炼必考知识点、易错点、面试高频考点,跳过冗余示例,直击语法本质与工程实践,帮你高效吃透 C++ 基础,夯实底层开发必备能力。

全部评论

相关推荐

评论
2
1
分享

创作者周榜

更多
牛客网
牛客网在线编程
牛客网题解
牛客企业服务