C++ 核心基础与面向对象 (OOP)

C++ 核心基础与面向对象 (OOP)

类和对象(上)

编程范式的演进

  • 面向过程 (C语言)

    • 核心思想:关注过程(步骤)。分析出解决问题的步骤,用函数一步步调用。
      弊端:数据和操作分离,代码复用率低,维护难。
      例子:洗衣服 -> 拿盆() -> 放水() -> 放衣服() -> 手搓()。
  • 面向对象 (C++)

    • 核心思想:关注对象。将事情拆分成不同对象,靠对象间的交互完成。
      优势:高内聚、低耦合,易维护、易复用、易扩展。
      例子:洗衣服 -> 人、衣服、洗衣机、洗衣粉(四个对象交互)。

类的定义与封装 (核心语法)

  • 类的引入 (struct vs class)

    • C++中的 struct:升级了。不仅能定义变量,还能定义函数。

      C++中的 class:专门用于定义类。

    • // C语言风格

      struct StackC {

      int* array;

      int size;

      };

      void Init(StackC* s); // 数据和方法分离

// C++风格

struct StackCpp {

int* _array;

void Init(int n); // 方法都在结构体内

};

  • 类的定义方式

    • 推荐规范:.h 文件声明,.cpp 文件定义。

      成员变量命名:建议加前缀 _ 或 m_,防止与函数形参混淆。

    • // Date.h

      class Date {

      public:

      void Init(int year); // 声明

      private:

      int _year; // 成员变量习惯加前缀

      };

// Date.cpp

void Date::Init(int year) { // 需要加类域限定符 ::

_year = year; // 这里的_year是成员,year是形参

}

  • 访问限定符与封装

    • 封装本质:隐藏细节(private),暴露接口(public)。
      三种限定符:
      public:类外可直接访问。
      protected:类外不可访问(继承中用)。
      private:类外不可访问。
      作用域:从该限定符开始,直到下一个限定符或类结束 }。
      默认权限面试题:
      class 的默认权限是 private。
      struct 的默认权限是 public (为了兼容C语言)。

类对象模型 (💾 内存与底层 - 面试高频)

  • 类的实例化

    • 定义:类只是图纸(不占空间),实例化出的对象才是房子(占物理内存)。

    • class Person { int _age; }; // 图纸,无空间

      int main() {

      Person man; // 造出了房子,man占用4字节空间

      // Person._age = 10; // 错误!图纸怎么存数据?

      man._age = 10; // 正确

      }

  • 对象的存储方式

    • 只保存成员变量:对象的大小只计算成员变量。
      成员函数去哪了?:存放在公共代码段。因为不同对象的成员变量值不同,但执行的函数代码是一模一样的,存多份是浪费。
  • 内存对齐规则 (计算 sizeof)

    • 计算机为了访问速度,不会把变量紧挨着存,而是按照规则对齐。
      规则1:第一个成员在偏移量为0处。
      规则2:其他成员对齐到 min(自身大小, 默认对齐数) 的整数倍地址。(VS默认8,Linux默认4)。
      规则3:结构体总大小为 max(所有成员对齐数) 的整数倍。
      空类大小:sizeof(空类) = 1。这1字节是为了占位,证明这个对象存在过,区分不同对象。
  • 计算示例

    • class A {
      char _c; // 1字节,偏移量0
      int _i; // 4字节。对齐数 min(4,8)=4。偏移量必须是4的倍数,所以偏移量1,2,3空着,从4开始存。
      // 此时占用了 0~7 共8字节。
      char _c2;// 1字节。对齐数1。放在偏移量8的位置。目前总大小9。
      };
      // 最终大小:最大成员int是4,总大小必须是4的倍数。9不是,向上取整到12。
      // sizeof(A) = 12

this 指针 (🔑 难点与考点)

  • 为什么需要 this 指针?

    • 类函数体只有一份,当 d1.Print() 和 d2.Print() 调用同一个函数时,编译器如何知道是打印 d1 还是 d2 的数据?
      答案:编译器隐式传递了对象的地址。
  • 编译器眼中的成员函数

    • 我们看到的:void Date::Print() {

      cout << _year << endl;

      }

      d1.Print();

    • 编译器处理后的(伪代码):// 多了一个隐藏参数 this

      void Date::Print(Date* const this) {

      cout << this->_year << endl;

      }

      d1.Print(&d1); // 传参传的是地址

  • this 指针的特性

    • 类型:类类型* const。即 this 本身不能被修改(不能写 this = nullptr)。
      存储位置:栈或寄存器(如VS用ECX寄存器传递)。注意:this指针不存储在对象里面!
      生命周期:随函数调用而生,随函数结束而灭。
  • 经典面试题:空指针调用

    • class A {
      public:
      void Print() { cout << "Print()" << endl; }
      void Show() { cout << _a << endl; }
      private:
      int _a;
      };

int main() {

A* p = nullptr;

p->Print(); // 【情况1】

p->Show(); // 【情况2】

}情况1:正常运行。

解析:虽然 p 是空指针,但调用 Print 函数并不需要解引用 p(函数地址在代码段)。传递给 Print 的 this 指针是 nullptr,但函数体内并没有使用 this,所以没挂。

情况2:运行崩溃。

解析:调用 Show 时,this 是 nullptr。函数体内访问 _a 本质是 this->_a,相当于 nullptr->_a,空指针解引用,导致程序崩溃。

面试题

  • C++中 struct 和 class 的区别是什么?
    答:C++中 struct 可以定义类,也能定义函数。主要区别在于默认访问权限:class 默认是 private,struct 默认是 public。另外在继承默认权限上也有区别(后续课程涉及)。
    面向对象的三大特性是什么?
    答:封装、继承、多态。
    结构体/类为什么要进行内存对齐?
    答:主要为了性能(CPU读取内存通常按块读取,对齐可减少读取次数)和平台移植性(某些硬件平台只能在特定地址访问特定类型数据)。
    this指针存在哪里?
    答:this指针是形参,所以存在于栈帧中;由于经常使用,某些编译器(如VS)会通过寄存器(ecx)传递优化。不存储在对象中。
    this指针可以为空吗?
    答:在语法上,指针可以为空。如果调用成员函数时对象指针为空,只要函数体内不发生解引用(即不访问成员变量),程序可以运行;一旦访问成员变量,就会崩溃。

类和对象(中)

类的6个默认成员函数

  • 定义:如果用户没有显式实现,编译器会自动生成的成员函数。

  • 空类 (class Empty {}):并非什么都没有,编译器会自动生成以下6个函数:

    构造函数 (Initialization)

    析构函数 (Cleaning up)

    拷贝构造函数 (Copy Initialization)

    赋值运算符重载 (Copy Assignment)

    取地址操作符重载 (Address-of)

    const取地址操作符重载 (Address-of const)

构造函数 (Constructor)

  • 概念:特殊的成员函数,名字与类名相同,创建对象时由编译器自动调用,保证成员初始化。

    核心任务:不是开空间(开空间是 operator new 或栈分配),而是初始化对象。

  • 特征:

    函数名与类名相同。

    无返回值(也不写void)。

    对象实例化时自动调用。

    支持重载(可以有多个构造函数,如无参、带参、全缺省)。

  • 默认构造函数:

    定义:不需要传参就可以调用的构造函数。

    包括:

    我们没写,编译器自动生成的。

    无参构造函数 Date() {}。

    全缺省构造函数 Date(int year=1900, ...) {}。

    注意:这三种只能存在一个,否则调用 Date d; 时会有二义性。

  • 编译器生成的默认构造函数行为:

    内置类型(int/char/指针等):不处理(通常是随机值)。

    自定义类型(class/struct等):调用该成员自己的默认构造函数。

    C++11补丁:允许在类声明时给内置类型成员缺省值(如 int _year = 1900;)。

  • class Date {

    public:

    // 全缺省构造函数 (推荐)

    Date(int year = 1900, int month = 1, int day = 1) {

    _year = year;

    _month = month;

    _day = day;

    }

    private:

    int _year; // C++11 支持在这里给缺省值 int _year = 1900;

    int _month;

    int _day;

    };

析构函数 (Destructor)

  • 概念:对象生命周期结束时自动调用,完成资源清理工作(如释放堆内存)。

  • 特征:

    函数名:~类名(如 ~Date)。

    无参数,无返回值。

    一个类只能有一个(不支持重载)。

    自动调用:对象销毁时。

  • 默认析构函数行为:

    内置类型:不处理(栈内存系统自动回收)。

    自定义类型:调用该成员自己的析构函数。

  • 使用场景:

    类中没有申请资源(如 Date):可不写,用默认的。

    类中申请了资源(如 Stack 的 malloc):一定要写,否则内存泄漏。

拷贝构造函数 (Copy Constructor)

  • 概念:用已存在的类对象创建新对象。

  • 特征:

    是构造函数的重载形式。

    参数只有一个且必须是类类型对象的引用(通常加 const)。

    Date(const Date& d) -> 正确

    Date(Date d) -> 错误(会引发无穷递归调用,因为传值传参本身就要调用拷贝构造)。

  • 默认拷贝构造函数行为:

    浅拷贝 (值拷贝):按字节序拷贝。

    内置类型:直接拷贝值(如 _year = d._year)。

    自定义类型:调用该成员的拷贝构造函数。

  • 深浅拷贝问题:

    浅拷贝风险:如果对象管理资源(如 Stack),浅拷贝会导致两个对象指向同一块内存。

    修改互相影响。

    析构时Double Free(同一块内存释放两次,程序崩溃)。

    结论:涉及资源管理(如 Stack)必须显式实现深拷贝;不涉及(如 Date)用默认的即可。

  • // 错误写法

    // Date(Date d) { ... }

    // 调用时 Date d2(d1);

    // 传参 d1 给 d,这是传值传参,需要调用拷贝构造 Date(d1) -> 再次传参 -> 再次调用... 死循环

赋值运算符重载 (Assignment Operator Overloading)

  • 运算符重载基础:

    关键字:operator + 运算符(如 operator==)。

    参数:对于类成员函数,隐含第一个参数 this 指针。

    不可重载的5个符:.*、::、sizeof、?:、.。

  • 赋值重载 operator=:

    格式:Date& operator=(const Date& d)

    返回 Date&:支持连续赋值 (d1 = d2 = d3)。

    检测自我赋值:if (this != &d),避免浪费性能或逻辑错误。

    特性:

    只能重载成成员函数(不能是全局,否则会与编译器默认生成的冲突)。

    默认行为:与拷贝构造类似,进行浅拷贝(值拷贝)。

  • 前置++ vs 后置++:

    前置++:Date& operator++() -> 返回+1后的引用(效率高)。

    后置++:Date operator++(int) -> 参数 int 仅作占位符区分;返回+1前的旧值(需创建临时对象,效率低)。

  • Date& operator=(const Date& d) {

    if (this != &d) { // 防止 d1 = d1

    _year = d._year;

    _month = d._month;

    _day = d._day;

    }

    return *this; // 支持连续赋值

    }

const 成员函数

  • 定义:void Print() const { ... }

  • 本质:修饰隐含的 this 指针。

    Date* const this (普通) -> const Date* const this (const修饰后)。

    限制:函数体内不能修改任何成员变量。

  • 权限规则(权限只能缩小,不能放大):

    const 对象 不能 调用 非 const 成员函数(权限放大❌)。

    非 const 对象 可以 调用 const 成员函数(权限缩小✅)。

    const 成员函数 不能 调用 非 const 成员函数(权限放大❌)。

    非 const 成员函数 可以 调用 const 成员函数(权限缩小✅)。

  • void Func(const Date& d) {

    d.Print(); // 如果 Print 没有加 const 修饰,这里会报错

    // 因为 d 是 const,传给非 const 的 this 指针属于权限放大

    }

取地址及 const 取地址操作符重载

  • 函数原型:

    Date* operator&()

    const Date* operator&() const

  • 现状:绝大多数情况不需要显式重载,编译器默认生成的就够用(返回 this)。

    特殊场景:不想让别人获取真实地址(比如返回 nullptr 或 假地址)时才需要重载。

类和对象(下)

构造函数的进阶 (初始化列表)

  • 背景:构造函数体内的赋值并不是真正的"初始化",而是"赋初值"。有些类型的成员变量必须在从内存诞生的一刻就赋值,这就引入了初始化列表。

  • 什么是初始化列表?

    定义:在构造函数参数列表后,以冒号 : 开始,逗号分隔,格式为 成员变量(初值)。

    • // 推荐写法
      Date(int year, int month)
      : _year(year) // 初始化
      , _month(month)
      {
      _day = 1; // 赋初值
      }
  • 为什么必须用初始化列表?(必考点)

    • 有些成员变量只有一次初始化机会,不能在函数体内赋值。
      必须使用的三种情况:
      引用成员变量 (int& _ref;) ------ 引用必须在定义时绑定。
      const成员变量 (const int _n;) ------ 常量必须在定义时初始化,之后不能修改。
      没有默认构造函数的自定义类型成员 ------ 必须显式调用它的带参构造。
  • 初始化顺序的坑

    • 规则:成员变量的初始化顺序 只取决于类中声明的顺序,与初始化列表中的书写顺序无关。

    • 错误示范:class A {

      int _a2;

      int _a1;

      public:

      // 错误!_a2会先初始化,但_a1此时还是随机值

      A(int x) : _a1(x), _a2(_a1) {}

      }

  • explicit 关键字

    • 作用:禁止构造函数进行隐式类型转换。
      场景:
      Date d1 = 2022; 会发生隐式转换:2022 (int) -> Date(2022) (临时对象) -> 拷贝构造给 d1。
      加上 explicit Date(int year) 后,上述代码会报错,强制用户写 Date d1(2022);。

static 静态成员

  • 背景:当我们需要一个数据属于"整个类"而不是"某个对象"时(例如统计创建了多少个对象)。

  • 静态成员变量

    • 存储位置:静态区(不占用对象的空间,计算 sizeof(对象) 时不包含它)。
      定义规则:必须在类外定义和初始化,类内只是声明。
      类内:static int _count;
      类外:int A::_count = 0; (不用加static)
  • 静态成员函数

    • 核心特性:没有 this 指针。
      访问限制:
      不能访问非静态成员变量/函数(因为没 this 指针,找不到对象)。
      只能访问静态成员变量/函数。
      调用方式:不需要对象也能调用,如 A::GetCount()。

友元 (Friend)

  • 背景:有时候为了方便(如 IO 操作),需要打破封装,允许外部函数访问类的私有成员。

  • 友元函数 (friend function)

    • 典型应用:operator<< (流插入运算符重载)。

      痛点:如果作为成员函数,this 指针抢占第一个参数,调用变成 d1 << cout,非常别扭。

      解决:定义为全局函数,把 cout 放第一个参数,设为友元以访问私有数据。

    • 代码示例:class Date {

      // 声明友元,告诉编译器 operator<< 可以访问我的 private

      friend ostream& operator<<(ostream& out, const Date& d);

      private:

      int _year;

      };

      // 全局函数实现

      ostream& operator<<(ostream& out, const Date& d) {

      out << d._year; // 直接访问私有成员

      return out;

      }

  • 友元类 (friend class)

    • 特性:
      单向性:A是B的友元 != B是A的友元。
      不可传递:A是B友元,B是C友元 != A是C友元。
      不可继承。

内部类 (Inner Class)

  • 背景:一个类仅仅为另一个类服务,且需要频繁访问外部类的私有成员。

  • 定义:定义在另一个类内部的类。

    关系:

    内部类天生是外部类的友元(可以直接访问外部类的 static 和 private 成员)。

    外部类不是内部类的友元。

    计算大小:sizeof(外部类) 不包含内部类的大小。内部类仅仅是受到类域限制,本质是独立的。

匿名对象 (Anonymous Object)

  • 语法:ClassName(args),例如 Date(2023, 10, 1)。

    生命周期:即用即销毁,生命周期只在当前这一行代码。

    常用场景:调用某个函数,只需要临时用一下对象,不想专门起名字。

  • // 为了调用 Sum 函数专门定义一个对象 s,太麻烦

    // Solution s; s.Sum(10);

// 推荐:匿名对象直接调用

Solution().Sum(10);

编译器优化 (Copy Elision)

  • 背景:C++ 标准允许编译器在传参和返回值时,省略不必要的拷贝构造,提高效率。

  • 优化场景 1:连续构造+拷贝构造 -> 优化为直接构造

    代码:void func(A a); func(1); (隐式转换)

    原流程:1 -> 构造临时对象 -> 拷贝构造给形参 a。

    优化后:直接用 1 构造形参 a。

  • 优化场景 2:函数返回对象 (RVO)

    代码:A f() { return A(); } A ret = f();

    原流程:构造临时对象 -> 拷贝构造给返回值临时变量 -> 拷贝构造给 ret。

    优化后:直接构造 ret。

    注意:赋值重载(operator=)无法被优化,因为它不是初始化,是已存在对象的赋值。

C/C++内存管理

C/C++ 内存分布 (Memory Layout)

  • 背景:程序运行时,操作系统会为其分配内存空间。为了高效管理,这块空间被划分成了不同的区域,每个区域存储不同类型的数据。

  • 栈区 (Stack)

    • 存储内容:非静态局部变量、函数参数、返回值。
      特点:向下增长(高地址 -> 低地址),自动管理(出作用域自动销毁)。
      代码示例: void Func(int a) { // 参数 a 在栈
      int b = 10; // 局部变量 b 在栈
      }
  • 存映射段 (Memory Mapping Segment)

    • 存储内容:加载动态库、共享内存。
      特点:高效 I/O 映射,用于进程间通信。
  • 堆区 (Heap)

    • 存储内容:动态分配的内存 (malloc/new 出来的)。
      特点:向上增长(低地址 -> 高地址),手动管理(需 free/delete)。
  • 数据段 (Data Segment / Static)

    • 存储内容:全局数据、静态数据 (static 修饰)。

      特点:程序结束后由系统释放。

    • int g_val = 1; // 全局变量

      static int s_val = 1; // 静态变量

  • 代码段 (Code Segment)

    • 存储内容:可执行代码(二进制指令)、只读常量。

    • const char* p = "abcd"; // "abcd" 字符串常量在代码段

  • int globalVar = 1;

    static int staticGlobalVar = 1;

    void Test() {

    static int staticVar = 1;

    int localVar = 1;

    int num1[10] = {1, 2, 3, 4};

    char char2[] = "abcd";

    const char* pChar3 = "abcd";

    int* ptr1 = (int*)malloc(4);

    // 问:

    // globalVar, staticGlobalVar, staticVar -> 数据段(静态区)

    // localVar, num1, char2, pChar3, ptr1 -> 栈

    // *char2 (数组内的字符) -> 栈 (拷贝到栈上的)

    // *pChar3 (指向的常量字符串) -> 代码段(常量区)

    // *ptr1 (指向的动态内存) -> 堆

    }

C语言动态内存管理 (malloc/calloc/realloc)

  • 背景:C 语言通过库函数来管理堆内存。

  • malloc

    • 原型:void* malloc(size_t size);
      作用:分配 size 字节的未初始化内存。
  • calloc

    • 原型:void* calloc(size_t num, size_t size);
      作用:分配内存并初始化为0。
  • realloc

    • 原型:void* realloc(void* ptr, size_t size);
      作用:对已分配的内存进行扩容。
      扩容机制:
      原地扩容:如果后面有足够空间,直接在原位置延伸。
      异地扩容:如果后面空间不足,找新空间 -> 拷贝数据 -> 释放旧空间 -> 返回新地址。
  • free

    • 作用:释放动态内存。
  • malloc/calloc/realloc 的区别?

    • malloc:只分配,不初始化。
      calloc:分配 + 初始化为0。
      realloc:调整已分配空间的大小。

C++ 动态内存管理 (new/delete)

  • 背景:C 语言的 malloc 无法处理自定义类型(不能调用构造函数),C++ 引入了 new/delete 操作符。

  • 基础用法

    • 单个对象:int* p1 = new int; // 申请

      delete p1; // 释放

    • 数组:int* p2 = new int[10]; // 申请10个int

      delete[] p2; // 释放必须匹配 []

  • 核心区别 (与 malloc/free 相比)

    • 内置类型:几乎一样,只是 new 失败抛异常,malloc 返回 NULL。
      自定义类型:
      new:申请空间 (operator new) + 调用构造函数。
      delete:调用析构函数 + 释放空间 (operator delete)。
      malloc/free:只管空间,不调用构造/析构。

底层原理 (operator new / operator delete)

  • 背景:new 和 delete 是操作符,它们底层调用的是全局函数 operator new 和 operator delete。

  • operator new

    • 本质:是对 malloc 的封装。
      逻辑:
      调用 malloc 申请空间。
      如果成功,返回指针。
      如果失败,尝试执行空间不足应对措施(如有),否则抛出 bad_alloc 异常。
  • operator delete

    • 本质:是对 free 的封装。
  • 实现原理总结

    • new T:调用 operator new (申请空间) -> 调用 T 的构造函数。
      delete ptr:调用 ptr 指向对象的析构函数 -> 调用 operator delete (释放空间)。
      new T[N]:调用 operator new[] -> 执行 N 次构造。
      delete[] ptr:执行 N 次析构 -> 调用 operator delete[]。

定位 new (Placement New)

  • 背景:如果我们已经有一块内存(比如内存池分配的),想在这块内存上初始化一个对象,该怎么办?

  • 定义:在已分配的原始内存空间中调用构造函数初始化一个对象。

    语法:new (place_address) type(initializer-list)

  • // 1. 只有空间,没有对象

    A* p = (A*)malloc(sizeof(A));

    // 2. 显示调用构造函数初始化

    new§ A(10);

    // 3. 析构时需要显式调用

    p->~A();

    free§;

  • 场景:配合内存池使用(避免频繁向系统申请小块内存)。

常见面试题 & 内存泄漏

  • malloc/free 和 new/delete 的区别 (8点背诵)

    属性:malloc/free 是函数;new/delete 是操作符。

    初始化:malloc 不初始化;new 可以初始化。

    计算大小:malloc 需要手动计算 (sizeof);new 自动计算。

    返回类型:malloc 返回 void* (需强转);new 返回具体类型指针。

    失败处理:malloc 返回 NULL;new 抛异常。

    自定义类型:malloc 不调构造/析构;new/delete 会调用。

    重载:opeartor new/delete 可以被重载。

    配对:必须配对使用,否则可能导致内存泄漏或崩溃。

  • 内存泄漏 (Memory Leak)

    定义:动态申请的内存,不使用了但没有释放。

    分类:

    堆内存泄漏:malloc/new 没释放。

    系统资源泄漏:文件描述符、Socket、管道没关闭。

    危害:长期运行会导致内存耗尽,系统卡死(如服务器后台)。

    如何避免:

    良好的编码规范(谁申请谁释放)。

    RAII 思想(资源获取即初始化)。

    智能指针(后续课程重点)。

    使用检测工具(如 Valgrind, VLD)。

继承

继承的概念与基础

  • 背景:面向对象三大特性之一(封装、继承、多态)。继承是代码复用的重要手段,允许在保持原有类特性的基础上进行扩展。

  • 定义

    • 基类 (Base Class):父类,被继承的类。
      派生类 (Derived Class):子类,继承基类的类。
      语法:class Derived : public Base { ... };
  • 继承关系与访问限定符

    • 三种继承方式:public、protected、private。

      成员访问权限变化表(核心记忆):

      基类 private 成员:在派生类中始终不可见(物理空间存在,但语法上禁止访问)。

      其他成员:访问权限 = Min(基类权限, 继承方式)。

      public > protected > private

      例如:基类 public + protected 继承 -> 派生类中变为 protected。

    • 默认继承方式:

      class 默认 private 继承。

      struct 默认 public 继承。

      实际建议:绝大多数场景使用 public 继承。

赋值转换 (切片/切割)

  • 背景:子类对象包含父类对象的所有成员,因此子类可以当做父类使用(IS-A 关系),但父类不能当做子类使用。

  • 向上转型 (Upcasting) ------ 安全

    • 子类对象 -> 父类对象:Person p = student; (发生拷贝/切片)。
      子类指针 -> 父类指针:Person* pp = &student; (指向子类中父类部分的起始地址)。
      子类引用 -> 父类引用:Person& rp = student; (引用子类中父类部分)。
      原理:切片 (Slicing)。就像把子类对象中多出来的部分切掉,只保留父类部分。
  • 向下转型 (Downcasting) ------ 有风险

    • 父类对象 -> 子类对象:禁止。
      父类指针/引用 -> 子类指针/引用:
      不安全(除非父类指针原本就指向子类对象)。
      安全转换需使用 dynamic_cast (后续多态章节)。

继承中的作用域

  • 背景:继承体系中,基类和派生类有独立的作用域。

  • 隐藏 (Hiding) / 重定义

    • 条件:派生类和基类有同名成员(变量或函数)。

      后果:子类成员将屏蔽父类同名成员。

      访问父类成员:需显示指定作用域 Base::Member。

      函数隐藏 vs 函数重载:

      重载:同一作用域。

      隐藏:不同作用域(分别在基类和派生类),只要同名就隐藏(不管参数列表是否相同)。

    • class A { public: void fun() {} };

      class B : public A {

      public:

      void fun(int i) {} // 隐藏了 A::fun,B对象无法直接调用无参fun()

      };

派生类的默认成员函数 (6个)

  • 背景:编译器会自动生成构造、析构等函数,在继承体系中,它们如何处理父类部分?

  • 原则:基类部分由基类负责,派生类部分由派生类负责。

  • 构造函数

    先调用基类构造(初始化基类部分),再执行派生类构造。

    如果基类没有默认构造函数,派生类必须在初始化列表中显式调用 Base(args)。

  • 拷贝构造

    必须显式调用基类的拷贝构造:Base(d)(利用切片机制)。

  • 赋值重载

    必须显式调用基类的赋值重载:Base::operator=(d)。

  • 析构函数

    调用顺序:先调派生类析构,再调基类析构(与构造相反)。

    特殊处理:编译器会将析构函数名统一处理为 destructor。如果不加 virtual,子类析构会隐藏父类析构。

    为什么不需要显式调用父类析构?:编译器为了保证析构安全(先子后父),会自动在子类析构结束后调用父类析构。

继承与静态成员 & 友元

  • 静态成员 (static)

    特性:整个继承体系中只有一份。

    现象:基类定义的 static 成员,被所有派生类对象共享。

  • 友元 (friend)

    特性:友元关系不能继承。

    解释:爸爸的朋友(基类的友元)不一定是儿子的朋友(不能访问派生类私有成员)。

菱形继承与虚拟继承 (Virtual Inheritance)

  • 背景:C++ 支持多继承,但这会导致菱形继承问题。

  • 菱形继承 (Diamond Inheritance)

    结构:A -> B, A -> C, D 继承 B 和 C。

    问题:

    数据冗余:D 中有两份 A 的成员。

    二义性:访问 A 的成员时,不知道是 B 的还是 C 的(需指定作用域 d.B::_a)。

  • 虚拟继承 (Virtual Inheritance)

    用法:class B : virtual public A; class C : virtual public A;

    作用:解决数据冗余和二义性。D 对象中只有一份 A 的成员。

  • 底层原理 (虚基表)

    对象模型:

    B 和 C 中不再直接存储 A 的成员,而是存储一个虚基表指针 (vbptr)。

    虚基表:存储了从当前位置到共享的虚基类(A)部分的偏移量。

    A 的成员被放在对象内存的最末端(共享区域)。

    优势:无论继承多复杂,内存中只有一份 A。

继承 vs 组合 (Inheritance vs Composition)

  • 设计原则:优先使用对象组合,而不是类继承。

  • 继承 (Is-a)

    定义:白箱复用 (White-box reuse)。

    特点:基类内部细节对子类可见,破坏了封装性,耦合度高。

    场景:Student is a Person。

  • 组合 (Has-a)

    定义:黑箱复用 (Black-box reuse)。

    特点:只能通过接口访问,封装性好,耦合度低。

    场景:Car has a Tire。

常见面试题

  • 什么是菱形继承?有什么问题?如何解决?

    答:多继承导致一个类有多个路径继承自同一个基类。问题是数据冗余和二义性。解决方法是虚拟继承(virtual)。

  • 虚继承的底层原理?

    答:通过虚基表指针和虚基表。虚基表中记录了偏移量,通过偏移量找到共享的基类成员。

  • 继承和组合的区别?

    答:继承是 is-a 关系,强耦合,白箱复用;组合是 has-a 关系,低耦合,黑箱复用。优先用组合。

  • 派生类析构函数为什么要先子后父?

    答:因为子类成员可能依赖父类成员(如访问父类变量)。如果先析构父类,子类析构时访问父类成员就会非法访问。

  • 重载、覆盖(重写)、隐藏(重定义)的区别?

    重载:同一作用域,函数名同,参数不同。

    隐藏:不同作用域(基类/派生类),函数名同。

    覆盖:不同作用域,函数名/参数/返回值相同,且基类函数有 virtual(多态章节详解)。

多态

多态的概念

  • 多种形态。具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态。

多态的定义及实现 (核心语法)

  • 多态构成条件 (缺一不可)

    • 必须同时满足以下两个条件,否则就是普通函数调用(静态绑定)。
      条件1:必须通过基类的指针或者引用调用虚函数。
      条件2:被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写。
  • 虚函数 (Virtual Function)

    • 定义:被 virtual 修饰的类成员函数。

    • class Person {

      public:

      virtual void BuyTicket() { cout << "全价" << endl; }

      };

  • 虚函数的重写 (Override)

    • 定义:派生类中有一个跟基类完全相同的虚函数。

      完全相同是指:

      返回值类型相同。

      函数名字相同。

      参数列表相同。

    • class Student : public Person {

      public:

      virtual void BuyTicket() { cout << "半价" << endl; } // 重写

      };

      void Func(Person& p) { p.BuyTicket(); } // 多态调用

    • 重写的两个例外:

      协变 (Covariance):返回值可以不同,但必须是父子关系的指针或引用(基类虚函数返回基类对象的指针/引用,派生类虚函数返回派生类对象的指针/引用)。

      析构函数:基类和派生类析构函数名不同(~Person vs ~Student),但只要基类析构加了 virtual,就会构成重写(编译器将析构函数名统一处理为 destructor)。建议:基类析构函数一律加 virtual。

  • C++11 override 和 final

    • final:修饰虚函数,表示该虚函数不能再被重写。
      override:检查派生类虚函数是否真的重写了基类某个虚函数,如果没有重写(比如拼写错误或参数不同),编译报错。推荐使用。

抽象类 (Abstract Class)

  • 纯虚函数:在虚函数后面写上 = 0。 virtual void Drive() = 0;

  • 抽象类定义:包含纯虚函数的类。

    特性:

    不能实例化出对象。

    派生类继承后也不能实例化,除非重写所有纯虚函数。

    意义:强制派生类重写虚函数;体现接口继承(只继承接口规范,不继承实现)。

多态的原理 (底层内存模型)

  • 虚函数表 (vtable)

    • 现象:一个包含虚函数的类对象,其大小通常比成员变量大小多4字节(32位)或8字节(64位)。
      本质:对象头部存储了一个指针,叫虚函数表指针 (vfptr)。
      虚表内容:一个函数指针数组,存储了该类所有虚函数的地址。最后通常以 nullptr 结尾。
  • 虚表生成与重写原理

    • 基类虚表:存放基类虚函数的地址。
      派生类虚表生成步骤:
      先将基类虚表内容拷贝一份。
      如果派生类重写了某个虚函数,用派生类自己的地址覆盖虚表中对应的基类虚函数地址。
      派生类自己新增的虚函数,按声明次序追加到虚表最后。
      多态调用过程 (动态绑定):
      ptr->BuyTicket():编译器发现是虚函数且通过指针调用 -> 运行时去 ptr 指向的对象中找到 vfptr -> 在虚表中找到对应的函数地址 -> call eax。
  • 常见误区

    • 虚函数存在哪? 代码段(常量区)。(和普通函数一样)
      虚表存在哪? 代码段(常量区)。(不是在对象里,对象里存的是指向虚表的指针)。
      虚表指针何时初始化? 在构造函数的初始化列表阶段。

继承中的虚函数表

  • 单继承

    派生类对象的 vfptr 指向派生类的虚表。

    虚表中包含:基类未重写的虚函数 + 重写后的虚函数 + 派生类新增的虚函数。

  • 多继承

    现象:派生类会有多个虚表指针(对应继承了几个带虚函数的基类)。

    规则:派生类自己新增的虚函数,默认放在第一个继承基类部分的虚表中。

    this指针修正:多继承中,通过不同基类指针指向同一个派生类对象时,指针地址可能不同(切片偏移),但在调用重写函数时,编译器会自动修正 this 指针指向派生类对象的起始地址。

常见面试题

  • inline 函数可以是虚函数吗?

    答:可以。但如果通过多态调用,编译器会忽略 inline 属性(因为多态是运行时确定,inline是编译时展开,没法展开)。只有通过对象直接调用时才可能内联。

  • 静态成员可以是虚函数吗?

    答:不能。静态成员没有 this 指针,无法访问虚表指针,也就无法通过虚表调用。

  • 构造函数可以是虚函数吗?

    答:不能。虚表指针是在构造函数初始化列表阶段才初始化的。如果构造函数是虚函数,调用它需要先查虚表,但此时虚表指针还没初始化,死锁了。

  • 析构函数最好是虚函数吗?

    答:是。特别是当使用基类指针指向派生类对象并 delete 时。如果基类析构不是虚函数,只会调用基类析构,导致派生类资源泄漏。

  • 对象访问普通函数快还是虚函数快?

    如果是普通对象调用:一样快。

    如果是指针/引用调用:普通函数快(编译时确定地址),虚函数慢(运行时需查表)。

  • 虚表是在什么阶段生成的?

    答:编译阶段。

  • C++ 菱形继承的问题?虚继承原理?

    问题:数据冗余和二义性。

    原理:通过虚基表指针 (vbptr) 和 虚基表。虚基表中存的是偏移量,通过偏移量找到共享的虚基类成员。注意区分虚函数表(vfptr)和虚基表(vbptr)。

相关推荐
小明同学012 小时前
[C++进阶]深入理解二叉搜索树
开发语言·c++·git·visualstudio
点云SLAM2 小时前
C++std::enable_if_t 与 std::is_same_v使用
c++·模板元编程·c++ 类型萃取·enable_if_t·is_same_v
C+++Python2 小时前
C++ vector
开发语言·c++·算法
莫问前路漫漫2 小时前
Python包管理工具pip完整安装教程
开发语言·python
superman超哥2 小时前
处理复杂数据结构:Serde 在实战中的深度应用
开发语言·rust·开发工具·编程语言·rust serde·rust数据结构
Java程序员威哥2 小时前
Arthas+IDEA实战:Java线上问题排查完整流程(Spring Boot项目落地)
java·开发语言·spring boot·python·c#·intellij-idea
superman超哥2 小时前
错误处理与验证:Serde 中的类型安全与数据完整性
开发语言·rust·编程语言·rust编程·rust错误处理与验证·rust serde
夔曦2 小时前
【python】月报考勤工时计算
开发语言·python
fl1768312 小时前
基于python实现PDF批量加水印工具
开发语言·python·pdf