从 struct 到 class:封装与访问控制的真正意义

从 struct 到 class:封装与访问控制的真正意义 文章目录引言一、C 的 struct数据敞着门全靠自觉1.1 最基本的 struct 用法1.2 C 的应对方案命名约定1.3 方案B不透明指针Opaque Pointer二、C 的答案private 一把锁2.1 第一版把 C 代码直接翻译成 C2.2 class vs struct唯一的区别三、封装的真正意义不是藏起来而是保护不变量四、public 和 private 的完整规则4.1 访问权限速查4.2 一个成员函数可以访问同类的其他对象的 private 成员4.3 声明顺序无所谓访问标签可以多次出现五、从 C 到 C 的封装演进完整对照阶段1纯 C —— 裸 struct 独立函数阶段2C 风格的不透明指针 —— 强制封装但代价高阶段3C 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#includestdio.h#includestring.hstructBankAccount{charowner[32];doublebalance;};intmain(){structBankAccountacc;strcpy(acc.owner,张三);acc.balance1000.0;// 任何人都可以直接改 balance没有任何阻拦acc.balance-500.0;// 余额变负数银行不会允许但C编译器不管printf(%s 的余额: %.2f\n,acc.owner,acc.balance);// 输出: 张三 的余额: -500.00 — 业务逻辑被破坏}$ gcc -stdc17 -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#includebank_account.h#includestring.hvoidbank_account_init(structBankAccount*acc,constchar*owner,doubleinitial){strncpy(acc-owner,owner,31);acc-owner[31]\0;acc-_private_balanceinitial0?initial:0;}intbank_account_deposit(structBankAccount*acc,doubleamount){if(amount0)return-1;acc-_private_balanceamount;return0;}intbank_account_withdraw(structBankAccount*acc,doubleamount){if(amount0||amountacc-_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文件知道结构体长什么样#includebank_account_opaque.h#includestdlib.h#includestring.hstructBankAccount{// 定义放在.c里charowner[32];doublebalance;};BankAccount*bank_account_create(constchar*owner,doubleinitial){BankAccount*accmalloc(sizeof(BankAccount));if(!acc)returnNULL;strncpy(acc-owner,owner,31);acc-owner[31]\0;acc-balanceinitial0?initial:0;returnacc;}// ... 其他函数实现类似这下外部代码连结构体成员都看不到——真正做到了封装。但代价也很明显对象必须分配在堆上malloc不能放栈上每个操作多一次指针解引用需要手动destroy代码量翻倍——每个结构体要写一整套 create/destroy/accessor二、C 的答案private一把锁2.1 第一版把 C 代码直接翻译成 C// bank_account_v1.cpp#includeiostream#includecstringclassBankAccount{public:// 公开接口voidinit(constchar*owner,doubleinitial){strncpy(owner_,owner,31);owner_[31]\0;balance_(initial0)?initial:0;}intdeposit(doubleamount){if(amount0)return-1;balance_amount;return0;}intwithdraw(doubleamount){if(amount0||amountbalance_)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::coutacc.get_balance()\n;// 1300}$ g -stdc17 -Wall bank_account_v1.cpp # 如果把注释的 acc.balance_ -500.0 放开 # error: double BankAccount::balance_ is private within this context核心变化就两个关键词C 依赖C 方案命名约定_private_xxxprivate:标签编译器强制执行“大家都是讲规矩的人”“你不讲规矩编译器直接报错”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(constPointother)const{intdxx_-other.x_;// 访问 other.x_合法intdyy_-other.y_;// 访问 other.y_合法returnsqrt(dx*dxdy*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#includestdio.hstructRect{intx,y,w,h;};voidrect_init(structRect*r,intx,inty,intw,inth){r-xx;r-yy;r-ww0?w:0;r-hh0?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 不可能是负数但没有机制能保证}阶段2C 风格的不透明指针 —— 强制封装但代价高// 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;};// 外部完全看不到成员——但也意味着不能放栈上、必须手动管理内存阶段3C class —— 用最少代码获得最强保证// rect_v3.cpp#includeiostreamclassRect{public:Rect(intx,inty,intw,inth):x_(x),y_(y),w_(w0?w:0),h_(h0?h:0){}intarea()const{returnw_*h_;}voidset_width(intw){w_w0?w:0;}voidset_height(inth){h_h0?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::coutarea r.area()\n;// 0// 不变量 intactwidth 永远不会是负数}维度C 裸 structC 不透明指针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成员仔细观察编译器的报错信息——这些信息就是你的免费文档