面向对象三大特性:封装、继承、多态。通过下面两份代码的对比,我们可以初步感受一下"封装"到底好在哪里。
目录
[1 先说结论:变的是形式,不变的是逻辑](#1 先说结论:变的是形式,不变的是逻辑)
[2 C 语言实现 Stack](#2 C 语言实现 Stack)
[C 语言版本的痛点](#C 语言版本的痛点)
[3 C++ 实现 Stack(入门版,含构造/析构)](#3 C++ 实现 Stack(入门版,含构造/析构))
[4 逐条对比:C++ 到底好在哪里](#4 逐条对比:C++ 到底好在哪里)
[变化 1:数据被保护起来了(封装的核心)](#变化 1:数据被保护起来了(封装的核心))
[变化 2:不再需要手动传结构体地址](#变化 2:不再需要手动传结构体地址)
[变化 3:Init 和 Destroy 再也不会忘](#变化 3:Init 和 Destroy 再也不会忘)
[变化 4:类型使用更方便](#变化 4:类型使用更方便)
[变化 5:缺省参数让初始化更灵活](#变化 5:缺省参数让初始化更灵活)
[5 拷贝构造的坑:浅拷贝 vs 深拷贝](#5 拷贝构造的坑:浅拷贝 vs 深拷贝)
[6 MyQueue:自动组合的威力](#6 MyQueue:自动组合的威力)
[7 括号匹配问题:C vs C++ 的代码量对比](#7 括号匹配问题:C vs C++ 的代码量对比)
[C++ 版本(简洁)](#C++ 版本(简洁))
[C 语言版本(繁琐)](#C 语言版本(繁琐))
[8 面试常问问题整理](#8 面试常问问题整理)
[Q5:Date d3(); 这行代码是什么意思?](#Q5:Date d3(); 这行代码是什么意思?)
[4.9 总结:C++ 的封装到底好在哪里](#4.9 总结:C++ 的封装到底好在哪里)
1 先说结论:变的是形式,不变的是逻辑
很多同学看到 C++ 的 Stack 之后会觉得"变了好多",但其实底层逻辑完全一样:动态数组 + top 指针 + realloc 扩容。真正变的是"数据和函数的组织方式"。
用一句话概括就是:
C 语言:数据和操作是分离的,你要自己把结构体地址传来传去。 C++:数据和操作被封装进类,对象自己知道自己的数据。
2 C 语言实现 Stack
cpp
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#include <assert.h>
typedef int STDataType;
typedef struct Stack
{
STDataType* a;
int top;
int capacity;
} ST;
void STInit(ST* ps)
{
assert(ps);
ps->a = NULL;
ps->top = 0;
ps->capacity = 0;
}
void STDestroy(ST* ps)
{
assert(ps);
free(ps->a);
ps->a = NULL;
ps->top = ps->capacity = 0;
}
void STPush(ST* ps, STDataType x)
{
assert(ps);
if (ps->top == ps->capacity)
{
int newcapacity = ps->capacity == 0 ? 4 : ps->capacity * 2;
STDataType* tmp = (STDataType*)realloc(ps->a, newcapacity * sizeof(STDataType));
if (tmp == NULL)
{
perror("realloc fail");
return;
}
ps->a = tmp;
ps->capacity = newcapacity;
}
ps->a[ps->top] = x;
ps->top++;
}
bool STEmpty(ST* ps)
{
assert(ps);
return ps->top == 0;
}
void STPop(ST* ps)
{
assert(ps);
assert(!STEmpty(ps));
ps->top--;
}
STDataType STTop(ST* ps)
{
assert(ps);
assert(!STEmpty(ps));
return ps->a[ps->top - 1];
}
int STSize(ST* ps)
{
assert(ps);
return ps->top;
}
int main()
{
ST s;
STInit(&s); // ⚠️ 必须手动初始化
STPush(&s, 1);
STPush(&s, 2);
STPush(&s, 3);
STPush(&s, 4);
while (!STEmpty(&s))
{
printf("%d\n", STTop(&s));
STPop(&s);
}
STDestroy(&s); // ⚠️ 必须手动销毁,否则内存泄漏
return 0;
}
C 语言版本的痛点
| 问题 | 说明 |
|---|---|
每个函数都要传 ST* |
因为数据和函数是分离的,函数不知道操作谁 |
忘记调用 STInit |
程序直接崩溃,a 指针是野指针 |
忘记调用 STDestroy |
内存泄漏,堆上的内存永远回不来 |
typedef struct Stack ST |
使用类型还要 typedef,否则每次要写 struct Stack |
3 C++ 实现 Stack(入门版,含构造/析构)
cpp
#include <iostream>
using namespace std;
typedef int STDataType;
class Stack
{
public:
// 构造函数:对象创建时自动调用,替代 STInit
Stack(int n = 4)
{
_a = (STDataType*)malloc(sizeof(STDataType) * n);
if (nullptr == _a)
{
perror("malloc 申请空间失败");
return;
}
_capacity = n;
_top = 0;
}
// 析构函数:对象销毁时自动调用,替代 STDestroy
~Stack()
{
free(_a);
_a = nullptr;
_top = _capacity = 0;
}
void Push(STDataType x)
{
if (_top == _capacity)
{
int newcapacity = _capacity * 2;
STDataType* tmp = (STDataType*)realloc(_a, newcapacity * sizeof(STDataType));
if (tmp == NULL)
{
perror("realloc fail");
return;
}
_a = tmp;
_capacity = newcapacity;
}
_a[_top++] = x;
}
void Pop()
{
assert(_top > 0);
--_top;
}
bool Empty()
{
return _top == 0;
}
STDataType Top()
{
assert(_top > 0);
return _a[_top - 1];
}
int Size()
{
return _top;
}
private:
// 成员变量:只有类内部可以访问
STDataType* _a;
size_t _capacity;
size_t _top;
};
int main()
{
Stack st; // ✅ 自动调用构造函数,不需要手动 Init
st.Push(1);
st.Push(2);
st.Push(3);
st.Push(4);
while (!st.Empty())
{
cout << st.Top() << endl;
st.Pop();
}
// ✅ 函数结束,st 生命周期到了,自动调用析构函数释放内存
// 不需要手动 Destroy
return 0;
}
4 逐条对比:C++ 到底好在哪里
变化 1:数据被保护起来了(封装的核心)
html
// C 语言:谁都能改 top,想怎么改怎么改
ST s;
STInit(&s);
s.top = -100; // 合法!但会导致程序崩溃
html
// C++:_top 是 private,外部根本访问不到
Stack st;
st._top = -100; // ❌ 编译报错!不允许访问私有成员
这就是封装的本质:不是藏着掖着,而是通过访问限定符建立规范,防止数据被随意破坏。
变化 2:不再需要手动传结构体地址
html
// C 语言:每个函数都要传 ST*
STPush(&s, 1);
STEmpty(&s);
STTop(&s);
html
// C++:this 指针隐式传递,对象自己知道自己是谁
st.Push(1);
st.Empty();
st.Top();
st.Push(1) 本质上等价于 Push(&st, 1),只不过编译器帮你做了,写起来更自然。
变化 3:Init 和 Destroy 再也不会忘
// C 语言新手最常犯的两个错误:
ST s;
// 忘写 STInit(&s); → _a 是野指针,Push 直接崩
STPush(&s, 1);
// 函数结束忘写 STDestroy(&s); → 内存泄漏
// C++:构造函数保证初始化,析构函数保证清理
// 两件事都是自动的,想忘都忘不了
Stack st; // 自动初始化
// ...
// 函数结束,自动析构
这是 C++ 相比 C 最实用的提升之一,在实际工程中能避免大量 bug。
变化 4:类型使用更方便
// C 语言必须 typedef,否则每次要写 struct Stack
typedef struct Stack { ... } ST;
// C++ 中 class/struct 定义的类型,类名本身就是类型名
class Stack { ... };
Stack st; // 直接用,不需要 typedef
变化 5:缺省参数让初始化更灵活
// C 语言:STInit 里 capacity 写死了,或者要多写一个带参数版本
void STInit(ST* ps) { ps->capacity = 0; }
// C++:构造函数带默认参数
Stack st; // 默认容量 4
Stack st2(10); // 指定初始容量 10
5 拷贝构造的坑:浅拷贝 vs 深拷贝
这是面试必考点,也是新手最容易踩的坑。
问题场景
Stack st1;
st1.Push(1);
st1.Push(2);
Stack st2 = st1; // ⚠️ 如果没有自己写拷贝构造,会发生什么?
编译器默认生成的拷贝构造是浅拷贝 ------把 st1 的每个成员原样复制给 st2。
st1._a ──────→ [ 1 | 2 | _ | _ ]
↑
st2._a ──────→ 同一块内存!
当程序结束,st1 和 st2 都调用析构函数,对同一块内存 free 两次,直接崩溃。
解决方案:自己写深拷贝构造函数
cpp
Stack(const Stack& st)
{
// 重新申请一块同样大的空间
_a = (STDataType*)malloc(sizeof(STDataType) * st._capacity);
if (nullptr == _a)
{
perror("malloc 申请空间失败");
return;
}
// 把数据复制过来
memcpy(_a, st._a, sizeof(STDataType) * st._top);
_top = st._top;
_capacity = st._capacity;
}
深拷贝之后:
st1._a ──────→ [ 1 | 2 | _ | _ ] ← st1 独享
st2._a ──────→ [ 1 | 2 | _ | _ ] ← st2 独享,两块不同的内存
记忆技巧 :如果一个类显式写了析构函数并释放了资源 ,那它几乎一定也需要写拷贝构造 和赋值运算符重载 。这三个往往同时出现,业界称之为"Rule of Three(三五法则)"。
6 MyQueue:自动组合的威力
用两个 Stack 实现队列时,C++ 的自动化优势更加明显。
html
class MyQueue
{
public:
// 什么都不写!
// 编译器自动生成构造函数,调用 Stack 的构造函数初始化 pushst 和 popst
// 编译器自动生成析构函数,调用 Stack 的析构函数释放资源
// 编译器自动生成拷贝构造,调用 Stack 的深拷贝构造
private:
Stack pushst;
Stack popst;
};
int main()
{
MyQueue mq; // pushst 和 popst 都被自动初始化了
MyQueue mq2 = mq; // 深拷贝,两个 Stack 都被正确复制
return 0; // 所有资源自动释放
}
在 C 语言里,如果你用两个 ST 实现队列,你需要手动 Init 两个、手动 Destroy 两个,顺序还不能搞错。C++ 的构造/析构机制把这些全部自动化了。
7 括号匹配问题:C vs C++ 的代码量对比
这是一个经典的 LeetCode 题(20. Valid Parentheses),用来直观感受两个版本的差距。
C++ 版本(简洁)
cpp
bool isValid(const char* s)
{
Stack st; // 自动初始化
while (*s)
{
if (*s == '[' || *s == '(' || *s == '{')
{
st.Push(*s);
}
else
{
if (st.Empty())
return false; // ✅ 直接 return,不用担心内存泄漏
char top = st.Top();
st.Pop();
if ((*s == ']' && top != '[')
|| (*s == '}' && top != '{')
|| (*s == ')' && top != '('))
return false; // ✅ 直接 return,析构自动处理
}
++s;
}
return st.Empty(); // 函数结束,st 自动析构
}
C 语言版本(繁琐)
cpp
bool isValid(const char* s)
{
ST st;
STInit(&st); // 必须手动初始化
while (*s)
{
if (*s == '(' || *s == '[' || *s == '{')
{
STPush(&st, *s);
}
else
{
if (STEmpty(&st))
{
STDestroy(&st); // ⚠️ return 前必须手动销毁
return false;
}
char top = STTop(&st);
STPop(&st);
if ((top == '(' && *s != ')')
|| (top == '{' && *s != '}')
|| (top == '[' && *s != ']'))
{
STDestroy(&st); // ⚠️ 又一个 return,又要手动销毁
return false;
}
}
++s;
}
bool ret = STEmpty(&st);
STDestroy(&st); // ⚠️ 正常 return 也要手动销毁
return ret;
}
C 语言版本有 3 个 return,每一个都要写 STDestroy,漏掉任何一个就是内存泄漏。 C++ 版本完全不用操心这件事。
8 面试常问问题整理
Q1:构造函数和析构函数的调用顺序是什么?
cpp
int main()
{
Stack st1; // 先构造 st1
Stack st2; // 再构造 st2
return 0;
// 先析构 st2(后构造的先析构)
// 再析构 st1
}
结论:同一个局部域内,后定义的对象先析构(类似栈的 LIFO 顺序)。
Q2:什么叫默认构造函数?
很多人认为只有"编译器自动生成的那个"才叫默认构造。这是错的!
默认构造函数是指:不传参数就能调用的构造函数,包含三种:
- 无参构造函数
Stack() {} - 全缺省构造函数
Stack(int n = 4) {} - 编译器自动生成的构造函数
这三种有且只能有一个存在。无参和全缺省虽然构成重载,但调用时会产生歧义,编译器报错。
Q3:拷贝构造的参数为什么必须是引用?
// 错误写法
Date(Date d) { ... } // ❌ 编译直接报错
// 正确写法
Date(const Date& d) { ... } // ✅
原因:如果参数是值传递,调用拷贝构造时需要先拷贝实参给形参,拷贝这个操作本身又要调用拷贝构造,无限递归,直到栈溢出。
Q4:什么时候用深拷贝,什么时候用浅拷贝?
| 情况 | 方案 |
|---|---|
成员变量全是内置类型,且没有指向堆上资源(如 Date) |
编译器自动生成的浅拷贝就够用 |
有成员指针指向堆上资源(如 Stack 里的 _a) |
必须自己写深拷贝 |
成员变量是其他自定义类型(如 MyQueue 包含 Stack) |
编译器自动调用成员的拷贝构造,不用自己写 |
记忆技巧 :显式写了析构 → 一定要写拷贝构造 → 一定要写赋值运算符重载。
Q5:Date d3(); 这行代码是什么意思?
Date d1; // ✅ 调用无参构造函数,创建对象 d1
Date d3(); // ⚠️ 这是函数声明!不是创建对象!
// 编译器认为你在声明一个叫 d3、无参、返回 Date 的函数
结论:用无参构造函数创建对象时,后面不能加括号。
Q6:赋值运算符重载和拷贝构造的区别?
Date d1(2024, 7, 5);
Date d2 = d1; // 拷贝构造!d2 是新创建的对象
Date d3(d1); // 拷贝构造!完全等价
Date d4(2024, 8, 1);
d4 = d1; // 赋值运算符重载!d4 已经存在,是两个已存在对象之间的赋值
记忆方法:
- 拷贝构造 :用一个已有对象初始化 一个新对象
- 赋值运算符 :两个都已经存在的对象之间赋值
4.9 总结:C++ 的封装到底好在哪里
| 维度 | C 语言 | C++ |
|---|---|---|
| 数据安全性 | 任何人都能随意修改结构体成员 | private 保护,只能通过成员函数访问 |
| 初始化 | 手动调用 Init,容易忘 |
构造函数自动调用,忘不了 |
| 资源释放 | 手动调用 Destroy,每个 return 都要写 |
析构函数自动调用,不会漏 |
| 函数调用 | 每次都要传结构体地址 &s |
this 指针隐式传递,调用更自然 |
| 类型使用 | 需要 typedef struct 简化 |
类名直接就是类型名 |
| 扩展性 | 函数和类型是松散关系 | 高内聚,方便维护和扩展 |
在入门阶段,C++ 的 Stack 看起来变化很大,但实质上变化不大。等我们后面学习 STL 中用适配器实现的 Stack,才能真正感受到 C++ 抽象和复用的威力。
下一章:初始化列表、static 成员、友元函数