1. 面向过程和面向对象初步认识
核心差异:
面向过程(C语言)与面向对象(C++)的本质区别,在于解决问题的“思维范式”不同——前者关注“步骤流程”,后者关注“对象交互”。
1.1 面向过程(Procedure-Oriented)
- 核心思想:将求解问题的过程拆解为“若干个函数步骤”,通过函数调用按顺序执行步骤,最终完成任务。
- 关注点:“怎么做”(步骤),数据与操作数据的函数是分离的。
- 示例(用C语言实现栈的入栈操作):
// 数据结构(栈)与操作函数分离 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++ 实现栈的入栈操作,后续会展开):
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语言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
内部定义函数(方法),实现 “数据与方法的封装”,示例如下:
#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
关键字定义类,类体包含“成员变量(属性)”和“成员函数(方法)”,定义结束必须加分号(与结构体语法一致,但语义更侧重封装)。
基础语法格式:
class ClassName { // ClassName 为类名,需符合标识符命名规则
public:
// 公有的成员函数(对外暴露的接口)
返回值类型 函数名(参数列表);
private:
// 私有的成员变量(内部数据,外部不可直接访问)
数据类型 变量名;
}; // 分号必须加,否则编译报错
public/private
是访问限定符:控制成员在类外部的访问权限(后续会详细展开,此-处先用于区分接口与数据)。- 成员变量:描述类的 “属性”(如日期类的年、月、日)。
- 成员函数:描述类的 “行为”(如日期类的初始化、打印日期)。
3.1 类的两种定义方式(附示例)
方式1:声明与定义全部放在类体中
特点:
成员函数的声明和实现都在类内部,编译器可能将其视为内联函数(适合函数体简短的场景,如简单的赋值、取值),但函数体复杂时不推荐(会导致类体臃肿)。
示例:日期类(Date)的完整内定义
#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
(类声明文件)
// 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
(成员函数实现文件)
// 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
(测试文件)
// 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 成员变量命名规则(避免歧义)
核心问题:
若成员变量与函数形参同名,会导致“命名冲突”——编译器无法区分赋值语句中的“左值是成员变量还是形参”,如下列错误示例:
错误示例:命名冲突
class Date {
public:
void Init(int year) {
year = year; // 歧义:左、右两边都是形参year,成员变量year未被赋值
}
private:
int year; // 成员变量与形参同名
};
规则 1:成员变量加 _ 前缀(最常用)
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 框架常用)
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
(私有),具体规则如下:
public
(公开成员)- 类外可直接访问(通过“对象.成员”或“对象->成员”语法)。
- 用途:定义对外暴露的接口(如初始化函数、数据获取函数),是外部与类交互的唯一通道。
protected
(保护成员)与private
(私有成员)- 类外不可直接访问(编译报错),仅能在类内部(成员函数中)或子类中(
protected
专属,后续继承章节讲解)访问。 - 用途:定义类的内部数据或辅助函数(如成员变量、内部校验逻辑),避免外部非法修改。
- 现阶段区别:在“类与对象”阶段,
protected
和private
功能完全一致(仅在继承时体现差异)。
- 类外不可直接访问(编译报错),仅能在类内部(成员函数中)或子类中(
权限作用域规则
- 访问权限的生效范围从“当前访问限定符出现的位置”开始,到“下一个访问限定符出现”或“类定义结束(
}
)”为止。 - 示例:
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; }; // 类结束,权限作用域终止
- 访问权限的生效范围从“当前访问限定符出现的位置”开始,到“下一个访问限定符出现”或“类定义结束(
类与结构体的默认访问权限差异
- class 关键字:默认访问权限为 private(需显式加 public 才能暴露接口)。
- struct 关键字:默认访问权限为 public(兼容 C 语言结构体的 “全公开” 特性)。
- 示例对比:
// 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,类外可调用
};
- 底层本质
- 访问限定符仅在编译阶段生效(编译器检查访问权限,非法访问报错)。
- 当代码编译为二进制文件后,内存中成员的存储位置无任何权限标识(即运行时无访问限制)—— 本质是 “编译期语法层面的管理”。
C++ 中 struct 和 class 的区别是什么?
- 核心区别 1:默认访问权限不同(类与对象阶段):
- struct 定义的类:默认访问权限为 public(兼容 C 语言结构体)。
- class 定义的类:默认访问权限为 private(符合面向对象封装思想)。
- 其他区别(后续章节):
- 继承时:struct 默认是 public 继承,class 默认是 private 继承。
- 模板参数列表:class 可用于定义模板类型参数,struct 也可,但早期 C++ 中 class 更常用(现代 C++ 中两者无差异)。
- 示例验证:
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步:
- 数据与方法有机结合
将描述对象的“数据”(成员变量)和“操作数据的方法”(成员函数)定义在同一个类中,形成逻辑整体。
示例:
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;
};
- 隐藏内部细节(
private/protected
)
将 “数据”(如_array、_capacity
)和 “内部辅助方法”(如扩容的具体逻辑)定义为private
,类外无法直接访问,避免非法修改(如用户直接修改_size
导致栈逻辑混乱)。 - 暴露安全接口(
public
)
将 “用户需要的交互操作”(如 Init 初始化、Push 入栈、Top 获取栈顶)定义为public
,用户通过这些接口与对象交互,无需关心内部实现(如无需知道 _array 是用 new 还是 malloc 分配的)。
封装的优势: - 安全性:内部数据仅能通过预设接口修改,避免非法操作(如栈的 _size 只能通过 Push/Pop 间接修改,防止用户直接赋值为负数)。
- 易用性:用户只需关注接口用法(如调用 Push 即可入栈),无需理解内部逻辑(如扩容算法)。
- 可维护性:内部实现修改时(如将 new 改为 malloc),只要接口签名不变,外部代码无需修改(如用户调用 Push 的方式不变)。
5. 类的作用域
核心定义:
类是一个独立的作用域(Class Scope),类的所有成员(成员变量、成员函数)都属于这个作用域。在类体外定义成员函数时,必须通过 ::
(作用域操作符)明确指定该函数所属的类域,否则编译器会将其视为全局函数,导致编译错误。
类作用域的体现:
- 类体内访问:类的成员函数在类内部可以直接访问同类的其他成员(无论
public
/private
),无需额外指定作用域(编译器默认在当前类域内查找)。 - 类体外访问:
- 访问成员变量:需通过“对象.成员变量”或“对象指针->成员变量”(且成员需为
public
)。 - 定义成员函数:必须加“
类名::
”,明确函数所属类域。
- 访问成员变量:需通过“对象.成员变量”或“对象指针->成员变量”(且成员需为
示例:类体外定义成员函数
以 Person
类为例,展示类作用域的使用:
#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个关键特性:
- 类不占内存,对象占内存
- 类仅定义成员的“结构和声明”,编译后不分配内存;
- 对象会根据类的成员变量大小,分配对应的内存(成员函数存于代码段,所有对象共享,不单独占用对象内存)。
- 错误示例:直接操作类的成员变量(编译失败):
class Person {
public:
int _age;
};
int main() {
// 错误:Person是类(模板),无内存,无法直接访问_age
Person._age = 100; // error C2059: 语法错误:“.”
return 0;
}
正确示例:实例化对象后操作成员变量:
int main() {
Person p; // 实例化对象p,分配内存存储_age
p._age = 100; // 正确:对象p有内存,可访问public成员_age
cout << p._age << endl; // 输出:100
return 0;
}
- 一个类可实例化多个对象
同一类的不同对象,共享类的成员函数(代码段),但拥有独立的成员变量(内存空间),修改一个对象的成员变量不会影响其他对象。
示例:
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;
}
- 实例化是 “创建实体” 的过程
类的实例化本质是 “根据类模板,在内存中开辟空间,初始化成员变量” 的过程。即使类没有显式的初始化函数,实例化对象时也会分配内存(成员变量默认初始化,如 int 为随机值)。
类比示例:- 类(如 “学生信息表模板”):仅规定有 “姓名、学号、年龄” 等字段;
- 实例化对象(如 “学生张三的信息表”):在模板基础上填写具体数据,形成实体表格,占用纸张(内存)空间。
类与对象的内存占用补充:
- 对象的内存大小 = 所有成员变量的大小之和(内存对齐规则需考虑,后续章节讲解);
- 成员函数不占用对象内存,所有对象共享同一套成员函数(存于代码段),通过 this 指针区分不同对象(后续章节讲解)。
示例(64 位系统,内存对齐为 8 字节):
7. 类对象模型
7.1 如何计算类对象的大小
核心问题:
类包含“成员变量”和“成员函数”,但类对象的大小并非两者之和——需明确对象中实际存储的内容,才能计算大小。
示例分析:
以包含成员变量和成员函数的类为例:
#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:对象中仅存储成员变量,成员函数存于公共代码段
逻辑:
- 所有成员函数统一存储在“代码段”(全局共享区域),不随对象实例化而复制;
- 对象中仅存储成员变量,调用函数时通过“类域+对象地址(this指针)”找到函数,无需对象存储函数相关信息。
验证:通过不同类的对象大小测试,证明此为C++实际采用的存储方式:
#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:第一个成员的偏移量为0
结构体/类的第一个成员,必须存储在“偏移量为0”的内存地址(即对象内存的起始位置)。规则2:其他成员对齐到“对齐数”的整数倍地址
- 对齐数 =
min(编译器默认对齐数, 成员变量自身大小)
; - 非第一个成员的存储地址,必须是其“对齐数”的整数倍(若当前地址不满足,编译器自动填充空白字节)。
- 对齐数 =
规则3:结构体总大小为“最大对齐数”的整数倍
- 最大对齐数 = 所有成员的对齐数中最大的值;
- 结构体总大小需向上取整为“最大对齐数”的整数倍(不足则填充空白字节)。
规则4:嵌套结构体的对齐
- 嵌套的结构体成员,需对齐到“自身最大对齐数”的整数倍地址;
- 整个结构体的总大小,需为“所有成员(含嵌套结构体)的最大对齐数”的整数倍。
示例1:基础结构体对齐计算(VS,默认对齐数8)
#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:嵌套结构体对齐计算
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;
}
面试题系列:
- 结构体怎么对齐?为什么要进行内存对齐?
- 对齐规则:4 条核心规则(第一个成员偏移 0、其他成员对齐到对齐数整数倍、总大小为最大对齐数整数倍、嵌套结构体对齐到自身最大对齐数);
- 对齐原因:CPU 按 “对齐字节数” 批量访问内存,对齐可减少 CPU 读取次数,提升效率(牺牲少量内存换效率)。
- 如何让结构体按照指定的对齐参数进行对齐?能否按照 3、4、5 即任意字节对齐?
- 方法:使用编译器指令 #pragma pack(n)(n 为指定对齐数),取消对齐用 #pragma pack();
- 限制:VS 中 n 需为 2 的幂(如 1、2、4、8、16),不支持 3、5 等非 2 幂对齐(硬件访问机制限制);GCC 支持任意 n 对齐,但非 2 幂对齐可能导致效率下降。
- 什么是大小端?如何测试某台机器是大端还是小端?有没有遇到过要考虑大小端的场景?
- 大小端定义:
- 大端(Big-Endian):数据的高位字节存低地址,低位字节存高地址(如 0x1234,地址 0 存 0x12,地址 1 存 0x34);
- 小端(Little-Endian):数据的低位字节存低地址,高位字节存高地址(如 0x1234,地址 0 存 0x34,地址 1 存 0x12);
- 测试方法(代码示例):
- 大小端定义:
#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
类为例,d1
和 d2
是两个不同对象,调用 Init
和 Print
时,函数需明确操作的是 d1
还是 d2
的 _year
、_month
、_day
:
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函数(用户无需手动编写)
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个核心特性:
- 类型固定
this
指针的类型为类类型* const
(如Date* const
),即指针本身不可修改(不能给this
赋值),但可通过this
修改指向对象的成员变量。
示例(错误):void Date::Init(int year, int month, int day) { this = nullptr; // 编译报错:this是const指针,不能赋值 }
- 仅在成员函数内部使用
this
指针仅能在非静态成员函数内部访问,类外或静态成员函数中无法使用。 - 本质是函数形参
this
指针是成员函数的隐含形参,不存储在对象中(对象内存仅包含成员变量)。当对象调用成员函数时,编译器自动将对象地址作为实参传递给this
。 - 传递方式
通常由编译器通过寄存器(如 VS 使用 ecx 寄存器)传递,而非通过函数调用栈,效率更高(无需压栈 / 出栈操作)。
面试题 1:this 指针存在哪里?
理论上:this 是函数形参,应存储在栈区(与普通函数参数存储位置一致)。
实际实现:VS 等编译器为优化效率,将 this 存储在ecx 寄存器中,减少栈操作开销。
面试题 2:this 指针可以为空吗?可以为空,但需避免通过空
this
指针访问成员变量(会导致解引用空指针,程序崩溃)。若成员函数未访问任何成员变量(仅执行独立逻辑),即使
this
为空,调用仍可正常执行(成员函数存于代码段,无需访问对象内存)。
示例验证:
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的特点
#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 的特点
#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 指针关联 |
调用方式 | 函数名 (& 对象,参数) | 对象。函数名 (参数) |
安全性 | 需手动检查指针,易出错 | 隐藏实现,限制访问,更安全 |
可读性 | 代码分散,逻辑关联弱 | 类内聚合,逻辑清晰 |