C++ 类成员变量完全指南:每一种成员都有它的脾气 C 类成员变量完全指南每一种成员都有它的脾气类的数据成员看起来不过是类里面定义的变量但实际上 C 提供了多种不同性质的成员类型普通成员、const 成员、引用成员、静态成员、mutable 成员、智能指针成员……每一种都有独特的行为规则和适用场景。选对了成员类型代码安全又高效用错了轻则编译报错重则隐藏运行时 bug。今天我们就逐一剖析。1. 普通成员变量最常见的那一种classWidget{intid;// 普通成员std::string name;// 普通成员doubleprice;// 普通成员};特点每个对象拥有一份独立的副本在对象构造时初始化对象析构时销毁存储在对象的内存空间中栈上或堆上初始化方式优先使用类内初始值或初始化列表。classWidget{intid0;// 类内初始值C11std::string nameunknown;public:Widget()default;// 使用类内初始值Widget(inti,std::string n):id(i),name(std::move(n)){}// 初始化列表覆盖};初始化顺序严格按声明顺序初始化与初始化列表中的顺序无关。classBad{inta;intb;public:Bad(intx):b(x),a(b){}// a 先初始化声明在前但此时 b 未初始化};// 警告a 的值是未定义的2. const 成员变量一旦初始化就不能改classConfig{constintmaxConnections;// const 成员conststd::string appName;public:Config(intmax,std::string name):maxConnections(max),appName(std::move(name)){}};核心规则必须用初始化列表初始化不能在构造函数体内赋值初始化后不可修改每个对象可以有不同值const 成员不是编译期常量Configdev(10,dev);Configprod(100,production);// dev.maxConnections 10, prod.maxConnections 100// 每个对象不同但各自都不能改const 成员的影响类的拷贝赋值运算符默认被删除因为 const 成员不能被赋值如果需要赋值需要手动处理通常意味着 const 成员不适合赋值场景Configc1(10,a);Configc2(20,b);// c1 c2; // 错误const 成员不能被赋值何时使用语义上创建后不可变的属性比如配置参数、ID、创建时间戳等。3. 引用成员变量不能为空、不能改绑classProcessor{std::ostreamout;// 引用成员constDataSourcesource;// 常引用成员public:Processor(std::ostreamos,constDataSourceds):out(os),source(ds){}// 必须用初始化列表};核心规则必须用初始化列表初始化不能为空没有空引用的概念不能改变绑定整个生命周期都指向同一个对象取引用成员的地址得到的是被引用对象的地址引用成员的影响比 const 更严格拷贝构造和拷贝赋值运算符默认被删除类不能有默认构造函数除非引用有默认绑定但引用没有// std::ostream out; // 引用成员// Processor p1, p2; // 错误没有默认构造函数// p1 p2; // 错误引用成员不能改绑赋值运算符被删除何时使用当对象必须在整个生命周期中访问另一个外部对象且这个关联不能为空也不能改变时。比如一个处理类需要持有输出流或数据源的引用。4. 静态成员变量属于类不属于对象classDatabase{staticintconnectionCount;// 声明不是定义staticconstintmaxRetries3;// const 整型可以在类内初始化public:staticintgetConnectionCount(){returnconnectionCount;}};// 必须在类外定义通常在 .cpp 文件中intDatabase::connectionCount0;特点属于类本身被所有对象共享存储在全局数据区不在对象内部必须在类外定义除非是 constexpr 或 inline static见下文可以不通过对象访问Database::connectionCountDatabase db1,db2;db1.connectionCount5;std::coutdb2.connectionCount;// 5同一个变量std::coutDatabase::connectionCount;// 5推荐这样访问C17 的 inline static定义可以放在类内classConfig{public:inlinestaticintglobalTimeout30;// C17类内定义inlinestaticstd::string appNameMyApp;};这省去了在.cpp文件中单独定义的麻烦是 C17 后的推荐写法。静态 const 整型的特殊待遇classLimit{staticconstintmaxSize1024;// const 整型/枚举可以在类内初始化intbuffer[maxSize];// 可以用作编译期常量};constexpr静态成员也支持类内初始化classLimit{staticconstexprdoublepi3.14159;};5. mutable 成员在 const 函数中也可以改const 成员变量被const修饰的类内成员变量一经初始化后值永久不可修改属于只读属性。必须在构造函数初始化列表初始化无法在函数体内赋值无论普通函数还是const成员函数全程不能修改常量对象、普通对象都无法修改该变量mutable 成员变量被mutable修饰的类内成员变量不受const成员函数限制在常量对象、const成员函数中依然可以正常修改。初始化无强制限制可任意赋值const成员函数内唯一能修改的成员变量专为「常量对象需要修改少量状态」设计classCache{mutablestd::mapint,ResultcacheData;// 缓存mutableintaccessCount0;// 访问计数mutablestd::mutex cacheMutex;// 互斥锁public:Resultquery(intid)const{std::lock_guardstd::mutexlock(cacheMutex);// 可以在 const 函数中加锁accessCount;// 可以修改mutable 成员autoitcacheData.find(id);if(it!cacheData.end())returnit-second;Result rcompute(id);// 耗时的计算cacheData[id]r;// 可以修改缓存returnr;}};为什么需要 mutableconst 成员函数承诺不修改对象状态但有些状态实际上是物理状态内存中的数据而非逻辑状态对象对外表现的意义。缓存、互斥锁、访问计数器这些不影响对象逻辑常量性的东西应该用mutable。典型场景缓存逻辑上查询操作不改对象意义但为了性能需要缓存结果互斥锁const 函数需要线程安全必须加锁引用计数、调试计数器对外的逻辑状态没变特性const 成员变量mutable 成员变量修改权限全局只读任何地方不能改const函数/常量对象中可修改初始化方式只能初始化列表赋值任意位置均可赋值适用场景固定不变的常量数据缓存、计数、日志、锁状态搭配const函数禁止修改允许修改常量对象访问只读可读写const 适用定义类中固定不变的数据如编号、固定配置、唯一标识。classPerson{public:Person(intid):uid(id){}private:constintuid;// 唯一ID不可修改};mutable 适用const函数内需要更新临时状态访问次数、缓存数据、互斥锁。classData{public:voidshowInfo()const{cnt;// const函数内正常修改mutable变量}private:mutableintcnt0;// 统计访问次数};const 是锁死变量全程不可改mutable 是冲破const限制。mutable只作用于const成员函数与常量对象不破坏正常封装逻辑。日常开发固定值用const常量方法内需变动状态用mutable。禁止滥用mutable仅用于日志、计数、缓存、锁等辅助状态。6. 指针成员所有麻烦的根源6.1 裸指针谨慎使用classOwner{int*data;// 指向动态分配的内存public:Owner(intval):data(newint(val)){}~Owner(){deletedata;}// 必须手动释放// 别忘了拷贝构造、拷贝赋值、移动构造、移动赋值};裸指针的问题你写了析构就得考虑三/五法则。忘了任何一个都会出事。6.2 智能指针推荐classModern{std::unique_ptrConfigconfig;// 独占所有权std::shared_ptrLoggerlogger;// 共享所有权std::weak_ptrConnectioncachedConn;// 弱引用不影响生命周期};优势自动释放不需要写析构函数明确表达所有权语义unique_ptr的类不可拷贝但可移动shared_ptr的类可拷贝共享// unique_ptr 成员类不可拷贝可移动classExclusive{std::unique_ptrintptr;public:Exclusive():ptr(std::make_uniqueint(0)){}// 拷贝构造函数默认被删除unique_ptr 不可拷贝// 移动构造函数默认可用};// shared_ptr 成员类可拷贝classShared{std::shared_ptrintptr;public:Shared():ptr(std::make_sharedint(0)){}// 拷贝构造和赋值默认可用};准则能用智能指针就不用裸指针让编译器管理资源。7. 类类型成员对象的嵌套classEngine{/* ... */};classWheel{/* ... */};classCar{Engine engine;// 包含组合std::vectorWheelwheels;// 包含多个std::string brand;// 也是类类型成员};特点构造顺序先构造成员再构造自身成员按声明顺序析构顺序先析构自身再析构成员逆声明顺序如果成员有默认构造函数可以不在初始化列表中显式初始化Car::Car():engine(),wheels(4),brand(Unknown){// engine 可以不写会自动调用默认构造// wheels(4) 调用 vector 的带参构造// brand(Unknown) 调用 string 的带参构造}8. 类内初始值 vs 初始化列表这是新手容易混淆的地方。classWidget{inta10;// 类内初始值intb;constintc;public:Widget()default;// a 10, b 未初始化危险c 未初始化错误Widget(intx):b(x),c(x){}// a 10, b x, c xWidget(intx,inty):a(x),b(y),c(x){}// a x覆盖类内初始值};优先级初始化列表 类内初始值 编译器默认初始化。实践准则能用类内初始值的就用类内初始值简洁、集中const 和引用成员必须用初始化列表初始化列表需要覆盖默认值时使用9. 各类型成员一览表成员类型每对象独立必须初始化列表影响拷贝典型用途普通成员是否否对象状态const 成员是是删除赋值不可变属性引用成员是引用不同对象是删除拷贝和赋值外部对象关联静态成员否共享类外定义否全局状态、配置mutable 成员是否否缓存、锁、计数器裸指针成员是否需要三/五法则尽量别用智能指针成员是否取决于类型资源管理10. 面试常考清单10.1 const 成员和引用成员为什么必须用初始化列表答案要点const 成员初始化后不能再赋值引用成员初始化后不能改绑。构造函数体内的是赋值不是初始化所以它们必须在初始化列表中完成唯一的初始化机会。10.2 静态成员变量为什么要在类外定义答案要点类内只是声明不分配存储空间。类外定义才分配实际内存。C17 的inline static可以在类内完成定义。10.3 mutable 关键字的作用为什么需要它答案要点mutable修饰的成员可以在 const 成员函数中被修改。用于区分物理常量性和逻辑常量性典型场景是缓存、互斥锁、引用计数等。10.4 静态成员函数能访问非静态成员吗答案要点不能。静态成员函数没有this指针不知道是哪个对象的成员。它只能访问静态成员。10.5 类内初始值和初始化列表的优先级答案要点初始化列表优先于类内初始值。如果初始化列表中有某个成员类内初始值被覆盖。10.6 const 成员变量会影响拷贝赋值吗答案要点会。编译器默认生成的拷贝赋值运算符会被删除因为 const 成员不能被赋值。如果有赋值需求需要手动设计但通常不应该给有 const 成员的对象赋值。10.7 成员初始化顺序是怎样的答案要点严格按照成员在类中声明的顺序初始化与初始化列表中的书写顺序无关。这由 C 标准保证避免不同编译单元中因顺序不同导致的问题。11. 实践准则总结普通成员用类内初始值给默认值初始化列表覆盖const / 引用成员必须用初始化列表注意它们对拷贝/赋值的影响静态成员C17 用inline static之前需要类外定义mutable 成员只用于不影响逻辑状态的物理状态缓存、锁指针成员优先用unique_ptr或shared_ptr避免裸指针初始化顺序声明顺序 初始化顺序初始化列表请保持一致设计类的成员变量时不仅要考虑存什么数据还要考虑所有权、生命周期、可变性。每一种成员类型都是为特定的语义需求而设计的选对了类型代码就成功了一半。