C++ 类与对象入门:从栈的改造看面向对象的魅力
最近在啃 C++ 面向对象的基础,越学越觉得类和对象这东西,简直是把我们写过的 C 语言数据结构 “重新打包升级” 的神器。今天就用我最熟悉的栈(Stack)来串一串这些知识点,顺便把 this 指针、构造 / 析构函数这些难点一次性讲明白。
一、从 “结构体 + 函数” 到 “类”:栈的两种写法对比
还记得我们用 C 语言写栈的时候,是怎么写的吗?
typedef int STDatatype;
typedef struct Stack {
STDatatype* a;
int top;
int capacity;
} ST;
void STInit(ST* ps) {
ps->a = (STDatatype*)malloc(sizeof(STDatatype) * 4);
ps->top = 0;
ps->capacity = 4;
}
我们得手动传结构体指针 ps,每次调用 STPush(&st, x),还得自己管内存、自己判空、自己扩容,非常 “半自动”。
而 C++ 的类,直接把 “数据” 和 “操作” 绑在一起,变成了一个整体:
typedef int STDatatype;
class Stack {
public:
void Init(int n = 4) {
_a = (STDatatype*)malloc(sizeof(STDatatype) * n);
_capacity = n;
_top = 0;
}
void Push(STDatatype x) {
if (_top == _capacity) {
// 自动扩容
STDatatype* tmp = (STDatatype*)realloc(_a, _capacity * 2 * sizeof(STDatatype));
if (tmp == nullptr) { perror("realloc fail"); return; }
_a = tmp;
_capacity *= 2;
}
_a[_top++] = x;
}
private:
STDatatype* _a;
int _top;
int _capacity;
};
现在我们只需要创建对象,直接调用成员函数就行:
Stack st; st.Init(); st.Push(10);
不用再传指针,也不用再担心传错结构体,这就是类的封装魅力 —— 把数据和操作打包,对外只暴露必要的接口。
二、this 指针:解决 “成员函数怎么知道操作哪个对象” 的问题
上面的 st.Push(10),编译器处理后,其实会变成:
Stack::Push(&st, 10);
它偷偷给成员函数传了一个当前对象的地址,也就是 this 指针。
1. this 指针的本质
-
成员函数的第一个隐藏参数,类型是
类名* const this(比如Stack* const this)。 -
成员函数里访问
_a,其实就是this->_a,只不过编译器帮我们省略了。 -
它存放在栈或寄存器里(VS 里会用
ecx寄存器传递),不属于对象本身,不占用对象的内存空间。
2. 两个经典的 nullptr 调用测试题
-
只打印信息,不访问成员变量:
class A {
public:
void Print() { cout << "A::Print()" << endl; }
private:
int _a;
};
int main() {
A* p = nullptr;
p->Print(); // 正常运行,因为没访问成员变量,不需要解引用 this
return 0;
}
-
访问成员变量:
class A {
public:
void Print() { cout << this->_a << endl; }
private:
int _a;
};
int main() {
A* p = nullptr;
p->Print(); // 运行崩溃,因为 this 是 nullptr,解引用访问成员变量会报错
return 0;
}
这就是为什么空指针调用成员函数,有的能跑,有的会崩 —— 关键看函数里有没有访问成员变量。
三、构造函数:告别手动 Init,让对象 “出生即初始化”
以前我们写栈,每次都要手动调用 Init(),很容易忘写导致野指针。构造函数就是来解决这个问题的。
1. 构造函数的核心规则
-
函数名和类名完全相同,没有返回值(连
void都不用写)。 -
对象创建时自动调用,只调用一次。
-
可以重载,支持带参数版本,也可以写无参的默认构造函数。
class Date {
public:
// 无参默认构造函数
Date() {
_year = 1;
_month = 1;
_day = 1;
}
// 带参数构造函数(重载)
Date(int year, int month, int day) {
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
int main() {
Date d1; // 调用无参构造函数
Date d2(2025, 4, 22); // 调用带参数构造函数
return 0;
}
2. 默认构造函数的坑
-
如果你不写任何构造函数,编译器会自动生成一个无参的默认构造函数。
-
但只要你写了任何一个构造函数,编译器就不会再生成默认构造函数了。
-
无参构造函数和全缺省构造函数不能同时存在,会产生调用歧义。
四、析构函数:自动释放资源,告别手动 Destroy
栈这种动态申请了内存的类,以前我们必须手动调用 Destroy() 释放内存,不然会内存泄漏。析构函数就是来做这个的。
1. 析构函数的核心规则
-
函数名是
~类名(),没有返回值,也没有参数,不能重载。 -
对象生命周期结束时自动调用,只调用一次。
-
编译器会自动生成默认析构函数,对内置类型不处理,对自定义类型会调用它的析构函数。
class Stack {
public:
Stack(int n = 4) {
_a = (STDatatype*)malloc(sizeof(STDatatype) * n);
_capacity = n;
_top = 0;
}
~Stack() {
free(_a); // 自动释放内存,不用再手动调用 Destroy
_a = nullptr;
_top = _capacity = 0;
}
// ... 其他成员函数
private:
STDatatype* _a;
int _top;
int _capacity;
};
现在我们写括号匹配的代码,就不用再手动 STDestroy(&st) 了,栈对象 st 出了作用域,析构函数会自动调用释放内存:
bool isValid(const char* s) {
Stack st; // 自动调用构造函数
while (*s) {
if (*s == '(' || *s == '[' || *s == '{') {
st.Push(*s);
} else {
if (st.Empty()) return false;
char top = st.Top();
st.Pop();
if ((*s == ')' && top != '(') ||
(*s == ']' && top != '[') ||
(*s == '}' && top != '{')) {
return false;
}
}
s++;
}
return st.Empty();
// 函数结束时,st 出作用域,自动调用析构函数释放内存
}
五、类的内存对齐:为什么 char + int 占 8 字节?
看这个例子:
class A {
public:
void Print() { cout << _ch << endl; }
private:
char _ch; // 1 字节
int _i; // 4 字节
};
你以为 sizeof(A) 是 5 字节?实际上它是 8 字节,这就是内存对齐。
内存对齐规则
-
第一个成员变量在偏移量为 0 的地址处。
-
后续成员变量要对齐到 “自身大小和对齐数的较小值” 的整数倍地址处。
-
结构体整体大小要对齐到 “最大成员大小和对齐数的较小值” 的整数倍。
-
目的是为了以空间换时间,让 CPU 能更高效地访问内存。
所以 char _ch 占 1 字节,后面要补 3 个填充字节,int _i 占 4 字节,总共 8 字节,刚好是 4 的倍数(对齐)。
六、写在最后:面向对象的第一印象
以前觉得面向对象很虚,直到用栈的例子改造了一遍,才发现它解决的都是我们写 C 语言时最头疼的问题:
-
封装:数据和操作绑定,减少传参错误。
-
this 指针:解决了成员函数如何区分对象的问题。
-
构造 / 析构:自动初始化和释放资源,减少内存泄漏和野指针。
-
内存对齐:理解了对象在内存中是怎么布局的。
这些都是面向对象的基础,后面还有拷贝构造、运算符重载、继承多态这些硬骨头,一步一步啃下来,感觉 C++ 越来越有意思了。
更多推荐

所有评论(0)