从零开始学 C++(三):类和对象入门|从 struct 到 class,理解封装、对象与 this 指针

写在前面

上一篇 《从零开始学 C++(二):引用进阶、inline 与 nullptr|从 C 指针思维继续过渡到 C++》 中,我们已经把引用进阶、内联函数讲透,并初步接触了 class 的基本定义与访问限定符。

从这一篇开始,我们真正进入 C++ 非常核心的一部分------类和对象。

如果之前已经用 C 写过顺序表、链表、栈、队列,就会发现一个很明显的问题:数据结构的数据和操作数据的函数通常是分开的。例如 C 语言中的栈:

cpp 复制代码
typedef struct Stack {
    int* data;
    int top;
    int capacity;
} Stack;

void StackInit(Stack* ps);
void StackPush(Stack* ps, int x);
void StackPop(Stack* ps);
int StackTop(Stack* ps);

结构体只负责保存数据,而函数全部放在外面,每次操作还要显式把 Stack* ps 传进去。

C++ 的类则向前走了一步:既然这些函数本来就是专门操作 Stack 数据的,为什么不把数据和操作数据的方法组织到一起?于是就有了:

cpp 复制代码
class Stack {
    // 数据
    // 操作这些数据的函数
};

但"类"又绝不仅仅是"能在结构体里写函数"。它还带来了:

  • public / private / protected 访问控制;
  • 封装;
  • 类作用域;
  • 对象实例化;
  • 隐含的 this 指针;
  • 更清晰的接口与实现边界。

本篇就从我们熟悉的 C 语言思维出发,把这些概念逐个理清。

代码仓库

C++成长之路/chapter03_class_object · Luminous/Code_2026 - 码云 - 开源中国


一、从 C 的 struct 走向 C++ 的 class

1.1 C 语言的分离式设计

先来看一个非常简单的日期类型。在 C 语言里可以定义:

cpp 复制代码
typedef struct Date {
    int year;
    int month;
    int day;
} Date;

它只能描述 Date 里面有什么数据,如果需要初始化和打印,还得额外写函数:

cpp 复制代码
void DateInit(Date* pd, int year, int month, int day);
void DatePrint(Date* pd);

数据和操作数据的函数是分离的,每次调用都要显式传入结构体指针。

1.2 C++ 类的组织方式

到了 C++,可以把它重新组织成:

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

class Date {
public:
    void Init(int year, int month, int day)
    {
        year_ = year;
        month_ = month;
        day_ = day;
    }

    void Print()
    {
        cout << year_ << "-"
             << month_ << "-"
             << day_ << endl;
    }
private:
    int year_;
    int month_;
    int day_;
};

调用方式:

cpp 复制代码
int main() {
    Date d;
    d.Init(2026, 9, 27);
    d.Print();
    return 0;
}

这时候代码的组织方式已经发生了明显变化:

复制代码
Date
├── 数据
│   ├── year_
│   ├── month_
│   └── day_
│
└── 操作数据的方法
    ├── Init()
    └── Print()

数据和针对这些数据的操作,被放进了同一个类型中。

1.3 类的基本定义格式

C++ 定义类最基本的格式:

cpp 复制代码
class 类名
{
public:
    // 对外接口
private:
    // 内部数据
};

例如:

cpp 复制代码
class Student {
public:
    void SetScore(int score)
    {
        score_ = score;
    }

    void PrintScore()
    {
        cout << score_ << endl;
    }
private:
    int score_;
};

这里有几个基础概念:

名称 含义
Student 类名
score_ 成员变量 / 数据成员
SetScore() 成员函数
PrintScore() 成员函数
public 公有访问区域
private 私有访问区域

还有一个很容易漏的地方:类定义结束后的分号不能省略,这一点和 struct 一样。

1.4 成员变量的命名习惯

代码中我写的是 int year_; int month_; int day_;,而不是 int year; int month; int day;。这是为了让成员变量更加容易识别。

例如:

cpp 复制代码
void Init(int year, int month, int day)
{
    year_ = year;
    month_ = month;
    day_ = day;
}

一眼就可以看出:

  • year → 函数参数
  • year_ → 当前对象的成员变量

这并不是 C++ 强制语法,而是一种命名习惯。常见写法还有 _year、m_year,不同团队可能采用不同规范。我个人更倾向 year_,因为语义清楚,也可以避免部分以下划线开头标识符的命名规则问题。


二、访问控制与封装

类和普通 C 结构体相比,一个非常重要的变化就是:类可以控制哪些成员允许外部访问。目前先认识三个关键字:public、private、protected,当前阶段重点理解前两个。

2.1 public:允许外部直接访问

例如:

cpp 复制代码
class Date {
public:
    void Print()
    {
        cout << year_ << endl;
    }
private:
    int year_;
};

外部可以:

cpp 复制代码
Date d;
d.Print();

因为 Print() 是 public。

2.2 private:只允许类内部直接访问

如果写:

cpp 复制代码
Date d;
d.year_ = 2026;

会编译失败。因为 year_ 是 private 成员。

但是类自己的成员函数 void Init(...) 可以访问它。也就是说:

  • 外部代码 ↓ 不能随便访问 private
  • 成员函数 ↓ 可以访问自己的 private 数据

2.3 封装的本质

刚接触面向对象时,经常会看到一句话:C++ 面向对象三大特性:封装、继承、多态。现在还没学习继承和多态,但是已经开始接触封装了。

封装并不只是"把函数和变量塞进一个 class"。更重要的是:把内部实现隐藏起来,只通过设计好的接口操作对象。

例如栈有:

cpp 复制代码
private:
    int data_[100];
    int size_;

如果完全公开,外部代码就可以随便修改 st.size_ = 9999;,破坏栈的内部状态。但如果 size_ 是 private,外部只能通过 Push()、Pop()、Top()、Size() 操作。

这样:

  • 数据怎么维护,由类自己负责;
  • 使用者怎么操作,通过公开接口完成。

这就是封装真正有价值的地方。


三、class 与 struct 的联系与区别

3.1 C++ 中 struct 也是类

一个很有意思的变化是:C++ 中的 struct 也被增强成了类。例如:

cpp 复制代码
struct Point {
    int x;
    int y;

    void Move(int dx, int dy)
    {
        x += dx;
        y += dy;
    }
};

这是合法的 C++。调用方式:

cpp 复制代码
Point p = { 10, 20 };
p.Move(5, 3);

也就是说,C++ 的 struct 不再只是 C 语言中单纯的数据集合。它同样可以拥有成员变量、成员函数、构造函数、访问控制、继承等其他类相关能力。

3.2 核心区别:默认访问权限

在当前阶段最重要的区别是:

  • class 默认访问权限:private
  • struct 默认访问权限:public

例如:

cpp 复制代码
class A { int x; };  // x 默认是 private
struct B { int x; }; // x 默认是 public

3.3 使用习惯的区分

实际开发中通常:

  • 单纯的数据结构、轻量聚合类型 → struct
  • 需要明显封装行为的对象 → class

但这更多是一种代码设计习惯,而不是绝对限制。

3.4 结构体名直接作为类型名

在 C 中,如果没有 typedef:

cpp 复制代码
struct ListNode {
    int val;
    struct ListNode* next;
};

使用时一般需要写 struct ListNode node;,所以我们以前经常用 typedef 包装。

但 C++ 中可以直接:

cpp 复制代码
struct ListNode {
    int val;
    ListNode* next;
};

然后直接 ListNode node;,不需要再额外写 typedef。这也是 C++ 对 C 风格结构体使用体验的一处改进。


四、类的声明与实现分离

4.1 类内声明、类外实现

如果所有代码都写在 class Date { ... }; 内部,类稍微复杂一点就会显得非常拥挤。因此 C++ 允许:类内部声明接口,类外部完成实现。

例如:

cpp 复制代码
class Date {
public:
    void Init(int year, int month, int day);
    void Print();
private:
    int year_;
    int month_;
    int day_;
};

然后类外实现:

cpp 复制代码
void Date::Init(int year, int month, int day)
{
    year_ = year;
    month_ = month;
    day_ = day;
}

void Date::Print()
{
    cout << year_
         << "-"
         << month_
         << "-"
         << day_
         << endl;
}

4.2 作用域解析运算符 ::

这里第一次看到一个非常重要的符号 ::,叫做作用域解析运算符。

为什么必须写 Date::?假设只写:

cpp 复制代码
void Init(int year, int month, int day)
{
    year_ = year;
}

编译器只会把它理解成一个普通的全局函数 Init,那么 year_ 到底是谁的成员?编译器不知道。

所以需要明确告诉编译器:void Date::Init(...),意思就是:这个 Init 属于 Date 类的作用域。此时 year_、month_、day_,编译器就会到 Date 类域中寻找。

所以 Date:: 本质是在说明:接下来这个名字属于 Date。


五、类与对象:蓝图与实例

5.1 类是类型,对象是实例

这是学习类和对象时非常重要的一组概念:类 ≠ 对象。

例如:

cpp 复制代码
class Date {
private:
    int year_;
    int month_;
    int day_;
};

此时只是定义了 Date 这种类型应该长什么样。可以把类理解成建筑设计图:设计图规定有几个房间、房间怎么排列、有哪些功能,但设计图本身不是房子。

真正执行 Date d1; Date d2; 才是在创建实际对象,这个过程叫类的实例化。

5.2 一个类可以实例化多个对象

例如:

cpp 复制代码
Date d1; Date d2; Date d3;

三个对象各自拥有独立的数据:

cpp 复制代码
d1 ── year_ / month_ / day_
d2 ── year_ / month_ / day_
d3 ── year_ / month_ / day_

例如:

复制代码
d1.Init(2026, 1, 1);
d2.Init(2026, 6, 1);

修改 d1 不会影响 d2,因为每个对象都有自己的一份非静态成员变量。

5.3 对象的内存布局

这是一个非常值得思考的问题:假设 Date 类有 Init、Print 两个成员函数,实例化三个对象,是不是每个对象都保存一份 Init、一份 Print?

并不是。可以理解成:

bash 复制代码
代码区域
├── Date::Init()
└── Date::Print()

对象 d1:只保存自己的数据
对象 d2:只保存自己的数据
对象 d3:只保存自己的数据

成员函数的机器代码没有必要随着每个对象重复存储。所以对象的大小主要与非静态数据成员以及内存对齐有关,而不是"成员函数写得越多 → 对象就越大"。

5.4 用 sizeof 验证对象大小

例如:

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

class Data {
public:
    void Print()
    {
        cout << "Data::Print()" << endl;
    }
private:
    char ch_;
    int value_;
};

class OnlyFunction {
public:
    void Func()
    {
        cout << "hello" << endl;
    }
};

class Empty { };

int main() {
    cout << sizeof(Data) << endl;
    cout << sizeof(OnlyFunction) << endl;
    cout << sizeof(Empty) << endl;
    return 0;
}

在常见环境中:

  • Data 的大小会受到 char、int 内存对齐影响;
  • OnlyFunction、Empty 即使没有非静态数据成员,通常也不会是 0 字节,而至少需要一个非零大小,用于保证不同对象能够拥有不同地址。因此空类对象通常大小为 1 字节。

至于具体对象大小,还与编译器、平台、对齐规则以及后续会学习到的继承、虚函数等机制有关,所以不要简单地把所有情况都套成固定数字。


六、this 指针:成员函数的隐式参数

6.1 this 指向当前调用对象

现在出现一个非常关键的问题:有 Date d1; Date d2;,然后调用 d1.Print(); d2.Print();,但我们明明只写了一份 void Date::Print(),那这个函数怎么知道第一次该打印 d1、第二次该打印 d2?

答案就是:this 指针。

每个普通的非静态成员函数中,都可以使用 this,它指向当前正在调用这个成员函数的对象。

例如:

cpp 复制代码
class Date {
public:
    void Init(int year, int month, int day)
    {
        this->year_ = year;
        this->month_ = month;
        this->day_ = day;
    }
private:
    int year_;
    int month_;
    int day_;
};

调用 Date d1; d1.Init(2026, 9, 27);,可以从理解层面看成:

  • 当前对象 = d1
  • this = &d1

所以 this->year_ 就是 d1.year_。

6.2 为什么平时可以省略 this

刚才 this->year_ = year;,其实通常我们直接写 year_ = year;。因为编译器知道 year_ 是当前类的成员变量,所以可以理解成编译器帮我们补出了 this->。

也就是说:year_ = year; 从概念上相当于 this->year_ = year;。

6.3 this 的典型使用场景

当成员变量和参数名字一样时,this 就非常有用。例如:

cpp 复制代码
class Date {
public:
    void Init(int year, int month, int day)
    {
        this->year = year;
        this->month = month;
        this->day = day;
    }
private:
    int year;
    int month;
    int day;
};

这里 year 优先对应参数,而 this->year 明确表示当前对象中的 year 成员。

不过如果采用 year_ 这样的命名规则,就不太需要频繁显式写 this->。

6.4 关于 this 的注意事项

  1. this 不是手动传进去的普通参数 理解层面可以把 d.Init(2026, 9, 27); 想象成成员函数知道当前对象地址 + 真正写出的参数,但代码中不能自己写 d.Init(&d, 2026, 9, 27);,也不能在成员函数参数列表中手动声明一个真正的 Date* this。this 是 C++ 语言提供的特殊机制。

  2. 不能给 this 赋值 this = nullptr; 是不允许的。

  3. 空指针调用成员函数是未定义行为 例如:

    cpp 复制代码
    class A {
    public:
        void Print()
        {
            cout << "A::Print()" << endl;
        }
    };
    
    int main() {
        A* p = nullptr;
        p->Print();
        return 0;
    }

    有些环境下这段程序可能"碰巧打印成功",于是很容易得出"Print 没访问成员变量,所以空指针调用没问题"的结论。这个结论是不可靠的。

    通过空指针去调用非静态成员函数属于未定义行为,不能因为某一次程序恰好运行成功,就认为这是合法写法。如果函数内部访问了成员变量,就更容易直接访问非法地址而崩溃。所以实际代码中不要写这种写法。


七、实战:用 C++ 类重写栈

7.1 完整栈类实现

类和对象学到这里,最适合做的一件事,就是把之前 C 语言的数据结构重新用 C++ 组织一次。

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

class Stack {
public:
    void Init()
    {
        size_ = 0;
    }

    void Push(int x)
    {
        assert(size_ < 100);
        data_[size_] = x;
        ++size_;
    }

    void Pop()
    {
        assert(size_ > 0);
        --size_;
    }

    int Top()
    {
        assert(size_ > 0);
        return data_[size_ - 1];
    }

    bool Empty()
    {
        return size_ == 0;
    }

    int Size()
    {
        return size_;
    }
private:
    int data_[100];
    int size_;
};

使用方式:

cpp 复制代码
int main() {
    Stack st;
    st.Init();

    st.Push(10);
    st.Push(20);
    st.Push(30);

    cout << "size = " << st.Size() << endl;
    cout << "top = " << st.Top() << endl;

    while (!st.Empty())
    {
        cout << st.Top() << " ";
        st.Pop();
    }
    cout << endl;

    return 0;
}

7.2 和 C 语言版本的对比

以前 C 语言:

cpp 复制代码
Stack st;
StackInit(&st);
StackPush(&st, 10);
StackPush(&st, 20);
printf("%d\n", StackTop(&st));

现在 C++:

cpp 复制代码
Stack st;
st.Init();
st.Push(10);
st.Push(20);
cout << st.Top() << endl;

可以明显看到:

  • C:函数(Stack* ps, ...) ↓ 显式传入对象地址
  • C++:对象.成员函数(...) ↓ 当前对象通过 this 被成员函数识别

但底层算法其实并没有发生神奇变化,栈顶入栈、栈顶出栈、数组存储、size 维护,这些逻辑还是以前学过的东西。改变的主要是代码的组织方式和访问规则。这也是从 C 数据结构过渡到 C++ 类特别自然的原因。

7.3 封装的工程价值

假设原来 C 语言:

cpp 复制代码
Stack st;
st.top = -999;

只要结构体成员公开,调用者理论上就可以随便修改。

而 C++ 中 size_ 是 private,外部写 st.size_ = 100; 直接编译失败。调用者只能通过 Push、Pop、Top、Size 操作。

所以类把代码分成了两个层次:

  • 外部关心:Push 怎么用?Pop 怎么用?Top 返回什么? → public
  • 类内部关心:数组怎么存?size 怎么变化?边界怎么维护? → private

这就是一个非常重要的工程思想:接口和实现分离。以后底层实现即使从数组换成链表,只要公开接口设计不变,外部很多代码甚至完全不用修改。


八、补充细节与常见误区

8.1 类内定义的成员函数与 inline

还有一个和上一篇 inline 可以联系起来的小知识点:像这样直接定义在类内部的成员函数,具有隐式 inline 属性。

但这里仍然要注意上一篇讲过的问题:inline 不代表编译器一定把函数调用展开。是否真正内联优化,依然由编译器决定。

8.2 常见误区汇总

问题 正确认识
类和对象是不是一个东西? 不是,类是类型描述,对象是实例
类定义本身就给成员变量分配对象空间了吗? 没有,要实例化对象才分配
每个对象是否各自保存一套成员函数? 普通成员函数代码不会为每个对象重复保存
private 是不是连成员函数也访问不了? 成员函数可以访问本类 private
struct 在 C++ 中只能存变量吗? 不是,C++ struct 也可以拥有成员函数
class 和 struct 完全一样吗? 当前最明显区别是默认访问权限
this 要不要自己传? 不需要,编译器隐式提供
成员变量不写 this-> 能访问吗? 可以,编译器自动补全
空指针调用不访问成员变量的函数安全吗? 不安全,属于未定义行为
类里写很多函数会让对象变得特别大吗? 普通成员函数不会增加每个对象大小

写在最后

第二篇还是在学习"C++ 比 C 多了哪些语法?",到了这一篇,开始真正看到 C++ 代码组织思想上的变化。

C 语言写栈时:struct Stack + StackInit() + StackPush() + StackPop() + StackTop() 到了 C++:class Stack ── private:数据 └── public:操作数据的接口

算法本身没有突然变得神秘,但是:数据属于谁、谁可以修改数据、函数应该放在哪里、外部允许看到什么、内部应该隐藏什么,开始有了更严格、更清晰的规则。这就是从 C 的"数据 + 一组操作函数",逐渐走向 C++"一个拥有状态和行为的对象"的重要一步。

目前我们的 Stack 还有一个很明显的问题:Stack st; st.Init(); 创建对象以后还必须手动调用 Init()。如果忘了呢?同样,假如内部以后使用动态内存,是不是还得记得手动释放?

C++ 显然不会让这个问题一直存在。下面会进入构造函数、析构函数以及对象的初始化与销毁以后,就可以把 Init()、Destroy() 进一步交给对象自己自动完成。这也是类和对象真正开始展现 C++ 特点的地方。

相关推荐
郝学胜-神的一滴1 小时前
游戏引擎原理与实践 01:聊聊游戏引擎的前世今生
开发语言·c++·游戏引擎·产品运营·软件工程·产品经理·untiy
晴天的雨.9922 小时前
【C++算法】三数之和
开发语言·c++·算法
pippocao2 小时前
王者荣耀日志组件BqLog 为何如此快之3——压缩日志执行路径优化
c++
传奇开心果编程2 小时前
【Flutter入门练中学】第11课:状态管理进阶与声明式路由
android·学习·flutter·ui·ios
AC赳赳老秦2 小时前
公开 CSV 数据集批量处理实战:用 OpenClaw 高效完成下载、清洗与标准化分析样本生成
java·开发语言·汇编·c++·python·deepseek·openclaw
承渊政道2 小时前
Linux系统学习【进程信号详细解析——认识、产生、保存以及捕捉信号】
linux·学习·ubuntu·ssh·vs code·进程信号
凉茶钱2 小时前
【吃透C++】static、友元、内部类、匿名对象与拷贝编译器优化全解析
c++·青少年编程·visual studio
无名猿2 小时前
左值引用、右值引用与万能引用:引用折叠全解
c++·现代c++·模板元编程·语法基础
多多爱学习2 小时前
括号有几层?得看还有多少个左括号没闭合
c语言·c++·算法·面试