文章目录
- 引言
- 一、C 的 struct:数据敞着门,全靠自觉
- 1.1 最基本的 struct 用法
- 1.2 C 的应对方案:命名约定
- 1.3 方案B:不透明指针(Opaque Pointer)
- 二、C++ 的答案:`private` 一把锁
- 2.1 第一版:把 C 代码直接翻译成 C++
- 2.2 `class` vs `struct`:唯一的区别
- 三、封装的真正意义:不是"藏起来",而是"保护不变量"
- 四、`public` 和 `private` 的完整规则
- 4.1 访问权限速查
- 4.2 一个成员函数可以访问同类的其他对象的 private 成员
- 4.3 声明顺序无所谓,访问标签可以多次出现
- 五、从 C 到 C++ 的封装演进:完整对照
- 阶段1:纯 C —— 裸 struct + 独立函数
- 阶段2:C 风格的不透明指针 —— 强制封装但代价高
- 阶段3:C++ class —— 用最少代码获得最强保证
- 总结
本系列为《C++深度修炼:基础、STL源码与多线程实战》第2篇
前置条件:理解 C 语言 struct 的基本用法,读过第1篇了解 C/C++ 的差异
引言
在 C 语言中,struct只是一个数据的容器。你可以把几个变量打包在一起,然后通过.和->访问它们——仅此而已。至于"哪些字段能改、哪些不能改"、“改了之后对象还合法吗”,全靠程序员的自觉和命名约定来维持。
C++ 的class不是struct的简单改名。它在语言层面增加了一道编译器强制执行的边界——告诉所有代码:“这个成员是公开 API,那个成员是内部实现,碰它就报错。”
本文从 C 程序员最熟悉的struct出发,一步步展示为什么需要封装,C 是如何"模拟"封装的,以及 C++ 如何用一行代码解决 C 需要靠约定才能维持的东西。
一、C 的 struct:数据敞着门,全靠自觉
1.1 最基本的 struct 用法
// demo_struct_basic.c#include<stdio.h>#include<string.h>structBankAccount{charowner[32];doublebalance;};intmain(){structBankAccountacc;strcpy(acc.owner,"张三");acc.balance=1000.0;// 任何人都可以直接改 balance,没有任何阻拦acc.balance=-500.0;// 余额变负数!银行不会允许,但C编译器不管printf("%s 的余额: %.2f\n",acc.owner,acc.balance);// 输出: 张三 的余额: -500.00 — 业务逻辑被破坏}$ gcc -std=c17 -Wall demo_struct_basic.c && ./a.out 张三 的余额: -500.00没有任何编译错误,也没有运行时检查。balance应该是"余额",但负数怎么可能是合法的余额?
1.2 C 的应对方案:命名约定
有经验的 C 程序员会用一套"约定"来弥补:
// bank_account.h — C风格封装,依赖约定#ifndefBANK_ACCOUNT_H#defineBANK_ACCOUNT_H// 方案A:命名约定 —— 名字里带"私有"提示structBankAccount{charowner[32];double_private_balance;// 靠命名警告:"别直接碰我"};// "公开API" 函数voidbank_account_init(structBankAccount*acc,constchar*owner,doubleinitial);intbank_account_deposit(structBankAccount*acc,doubleamount);intbank_account_withdraw(structBankAccount*acc,doubleamount);doublebank_account_get_balance(conststructBankAccount*acc);#endif// bank_account.c#include"bank_account.h"#include<string.h>voidbank_account_init(structBankAccount*acc,constchar*owner,doubleinitial){strncpy(acc->owner,owner,31);acc->owner[31]='\0';acc->_private_balance=initial>=0?initial:0;}intbank_account_deposit(structBankAccount*acc,doubleamount){if(amount<=0)return-1;acc->_private_balance+=amount;return0;}intbank_account_withdraw(structBankAccount*acc,doubleamount){if(amount<=0||amount>acc->_private_balance)return-1;acc->_private_balance-=amount;return0;}doublebank_account_get_balance(conststructBankAccount*acc){returnacc->_private_balance;}这个方案在工程上能用,但它有一个致命缺陷:约定是给人读的,编译器不执行。
// 任何.c文件里,绕过API直接改:acc._private_balance=-999999.0;// 编译器:没问题啊,它就是 double1.3 方案B:不透明指针(Opaque Pointer)
更高级的 C 封装手法是把结构体完全藏起来:
// bank_account_opaque.h — 结构体定义不暴露给外部typedefstructBankAccountBankAccount;// 只声明类型,不暴露成员BankAccount*bank_account_create(constchar*owner,doubleinitial);voidbank_account_destroy(BankAccount*acc);intbank_account_deposit(BankAccount*acc,doubleamount);intbank_account_withdraw(BankAccount*acc,doubleamount);doublebank_account_get_balance(constBankAccount*acc);// bank_account_opaque.c — 只有这个.c文件知道结构体长什么样#include"bank_account_opaque.h"#include<stdlib.h>#include<string.h>structBankAccount{// 定义放在.c里charowner[32];doublebalance;};BankAccount*bank_account_create(constchar*owner,doubleinitial){BankAccount*acc=malloc(sizeof(BankAccount));if(!acc)returnNULL;strncpy(acc->owner,owner,31);acc->owner[31]='\0';acc->balance=initial>=0?initial:0;returnacc;}// ... 其他函数实现类似这下外部代码连结构体成员都看不到——真正做到了封装。但代价也很明显:
- 对象必须分配在堆上(
malloc),不能放栈上 - 每个操作多一次指针解引用
- 需要手动
destroy - 代码量翻倍——每个结构体要写一整套 create/destroy/accessor
二、C++ 的答案:private一把锁
2.1 第一版:把 C 代码直接翻译成 C++
// bank_account_v1.cpp#include<iostream>#include<cstring>classBankAccount{public:// 公开接口voidinit(constchar*owner,doubleinitial){strncpy(owner_,owner,31);owner_[31]='\0';balance_=(initial>=0)?initial:0;}intdeposit(doubleamount){if(amount<=0)return-1;balance_+=amount;return0;}intwithdraw(doubleamount){if(amount<=0||amount>balance_)return-1;balance_-=amount;return0;}doubleget_balance()const{returnbalance_;}private:// 内部数据——外部代码不能碰charowner_[32];doublebalance_;};intmain(){BankAccount acc;acc.init("张三",1000.0);// acc.balance_ = -500.0; // ❌ 编译错误!'balance_' is privateacc.deposit(500);acc.withdraw(200);std::cout<<acc.get_balance()<<'\n';// 1300}$ g++ -std=c++17 -Wall bank_account_v1.cpp # 如果把注释的 acc.balance_ = -500.0 放开: # error: 'double BankAccount::balance_' is private within this context核心变化就两个关键词:
| C 依赖 | C++ 方案 |
|---|---|
命名约定_private_xxx | private:标签,编译器强制执行 |
| “大家都是讲规矩的人” | “你不讲规矩,编译器直接报错” |
2.2classvsstruct:唯一的区别
C++ 保留了struct关键字,它的功能和class几乎完全一样——唯一的区别是默认访问权限:
// 这两个是等价的:classPoint{intx,y;// class:默认 private};structPoint{intx,y;// struct:默认 public};// 写成下面这样就完全一样了:classPoint{public:intx,y;};structPoint{private:intx,y;};💡工程惯例:用
struct当纯数据容器(所有字段 public,类似 C 的 POD),用class当有封装逻辑的对象。这是一条"给人类读者看的信号",编译器并不关心。
三、封装的真正意义:不是"藏起来",而是"保护不变量"
很多 C 程序员第一次看到private,直觉反应是"把数据藏起来,不让别人看"。
这理解偏了。封装的真正目的是保护不变量(Invariant)。
拿BankAccount来说,核心不变量只有一个:balance_永远不能小于 0。
- C 的做法:依赖每个调用者都不犯错误。一百个调用者里有一个忘记检查,不变量就破了。
- C++ 的做法:能改
balance_的只有deposit()和withdraw(),它们各自保证了不变量。外面的代码想改也改不了——编译器替你挡住了。
classThermometer{public:voidset_celsius(doublec){celsius_=c;if(celsius_<-273.15)celsius_=-273.15;// 不变量:不低于绝对零度}doubleget_celsius()const{returncelsius_;}// 不在 set_celsius 之外,没有任何办法把一个非法温度塞进去private:doublecelsius_;};这就是封装的价值:不变量只需要在一个地方维护,而不是散布在整个代码库的每个调用点。
四、public和private的完整规则
4.1 访问权限速查
classExample{public:// 任何人可见intpublic_member;voidpublic_method();private:// 只有本类的成员函数和友元可见intprivate_member_;voidprivate_method_();protected:// 本类 + 派生类可见(下一篇讲继承时详谈)intprotected_member_;};4.2 一个成员函数可以访问同类的其他对象的 private 成员
classPoint{public:// 初始化列表见第3篇(构造与析构),此处关注访问规则Point(intx,inty):x_(x),y_(y){}// 可以访问 other 的 private 成员——因为 other 也是 Pointdoubledistance_to(constPoint&other)const{intdx=x_-other.x_;// 访问 other.x_,合法!intdy=y_-other.y_;// 访问 other.y_,合法!returnsqrt(dx*dx+dy*dy);}private:intx_,y_;};这个规则背后的道理是:封装是类的边界,不是对象的边界。同一个类的两个对象,“互相信任”。
4.3 声明顺序无所谓,访问标签可以多次出现
classWidget{public:intget_width()const;// public 区段 1private:intwidth_;public:intget_height()const;// public 区段 2 —— 完全合法private:intheight_;};不过工程上通常把同一访问级别的内容放在一起,保持代码可读。
五、从 C 到 C++ 的封装演进:完整对照
让我们把一个简单的"矩形"结构体做一遍从 C 到 C++ 的完整演进:
阶段1:纯 C —— 裸 struct + 独立函数
// rect_v1.c#include<stdio.h>structRect{intx,y,w,h;};voidrect_init(structRect*r,intx,inty,intw,inth){r->x=x;r->y=y;r->w=w>0?w:0;r->h=h>0?h:0;}intrect_area(conststructRect*r){returnr->w*r->h;}intmain(){structRectr;rect_init(&r,10,20,100,50);r.w=-100;// 鬼鬼祟祟改了个非法值,编译器不管printf("area = %d\n",rect_area(&r));// -5000// 业务上 width 不可能是负数,但没有机制能保证}阶段2:C 风格的不透明指针 —— 强制封装但代价高
// rect_v2.htypedefstructRectRect;Rect*rect_create(intx,inty,intw,inth);voidrect_destroy(Rect*r);intrect_area(constRect*r);// rect_v2.cstructRect{intx,y,w,h;};// 外部完全看不到成员——但也意味着不能放栈上、必须手动管理内存阶段3:C++ class —— 用最少代码获得最强保证
// rect_v3.cpp#include<iostream>classRect{public:Rect(intx,inty,intw,inth):x_(x),y_(y),w_(w>0?w:0),h_(h>0?h:0){}intarea()const{returnw_*h_;}voidset_width(intw){w_=w>0?w:0;}voidset_height(inth){h_=h>0?h:0;}intwidth()const{returnw_;}intheight()const{returnh_;}private:intx_,y_,w_,h_;};intmain(){Rectr(10,20,100,50);// 栈上分配,无需手动释放// r.w_ = -100; // ❌ 编译错误r.set_width(-100);// ✅ setter 内部做了守卫,width 实际被设为 0std::cout<<"area = "<<r.area()<<'\n';// 0// 不变量 intact:width 永远不会是负数}| 维度 | C 裸 struct | C 不透明指针 | C++ class |
|---|---|---|---|
| 栈上分配 | ✅ | ❌(必须 malloc) | ✅ |
| 封装强度 | 全靠约定 | 编译器强制 | 编译器强制 |
| 代码量 | 少(但脆弱) | 多 | 中等 |
| 不变量保证 | 无 | 强 | 强 |
| 自动释放 | ❌ | ❌(手动 free) | ✅(析构函数,下篇讲) |
总结
封装不是"把变量藏起来不让人看",而是把不变量围起来,只留几条经过守卫的门。
- C 依赖的命名约定和不透明指针,本质上是在用人肉编译器的过程保证安全
- C++ 的
private让真正的编译器替你做了这件事——而且零运行时开销 struct和class的区别只是默认权限——用哪个是给读者发信号,不是给编译器发指令
从下一篇开始,我们将进入 C++ 最核心的机制之一:构造与析构函数——它们如何让对象从诞生的那一刻起就是合法的,又是如何在生命周期结束时自动打扫战场。这是 C 语言"手动挡"到 C++ "自动挡"的关键跃迁。
📝动手练习:
- 把你之前写过的一个 C 结构体改成 C++ class,给所有字段加上
private,写 setter/getter 保护不变量- 写一个
class Temperature,存储开尔文温度。set_celsius和set_fahrenheit自动转换为开尔文,确保温度不低于 0K- 尝试在外部代码中访问
private成员,仔细观察编译器的报错信息——这些信息就是你的"免费文档"