【C++——类和对象(上)】筑基篇:以封装奠定面向对象之基

欢迎阅读本篇学习笔记。

本篇作为个人计算机专业的学习记录,这里将系统梳理关于C++类定义与对象实例的部分知识点,从基础概念到代码实现逐步展开,已便于后续的复习巩固。如有不足,欢迎大家在评论区交流指正,感谢大家的阅读与支持!

如果说 C 语言是面向过程的思路,讲究步骤清晰、步步推进;那么 C++ 的面向对象,就是把数据与操作数据的方法打包成一个个独立单元,让代码组织方式更贴近真实世界的认知。而封装,正是我们踏入面向对象世界的第一道门槛。

本篇作为类与对象系列的上篇,我们从最基础的类定义讲起,逐步拆解访问限定符、类作用域与对象实例化,帮你筑牢面向对象的根基。


一、类的定义

1. 基本定义格式

在 C++ 中,定义类需要使用 class 关键字,后面紧跟类名,再用一对花括号包裹类的内容,花括号内就是这个类的成员。类中既可以定义变量(称为成员变量 / 属性),也可以定义函数(称为成员函数 / 方法)。

有一个很实用的编码习惯:为了区分成员变量和普通形参,通常会给成员变量加上特殊标记,比如前缀下划线 _ 或者前缀 m_。这不是语法强制要求,但能大幅提升代码可读性,也能避免形参与成员变量重名的问题。

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

// 定义一个日期类
class Date
{
public:
    // 成员函数:初始化日期
    void Init(int year, int month, int day)
    {
        // _year 是成员变量,year 是形参,下划线区分避免重名
        _year = year;
        _month = month;
        _day = day;
    }

    void Print()
    {
        cout << _year << "/" << _month << "/" << _day << endl;
    }

private:
    // 成员变量(属性)
    int _year;
    int _month;
    int _day;
};

2. class 与 struct 的联系与区别

很多同学会疑惑:C 语言已经有 struct 了,C++ 为什么还要加 class? 其实在 C++ 中,struct 已经被升级强化了 ------ 它不再只是存放数据的结构体,也可以在里面定义函数,本质上已经具备了类的能力,同时完全兼容 C 语言的用法。

二者最核心的区别在于默认访问权限:

  • class 定义的类,默认访问权限是私有(private)
  • struct 定义的类,默认访问权限是公有(public)

日常开发中,如果是纯数据结构、或者需要兼容 C 的场景,我们习惯用 struct;如果是完整的面向对象类,优先使用 class。

复制代码
// C++ 中 struct 也可以定义成员函数
struct ListNode
{
    // 成员函数:初始化节点
    void Init(int x)
    {
        val = x;
        next = nullptr;
    }

    int val;
    ListNode* next;
};

二、访问限定符:封装的语法基石

面向对象的三大特性是封装、继承、多态,而封装是入门的第一步。所谓封装,就是把数据和操作数据的方法绑定在一起,隐藏内部实现细节,只对外暴露安全可控的访问接口。 C++ 正是通过访问限定符,来实现这种 "内外有别" 的封装效果。

三种访问限定符

  • public(公有):被修饰的成员在类外可以直接访问,通常用来放对外提供的成员函数接口。
  • protected(保护):和继承机制相关,后续讲到继承时再深入展开。
  • private(私有):被修饰的成员在类外不能直接访问,通常用来放成员变量,把数据保护起来。

使用规则

  1. 访问限定符的作用范围:从当前限定符出现的位置开始,一直到下一个访问限定符出现为止;如果后面没有其他限定符,就一直持续到类的末尾。
  2. class 默认权限是 private,struct 默认权限是 public。
  3. 工程里的通用写法:成员变量统一设为 private 隐藏起来,需要对外提供的操作函数设为 public。这样外部不能随意修改内部数据,只能通过我们提供的接口访问,安全性和可控性更强。

三、类作用域

类本身就定义了一个独立的作用域,叫做类域。类里的所有成员,都被限制在这个作用域当中。

1. 类外定义成员函数

当函数声明写在类内、定义写在类外时,必须在函数名前加上 类名::,用作用域解析符指明这个函数属于哪个类,否则编译器会把它当成全局函数。

复制代码
class Stack
{
public:
    // 类内只写声明
    void Init(int n = 4);

private:
    int* _array;
    size_t _capacity;
    size_t _top;
};

// 类外定义:必须加 Stack:: 指明所属类域
void Stack::Init(int n)
{
    _array = (int*)malloc(sizeof(int) * n);
    _capacity = n;
    _top = 0;
}

2. 名字查找规则

当我们调用对象的成员函数时,编译器查找名字有固定顺序: 先在函数局部域查找,找不到就去类域里找,类域里还找不到,最后才去全局域查找。 也正因为有类域做隔离,不同的类里可以定义同名的成员变量和成员函数,不会发生命名冲突。


四、对象的实例化

1. 类与对象的关系

类是什么?类只是一张设计蓝图,它描述了一种类型具备哪些属性、哪些方法。类本身不占用实际的内存空间,就像建筑图纸只画了房子的布局,但图纸本身不能住人。

对象是什么?用类创建出具体实体的过程,就叫做实例化,实例化产生的实体就是对象。对象才会真正占用内存空间,用来存储具体的数据。就像按照图纸盖出来的房子,才有真实的空间可以存放家具。

  • 一个类可以实例化出任意多个对象
  • 每个对象都拥有自己独立的成员变量
  • 同一个类的所有对象,共享同一份成员函数代码(函数代码存在公共代码段,不用每个对象都存一份)

2. 实例化代码示例

复制代码
int main()
{
    // 用 Date 类实例化出两个对象 d1、d2
    Date d1;
    Date d2;

    d1.Init(2024, 3, 31);
    d2.Init(2024, 7, 5);

    d1.Print(); // 2024/3/31
    d2.Print(); // 2024/7/5

    return 0;
}

五、对象的内存布局与大小计算

1. 对象里到底存了什么

很多同学刚开始会有疑问:一个类里同时包含成员变量和成员函数,实例化出的对象会把这些内容都存一份吗?

答案是否定的,二者的存储方式完全不同:

  • 成员变量:每个对象独立存储 每个对象都拥有独立的成员变量空间,存放各自的数据。就像同一张图纸盖出的房子,每户的家具、物品都是相互独立的。
  • 成员函数:所有对象共享同一份 成员函数编译后是一段可执行指令,存放在公共代码段。如果每个对象都复制一份函数代码,会造成极大的空间浪费。因此同一个类的所有对象,共享同一份成员函数代码。

这种设计既保证了对象数据独立,又最大程度节省了内存,是非常经典的空间优化思路。

2. 空类的特殊大小:1 字节占位

如果一个类没有任何成员变量,它实例化出的对象大小是多少?答案是 1 字节。

复制代码
class Empty {};

int main()
{
    Empty e;
    cout << sizeof(e) << endl; // 输出结果为 1
    return 0;
}

为什么不是 0 字节? 因为每个对象在内存中都必须拥有一个独一无二的地址,用来标识它的存在。如果大小为 0,就无法在内存中区分不同的对象。这 1 个字节不存储任何有效数据,纯粹是为了占位、标识对象存在。

3. 内存对齐规则

和 C 语言的结构体一样,C++ 类的对象在内存中存储时,也需要遵循内存对齐规则。对齐的本质是用少量空间换取读取效率,让 CPU 可以更高效地访问内存。

核心对齐规则:

  1. 第一个成员变量,存储在对象偏移量为 0 的地址处。
  2. 其余成员变量,要对齐到对齐数的整数倍地址处。
  • 对齐数 = 编译器默认对齐数 与 该成员变量大小的较小值
  • VS 系列编译器的默认对齐数为 8
  1. 对象的总大小,必须是所有成员中最大对齐数的整数倍。
  2. 如果类中嵌套了结构体类型,嵌套的结构体对齐到自身最大对齐数的整数倍处,整体大小也要是所有最大对齐数的整数倍。
示例:计算类对象大小
复制代码
class A
{
public:
    void Print(); // 成员函数不占用对象内存空间
private:
    char _ch;
    int _i;
};

class B
{
public:
    void Print();
};

class C
{};

int main()
{
    cout << sizeof(A) << endl; // 8 字节:char占1 + 填充3 + int占4
    cout << sizeof(B) << endl; // 1 字节:无成员变量,占位1字节
    cout << sizeof(C) << endl; // 1 字节:空类,占位1字节
    return 0;
}

重点提醒:计算类对象大小时,只统计成员变量,成员函数不占用对象的内存空间。


六、this 指针:隐藏的对象身份牌

1. 一个核心问题

我们知道,多个对象共享同一份成员函数代码。那当**d1.Init()** 和 d2.Init() 调用同一个 Init 函数时,函数怎么知道自己该操作 d1 的数据,还是 d2 的数据?

解开这个问题的钥匙,就是 this 指针。

2. 底层原理

C++ 编译器会给每一个非静态成员函数,都隐式增加一个指针参数,这个指针就叫做 this 指针。它指向当前调用函数的对象,函数体内所有对成员变量的访问,本质都是通过 this 指针去间接访问的。

比如我们书写的代码是:

复制代码
void Date::Init(int year, int month, int day)
{
    _year = year;
    _month = month;
    _day = day;
}

经过编译器处理后,真实的函数原型等价于:

复制代码
void Date::Init(Date* const this, int year, int month, int day)
{
    this->_year = year;
    this->_month = month;
    this->_day = day;
}

3. 核心特性

  • this 指针是编译器自动添加的,我们不能在形参列表里显式书写 this 指针,但可以在函数体内显式使用。
  • this 指针本身是常量指针**(Type* const)**,它的指向不能被修改,但指向的对象内容可以修改。
  • this 指针作为函数形参存储在栈上,并不存放在对象内部,不占用对象的内存空间。

4. 经典面试题:空指针调用成员函数

这是面试中的高频考点,我们分两种情况来看运行结果:

情况 1:成员函数中没有访问成员变量
复制代码
class A
{
public:
    void Print()
    {
        cout << "A::Print()" << endl;
    }
private:
    int _a;
};

int main()
{
    A* p = nullptr;
    p->Print(); // 程序正常运行
    return 0;
}

✅ 结果:正常运行,不会崩溃。

很多同学第一反应是空指针解引用一定会崩溃,但这道题的核心考点,正是区分「成员函数」和「成员变量」在内存中的存储差异,以及 this 指针的底层传递逻辑。我们逐层拆解:

1. 成员函数不存放在对象内存中

C++ 类的成员函数编译后,是一段可执行的指令代码,存放在公共代码段。同一个类的所有对象共享同一份成员函数代码,并不会在每个对象里都复制一份。 因此,调用成员函数时,根本不需要去对象的内存空间里查找函数地址 ------函数的地址在编译阶段就已经确定了。

2. p->Print() 的底层到底做了什么

当编译器看到 p->Print() 时,会将其转换为类似这样的调用形式:

复制代码
A::Print(p);

也就是:

  • 直接调用编译期就确定好地址的 A::Print 函数
  • 将指针 p 作为隐藏的 this 指针参数,传递给成员函数

整个过程并没有对 p 做解引用去读取对象内存,仅仅是把 p 的值(也就是 nullptr)当成了函数的一个参数传进去。

3. 为什么传空指针也不会崩溃

程序崩溃的前提,是对空指针进行解引用访问内存 。 而这个 Print 函数内部只打印了字符串,完全没有访问任何成员变量,也就全程没有用到 this 指针。虽然传进来的 this 是 nullptr,但函数自始至终都没有去触碰它,自然不会触发空指针解引用错误。

情况 2:成员函数中访问成员变量

拓展对比:什么情况会崩溃

如果我们把 Print 函数修改一下,让它访问成员变量:

复制代码
void Print()
{
    cout << _a << endl; 
    // 编译器处理后等价于:cout << this->_a << endl;
}

此时程序就会运行崩溃。 因为访问成员变量 _a 的本质,是通过 this 指针去间接访问对象内存。当 this 为 nullptr 时,this->_a 就构成了空指针解引用,进而触发程序崩溃。

📌重点标记:成员函数本身不依赖对象内存,只传递空指针不会崩溃;只有当函数内部通过 this 访问成员变量时,才会真正触发空指针解引用。


七、从栈的实现看 C 与 C++ 的差异

我们以栈这个经典数据结构为例,直观感受两种语言在设计思路上的区别。

C 语言实现数据结构,结构体和操作函数是分离的;结构体只负责存储数据,操作函数以全局函数形式存在。C 语言没有语法层面的访问权限控制,结构体成员对外完全可见,外部可直接读写结构体内部数据,需要依靠程序员编码规范来避免误用。

而在 C++ 中,我们把数据和操作数据的方法封装到同一个类里:

  • 成员变量设为私有,外部无法直接修改,从语法层面保证了数据安全
  • 对外只暴露公有的成员函数接口,使用方式统一规范
  • 数据与方法绑定在一起,语义更清晰,代码组织更合理

1. 结构定义:数据可见性天差地别

C 语言的结构体,所有成员默认完全公开,外部可以随意读写修改;而 C++ 通过访问限定符,把数据保护了起来。

C 语言写法:结构体成员全部暴露

复制代码
typedef struct Stack
{
    int* _a;        // 动态数组
    int _capacity;  // 容量
    int _top;       // 栈顶
} Stack;

外部代码可以直接修改 _top、_capacity,甚至把数组指针改乱,没有任何语法约束,全靠使用者自觉。

C++ 写法:成员变量私有隐藏

复制代码
class Stack
{
public:
    // 对外只暴露操作接口
    void Push(int x);
    void Pop();
    int Top();

private:
    // 成员变量私有,类外不能直接访问
    int* _a;
    int _capacity;
    int _top;
};

成员变量被设为私有,外部无法直接触碰内部数据,只能通过公有成员函数间接操作。这就是封装最直观的体现:隐藏内部细节,只对外暴露可控的接口。

2. 函数调用:从手动传参到自动传递

C 语言操作数据结构时,必须手动把结构体地址传进函数;C++ 则由编译器自动完成这件事。

C 语言写法:每次调用都要手动传地址

复制代码
// 函数定义:第一个参数永远是结构体指针
void StackPush(Stack* ps, int x);

// 调用方式:手动取地址传入
Stack st;
StackInit(&st);
StackPush(&st, 10);

每一个操作函数都要接收结构体指针,写法繁琐,也容易漏传、传错。

C++ 写法:对象直接调用成员函数

复制代码
// 调用方式:通过对象直接调用
Stack st;
st.Init();
st.Push(10);

看起来没有传指针,实际上编译器会隐式把 &st 作为 this 指针传给成员函数。使用者不用关心底层传递,代码更简洁,语义也更自然 ------「这个栈自己执行入栈操作」。

3. 命名管理:从全局污染到域隔离

C 语言所有函数都在全局域,为了避免重名,必须给函数加上模块前缀;C++ 有类域做天然隔离,命名可以更清爽。

C 语言:必须加前缀区分模块

复制代码
// 为了不和其他数据结构冲突,函数名要加 Stack 前缀
void StackInit();
void StackPush();
void StackPop();

项目越大,函数名越长,命名冲突的风险也越高。

C++:类域天然隔离重名

复制代码
class Stack {
public:
    void Init();
    void Push();
    void Pop();
};

class Queue {
public:
    void Init();
    void Push();
    void Pop();
};

不同的类里可以有完全同名的函数,编译器通过类域区分,不会冲突。命名不用再堆冗长的前缀,代码更干净。

4. 设计思想:从面向过程到面向对象

  • C 语言:数据与操作分离。结构体仅保存数据,操作数据的函数是独立的全局函数,数据和操作在语法上相互独立,需要人为约定来保护数据。
  • C++:数据与操作封装成一个整体。将数据和操作该数据的方法放在同一个类中,借助访问限定符,在语法层面实现数据保护。

🔆封装的意义,不止是写法变好看了。它通过权限控制强制规范了访问方式,降低了误用概率,也让代码的可维护性和扩展性大幅提升。这也是我们从 C 语言过渡到 C++ 面向对象,最先感受到的变化。

好啦,那这篇内容就先整理到这里,这次把类和对象的部分相关知识整理了一下,为了方便自己日后复习回顾,也希望能给正在学习的小伙伴一些参考。如果文中有理解不到位或是书写有误的地方,欢迎大家在评论区指出,一起交流共同进步。谢谢大家!

相关推荐
apcipot_rain4 小时前
C++转Java日记(06)
java·开发语言
小七在进步11 小时前
string的使用(1)
c++·visual studio
SomeOtherTime11 小时前
Rust所有权(AI回答)
开发语言·后端·rust
Easyhood11 小时前
Android 开发入门实践(十四):Compose 副作用入门与四个常用 API
android·开发语言·kotlin·android jetpack·compose
白杨尚青12 小时前
C++进阶篇(六):map 和 set 的模拟实现——用红黑树封装 mymap/myset(迭代器 + operator[])
开发语言·c++·迭代器·stl·set·map·红黑树
kloc14 小时前
聊天室项目入口网关子服务的实现
linux·c++
饮水桶_T15 小时前
JAVA IO流笔记
开发语言·数据结构·算法
csdn_aspnet18 小时前
C++ 3个有序点的方向(Orientation of 3 ordered points)
c++·算法
Felven18 小时前
C. Brr Brrr Patapim
c语言·开发语言