c++ 类和对象(上)

类和对象(上)

  • [1. 面向过程和面向对象初步认识](#1. 面向过程和面向对象初步认识)
    • [**1.1 面向过程(Procedure-Oriented)**](#1.1 面向过程(Procedure-Oriented))
    • [1.2 面向对象(Object-Oriented)](#1.2 面向对象(Object-Oriented))
    • [1.3 对比总结](#1.3 对比总结)
  • [2. 类的引入](#2. 类的引入)
    • [**2.1 C语言struct的局限性**](#2.1 C语言struct的局限性)
    • [2.2 C++ 对 struct 的扩展(支持定义函数)](#2.2 C++ 对 struct 的扩展(支持定义函数))
  • [3. 类的定义](#3. 类的定义)
  • [4. 类的访问限定符及封装](#4. 类的访问限定符及封装)
    • [4.1 访问限定符](#4.1 访问限定符)
    • [C++ 中 struct 和 class 的区别是什么?](#C++ 中 struct 和 class 的区别是什么?)
    • [4.2 封装](#4.2 封装)
  • [5. 类的作用域](#5. 类的作用域)
  • [6. 类的实例化](#6. 类的实例化)
  • [7. 类对象模型](#7. 类对象模型)
  • [8. this 指针](#8. this 指针)
    • [8.1 this 指针的引出](#8.1 this 指针的引出)
    • [8.2 this指针的特性](#8.2 this指针的特性)
    • [8.3 C语言和C++实现Stack的对比](#8.3 C语言和C++实现Stack的对比)
      • [1. C语言实现Stack的特点](#1. C语言实现Stack的特点)
      • [2. C++ 实现 Stack 的特点](#2. C++ 实现 Stack 的特点)

1. 面向过程和面向对象初步认识

核心差异

面向过程(C语言)与面向对象(C++)的本质区别,在于解决问题的"思维范式"不同------前者关注"步骤流程",后者关注"对象交互"。

1.1 面向过程(Procedure-Oriented)

  • 核心思想:将求解问题的过程拆解为"若干个函数步骤",通过函数调用按顺序执行步骤,最终完成任务。

  • 关注点:"怎么做"(步骤),数据与操作数据的函数是分离的。

  • 示例(用C语言实现栈的入栈操作)

    cpp 复制代码
    // 数据结构(栈)与操作函数分离
    typedef struct Stack {
        int* _array;
        int _capacity;
        int _size;
    } Stack;
    
    // 初始化栈(函数需显式传入栈对象指针)
    void StackInit(Stack* s, int capacity) {
        s->_array = (int*)malloc(sizeof(int)*capacity);
        s->_capacity = capacity;
        s->_size = 0;
    }
    
    // 入栈(函数需显式传入栈对象指针)
    void StackPush(Stack* s, int data) {
        s->_array[s->_size++] = data;
    }
    
    int main() {
        Stack s;
        // 需手动调用函数,按"初始化→入栈"步骤执行
        StackInit(&s, 10);
        StackPush(&s, 1);
        return 0;
    }
    ``
  • 特点:逻辑直观,但数据与函数分离,当项目规模扩大时(如多个相似数据结构),代码复用性、维护性较差。

1.2 面向对象(Object-Oriented)

  • 核心思想:将问题中的实体抽象为 "对象",每个对象包含 "数据(属性)" 和 "操作数据的行为(方法)",通过对象之间的交互完成任务。
  • 关注点:"谁来做"(对象),数据与方法封装在对象内部,对外隐藏实现细节。
  • 示例(用 C++ 实现栈的入栈操作,后续会展开):
cpp 复制代码
class Stack {
public:
    // 方法(操作数据的行为)与数据封装在一起
    void Init(int capacity) {
        _array = new int[capacity];
        _capacity = capacity;
        _size = 0;
    }
    void Push(int data) {
        _array[_size++] = data;
    }
private:
    // 数据(属性)
    int* _array;
    int _capacity;
    int _size;
};

int main() {
    Stack s;
    // 对象直接调用自身方法,无需显式传递指针
    s.Init(10);
    s.Push(1);
    return 0;
}
  • 特点:封装性、复用性、扩展性强,适合大型项目开发(如游戏引擎、操作系统)。

1.3 对比总结

维度 面向过程(C) 面向对象(C++)
核心单元 函数 对象(数据 + 方法)
数据与方法关系 分离,函数需显式传数据指针 封装,方法属于对象内部
关注点 步骤流程(怎么做) 实体交互(谁来做)
适用场景 小型项目、底层开发 (如驱动) 中大型项目、复杂系统(如 APP)

2. 类的引入

核心作用

C++中的class(类)是"对象的模板",用于定义对象的"属性(数据)"和"方法(行为)",是实现"封装"的核心语法。C++兼容C语言的struct,但扩展了struct的功能------允许在其中定义函数,不过更推荐用class实现面向对象。

2.1 C语言struct的局限性

C语言的struct仅能定义"数据成员",无法定义"函数成员",操作结构体的函数必须在外部定义,导致数据与操作分离:

c 复制代码
// C语言struct:仅含数据,无函数
typedef struct Stack {
    int* _array;
    int _capacity;
    int _size;
} Stack;

// 操作函数需在外部定义,需显式传递结构体指针
void StackInit(Stack* s, int capacity) {
    s->_array = (int*)malloc(sizeof(int)*capacity);
}

2.2 C++ 对 struct 的扩展(支持定义函数)

C++ 兼容 C 语言的struct,但允许在struct内部定义函数(方法),实现 "数据与方法的封装",示例如下:

cpp 复制代码
#include <iostream>
#include <cstdlib>
using namespace std;

typedef int DataType;
// C++的struct:可定义数据和函数
struct Stack {
    // 方法1:初始化栈(无需显式传指针,this指针隐式传递)
    void Init(size_t capacity) {
        _array = (DataType*)malloc(sizeof(DataType) * capacity);
        if (nullptr == _array) {  // nullptr是C++的空指针常量,比NULL更安全
            perror("malloc申请空间失败");
            return;
        }
        _capacity = capacity;
        _size = 0;
    }

    // 方法2:入栈(接收const引用,避免拷贝开销)
    void Push(const DataType& data) {
        // 注:原代码未处理扩容,实际需判断_size是否等于_capacity,此处暂按原代码保留
        _array[_size] = data;
        ++_size;
    }

    // 方法3:获取栈顶元素
    DataType Top() {
        return _array[_size - 1];  // 需确保_size>0,否则越界
    }

    // 方法4:销毁栈(释放内存,避免泄漏)
    void Destroy() {
        if (_array) {
            free(_array);
            _array = nullptr;  // 置空,避免野指针
            _capacity = 0;
            _size = 0;
        }
    }

    // 数据成员(属性)
    DataType* _array;   // 栈数组
    size_t _capacity;   // 栈容量
    size_t _size;       // 栈中有效元素个数
};

int main() {
    Stack s;  // 创建struct对象(C++中struct也是类,对象创建方式与class一致)
    s.Init(10);       // 对象调用方法:隐式传递this指针,指向s
    s.Push(1);        // 入栈1
    s.Push(2);        // 入栈2
    s.Push(3);        // 入栈3
    cout << s.Top() << endl;  // 输出栈顶3
    s.Destroy();      // 销毁栈,释放内存
    return 0;
}
  • 更推荐使用class来代替struct来实现类(原因后面会讲)

3. 类的定义

核心语法框架

C++中通过 class 关键字定义类,类体包含"成员变量(属性)"和"成员函数(方法)",定义结束必须加分号 (与结构体语法一致,但语义更侧重封装)。

基础语法格式:

cpp 复制代码
class ClassName {  // ClassName 为类名,需符合标识符命名规则
public:
    // 公有的成员函数(对外暴露的接口)
    返回值类型 函数名(参数列表);
private:
    // 私有的成员变量(内部数据,外部不可直接访问)
    数据类型 变量名;
};  // 分号必须加,否则编译报错
  • public/private访问限定符:控制成员在类外部的访问权限(后续会详细展开,此-处先用于区分接口与数据)。
  • 成员变量:描述类的 "属性"(如日期类的年、月、日)。
  • 成员函数:描述类的 "行为"(如日期类的初始化、打印日期)。

3.1 类的两种定义方式(附示例)

方式1:声明与定义全部放在类体中

特点

成员函数的声明和实现都在类内部,编译器可能将其视为内联函数(适合函数体简短的场景,如简单的赋值、取值),但函数体复杂时不推荐(会导致类体臃肿)。

示例:日期类(Date)的完整内定义

cpp 复制代码
#include <iostream>
using namespace std;

// 类的声明与定义全部放在类体中
class Date {
public:
    // 成员函数1:初始化日期(类内定义,可能被视为内联函数)
    void Init(int year, int month, int day) {
        // 用 _ 前缀区分成员变量与形参
        _year = year;
        _month = month;
        _day = day;
    }

    // 成员函数2:打印日期(类内定义)
    void Print() {
        cout << _year << "年" << _month << "月" << _day << "日" << endl;
    }

private:
    // 成员变量(私有,外部无法直接修改)
    int _year;  // 年
    int _month; // 月
    int _day;   // 日
};

// 测试代码
int main() {
    Date d1;          // 创建Date类对象d1
    d1.Init(2024, 5, 20);  // 调用成员函数初始化
    d1.Print();       // 调用成员函数打印 → 输出:2024年5月20日
    return 0;
}

注意:若类内成员函数体较长(如包含复杂的日期合法性校验),编译器可能不会将其视为内联函数,且会导致类定义可读性下降,因此仅推荐简单函数用此方式。

方式2:类声明放.h文件,成员函数定义放.cpp文件

特点

  • 分离"接口"与"实现":.h文件存放类的声明(成员函数签名、成员变量声明),供外部引用;.cpp文件存放成员函数的实现,隐藏内部逻辑。
  • 适合大型项目:避免重复定义(.h文件需加头文件保护),提升代码可维护性(修改实现无需改动.h文件)。

示例:日期类的分离式定义

步骤1:创建 Date.h(类声明文件)

cpp 复制代码
// Date.h
// 类的声明(仅暴露接口和成员变量结构,不包含实现)
class Date {
public:
    // 成员函数声明(仅签名,无函数体)
    void Init(int year, int month, int day);  // 初始化日期
    void Print();                             // 打印日期
    bool IsLeapYear();                        // 判断是否为闰年(新增示例函数)

private:
    // 成员变量声明
    int _year;
    int _month;
    int _day;
};

步骤 2:创建 Date.cpp(成员函数实现文件)

cpp 复制代码
// Date.cpp
#include "Date.h"  // 包含类声明头文件
#include <iostream>
using namespace std;

// 成员函数实现:需加 "类名::" 作用域限定符,表明属于Date类
void Date::Init(int year, int month, int day) {
    // 此处可添加日期合法性校验(如月份1-12,天数根据月份调整)
    _year = (year >= 1 ? year : 1);
    _month = (month >= 1 && month <= 12 ? month : 1);
    _day = (day >= 1 && day <= 31 ? day : 1);  // 简化校验,实际需结合月份
}

void Date::Print() {
    cout << _year << "年" << _month << "月" << _day << "日" << endl;
}

bool Date::IsLeapYear() {
    // 闰年规则:能被4整除且不能被100整除,或能被400整除
    return (_year % 4 == 0 && _year % 100 != 0) || (_year % 400 == 0);
}

步骤 3:创建 main.cpp(测试文件)

cpp 复制代码
// main.cpp
#include "Date.h"
#include <iostream>
using namespace std;

int main() {
    Date d2;
    d2.Init(2020, 2, 29);  // 2020是闰年,2月有29天
    d2.Print();            // 输出:2020年2月29日
    
    if (d2.IsLeapYear()) {
        cout << d2._year << "是闰年" << endl;  // 错误!_year是private,外部无法直接访问
        // 正确写法:无需直接访问_year,函数内部已使用成员变量
        cout << "该日期所在年份是闰年" << endl;  // 输出:该日期所在年份是闰年
    }
    return 0;
}

关键细节

  • 成员函数实现时必须加 类名::(如 Date::Init),否则编译器会认为是全局函数,导致 "未定义" 错误。
  • 头文件保护(#ifndef/#define/#endif 或者 #pragma once):防止多个.cpp 文件包含同一.h 时,类被重复声明,引发编译错误。
  • 此方式是工业界标准写法,推荐优先使用。

3.2 成员变量命名规则(避免歧义)

核心问题

若成员变量与函数形参同名,会导致"命名冲突"------编译器无法区分赋值语句中的"左值是成员变量还是形参",如下列错误示例:

错误示例:命名冲突

cpp 复制代码
class Date {
public:
    void Init(int year) {
        year = year;  // 歧义:左、右两边都是形参year,成员变量year未被赋值
    }
private:
    int year;  // 成员变量与形参同名
};

规则 1:成员变量加 _ 前缀(最常用)

cpp 复制代码
class Date {
public:
    // 形参year,成员变量_year,无歧义
    void Init(int year, int month, int day) {
        _year = year;    // 明确:将形参year赋值给成员变量_year
        _month = month;
        _day = day;
    }
private:
    int _year;  // _前缀标识成员变量
    int _month;
    int _day;
};

规则 2:成员变量加 m 前缀(微软 MFC 框架常用)

cpp 复制代码
class Date {
public:
    void Init(int year, int month, int day) {
        mYear = year;    // m前缀标识成员变量(m=member)
        mMonth = month;
        mDay = day;
    }
private:
    int mYear;   // m前缀
    int mMonth;
    int mDay;
};

其他方式也可以的只要你能区分就可以

核心原则:

团队内部统一命名规则,避免个人风格差异导致代码可读性下降

4. 类的访问限定符及封装

4.1 访问限定符

核心作用

访问限定符是C++实现"封装"的核心语法工具,通过控制类成员(变量/函数)在类外部的访问权限,隐藏内部实现细节,仅对外暴露安全的交互接口。

访问限定符分类与规则

C++提供3种访问限定符:public(公开)、protected(保护)、private(私有),具体规则如下:

  1. public(公开成员)

    • 类外可直接访问(通过"对象.成员"或"对象->成员"语法)。
    • 用途:定义对外暴露的接口(如初始化函数、数据获取函数),是外部与类交互的唯一通道。
  2. protected(保护成员)与 private(私有成员)

    • 类外不可直接访问 (编译报错),仅能在类内部(成员函数中)或子类中(protected 专属,后续继承章节讲解)访问。
    • 用途:定义类的内部数据或辅助函数(如成员变量、内部校验逻辑),避免外部非法修改。
    • 现阶段区别:在"类与对象"阶段,protectedprivate 功能完全一致(仅在继承时体现差异)。
  3. 权限作用域规则

    • 访问权限的生效范围从"当前访问限定符出现的位置"开始,到"下一个访问限定符出现"或"类定义结束(})"为止。

    • 示例:

      cpp 复制代码
      class Date {
      public:
          // public权限开始:以下成员类外可访问
          void Init(int year, int month, int day) {
              _year = year;  // 类内部可访问private成员
          }
          void Print() {
              cout << _year << "-" << _month << "-" << _day << endl;
          }
      private:
          // private权限开始:以下成员类外不可访问
          int _year;
          int _month;
          int _day;
      };  // 类结束,权限作用域终止
  4. 类与结构体的默认访问权限差异

    • class 关键字:默认访问权限为 private(需显式加 public 才能暴露接口)。
    • struct 关键字:默认访问权限为 public(兼容 C 语言结构体的 "全公开" 特性)。
    • 示例对比:
cpp 复制代码
// class默认private:_year类外不可访问,Init需显式public
class A {
    int _year;  // 默认private,类外无法访问
public:
    void Init(int y) { _year = y; }  // 显式public,类外可调用
};

// struct默认public:_year和Init类外均可访问
struct B {
    int _year;  // 默认public,类外可直接修改(b._year = 2024;)
    void Init(int y) { _year = y; }  // 默认public,类外可调用
};
  1. 底层本质
    • 访问限定符仅在编译阶段生效(编译器检查访问权限,非法访问报错)。
    • 当代码编译为二进制文件后,内存中成员的存储位置无任何权限标识(即运行时无访问限制)------ 本质是 "编译期语法层面的管理"。

C++ 中 struct 和 class 的区别是什么?

  • 核心区别 1:默认访问权限不同(类与对象阶段):
    • struct 定义的类:默认访问权限为 public(兼容 C 语言结构体)。
    • class 定义的类:默认访问权限为 private(符合面向对象封装思想)。
  • 其他区别(后续章节):
    • 继承时:struct 默认是 public 继承,class 默认是 private 继承。
    • 模板参数列表:class 可用于定义模板类型参数,struct 也可,但早期 C++ 中 class 更常用(现代 C++ 中两者无差异)。
  • 示例验证:
cpp 复制代码
struct S {
    int a;  // 默认public,类外可访问
};
class C {
    int b;  // 默认private,类外不可访问
};

int main() {
    S s; s.a = 10;  // 合法(struct默认public)
    C c; c.b = 20;  // 编译报错(class默认private)
    return 0;
}

4.2 封装

核心定义

封装是面向对象三大特性(封装、继承、多态)的基础,指"将数据(属性)和操作数据的方法(行为)有机结合,隐藏对象的内部实现细节,仅对外公开接口与对象交互"。

本质

封装不是"隐藏所有内容",而是一种管理策略------通过"隐藏细节、暴露接口",降低用户使用成本,同时保证数据安全性(避免外部非法修改)。

生活中的封装示例(电脑)

  • 隐藏的细节:CPU的运算逻辑、主板的线路布局、内存的读写机制等(普通用户无需关心)。
  • 暴露的接口:开关机键、键盘/鼠标接口、USB插孔、显示器接口等(用户通过这些接口与电脑交互)。
  • 优势:用户无需学习硬件原理,只需掌握接口使用(如按开机键启动、用键盘输入),即可完成日常操作;同时硬件细节被保护(如避免用户误触主板线路导致损坏)。

C++中封装的实现方式

通过"类 + 访问限定符"实现封装,具体分3步:

  1. 数据与方法有机结合
    将描述对象的"数据"(成员变量)和"操作数据的方法"(成员函数)定义在同一个类中,形成逻辑整体。
    示例:
cpp 复制代码
   class Stack {
   public:
       // 操作数据的方法(与数据封装在一起)
       void Init(size_t capacity) {
           _array = new int[capacity];
           _capacity = capacity;
           _size = 0;
       }
       void Push(int data) {
           if (_size == _capacity) { /* 扩容逻辑 */ }
           _array[_size++] = data;
       }
   private:
       // 数据(与方法封装在一起)
       int* _array;
       size_t _capacity;
       size_t _size;
   };
  1. 隐藏内部细节(private/protected
    将 "数据"(如 _array、_capacity)和 "内部辅助方法"(如扩容的具体逻辑)定义为 private,类外无法直接访问,避免非法修改(如用户直接修改_size导致栈逻辑混乱)。
  2. 暴露安全接口(public
    将 "用户需要的交互操作"(如 Init 初始化、Push 入栈、Top 获取栈顶)定义为 public,用户通过这些接口与对象交互,无需关心内部实现(如无需知道 _array 是用 new 还是 malloc 分配的)。
    封装的优势:
  3. 安全性:内部数据仅能通过预设接口修改,避免非法操作(如栈的 _size 只能通过 Push/Pop 间接修改,防止用户直接赋值为负数)。
  4. 易用性:用户只需关注接口用法(如调用 Push 即可入栈),无需理解内部逻辑(如扩容算法)。
  5. 可维护性:内部实现修改时(如将 new 改为 malloc),只要接口签名不变,外部代码无需修改(如用户调用 Push 的方式不变)。

5. 类的作用域

核心定义

类是一个独立的作用域(Class Scope) ,类的所有成员(成员变量、成员函数)都属于这个作用域。在类体外定义成员函数时,必须通过 ::(作用域操作符)明确指定该函数所属的类域,否则编译器会将其视为全局函数,导致编译错误。

类作用域的体现

  1. 类体内访问 :类的成员函数在类内部可以直接访问同类的其他成员(无论 public/private),无需额外指定作用域(编译器默认在当前类域内查找)。
  2. 类体外访问
    • 访问成员变量:需通过"对象.成员变量"或"对象指针->成员变量"(且成员需为 public)。
    • 定义成员函数:必须加"类名::",明确函数所属类域。

示例:类体外定义成员函数

Person 类为例,展示类作用域的使用:

cpp 复制代码
#include <iostream>
#include <cstring>
using namespace std;

// 1. 类定义(类域:Person)
class Person {
public:
    // 成员函数声明(属于Person类域)
    void PrintPersonInfo();
    void SetPersonInfo(const char* name, const char* gender, int age);
private:
    // 成员变量(属于Person类域)
    char _name[20];
    char _gender[3];
    int _age;
};

// 2. 类体外定义成员函数:必须加 Person:: 指明类域
void Person::SetPersonInfo(const char* name, const char* gender, int age) {
    // 类体外定义的成员函数,仍可直接访问private成员(属于同一类域)
    strncpy(_name, name, sizeof(_name)-1);  // 安全拷贝字符串,留'\0'位置
    _name[sizeof(_name)-1] = '\0';          // 确保字符串结束
    strncpy(_gender, gender, sizeof(_gender)-1);
    _gender[sizeof(_gender)-1] = '\0';
    _age = age;
}

// 2. 类体外定义成员函数:加 Person:: 指明类域
void Person::PrintPersonInfo() {
    // 直接访问private成员,输出信息
    cout << "姓名:" << _name << ",性别:" << _gender << ",年龄:" << _age << endl;
}

// 测试代码
int main() {
    Person p;
    // 调用类成员函数(通过对象.函数名,编译器自动关联Person类域)
    p.SetPersonInfo("张三", "男", 20);
    p.PrintPersonInfo();  // 输出:姓名:张三,性别:男,年龄:20

    // 错误示例:类体外直接访问private成员(编译报错,无访问权限+未指定对象)
    // cout << p._name << endl;  // error:_name是private,类外不可访问
    // Person::PrintPersonInfo(); // error:非静态成员函数需通过对象调用

    return 0;
}

关键注意点:

  • 若类体外定义成员函数时省略 类名::,编译器会认为是 "全局函数",而全局函数无法访问类的 private 成员,且函数签名与类内声明不匹配,会报 "未定义的引用" 错误。
  • 类作用域与全局作用域、局部作用域是独立的,即使成员名与全局变量同名,类内也优先使用类域的成员(如类内_age与全局 int _age 不冲突)。

6. 类的实例化

核心定义

类的实例化是"用类类型创建具体对象的过程"。类本身只是一个"抽象的模板"(描述对象有哪些成员),不占用实际内存;只有实例化出的对象,才会分配内存存储成员变量(成员函数不占对象内存,存于代码段)。

类与对象的关系

类是"模板",对象是"模板实例化的产物"------类比建筑设计图(类)与实际建造的房子(对象):设计图仅描述房子的结构(墙、窗、门),不占用空间;按设计图建造的房子才是实体,占用物理空间。

类实例化的3个关键特性

  1. 类不占内存,对象占内存
    • 类仅定义成员的"结构和声明",编译后不分配内存;
    • 对象会根据类的成员变量大小,分配对应的内存(成员函数存于代码段,所有对象共享,不单独占用对象内存)。
    • 错误示例:直接操作类的成员变量(编译失败):
cpp 复制代码
   class Person {
   public:
       int _age;
   };

   int main() {
       // 错误:Person是类(模板),无内存,无法直接访问_age
       Person._age = 100;  // error C2059: 语法错误:"."
       return 0;
   }

正确示例:实例化对象后操作成员变量:

cpp 复制代码
  int main() {
    Person p;  // 实例化对象p,分配内存存储_age
    p._age = 100;  // 正确:对象p有内存,可访问public成员_age
    cout << p._age << endl;  // 输出:100
    return 0;
}
  1. 一个类可实例化多个对象
    同一类的不同对象,共享类的成员函数(代码段),但拥有独立的成员变量(内存空间),修改一个对象的成员变量不会影响其他对象。
    示例:
cpp 复制代码
int main() {
    Person p1, p2;  // 实例化两个对象p1、p2
    p1._age = 20;   // p1的_age赋值20
    p2._age = 30;   // p2的_age赋值30(与p1独立)
    cout << "p1年龄:" << p1._age << endl;  // 输出:20
    cout << "p2年龄:" << p2._age << endl;  // 输出:30
    return 0;
}
  1. 实例化是 "创建实体" 的过程
    类的实例化本质是 "根据类模板,在内存中开辟空间,初始化成员变量" 的过程。即使类没有显式的初始化函数,实例化对象时也会分配内存(成员变量默认初始化,如 int 为随机值)。
    类比示例:
    • 类(如 "学生信息表模板"):仅规定有 "姓名、学号、年龄" 等字段;
    • 实例化对象(如 "学生张三的信息表"):在模板基础上填写具体数据,形成实体表格,占用纸张(内存)空间。
      类与对象的内存占用补充:
  • 对象的内存大小 = 所有成员变量的大小之和(内存对齐规则需考虑,后续章节讲解);
  • 成员函数不占用对象内存,所有对象共享同一套成员函数(存于代码段),通过 this 指针区分不同对象(后续章节讲解)。
    示例(64 位系统,内存对齐为 8 字节):

7. 类对象模型

7.1 如何计算类对象的大小

核心问题

类包含"成员变量"和"成员函数",但类对象的大小并非两者之和------需明确对象中实际存储的内容,才能计算大小。

示例分析

以包含成员变量和成员函数的类为例:

cpp 复制代码
#include <iostream>
using namespace std;

class A {
public:
    // 成员函数:打印成员变量_a
    void PrintA() {
        cout << _a << endl;
    }
private:
    // 成员变量:char类型,占1字节
    char _a;
};

int main() {
    A obj;
    // 计算对象大小:输出1字节(仅包含成员变量_a,不含成员函数)
    cout << "sizeof(A) = " << sizeof(A) << endl;    // 结果:1
    cout << "sizeof(obj) = " << sizeof(obj) << endl;// 结果:1
    return 0;
}

关键结论

类对象的大小 = 类中成员变量的大小之和(需遵循内存对齐规则),成员函数不占用对象内存 ------ 所有对象共享同一套成员函数(存储在代码段),无需每个对象单独存储,避免空间浪费。

7.2 类对象的存储方式猜测与验证

三种存储方式猜测

针对"类对象如何存储成员变量和成员函数",存在三种可能的设计思路,需通过代码验证实际存储方式:

猜测1:对象中包含所有成员(变量+函数)

  • 逻辑:每个对象独立存储成员变量和成员函数,函数随对象创建而复制。
  • 缺陷:若一个类实例化1000个对象,会保存1000份相同的成员函数代码,导致严重的内存浪费(函数代码重复存储),不符合工程设计效率要求。

猜测2:对象中存储成员变量 + 成员函数地址

  • 逻辑:成员函数统一存于代码段,对象中仅存储成员变量和指向函数的地址(指针),通过地址调用函数。
  • 缺陷:虽解决了函数重复存储问题,但每个对象需额外存储函数地址(如64位系统中指针占8字节),增加了对象的内存开销------对于无成员变量的类,对象仍需存储地址,不符合"最小内存占用"原则。

猜测3:对象中仅存储成员变量,成员函数存于公共代码段

  • 逻辑

    1. 所有成员函数统一存储在"代码段"(全局共享区域),不随对象实例化而复制;
    2. 对象中仅存储成员变量,调用函数时通过"类域+对象地址(this指针)"找到函数,无需对象存储函数相关信息。

  • 验证:通过不同类的对象大小测试,证明此为C++实际采用的存储方式:

cpp 复制代码
#include <iostream>
using namespace std;

// 类1:有成员变量(int,4字节)和成员函数
class A1 {
public:
    void f1() {}
private:
    int _a;
};

// 类2:仅有成员函数,无成员变量
class A2 {
public:
    void f2() {}
};

// 类3:空类(无成员变量,无成员函数)
class A3 {};

int main() {
    // 输出结果:4字节(仅成员变量_a的大小,遵循内存对齐)
    cout << "sizeof(A1) = " << sizeof(A1) << endl;  
    // 输出结果:1字节(无成员变量,编译器分配1字节占位,标识对象存在)
    cout << "sizeof(A2) = " << sizeof(A2) << endl;  
    // 输出结果:1字节(空类特殊处理,分配1字节占位)
    cout << "sizeof(A3) = " << sizeof(A3) << endl;  
    return 0;
}

核心结论:

  • C++ 采用 "猜测 3" 的存储方式:

    • 成员变量:存储在对象中,每个对象独立拥有,占对象内存;
    • 成员函数:存储在公共代码段,所有对象共享,不占对象内存;
    • 空类 / 无成员变量的类 :编译器分配 1 字节内存 "占位"(非存储数据),仅用于标识对象的存在(避免多个空类对象地址重叠)。
      为什么空类的大小是 1 字节而不是 0?
  • 若空类大小为 0,实例化多个空类对象时,所有对象会占用同一地址(内存中无法区分多个对象);

  • 分配 1 字节内存是 "占位符" 作用,仅用于保证每个对象有唯一的内存地址,不存储任何有效数据。

7.3 结构体内存对齐规则(类对象内存对齐遵循相同规则)

核心目的

内存对齐是编译器的一种优化策略,通过牺牲少量内存,换取CPU对内存的"高效访问"------CPU访问内存时,通常按"对齐字节数"(如4字节、8字节)批量读取,未对齐的内存需多次读取,效率低下。

结构体(类对象)内存对齐的4条规则

以VS编译器(默认对齐数8字节)为例,规则如下:

  1. 规则1:第一个成员的偏移量为0

    结构体/类的第一个成员,必须存储在"偏移量为0"的内存地址(即对象内存的起始位置)。

  2. 规则2:其他成员对齐到"对齐数"的整数倍地址

    • 对齐数 = min(编译器默认对齐数, 成员变量自身大小)
    • 非第一个成员的存储地址,必须是其"对齐数"的整数倍(若当前地址不满足,编译器自动填充空白字节)。
  3. 规则3:结构体总大小为"最大对齐数"的整数倍

    • 最大对齐数 = 所有成员的对齐数中最大的值;
    • 结构体总大小需向上取整为"最大对齐数"的整数倍(不足则填充空白字节)。
  4. 规则4:嵌套结构体的对齐

    • 嵌套的结构体成员,需对齐到"自身最大对齐数"的整数倍地址;
    • 整个结构体的总大小,需为"所有成员(含嵌套结构体)的最大对齐数"的整数倍。

示例1:基础结构体对齐计算(VS,默认对齐数8)

cpp 复制代码
#include <iostream>
using namespace std;

struct S1 {
    char c1;  // 成员1:char(1字节),对齐数=min(8,1)=1,偏移量0
    int i;    // 成员2:int(4字节),对齐数=min(8,4)=4,需对齐到4的整数倍(偏移量4,填充3字节空白)
    char c2;  // 成员3:char(1字节),对齐数=1,偏移量8
};

int main() {
    // 计算总大小:最大对齐数=4,总大小需为4的整数倍(当前偏移量8+1=9,向上取整为12)
    cout << "sizeof(S1) = " << sizeof(S1) << endl;  // 结果:12
    return 0;
}

示例 2:嵌套结构体对齐计算

cpp 复制代码
struct S2 {
    char c;        // 成员1:char(1字节),对齐数1,偏移量0
    struct S1 s;   // 成员2:嵌套S1(最大对齐数4),需对齐到4的整数倍(偏移量4,填充3字节空白)
    double d;      // 成员3:double(8字节),对齐数min(8,8)=8,需对齐到8的整数倍(偏移量4+12=16,无需填充)
};

int main() {
    // 总大小计算:S1大小12,当前偏移量16+8=24;最大对齐数=8(d的对齐数),24是8的整数倍 → 总大小24
    cout << "sizeof(S2) = " << sizeof(S2) << endl;  // 结果:24
    return 0;
}

面试题系列:

  1. 结构体怎么对齐?为什么要进行内存对齐?
    • 对齐规则:4 条核心规则(第一个成员偏移 0、其他成员对齐到对齐数整数倍、总大小为最大对齐数整数倍、嵌套结构体对齐到自身最大对齐数);
    • 对齐原因:CPU 按 "对齐字节数" 批量访问内存,对齐可减少 CPU 读取次数,提升效率(牺牲少量内存换效率)。
  2. 如何让结构体按照指定的对齐参数进行对齐?能否按照 3、4、5 即任意字节对齐?
    • 方法:使用编译器指令 #pragma pack(n)(n 为指定对齐数),取消对齐用 #pragma pack();
    • 限制:VS 中 n 需为 2 的幂(如 1、2、4、8、16),不支持 3、5 等非 2 幂对齐(硬件访问机制限制);GCC 支持任意 n 对齐,但非 2 幂对齐可能导致效率下降。
  3. 什么是大小端?如何测试某台机器是大端还是小端?有没有遇到过要考虑大小端的场景?
    • 大小端定义:
      • 大端(Big-Endian):数据的高位字节存低地址,低位字节存高地址(如 0x1234,地址 0 存 0x12,地址 1 存 0x34);
      • 小端(Little-Endian):数据的低位字节存低地址,高位字节存高地址(如 0x1234,地址 0 存 0x34,地址 1 存 0x12);
    • 测试方法(代码示例):
cpp 复制代码
#include <iostream>
using namespace std;

int main() {
    int a = 0x12345678;  // 4字节整数,高位0x12,低位0x78
    char* p = (char*)&a; // 强制转换为char*,仅访问第一个字节(低地址)
    if (*p == 0x78) {
        cout << "小端机器" << endl;
    } else if (*p == 0x12) {
        cout << "大端机器" << endl;
    }
    return 0;
}

8. this 指针

8.1 this 指针的引出

核心问题

同一类的多个对象共享成员函数,当不同对象调用同一成员函数时,函数如何区分操作的是哪个对象的成员变量?

示例分析

Date 类为例,d1d2 是两个不同对象,调用 InitPrint 时,函数需明确操作的是 d1 还是 d2_year_month_day

cpp 复制代码
class Date { 
public:
    void Init(int year, int month, int day) {
        _year = year;   // 如何确定是d1还是d2的_year?
        _month = month;
        _day = day;
    }
    void Print() {
        cout << _year << "-" << _month << "-" << _day << endl;
    }
private:
    int _year, _month, _day;
};

int main() {
    Date d1, d2;
    d1.Init(2022, 1, 11);  // 希望初始化d1的成员
    d2.Init(2022, 1, 12);  // 希望初始化d2的成员
    d1.Print();  // 希望打印d1的日期
    d2.Print();  // 希望打印d2的日期
    return 0;
}

解决方案:this 指针

C++ 编译器为每个非静态成员函数添加一个隐藏的指针形参(this 指针),该指针自动指向调用函数的对象:

  • d1.Init(...) 时,this 指针指向 d1
  • d2.Init(...) 时this 指针指向 d2
  • 函数体内对成员变量的访问(如 _year),实际被编译器转换为 this->_year
  • 编译器视角的函数转换: 编译器实际处理的Init函数(用户无需手动编写)
cpp 复制代码
void Init(Date* const this, int year, int month, int day) {
    this->_year = year;
    this->_month = month;
    this->_day = day;
}

// 调用时自动传递对象地址
d1.Init(&d1, 2022, 1, 11);  // 编译器隐式添加this实参
d2.Init(&d2, 2022, 1, 12);

结论:
this 指针是编译器自动维护的隐藏参数,用于区分不同对象,确保成员函数正确操作调用者的成员变量,用户无需显式传递或声明。

8.2 this指针的特性

this指针的4个核心特性

  1. 类型固定
    this 指针的类型为 类类型* const(如 Date* const),即指针本身不可修改(不能给 this 赋值),但可通过 this 修改指向对象的成员变量。
    示例(错误):

    cpp 复制代码
    void Date::Init(int year, int month, int day) {
        this = nullptr;  // 编译报错:this是const指针,不能赋值
    }
  2. 仅在成员函数内部使用
    this 指针仅能在非静态成员函数内部访问,类外或静态成员函数中无法使用。

  3. 本质是函数形参
    this 指针是成员函数的隐含形参,不存储在对象中(对象内存仅包含成员变量)。当对象调用成员函数时,编译器自动将对象地址作为实参传递给 this

  4. 传递方式
    通常由编译器通过寄存器(如 VS 使用 ecx 寄存器)传递,而非通过函数调用栈,效率更高(无需压栈 / 出栈操作)。

面试题 1:this 指针存在哪里?

  • 理论上:this 是函数形参,应存储在栈区(与普通函数参数存储位置一致)。

  • 实际实现:VS 等编译器为优化效率,将 this 存储在ecx 寄存器中,减少栈操作开销。
    面试题 2:this 指针可以为空吗?

  • 可以为空,但需避免通过空 this 指针访问成员变量(会导致解引用空指针,程序崩溃)。

  • 若成员函数未访问任何成员变量(仅执行独立逻辑),即使 this 为空,调用仍可正常执行(成员函数存于代码段,无需访问对象内存)。
    示例验证:

cpp 复制代码
class A {
public:
    void Print1() {
        cout << "Print1: 未访问成员变量" << endl;
    }
    void Print2() {
        cout << "Print2: " << _a << endl;  // 访问成员变量,需解引用this
    }
private:
    int _a;
};

int main() {
    A* p = nullptr;
    p->Print1();  // 正常运行:未访问成员变量,无需解引用this
    p->Print2();  // 崩溃:通过空this访问_a,解引用空指针
    return 0;
}

8.3 C语言和C++实现Stack的对比

核心差异

C语言中数据(结构体)与操作数据的函数分离,需显式传递指针;C++通过类将数据与方法封装,由编译器自动维护 this 指针,简化调用并提升安全性。

1. C语言实现Stack的特点

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>

typedef int DataType;
typedef struct Stack {
    DataType* array;  // 数据成员(仅存储数据)
    int capacity;
    int size;
} Stack;

// 所有操作函数需显式传递Stack*参数
void StackInit(Stack* ps) {
    assert(ps != NULL);  // 必须手动检查指针非空
    ps->array = (DataType*)malloc(3 * sizeof(DataType));
    ps->capacity = 3;
    ps->size = 0;
}

void StackPush(Stack* ps, DataType data) {
    assert(ps != NULL);  // 重复检查指针
    // ... 扩容逻辑 ...
    ps->array[ps->size++] = data;  // 需通过ps指针访问成员
}

// 其他函数:StackDestroy、StackTop等(均需显式传递Stack*)

int main() {
    Stack s;
    StackInit(&s);          // 手动传递地址
    StackPush(&s, 1);       // 每次调用都需传递&s
    printf("%d\n", StackTop(&s));
    StackDestroy(&s);
    return 0;
}

缺点:

  • 数据与操作分离 ,函数需显式传递 Stack*,易遗漏或传递错误指针;
  • 需手动检查指针非空(assert,增加代码冗余;
  • 结构体仅含数据,无法隐藏内部实现(如 CheckCapacity 等辅助函数需暴露)。

2. C++ 实现 Stack 的特点

cpp 复制代码
#include <iostream>
#include <cstdlib>
using namespace std;

typedef int DataType;
class Stack {
public:
    // 成员函数无需显式传递Stack*,编译器自动传递this
    void Init() {
        _array = (DataType*)malloc(3 * sizeof(DataType));
        _capacity = 3;
        _size = 0;
    }

    void Push(DataType data) {
        CheckCapacity();  // 直接调用类内函数,无需传递参数
        _array[_size++] = data;  // 隐式通过this访问成员
    }

    DataType Top() {
        return _array[_size - 1];  // this->_array[this->_size - 1]
    }

    // 其他函数:Pop、Size、Destroy等
private:
    // 辅助函数设为private,隐藏实现细节
    void CheckCapacity() {
        if (_size == _capacity) {
            // ... 扩容逻辑 ...
        }
    }

    // 数据成员设为private,避免外部直接修改
    DataType* _array;
    int _capacity;
    int _size;
};

int main() {
    Stack s;
    s.Init();    // 无需传递地址,编译器自动绑定this到s
    s.Push(1);   // 调用简洁,类似"对象.行为"
    cout << s.Top() << endl;
    return 0;
}

优点:

  • 数据与方法封装在类中,调用方式为 "对象。方法",符合自然认知;
  • 编译器自动传递 this 指针,无需手动传递对象地址,减少错误;
  • 通过访问限定符(private)隐藏内部实现(如 CheckCapacity)和数据,仅暴露必要接口(Push、Top),提升安全性和可维护性。
  • 对比总结:
维度 C 语言实现 C++ 实现
数据与方法关系 分离,需显式传递结构体指针 封装,编译器通过 this 指针关联
调用方式 函数名 (& 对象,参数) 对象。函数名 (参数)
安全性 需手动检查指针,易出错 隐藏实现,限制访问,更安全
可读性 代码分散,逻辑关联弱 类内聚合,逻辑清晰
相关推荐
泽虞4 小时前
《LINUX系统编程》笔记p8
linux·运维·服务器·c语言·笔记·面试
阿捏利7 小时前
C++ Primer Plus 第六版 第二章 编程题
c++·编程题·c++ primer plus
海洋的渔夫7 小时前
1-ruby介绍、环境搭建、运行 hello world 程序
开发语言·后端·ruby
悠哉悠哉愿意9 小时前
【数学建模学习笔记】数据标准化
笔记·学习·数学建模
何妨重温wdys10 小时前
贪心算法解决钱币找零问题(二)
算法·贪心算法
葵野寺11 小时前
【RelayMQ】基于 Java 实现轻量级消息队列(五)
java·开发语言·java-rabbitmq
007php00711 小时前
Go 面试题: new 和 make 是什么,差异在哪?
后端·算法·docker·容器·面试·职场和发展·golang
扯淡的闲人11 小时前
Go语言入门学习笔记
笔记·学习·golang
一支鱼11 小时前
leetcode-3-无重复字符的最长子串
算法·leetcode·typescript