C++面向对象

1.面向对象的三大特性:

封装,继承,多态

2.重载、**重写 和 **隐藏

对比项 重载(Overloading) 重写(Overriding) 隐藏(Hiding)
作用域 同一个类 继承体系(子类 vs. 父类) 继承体系(子类 vs. 父类)
函数名 相同 相同 相同
参数列表 必须不同 必须相同 可相同或不同
返回类型 可以不同,但不影响重载 不能改变(C++11 允许协变返回类型) 可以不同
访问方式 直接调用 通过基类指针或引用调用 直接调用
虚函数 无关 必须是 virtual 无关
影响 编译期选择不同方法 运行时调用子类方法(动态绑定) 隐藏父类同名方法,必须使用 Base::func() 访问
  1. 重载(Overloading) 发生在同一个类参数不同,返回值可以不同,属于编译期多态
  2. 重写(Overriding) 发生在继承体系子类重写基类的虚函数,必须函数签名(用于在重载时标识不同的函数)一致,属于运行时多态
  3. 隐藏(Hiding) 发生在继承体系,但不是虚函数重写,而是子类定义了同名函数,隐藏了父类版本,调用父类方法需要 Base::func()

在实践中,虚函数重写是 OOP 里最重要的特性,它允许多态行为,而隐藏可能导致代码难以维护,因此建议避免让子类的方法隐藏父类的方法,除非有特殊需求。

3.多态及其实现办法

  • 多态分为运行时多态(通过虚函数实现)和编译时多态(通过函数重载和模板实现)。
  • 运行时多态执行的核心步骤 (运行时多态,虚函数必须为 virtual,否则可能会引起内存泄漏)
    1. 对象构造时初始化虚函数指针(vptr,存储在对象的实例内存中)
      • 当对象被构造时,编译器会隐式地在对象内存中插入一个指向**虚函数表(vtable)**的指针(vptr)。
      • vptr 的值由对象的实际类型决定。例如:
        • Base 类型的对象,vptr 指向 Base 的虚函数表。
        • Derived 类型的对象,vptr 指向 Derived 的虚函数表。
    2. 虚函数表(vtable)的结构存储在只读数据段中,并且全局唯一
      • 每个有虚函数的类都有一个对应的虚函数表(静态存储区)。
      • 虚函数表是一个函数指针数组,每个条目指向该类的虚函数实现。
      • 若派生类覆盖了基类的虚函数,则虚函数表中对应的条目会被替换为派生类的函数地址。
    3. 通过基类指针或引用调用虚函数
      • 当通过基类指针或引用调用虚函数时,编译器会生成代码:
        1. 从对象的 vptr 找到对应的虚函数表。
        2. 根据虚函数在表中的索引(由编译器确定)找到函数地址。
        3. 调用该函数地址指向的实现。

4.虚函数和纯虚函数

在虚函数中,基类需要提供默认实现,并且基类是可以实例化的。但是在纯虚函数中,基类不提供实现(但是可以定义,但是使用必须显示的使用),同时基类不允许被实例化,并且派生类必须覆盖,要给出纯虚函数的实现,否则仍然是抽象类。(只要类中有一个纯虚函数,那么这个类就不能被初始化)

纯虚函数更多的应用在接口之中,来实现接口和实现的分离,充分体现面向对象的特性。此外,纯虚函数也和虚函数一样,要指定虚构函数,并且为`virtual`

为什么纯虚类的构造函数必须是`virtual`的:因为多态对象的析构是从派生类开始到基类结束的,只有这样才能保证内存被正确的释放。在析构时,需要动态调用来保证资源的全部释放。

5.多继承

CPP中,与 Java不同的一点是 CPP允许多继承。

1
2
3
class Derived : public Base1, public Base2 {
// ...
};

但是在多继承中,常见的问题是二义性问题,和棱形问题

  • 菱形问题:

    • 当一个派生类从两个或多个基类继承,而这些基类又共同继承自同一个基类时,会导致二义性 和数据冗余 。用虚继承可以解决这个问题
  • 二义性问题:

    • 如果多个基类有同名成员,直接访问会引发编译错误。用显式指定基类的作用域可以解决这个问题。

6.深拷贝和浅拷贝

  • 深拷贝:会重新复制内容并且分配内存,并且每个对象独立管理其对应的内存(类似于 unique_ptr
  • 浅拷贝:只会复制指针的地址,并且多个对象共享同一块内存。但是可能会导致double free,悬垂指针的问题。
  • 此外资源管理类 必须遵循”Rule of Three “: 如果定义了析构函数、拷贝构造函数或赋值运算符中的任意一个,通常需定义全部三个

7.Base* ptr = new Derived()的一些理解

  • 首先是Base* ptr,这表明ptr是静态类型Base*,并且是保存在栈空间上的

  • 然后ptr指向的是堆空间上的Derive()对象。这表明动态类型是Derived()

  • 这里,由于Derived是继承的Base,因此在Derived的堆空间中,结构是这样的(如果Base有虚函数),同时Base中的属性也会被继承下来(无论是否private)只不过在Derived中无法访问。

    1
    2
    3
    4
    5
    6
    7
    8
    堆上的 Derived 对象:
    +------------------+
    | vptr(指向Derived) |
    +------------------+
    | base_data |
    +------------------+
    | derived_data |
    +------------------+
  • 注意,这里的Base* ptr只能访问Base类中的属性和方法。当访问Base中的虚函数时,编译器会因为访问虚函数,因此触发动态调用,使用虚函数表指针去虚函数表中寻找

8.单继承和多继承的虚函数表

单继承

  • 只有 1 张 vtable,对象中只有 1 个 vptr
  • 调用虚函数时vptr 访问 vtable,跳转到函数地址。

多继承

  • 每个 Base 类有独立的 vtableDerived 有多个 vptr
  • 访问 Base1 相关函数时,走 vptr1 → vtable_for_Base1
  • 访问 Base2 相关函数时,走 vptr2 → vtable_for_Base2

9.如何禁止构造函数的使用

方法 适用场景 是否允许子类实例化
= delete 彻底禁止构造
private 构造函数 外部不能创建对象,但 friend 可创建
protected 构造函数 允许子类创建对象,但基类不可实例化
static 方法 通过 static 方法创建对象(单例模式)
抽象类(纯虚函数) 作为基类强制继承

10.什么是默认构造函数

默认构造函数没有参数所有参数都有默认值的构造函数。如果程序员没有显式定义构造函数,编译器会自动提供一个默认构造函数

  • 没有参数 或者 所有参数都有默认值
  • 用于创建对象时自动调用,初始化对象的成员变量。
  • 如果没有显式定义,编译器会自动生成一个(但不会初始化成员变量)。
  • 如果定义了其它带参数的构造函数,编译器不会再提供默认构造函数(C++11 之后)。

11.如何提高构造函数的效率

使用成员初始化列表构造效率最高的原因:避免了默认构造和赋值的双重开销,而是直接调用构造函数

1
2
3
4
5
6
7
8
9
10
11
class Example {
std::string s;
public:
// 初始化列表:直接调用 std::string 的构造函数
Example() : s("Hello") {}

// 构造函数体内赋值:先默认构造 s,再赋值
Example() {
s = "Hello"; // 生成临时字符串对象,再赋值给 s
}
};

优势从上面就显而易见

优化方法 适用场景 优化点
使用成员初始化列表 => A() : x(10), y(3.14) {} 成员变量的初始化 避免默认构造 + 赋值
避免不必要的构造 => void foo(A a) { } // ❌ void foo(const A& a) { } // ✅ 传参、返回值 传引用、RVO 优化
使用 explicit 防止隐式转换 避免额外构造
避免 new 动态分配 资源管理 使用栈分配或智能指针
优化容器初始化 std::vector 等容器 直接初始化,避免扩容
使用 = default 编译器优化 让编译器自动优化
使用 std::move 资源移动优化 避免不必要的拷贝

12.类对象初始化顺序

首先按照继承的顺序进行基类的初始化 –> 然后按照成员变量的申明顺序初始化,和构造函数初始化列表中的顺序无关 –> 然后进行构造函数体的执行

构造函数初始化列表指的是:

1
2
3
Derived() : m2("m2"), m1("m1") { // 初始化列表顺序:m2 → m1(实际顺序仍按声明)
cout << "Derived constructor\n";
}

13.友元函数的作用和使用场景

  1. 运算符重载:当需要重载 <<或者 >>的时候,由于左操作数是 ostreamistream
  2. 跨类访问私有成员
  3. 工具函数需要访问私有数据

需要注意的是:

友元函数的注意事项

  1. 破坏封装性
    友元函数会暴露类的内部实现,需谨慎使用,避免过度依赖。
  2. 友元关系不可传递
    若类 A 是类 B 的友元,类 B 是类 C 的友元,类 A 不会自动成为C 的友元。
  3. 友元声明的位置
    友元函数的声明必须出现在类的内部(通常在 publicprivate 区域,但权限不影响友元的访问能力)。
  4. 友元函数不是成员函数
    • 友元函数不属于类的成员,没有 this 指针。
    • 调用时直接通过函数名,而非对象(如 exchange(a, b))。
  5. 友元类
    可以将整个类声明为友元:
1
2
3
class A {
friend class B; // 类 B 的所有成员函数都可以访问 A 的私有成员
};

14.静态绑定和动态绑定

绑定时机 编译时 运行时
函数类型 非虚函数、重载函数、模板函数 虚函数(virtual functions)
性能 无额外开销 需查虚函数表,有轻微开销
灵活性 固定,无法动态改变行为 灵活,支持多态

15.cpp模板编程

C++ 模板编程是泛型编程的核心技术,允许你编写与类型无关的通用代码。模板编程也是一种多态

  • 函数模板:用于创建通用函数,自动推导参数类型
  • 类模板:用于创建通用类(如容器)
  • 模板特化:为特定类型提供定制化实现
  • 模板元编程:在编译期执行计算,例如计算阶乘
1
2
3
4
5
6
7
8
9
10
template <int N>
struct Factorial {
static const int value = N * Factorial<N-1>::value;
};
template <>
struct Factorial<0> { // 终止条件
static const int value = 1;
};
// 使用
std::cout << Factorial<5>::value; // 输出 120(编译期计算)

16.如何避免不必要的拷贝来提高效率

  • 使用引用或者指针来传递值 -> 避免不必要的大型对象拷贝
  • 针对资源管理类使用 std::move来移动所属权
  • 尽可能的避免使用临时对象

17.实例化一个对象需要哪几个阶段

阶段 描述
1. 分配内存 在栈上或堆上分配存储空间
2. 调用构造函数 初始化对象、调用基类构造函数
3. 执行初始化 初始化成员变量,拷贝/移动构造等
4. 使用对象 调用方法、访问成员变量
5. 调用析构函数 释放资源,执行清理操作

对象的实例化并不仅仅是构造函数的调用,而是一个完整的生命周期管理过程

18.如何让类中的函数无法访问类的成员变量

方法 是否能访问成员变量 适用场景
static 成员函数 ❌ 无法访问 工具类、无状态函数
内部 private ❌ 无法访问 数据封装,防止误操作
PIMPL 设计模式 ❌ 无法访问 API 设计,隐藏实现细节
friend 友元类 ✅ 友元可访问 需要控制访问权限

如果目标是让类的普通成员函数无法访问数据成员,最佳方案是使用 static 方法隐藏数据(PIMPL、内部类)

19.怎么限制类的对象只能创建在栈上或者堆上

  • 限制在堆上:

    • 将构造函数以及析构函数设为私有,同时提供一个静态的工厂函数用来返回在堆上创建的对象。(注意,这样做必须要手动释放堆空间,调用delete()直接delete this即可)
  • 限制在栈上:

    • 删除 newdelete关键字,来禁止在堆上创建
    1
    2
    void* operator new(size_t) = delete;  // 禁止 new
    void operator delete(void*) = delete; // 禁止 delete

20.类的默认私有继承和公有继承

在继承中,父类的所有属性和操作都会被继承到子类中,但是由于继承的默认是私有继承,导致无法直接访问父类的方法。(在下面的代码中,如果是 class Test : Derived那么Test的实例t无法直接调用 t.Derived::foo())。而在类中的 private,protected,public修饰的操作或者属性,仅仅用于类的不同访问者的访问权限。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <iostream>
using namespace std;
class Base {
public:
virtual void foo() = 0;
virtual void bar() = 0;
virtual ~Base() = default;
};
class Derived : Base { // 需要 public 继承
public:
void foo() override { // override 关键字增强可读性
cout << "Derived::foo" << endl;
}

void bar() final { // 需要 override 来表明继承自 Base
cout << "Derived::bar" << endl;
}
};
class Test : public Derived{ // 需要 public 继承
public:
void foo() override {
cout << "Test::foo" << endl;
}
};
int main() {
Derived d;
d.foo();
d.bar();
Test t;
t.foo();
t.Derived::foo(); // 这行仍然是合法的,但没有必要
return 0;
}
作者

kosa-as

发布于

2025-02-25

更新于

2025-03-18

许可协议

评论