☰
C++类与对象全解析:封装、this指针、构造析构与拷贝构造
2026/10/7 3:31:43 网站建设 项目流程

搞了几年C++之后再回头看“类和对象”这四个字,真是又亲切又感慨。很多人初学C++时卡在第一个坎上,往往不是语法不会背,而是没搞明白:类这个东西到底为什么要存在?对象到底是什么?如果只是把变量和函数堆在一起,我直接用struct加几个全局函数不也行吗?等你真去写一个像样的项目——比如做个角色战斗系统、写个学生成绩管理模块——你就会发现,数据和逻辑散落着写,改一个需求要动十来个函数,那才是真正的灾难。类存在的意义,就是把“数据是什么样”和“能对数据做什么”绑在一起,形成一份可以反复复制的蓝图。而对象,就是根据这份蓝图造出来的真实个体。这篇博文是“类和对象”的上篇,我会把类的定义、封装、对象模型、this指针、构造函数、析构函数、拷贝构造这些基础概念掰开揉碎了讲,配合代码和踩坑实录,适合刚学完C++基础语法、准备进入面向对象阶段的朋友。

1. 类的定义与封装:从struct到class的思维转变

1.1 类到底是什么,为什么说它是一张蓝图

先看一个C语言风格的老写法。假设要管理一个游戏里的角色,大家通常会定义几个结构体,然后写一堆全局函数:

struct Player { char name[32]; int hp; int level; float posX; float posY; }; void playerAddHp(struct Player* p, int value) { if (p->hp + value > 100) { p->hp = 100; } else { p->hp += value; } }

这套写法的核心问题不是不能跑,而是数据结构和操作数据的函数之间没有“归属关系”。当项目里有十来个这样的结构体,每个结构体又有七八个相关操作函数的时候,函数命名就会开始混乱:playerAddHp、enemyReduceHp、npcTalk…再往后你会发现某些函数写错了作用对象,比如把敌人血量加到玩家身上。这种错误编译器检查不出来,因为它只看到“一个函数接收一个指针”,并不知道这个指针到底是玩家还是敌人。

类的思路就是把数据和操作打包。C++允许你在结构体里面直接声明函数,并且你可以把允许外部访问的接口公开,把不希望外部乱动的数据保护起来:

class Player { public: void addHp(int value) { if (hp + value > maxHp) { hp = maxHp; } else { hp += value; } } private: char name[32]; int hp; int level; float posX; float posY; static const int maxHp = 100; };

这时候“玩家能做什么”和“玩家拥有什么”就被绑定在了一起。类就是图纸,你根据图纸造出来的具体玩家就是对象。图纸可以只有一份,对象可以造很多个,每个对象都有自己的名字、血量、坐标,互不干扰。

1.2 访问限定符:public、private、protected的取舍

C++提供了三个访问级别:public(外部可见)、private(仅类内部可见)、protected(类和子类可见,上篇先不用纠结它,下篇讲继承时再细说)。

class默认是private,struct默认是public,这是两者唯一的语法区别。但习惯上人们仍然用struct表示“纯数据聚合”——就是只有变量没有逻辑的那种,可以类比为C语言的结构体;而一旦里面出现了操作逻辑,就建议用class。

封装有什么实际意义?我举一个特别常见的例子。假设你不做封装,玩家血量直接public,外面想改就改:

player.hp = 99999; // 直接改,越级加血

一旦血量的上限规则改成了“满级玩家上限500,普通玩家上限100”,你就得去全项目里搜索所有直接给hp赋值的代码,一个一个改。而封装之后,外面只能通过addHp()接口操作,你想改上限逻辑,只需要改类内部一个函数,所有调用方都自动使用新规则。这就是“隐藏内部细节,稳定对外接口”的价值。

我自己的体会是:新学者最容易犯的错误,是一上来把所有的成员变量都写成public图方便。短项目确实舒服,但一旦超过几千行,这种“爽快”一定会加倍还回去。习惯上推荐的做法是:成员变量一律private,通过public的成员函数去操作,这也是Google C++ Style Guide里倡导的主流思路。封装不是限制你,而是给你的代码留一条在将来还能安全修改的后路。

1.3 成员函数的定义方式:内联还是外置

成员函数可以写在类内部,也可以只声明在类内部、定义在类外部。写在类内的成员函数默认是inline的——适合那种函数体很短、频繁调用的接口,比如getter/setter。函数体逻辑比较长时,建议放到类外:

class Player { public: void init(const std::string& name, int level); private: std::string name_; int level_; }; void Player::init(const std::string& name, int level) { name_ = name; level_ = level; }

类外定义时要用类名::函数名的语法,这个::叫做“作用域限定符”,意思是“这个函数属于哪个类”。这个写法刚开始容易漏掉,常见报错就是“缺少类型说明符”之类,实际上是编译器不认识这个独立存在的函数。这种外置定义在大型项目里还有个额外好处:别人看头文件时一眼扫过去,就能知道这个类对外暴露了哪些能力,不至于被函数实现细节淹没。

2. 对象模型:对象在内存里到底长什么样

2.1 对象占多少字节,成员函数存哪去了

很多初学者会困惑:对象既然包含了函数,那对象的内存里是不是也存了一份函数代码?答案是否定的。对象的内存只存放成员变量,成员函数存放在代码段,所有同一类的对象共享同一份函数代码。这就好比一个班级的所有学生都遵守同一套校规,校规贴在公告栏里只有一份,但每个学生自己的作业本、课本是各带各的。

来看个实际例子:

#include <iostream> class Student { public: void setAge(int age) { age_ = age; } private: int age_; char name_[16]; double score_; }; int main() { Student s; std::cout << sizeof(s) << std::endl; // 环境不同结果可能不同 return 0; }

按对齐规则来分析:char name_[16]占16字节,int age_占4字节,double score_占8字节。在64位平台上,默认对齐是“按最大成员对齐”,也就是按8字节对齐,int后面需要补4字节空位。所以总大小是16 + 8 + 8 = 32字节。实际跑一下你会发现sizeof(Student)在很多64位编译器下确实是32,但在32位环境下可能是24。别纠结具体数字,因为编译器、平台、编译器选项都会影响,关键在于理解这个原理:对象的大小由成员变量决定,和你有多少个成员函数无关。

这里必须提一个新手必踩的坑:空类。class Empty {};的sizeof是多少?答案是1。为什么?因为C++规定同一类型的对象必须有不同的地址,如果空类不占任何空间,你定义一个对象数组时每个元素地址就会相同。为了满足“每个对象都有唯一地址”的原则,编译器会强行为空类分配1字节。这个看似愚蠢的规则,实际上是语言为指针比较、容器分配等场景打的地基。

2.2 this指针:对象是怎么知道“我是我”的

接下来是C++新手最懵的一个概念:this指针。看下面这段代码:

class Dog { public: void setAge(int age) { age_ = age; } private: int age_; }; int main() { Dog dog1; Dog dog2; dog1.setAge(3); dog2.setAge(5); return 0; }

问题来了:setAge只有一份代码,它怎么知道age_ = age是给dog1设置还是给dog2设置?答案是:编译器的黑魔法。成员函数在编译后会被改写成普通函数,并偷偷塞进一个隐藏参数——指向当前对象的指针,这个指针就是this。上面的setAge实际上会被编译器翻译成类似下面这样:

void Dog_setAge(Dog* this, int age) { this->age_ = age; }

所以dog1.setAge(3)在编译后等价于Dog_setAge(&dog1, 3)。this指针指向调用该函数的那个对象。

懂了这个原理,很多诡异的问题就都能解释了。比如为什么“表达式必须包含类类型”这个报错总在成员函数调用时出现?很大可能就是你忘了加括号或者用错了变量名。还有经典的崩溃场景:

class Dog { public: void setAge(int age) { age_ = age; } private: int age_; }; int main() { Dog* dog = nullptr; dog->setAge(3); // 崩溃! return 0; }

注意,setAge函数体里并没有对this做判空,只要访问age_就会解引用空指针,直接段错误。你可能奇怪,我明明只是调个函数,怎么还没进函数就崩了?实际上函数确实进去了,崩在this->age_ = age这一行。这就是把this指针理解透彻的价值:你不需要背规则,只需要知道成员访问的本质就是指针操作。

2.3 const成员函数:给this加一道只读锁

有了this指针,再来看const成员函数就简单了。普通成员函数的this类型是Dog* const——指向的对象可以修改,指针本身不可改。而const成员函数,比如:

class Dog { public: int getAge() const { return age_; } private: int age_; };

这里的this类型就变成了const Dog* const,也就是说通过this访问到的成员都是只读的。所以const成员函数里如果试图修改任何成员变量,编译器会直接报错。这等于语言帮你拦住了一类典型的误操作。

那么问题来了:什么时候该给成员函数加const?我的习惯是:凡是“只查询、不改状态”的成员函数,一律加const。getter、打印函数、计算函数基本都符合这个条件。这样写有个额外好处:const对象只能调用const成员函数,如果你有一个const Dog对象,想读它的age,而getAge偏偏不是const的,编译器会拒绝调用。这会让你的类用起来很别扭。反过来,给所有不改状态的函数都标const,类对外传递只读对象时就不容易出错。

顺带提一下mutable关键字:const成员函数里想要修改某个特定成员,可以在声明时给该成员加mutable,它表示“即使在const语境下这个成员也是可变的”。典型场景是缓存——比如某个计算函数结果很昂贵,想在第一次调用时算好存下来,后面直接返回缓存。这个进阶知识现在了解即可,知道有这回事就行。

3. 构造函数:对象出生时的第一步

3.1 不写构造函数会怎样:默认构造函数的真面目

在C里,定义一个局部结构体变量如果不手动初始化,里面的值是随机垃圾。C++想解决这个痛点,于是创造了构造函数:一个与类同名、没有返回值的函数,在对象创建时自动被调用。

这里有一个新手最容易掉进去的坑。看代码:

class Student { public: Student(const std::string& name, int id) { name_ = name; id_ = id; } private: std::string name_; int id_; }; int main() { Student s; // 编译错误:no matching constructor for initialization of 'Student' }

为什么Student s会失败?因为一旦你自己写了任何一个构造函数,编译器就不会再自动生成“默认构造函数”(即无参构造函数)了。早年很多初学者在这个地方卡住半小时,潜意识里总觉得“我没写构造函数,编译器会给一个”,但实际上只要有自定义构造,默认构造就不存在了。

如果你想保留两种创建方式,就必须显式地写上无参构造:

class Student { public: Student() : id_(0) {} Student(const std::string& name, int id) { name_ = name; id_ = id; } // ... };

这里顺带提醒一个细节:如果两个构造函数都是无参/全缺省,会构成重定义的二义性。比如同时写Student() {}和Student(int id = 0) {},编译器就分不清调用哪个了。缺省参数在构造函数里要谨慎使用。

3.2 初始化列表:真正的初始化发生在哪里

一种常见的低级错误是把初始化列表和构造函数体内的赋值混淆。看下面两段代码:

// 写法一:构造函数体内赋值 Student(const std::string& name, int id) { name_ = name; id_ = id; } // 写法二:初始化列表 Student(const std::string& name, int id) : name_(name), id_(id) {}

两者的区别在于:写法一是先调用成员变量的默认构造,再执行赋值操作;写法二则是直接调用成员变量的拷贝构造/构造。对于int这种内置类型区别微乎其微,但对于std::string这种有动态内存的类,写法二少了一次“先默认构造再赋值”的多余动作,效率更高,而且有些成员你根本没法写在函数体里。

哪些成员必须用初始化列表?

  • const成员:必须初始化,不能先默认构造再赋值。
  • 引用成员:同理,引用必须在定义时绑定对象。
  • 没有默认构造函数的类类型成员。

一个特别经典、面试常问的坑:初始化列表的执行顺序不受你写列表的顺序影响,而是按成员在类中声明的顺序。看这个例子:

class Test { public: Test(int x) : b_(x), a_(b_) {} // 先初始化a_还是b_? void print() { std::cout << a_ << " " << b_ << std::endl; } private: int a_; int b_; };

你以为a_ = b_ = x?实际并不是。因为int a_在类中先声明,所以a_先初始化,此时b_还是垃圾值,a_拿到的是个乱数据。然后b_才被初始化为x。运行结果往往是“随机值 x”,而不是“x x”。

这个坑几乎每个写C++久一点的人都踩过。规避方法就一条:让初始化列表里的书写顺序和成员声明顺序保持一致。很多编译器在高警告级别下会对顺序不一致给出-Wreorder警告,开发时把这个警告当错误处理比较明智。

3.3 explicit:拦下隐式类型转换的“暗箭”

单参构造函数有个隐藏行为:它允许编译器做隐式类型转换。比如:

class Student { public: Student(const std::string& name) : name_(name) {} private: std::string name_; }; void printStudent(const Student& s) { // ... } int main() { printStudent(std::string("张三")); // 编译通过,隐式创建了一个临时Student对象 }

这个语法会默默构造出一个临时对象传给函数,省却你手写Student("张三")。有时候确实方便,但更多时候它会造成误解:明明应该只接受Student对象,结果任何能转成Student的类型都被默默放行了。

一旦给构造函数加上explicit,这种隐式转换就被禁止,调用处必须显式写出构造:

class Student { public: explicit Student(const std::string& name) : name_(name) {} }; void printStudent(const Student& s) {} int main() { printStudent(std::string("张三")); // 编译错误 printStudent(Student("张三")); // 需要显式创建 }

我的建议是:除了极少数刻意想要隐式转换的场景,所有单参构造函数都加上explicit。这个习惯能帮你避免很多“编译没报错但逻辑不对”的隐晦问题。真实项目里,很多C++编码规范直接把这个推荐写成了必须遵守的规则。

4. 析构函数与资源管理:对象离开时不能留下烂摊子

4.1 析构顺序:为什么栈对象是后进先出

构造函数管“出生”,析构函数管“善后”。析构函数的名字是~类名,没有参数、没有返回值。它在对象生命周期结束时自动调用,你不需要也不应该手动去调(除非极其特殊的placement new场景,但这属于天坑,平时别碰)。

栈对象的析构有一个特点:构造顺序和析构顺序相反。想象一下往桌子上堆放盘子,先放的盘子在最底下,后放的盘子在最上面,最后收的时候你得从最上面开始收。代码里很好验证:

class DebugObj { public: DebugObj(const std::string& name) : name_(name) { std::cout << "构造 " << name_ << std::endl; } ~DebugObj() { std::cout << "析构 " << name_ << std::endl; } private: std::string name_; }; int main() { DebugObj a("a"); DebugObj b("b"); // a先构造,b后构造;b先析构,a后析构 return 0; }

输出结果是构造a、构造b、析构b、析构a。这个“后进先出”的顺序不是为了好玩,它保证了依赖关系:后创建的对象往往依赖先创建对象的资源,先销毁被依赖方会导致崩溃,所以语言强制从后往前销毁。

对堆对象而言,delete触发析构。如果你忘了delete,析构函数不会被调用,资源就泄露了。C++里最一刀切的解决办法就是:优先使用栈对象和容器,别自己new。一条硬建议:除非你写的是需要精细控制生命周期的底层组件,否则main函数和业务逻辑里根本不该出现裸new。

4.2 RAII:把你最常写的资源管理封装成类

RAII是C++里一个没人敢直译的术语,全名是Resource Acquisition Is Initialization。翻译成人话:资源在构造函数里获取,在析构函数里释放。这样资源的生命周期就和对象绑定,对象死了资源就自动释放。

举个实际例子。自己管理文件:

FILE* fp = fopen("log.txt", "w"); if (fp) { fprintf(fp, "Hello\n"); fclose(fp); // 如果中间有异常或提前return,容易漏掉 }

用RAII封装后:

class FileWrapper { public: explicit FileWrapper(const char* path, const char* mode) { fp_ = fopen(path, mode); } ~FileWrapper() { if (fp_) { fclose(fp_); } } void write(const char* text) { if (fp_) { fputs(text, fp_); } } private: FILE* fp_; }; int main() { FileWrapper file("log.txt", "w"); file.write("Hello\n"); // 不管这里经历多少分支提前return,析构函数都会自动关闭文件 return 0; }

这就是为什么C++11以后官方容器、智能指针能大幅替代手写内存管理——它们内部就是RAII。理解RAII,是C++资源管理观念中最重要的一步,比背十个语法细节都值钱。写出好的C++代码,核心思维之一就是“让编译器替我做清理”。

4.3 析构函数不要随便抛异常

析构函数里写代码要格外小心:不要抛出异常。如果析构函数在栈展开(异常处理过程中)又抛出异常,程序会直接调用std::terminate终止运行,连清理的机会都没有。这个知识点可以不用记那么多细节,只要记住结论:析构函数里尽量做简单的释放操作,如果真要检查错误,不要让它抛出到析构外部。

我在实际项目里见过一个很折磨人的bug:某个类的析构函数里调用了socket close,而析构时恰好网络超时抛了异常,程序直接无征兆退出,日志里什么都没有。排查了两天才定位到问题。这类教训踩过一次就再也不敢往析构函数里放复杂逻辑了。

5. 拷贝构造:最容易被误解的“复制”

5.1 默认拷贝构造在做什么:浅拷贝的隐患

用一个已有对象去初始化另一个对象,C++会调用拷贝构造函数:

class Student { public: Student() = default; Student(const Student& other) { name_ = other.name_; id_ = other.id_; } private: std::string name_; int id_; }; int main() { Student s1; Student s2(s1); // 拷贝构造 Student s3 = s1; // 这也是拷贝构造,不是赋值 return 0; }

如果你不写拷贝构造函数,编译器会帮你生成一个默认版本。默认版本对所有成员变量做“逐成员拷贝”,也就是把每个成员按字节原样复制。对于int、double、std::string这些自带拷贝语义的类型,这样没什么问题。但一旦类里有裸指针成员,浅拷贝就出大事了。

看这个经典爆炸现场:

class Buffer { public: Buffer() : data_(new int[100]), size_(100) {} ~Buffer() { delete[] data_; } int* data_; int size_; }; int main() { Buffer a; Buffer b(a); // 浅拷贝:b.data_ 和 a.data_ 指向同一块内存 // 出了作用域,a析构delete一次,b析构又delete一次 -> double free,崩溃 return 0; }

b.data_和a.data_是同一个地址,析构时释放了两次,程序直接崩溃或堆损坏。这种问题在调试时很隐蔽,有时候崩溃的时机跟分配器状态有关,一会儿崩一会儿不崩,非常闹心。

5.2 编译器什么时候偷偷调用拷贝构造

三个最常见场景:

  • 函数按值传参:void foo(Student s);调用时创建形参副本。
  • 函数按值返回:返回局部对象时创建返回值副本。
  • 用=初始化:Student s2 = s1;以及花括号初始化Student s2{s1};。

第三个场景尤其容易混淆。注意Student s3 = s1;和Student s3; s3 = s1;完全不同:前者是拷贝构造,后者是先构造再拷贝赋值。很多老手也会下意识把=全部理解成赋值,实际上初始化场景下=就是构造语法的一部分,不是赋值。

顺带一提,现代C++有返回值优化(RVO/NRVO),很多按值返回的拷贝会被编译器优化掉,直接构造到调用方内存里,所以你看不到拷贝构造被调用。这是好事,性能变好。但别因此以为“拷贝构造不会发生”,在容器里push_back对象时拷贝就经常真实发生。比如std::vector<Student>在扩容时会把所有旧元素拷贝到新内存块,如果Student没有正确实现深拷贝,元素一多同样会踩double free。

5.3 深拷贝的规范写法:拷贝三件套的起点

针对裸指针成员,正确做法是深拷贝:先给目标分配一块新内存,再把内容逐份复制。

class Buffer { public: Buffer() : data_(new int[100]), size_(100) {} Buffer(const Buffer& other) : size_(other.size_) { data_ = new int[size_]; std::copy(other.data_, other.data_ + size_, data_); } ~Buffer() { delete[] data_; } private: int* data_; int size_; };

写完拷贝构造函数,还有一个紧密相关的东西:拷贝赋值运算符operator=。它同样需要深拷贝逻辑,而且还要处理“自赋值”和“先释放旧资源再拷贝新资源”的问题。这个属于下篇的重头戏,这里先记住一个结论:如果你需要手写析构函数,那么几乎必然需要手写拷贝构造和拷贝赋值,这就是著名的“三件套规则”(Rule of Three)。

不过更省心的方案是:别用裸指针,改用std::string、std::vector、std::shared_ptr这些自带拷贝语义的RAII类型。它们自己在内部做好深拷贝管理,你就可以让编译器生成的默认拷贝构造直接干活,不用自己动一根手指。

这里也回应一个很多人问过的问题——对象数组去重怎么做。如果你定义了一个vector<Student>,里面可能有重复元素,想用std::sort加std::unique去重,前提就是你的类必须支持operator<(或者提供一个比较器)和operator==。而std::unique本质是要比较相邻元素是否相等,默认比较是逐个成员比较。如果类里有裸指针,默认的相等比较就比较的是地址而不是内容,去重结果就会莫名其妙。所以这问题的根子还是回到了“正确实现拷贝语义”上。

6. 常见问题排查与避坑实录

6.1 “表达式必须包含类类型”到底错在哪

这个报错在Visual Studio里特别常见,属于C++新手“命中率”最高的一类错误。它一般出现于你试图在非成员函数的位置访问成员时。三个典型原因:

第一,忘记加括号调用成员函数。比如你写obj.getAge而不是obj.getAge(),编译器认为你在引用一个数据成员,但又找不到名为getAge的成员变量,于是报“表达式必须包含类类型”。解决办法就是检查一切成员函数调用是否带了()。

第二,变量名和类型名冲突。比如:

class Student { public: int id_; }; int main() { Student Student; // 变量名和类型名同名 Student.id_; // 编译器懵了:这里的Student是变量还是类型? }

这种代码编译通常会直接报一堆乱七八糟的错误。警惕把变量名起成和类名一模一样,尤其是简短的单词类名特别容易撞。

第三,用.去访问指针。你声明的是Student* p,却写成了p.name_。正确写法应该是p->name_。.是“对象直接访问成员”,->是“指针间接访问成员”,二者使用场景完全不同。Visual Studio的报错信息往往不会直说“你应该用->”,只会说“表达式必须包含类类型”,因为编译器拿到的是指针类型而不是类类型。看到这个报错,先检查你手上拿的到底是指针还是对象。

这类问题的排查思路其实是通用的:读报错先看“表达式”是什么类型,是Student还是Student*,是函数名还是变量名,再决定语法怎么改。

6.2 判断对象为空的正确姿势:C++里对象没有“空”这一说

有人会问:JavaScript里可以if (!obj)判断对象为空,C++里能不能这样判断?直接回答:不能,而且语义完全不同。C++里一个栈对象一旦定义,就一定占用了内存、一定已经被构造好了,根本不存在“空对象”这个概念。C++里可以去判断“是否为空”的是指针:

Student* p = new Student(); if (p != nullptr) { // 指针非空,可以安全访问 }

但要注意:指针非空不能保证指向的对象是有效的。野指针、已delete的指针都不是nullptr,访问它们照样崩溃。很多新手写出“先delete再继续用指针,还自信地判了一下非空”的代码,然后就百思不得其解为什么还是崩。delete之后记得把指针置为空,这是保命习惯:

delete p; p = nullptr; // 之后就算误用也是可控的崩溃,而不是随机的堆破坏

如果确实需要表达“这个对象没有值”的状态,常见设计方案是给类加一个bool isValid_成员,并提供一个默认构造出来的“空对象”:

class Student { public: Student() : id_(-1), isValid_(false) {} bool isValid() const { return isValid_; } private: int id_; bool isValid_; };

比如数据库查询“按ID查学生,查不到就返回默认构造对象”,调用方就可以通过isValid()判断结果是否有效,而不是傻乎乎地试图拿指针搞空判断。

6.3 环境与工具相关的坑

热词里出现了不少“visual c++ redistributable”“vscode配置c++环境”的搜索,说明很多同学卡在了环境搭建这一关。这里多说一句。

Microsoft Visual C++ Redistributable是Windows上运行由Visual Studio编译的C++程序时所需的运行库合集。你写的程序如果用了标准库、运行时库的某些功能,跑在没有这个运行库的机器上就会提示“缺少VCRUNTIME140.dll”之类的错误。所以发布Windows版C++程序时,要么把对应版本的redistributable一并安装,要么用静态链接把所有运行库打进exe里。这个知识点不算类和对象的内容,但非常实用,事先了解能省下不少发布后的麻烦。

VSCode配置C/C++环境,核心不是装多少插件,而是理解编译和调试是两套流程。编译依赖编译器(比如MinGW-w64),调试依赖调试器(gdb或lldb),而VSCode本身只是套壳界面。配置tasks.json负责“怎么把代码编成exe”,配置launch.json负责“怎么启动调试器并让断点生效”。很多新手配不好环境,是因为搞混了这两个文件的分工。如果编译都过不了,断点调试就更谈不上了。

至于“表达式必须包含类类型”“判断对象为空”“对象数组去重”这些错误,在VSCode里排查时也建议充分利用调试器:断点打在自己写的调用处,观察变量面板里表达式类型和值的变化,比盯着报错信息猜效率高得多。

6.4 几个对象使用中的隐蔽陷阱速查

我整理了一份自己带新手时常用的踩坑表,按频率排序:

现象常见原因解决方案
没写构造函数但对象()报错写了带参构造,编译器不生成默认构造显式补一个无参构造
const对象无法调普通函数普通成员函数可能修改对象把只读函数标记为const
类里带了裸指针,复制后运行崩溃浅拷贝导致double free实现深拷贝或换用智能指针
初始化列表顺序不对导致随机值初始化顺序按成员声明顺序列表顺序与声明顺序保持一致
函数返回局部对象地址局部对象生命周期已结束返回值,不要返回引用/地址
VS报“表达式必须包含类类型”忘记加()或指针用.访问检查表达式类型与访问符号

这六个问题覆盖了类和对象基础阶段九成以上的疑难杂症。遇到报错时别急着上网搜,先带着“类型到底是什么、生命周期到哪了、访问方式对不对”这三个问题去读代码,大多数问题都能自己定位。

6.5 从这段基础延伸:抽象类与普通类的区别

最后多说一个与上篇相关但常被提前问到的点:抽象类和普通类的区别。简单说,抽象类是含有纯虚函数的类,它不能直接实例化,只能被继承。比如:

class Shape { public: virtual double area() const = 0; // 纯虚函数 }; // Circle继承Shape,并必须实现area()才能成为普通类

一个类里只要有一个纯虚函数,它就是抽象类。抽象类的意义在于定义接口规范:父类不关心子类怎么算面积,只强制子类必须提供area()能力。这种设计正好呼应了开头关于类的核心目的——“数据和能力绑定,接口对外稳定”。但上篇还没讲到继承和虚函数,这里先有个印象即可:类与对象的世界里,除了“造对象”,还有“定接口”的层次,那是下篇要展开的故事。

关于类和对象(上篇),我自己在实际学习过程中的一个体会是:这一阶段的语法点虽然不少,但它们之间其实有一条极细的逻辑线——对象要安全出生(构造函数)、独立生活(封装和对象模型)、体面离场(析构函数)、正确复制(拷贝构造)。写代码时如果始终带着“我要保证这个对象从生到死都状态正确”的念头,你会发现很多规则根本不需要死记,编译器就是在帮你检查这个保证而已。最后的最后再分享一个我个人用着很舒服的小习惯:每写一个类,先别急着写功能,先在注释里写下三个问题——成员变量有哪些、对象如何被创建、对象死亡时该释放什么。三个问题想清楚,类的大框架基本就立住了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询