C++的类和对象,大概是每个学C++的人绕不过去的一道坎。我最早学的时候,把书本上的概念背得滚瓜烂熟,但真到写项目,还是被各种诡异崩溃整得头皮发麻。后来我才意识到,类和对象不是一堆名词解释,它背后是一套实实在在的内存规则和编译机制。这篇笔记不会按教科书顺序给你念定义,而是把我在实际开发中反复使用、反复踩坑的核心知识点重新梳理一遍,适合刚学完基础语法、正在写小项目的同学,也适合准备面试想快速把知识串起来的开发者。搞清楚类与对象,C++才算真正入门。
1. 从结构体到类:封装这一步到底改变了什么
1.1 数据和函数分离的痛点
先看C语言时代的写法。你要管理一个学生信息,通常会定义一个结构体:
struct Student { char name[32]; int age; int scores[3]; };然后配套写一堆函数:initStudent、printStudent、updateScore。调用的时候,你得手动把Student对象传给函数。问题在哪?这些函数和结构体之间没有强制关联。你完全可以传一个Teacher对象给initStudent,编译器拦都不拦,因为C语言只检查参数类型是不是匹配,至于这个函数"逻辑上该操作谁",它管不着。
项目小的时候无所谓,项目一复杂,数据散落各处、操作数据的函数满天飞,维护起来非常痛苦。你改了一个结构体的字段,所有操作它的函数同步改一遍,漏改一个就出诡异bug。
1.2 封装:把数据和操作绑定在一起
C++的类做的事情,第一就是把这层关系从"约定"变成"强制"。数据成员放进类里,操作这些数据的成员函数也放进类里:
class Student { private: std::string name; int age; public: void setName(const std::string& n) { name = n; } void printInfo() const; };从外部看,你只能通过setName去改名字,不能直接碰name成员。这么做最大的收益不是"安全",而是接口稳定。类内部怎么存储、怎么计算,那是实现细节,只要公开接口不变,你可以放心重写类内部逻辑,调用方完全无感。
我在实际项目中常跟别人讲一个比喻:封装就好比你去餐厅吃饭。你只需要对菜单点菜,不需要关心后厨怎么洗菜、用什么锅炒。如果某天后厨换了设备、改了流程,只要菜单不变,你作为食客一点都不受影响。类也一样,public接口就是菜单,private内部就是后厨。
1.3 class和struct:不只是名字不同
很多人知道C++里struct也能有成员函数,但容易忽略默认访问权限的差异:
struct A { int x; // public }; class B { int x; // private };struct默认是public访问,class默认是private访问。实践中,我用struct放纯数据聚合(比如坐标点、配置项),用class放带有逻辑和状态的对象。这个约定没什么强制性,但能让读代码的人一眼看出意图。
1.4 this指针:成员函数隐藏的第一个参数
成员函数能直接访问成员变量,靠的是一个隐藏指针this。你写:
student.setName("Tom");编译器心里想的是:
Student::setName(&student, "Tom");this指向调用该成员函数的那个对象。所以下面这种写法是成立的,它显式使用了this来消除参数名和成员名的歧义:
void Student::setName(const std::string& name) { this->name = name; }理解this很重要,后面讲拷贝、继承、多态时都要用到它。很多初学者忽略了这一点,导致后来遇到"成员函数里明明同名变量,为什么赋值没生效"这种问题。
2. 构造函数与析构函数:对象的诞生和消亡不是玄学
2.1 构造函数:创建对象的必经之路
构造函数和类同名,没有返回值。它做的事是:对象一创建,立刻执行一段初始化逻辑,保证对象处于可用状态。没有构造函数的话,你得写一堆init函数,还得记得每次创建对象后手动调用,漏一次,对象状态就是垃圾。
一个简单的类:
class Timer { private: int startTime; public: Timer() : startTime(0) {} explicit Timer(int t) : startTime(t) {} };上面写了两个构造函数:一个是默认构造函数,一个是带参数的构造函数。用explicit修饰单参数构造函数,是为了防止隐式转换。比如你写了explicit Timer(int t),那么Timer t = 42;这种写法会被拒绝,编译器要求你明确写成Timer t(42);。这能挡掉很多意图不明的隐式转换,属于我强烈建议的习惯。
2.2 初始化列表:真正的"初始化",不是赋值
初学者容易写成:
Student::Student(const std::string& n, int a) { name = n; // 赋值 age = a; // 赋值 }而推荐写法是:
Student::Student(const std::string& n, int a) : name(n), age(a) { // 初始化 }两者有什么区别?初始化列表是初始化,函数体内是赋值。对内置类型(int、double)差别不大,但对以下三类成员,只能在初始化列表里初始化:
const成员,一旦创建不能修改,必须有初值;- 引用成员,引用必须在定义时绑定;
- 没有默认构造函数的类类型成员。
比如:
class Config { private: const int version; int& ref; public: Config(int v, int& x) : version(v), ref(x) {} };如果写成version = v,编译器直接报错,因为version是const的,你已经来不及给它初值了。
还有一个细节:初始化列表的执行顺序不按列表书写顺序,而是按成员在类中的声明顺序。这是个真实的坑:
class Foo { private: int a; int b; public: Foo() : b(10), a(b) {} // a先初始化,用的是未定义的b };因为a先声明,所以先初始化a,此时b还没被初始化,a拿到的是一个随机值。正确的做法是让初始化列表的顺序和成员声明顺序保持一致,或者干脆都用参数传进来的值。
2.3 析构函数:对象销毁时自动善后
析构函数名是~类名,没有参数、没有返回值,一个类只能有一个。它在对象生命周期结束时自动调用,不需要你手动调(手动调obj.~Foo()是极其罕见且危险的用法)。
三种典型场景:
栈对象:
void func() { Student s("Tom", 18); // 函数结束时,s的析构函数自动调用 }堆对象:
void func() { Student* s = new Student("Tom", 18); delete s; // 这里才调用析构函数 }如果你new了却不delete,析构函数永远不会执行,这就是经典的内存泄漏。
全局对象:
全局对象在程序启动时构造、程序退出时析构。如果构造函数依赖其他全局对象,顺序不确定时,程序可能在启动阶段就崩溃。所以我一直倾向于少用全局对象,尤其是带复杂逻辑的类。
析构函数最常见的用途是释放资源:堆内存、文件句柄、网络连接、互斥锁。你申请的资源,必须保证在析构里释放。
注意:如果你写了
delete,放心交给析构函数去做。不要担心性能损耗,编译器在大多数情况下会对你不可见、可内联的析构做优化。
3. 拷贝控制:浅拷贝的坑、深拷贝的代价、移动语义的优化
3.1 默认拷贝构造函数在做什么
如果你不写拷贝构造函数,编译器会生成一个默认版本,行为是逐成员拷贝。对内置类型就是直接赋值,对类类型成员会调用它的拷贝构造函数。这看起来很美好,但有致命缺陷:如果类里有裸指针,逐成员拷贝会让两个对象指向同一块内存。
class StringWrapper { private: char* data; public: StringWrapper(const char* str) { data = new char[strlen(str) + 1]; strcpy(data, str); } ~StringWrapper() { delete[] data; } };StringWrapper a("hello"); StringWrapper b = a;执行后,a.data和b.data指向同一块内存。程序结束,两个对象的析构函数都执行,同一块内存被delete[]两次,直接崩溃,术语叫double free。
3.2 深拷贝:自己管理资源时的正确姿势
要解决上面的问题,必须实现深拷贝,让拷贝后的对象拥有自己独立的资源:
StringWrapper(const StringWrapper& other) { data = new char[strlen(other.data) + 1]; strcpy(data, other.data); }同时还要实现拷贝赋值运算符。拷贝赋值比拷贝构造多一个步骤:先处理自身已有资源,防止内存泄漏。
StringWrapper& operator=(const StringWrapper& other) { if (this == &other) return *this; // 防止自赋值 delete[] data; data = new char[strlen(other.data) + 1]; strcpy(data, other.data); return *this; }注意那个if (this == &other),很多新手会漏掉。当你写a = a;时,没有自赋值检查,代码会先释放a.data,再访问other.data,此时内存已经没了,后果不可预知。
自赋值检查的代价很低,我建议在自定义拷贝赋值运算符时一律加上。
3.3 移动语义:减少不必要的拷贝
现代C++里,深拷贝虽然安全,但代价不小——数据量一大,频繁拷贝会拖慢程序。移动语义想的办法是:既然源对象马上要销毁了,不如把它的资源直接"偷"过来,而不是复制一份。
StringWrapper(StringWrapper&& other) noexcept : data(other.data) { other.data = nullptr; // 源对象置空,析构时不会释放已移交的资源 }&&表示右值引用,专门接收临时对象这类"快被销毁的值"。std::move的作用是把一个左值"伪装"成右值,好让编译器选择移动构造而不是拷贝构造。
移动构造的性能优势在大对象身上立竿见影。比如一个内部有vector、string甚至大量动态内存的类,拷贝要分配新内存、逐字节复制,移动只是搬个指针。我见过一个模拟项目,改用了移动语义后,某个密集调用路径的耗时下降了三分之一以上。
3.4 Rule of Three / Rule of Five
C++社区总结了经验法则:如果你需要自定义析构函数、拷贝构造函数、拷贝赋值运算符中的任何一个,通常三个都需要。C++11加入移动构造和移动赋值后,扩展成五个。
不需要死背,你只要理解背后的逻辑:这类"三件套"几乎都意味着类在管理某种资源。资源管理规则一旦自定义,编译器的默认逐成员拷贝就满足不了你的需求了。与其纠结,不如记住一条更现代的实践:优先用std::vector、std::string、std::unique_ptr这些管理资源的标准组件,尽量别自己写资源管理类。标准组件替你实现了拷贝、移动、析构的正常行为,很多崩溃和泄漏根本不会出现。
4. 继承与多态:从内存布局到虚表机制
4.1 继承的子对象布局
继承是一个类"包含"另一个类。最简单的理解:Derived对象内部,先有一块Base子对象,再加上自己新增的成员。
class Base { protected: int x; }; class Derived : public Base { private: int y; };Derived在内存里大致是[Base部分 | Derived新增部分]。这就是为什么可以用派生类对象给基类引用或指针赋值——基类部分确实排在对象开头,Base* b = &d;只需要取对象的起始地址即可。反过来不行:基类对象里没有派生类新增的成员,不能强转成派生类指针。
4.2 虚函数和动态绑定:vptr和vtable
看一个经典例子:
class Shape { public: virtual double area() const { return 0; } }; class Circle : public Shape { private: double r; public: Circle(double radius) : r(radius) {} double area() const override { return 3.14159265358979 * r * r; } };为什么要virtual?如果没有virtual,你用基类指针调用area,编译器看指针类型是Shape,直接静态绑定到Shape::area,永远调不到派生类版本。有virtual后,事情变成:编译器在每个含有虚函数的对象里,暗插一个指针vptr,它指向该类的虚函数表(vtable)。vtable是一张函数指针数组,里面按声明顺序存着这个类所有虚函数的实际地址。
Circle对象创建时,vptr被设置为指向Circle类的vtable,所以Shape* s = &circle; s->area();执行时,程序顺着vptr查到Circle::area的地址并调用。这正是动态绑定(多态)的底层原理。
我当初理解这个机制后,很多疑惑迎刃而解。比如为什么虚函数调用比普通成员函数慢一点点?因为多了一次间接寻址;为什么不能把构造函数设为虚函数?因为对象还没创建,vptr还不存在。
4.3 虚析构函数不是选项,是必须
一旦类里有虚函数,析构函数也建议声明为virtual。否则你干这种事:
Shape* s = new Circle(1.0); delete s;如果Shape的析构函数不是虚函数,delete s只会根据静态类型Shape*调用~Shape(),Circle的析构函数不会被调用。如果Circle持有资源,就会泄漏,严格来说这是未定义行为。
virtual ~Shape() = default;一行代码,就能保证通过基类指针删除派生类对象时,先调派生类析构,再调基类析构,链条完整。这不是优化建议,是必须做的事。
4.4 构造和析构期间调用虚函数:不会多态
这是一个容易让人困惑的坑。在构造函数里调用虚函数,不会触发动态绑定。比如:
class Base { public: Base() { log(); } virtual void log() const { std::cout << "Base"; } }; class Derived : public Base { public: void log() const override { std::cout << "Derived"; } }; Derived d; // 输出 "Base"原因前面已经铺垫过了:构造Derived时,先构造Base部分,此时vptr指向Base的虚表,虚调用自然到不了Derived::log。基类构造函数执行完之后,vptr才被重新赋值为指向Derived的虚表。析构时的顺序反过来,先析构派生类部分,再析构基类部分,所以基类析构函数里的虚调用同样不会多态。
这个行为看起来反直觉,其实是对的。构造期间,派生类部分还没初始化完成,如果此时调用了派生类版本的虚函数,它可能访问尚未初始化的成员,反而更危险。
4.5 纯虚函数与抽象类:强制子类实现
class Shape { public: virtual double area() const = 0; };= 0表示纯虚函数,拥有纯虚函数的类是抽象类,不能直接实例化。子类必须实现area,否则自己也是抽象类。这用来表达"你必须有面积,但具体怎么算我不关心"的契约。
实际项目中,我喜欢把抽象类当接口用,只声明纯虚函数,不带数据成员。这样解耦清晰,依赖倒置做起来也干净。但别过度设计,一个只有两个子类的简单继承,硬套抽象接口反而增加理解成本。
5. 我的踩坑笔记:类和对象最容易出问题的几个瞬间
5.1 返回局部对象的引用,悬垂引用崩溃
我见过最多的问题之一:
const std::string& getName() { Student s(...); return s.getName(); // 返回一个指向局部对象的引用/指针 }函数返回后,局部对象Student已经析构,返回的引用指向一块已经失效的内存。调用方访问它,未定义行为,有时候碰巧能打印出正确内容,有时候直接乱码或崩溃。这种bug难排查就难在"时好时坏"。
正确做法是返回值,而不是引用:
std::string getName() const { return name; }C++有返回值优化(copy elision),返回小型对象通常不会有多余的拷贝开销,不需要担心性能。你担心性能的话,考虑移动语义,而不是返回悬垂引用。
5.2 空指针调用成员函数:编译能过,但它是UB
你可能碰到过这种代码:
Student* s = nullptr; s->printInfo();奇怪的是它没崩溃。为什么不崩?因为printInfo如果没有访问任何成员变量,编译器根本不需要解引用s,函数体内只用到了this指针本身,而this恰好是nullptr,你说它是UB,但很多实现下确实能跑。
千万别依赖这种"侥幸能跑"的行为。只要printInfo内部访问了任何成员,必然解引用空指针,大概率崩溃。这种代码属于典型的"改了编译器说不定就崩"。
我的建议很简单:调用成员函数前,永远确保对象有效,尤其是指针从别的模块传过来时。
5.3 拷贝构造 vs 拷贝赋值,分不清楚
不少初学者混淆这两个:
Student a("Tom"); Student b = a; // 拷贝构造,生成了新对象 b = a; // 拷贝赋值,b已存在,进行赋值拷贝构造发生在"对象还不存在,拿另一个对象做模板"的时候。拷贝赋值发生在"对象已经存在,把另一个对象的状态抄过来"的时候。写拷贝赋值运算符时,要按前面说的步骤处理:自赋值检查、释放旧资源、拷贝新资源、返回引用。写拷贝构造函数时,不需要释放旧资源,因为新对象还没资源。
两者搞混,最典型的症状就是:某个类里资源被释放两次,或新对象和旧对象共享同一块内存后,修改一个连带改了另一个。
5.4 运算符重载的隐式转换陷阱
看到explicit关键字时,有人会问:为什么单参数构造函数最好是explicit?因为不加的话,编译器会在你不经意的时候做隐式转换。
class Money { public: Money(double amount) {} }; void pay(const Money& m) {} pay(100.0); // 如果不加explicit,编译器自动构造Money(100.0)有时候这是便利,比如字符串类允许const char*隐式转换。但更多时候,它掩盖了类型错误。假如你有个Money类,只想接受金额,传100.0也许就通了,但哪天你写pay(某个对象),如果那个对象恰好定义了到double的转换,隐式转换会悄悄发生,错误在编译期完全看不出来。我建议所有单参数构造函数默认加explicit,除非你明确需要隐式转换。
5.5 类定义放头文件的编译期成本
这是工程层面的坑。把完整的类定义写在头文件里,每个包含它的.cpp文件都要编译一遍类内联函数的定义;要是在类内写了体积很大的函数体,编译时间会明显拉长,稍微改一下,一堆文件重新编译。
经验做法:类定义放头文件,函数声明放类里,复杂实现放.cpp。只有非常短小、需要高频调用的函数(比如getter)才在类内定义,让编译器有机会内联。另外,析构函数和关键构造函数放到.cpp实现还有一个额外好处:可以让pimpl之类的隐藏实现变得可行,减少头文件依赖。
我在实际项目里,经常用这个思路控制模块之间的编译耦合。某个同事改一个内部实现,以前要重编译几乎整个项目,后来把类定义和实现分开,重编译范围缩小到几个文件,节省的时间非常可观。
最后分享一点个人体会
类和对象这一块,理论学习是一回事,真正把它用对是另一回事。我自己的经验是:不要一口气把特性全用上,刚开始就老老实实写简单的类,该用const的地方用const,该加explicit的地方加explicit,能交给标准库管理的资源就别自己手动new/delete。等这些习惯内化了,再慢慢研究继承和多态的高级技巧,效率会高很多。这套笔记是我从一次次编译报错、一次次莫名崩溃里提炼出来的核心内容,你按这个顺序复习一遍,再回到项目里对照着改代码,很多当时的疑惑会豁然开朗。 C++的类和对象,大概是每个学C++的人绕不过去的一道坎。我最早学的时候,把书本上的概念背得滚瓜烂熟,但真到写项目,还是被各种诡异崩溃整得头皮发麻。后来我才意识到,类和对象不是一堆名词解释,它背后是一套实实在在的内存规则和编译机制。这篇笔记不会按教科书顺序给你念定义,而是把我在实际开发中反复使用、反复踩坑的核心知识点重新梳理一遍,适合刚学完基础语法、正在写小项目的同学,也适合准备面试想快速把知识串起来的开发者。搞清楚类与对象,C++才算真正入门。
1. 从结构体到类:封装这一步到底改变了什么
1.1 数据和函数分离的痛点
先看C语言时代的写法。你要管理一个学生信息,通常会定义一个结构体:
struct Student { char name[32]; int age; int scores[3]; };然后配套写一堆函数:initStudent、printStudent、updateScore。调用的时候,你得手动把Student对象传给函数。问题在哪?这些函数和结构体之间没有强制关联。你完全可以传一个Teacher对象给initStudent,编译器拦都不拦,因为C语言只检查参数类型是不是匹配,至于这个函数"逻辑上该操作谁",它管不着。
项目小的时候无所谓,项目一复杂,数据散落各处、操作数据的函数满天飞,维护起来非常痛苦。你改了一个结构体的字段,所有操作它的函数同步改一遍,漏改一个就出诡异bug。
1.2 封装:把数据和操作绑定在一起
C++的类做的事情,第一就是把这层关系从"约定"变成"强制"。数据成员放进类里,操作这些数据的成员函数也放进类里:
class Student { private: std::string name; int age; public: void setName(const std::string& n) { name = n; } void printInfo() const; };从外部看,你只能通过setName去改名字,不能直接碰name成员。这么做最大的收益不是"安全",而是接口稳定。类内部怎么存储、怎么计算,那是实现细节,只要公开接口不变,你可以放心重写类内部逻辑,调用方完全无感。
我在实际项目中常跟别人讲一个比喻:封装就好比你去餐厅吃饭。你只需要对菜单点菜,不需要关心后厨怎么洗菜、用什么锅炒。如果某天后厨换了设备、改了流程,只要菜单不变,你作为食客一点都不受影响。类也一样,public接口就是菜单,private内部就是后厨。
1.3 class和struct:不只是名字不同
很多人知道C++里struct也能有成员函数,但容易忽略默认访问权限的差异:
struct A { int x; // public }; class B { int x; // private };struct默认是public访问,class默认是private访问。实践中,我用struct放纯数据聚合(比如坐标点、配置项),用class放带有逻辑和状态的对象。这个约定没什么强制性,但能让读代码的人一眼看出意图。
1.4 this指针:成员函数隐藏的第一个参数
成员函数能直接访问成员变量,靠的是一个隐藏指针this。你写:
student.setName("Tom");编译器心里想的是:
Student::setName(&student, "Tom");this指向调用该成员函数的那个对象。所以下面这种写法是成立的,它显式使用了this来消除参数名和成员名的歧义:
void Student::setName(const std::string& name) { this->name = name; }理解this很重要,后面讲拷贝、继承、多态时都要用到它。很多初学者忽略了这一点,导致后来遇到"成员函数里明明同名变量,为什么赋值没生效"这种问题。
2. 构造函数与析构函数:对象的诞生和消亡不是玄学
2.1 构造函数:创建对象的必经之路
构造函数和类同名,没有返回值。它做的事是:对象一创建,立刻执行一段初始化逻辑,保证对象处于可用状态。没有构造函数的话,你得写一堆init函数,还得记得每次创建对象后手动调用,漏一次,对象状态就是垃圾。
一个简单的类:
class Timer { private: int startTime; public: Timer() : startTime(0) {} explicit Timer(int t) : startTime(t) {} };上面写了两个构造函数:一个是默认构造函数,一个是带参数的构造函数。用explicit修饰单参数构造函数,是为了防止隐式转换。比如你写了explicit Timer(int t),那么Timer t = 42;这种写法会被拒绝,编译器要求你明确写成Timer t(42);。这能挡掉很多意图不明的隐式转换,属于我强烈建议的习惯。
2.2 初始化列表:真正的"初始化",不是赋值
初学者容易写成:
Student::Student(const std::string& n, int a) { name = n; // 赋值 age = a; // 赋值 }而推荐写法是:
Student::Student(const std::string& n, int a) : name(n), age(a) { // 初始化 }两者有什么区别?初始化列表是初始化,函数体内是赋值。对内置类型(int、double)差别不大,但对以下三类成员,只能在初始化列表里初始化:
const成员,一旦创建不能修改,必须有初值;- 引用成员,引用必须在定义时绑定;
- 没有默认构造函数的类类型成员。
比如:
class Config { private: const int version; int& ref; public: Config(int v, int& x) : version(v), ref(x) {} };如果写成version = v,编译器直接报错,因为version是const的,你已经来不及给它初值了。
还有一个细节:初始化列表的执行顺序不按列表书写顺序,而是按成员在类中的声明顺序。这是个真实的坑:
class Foo { private: int a; int b; public: Foo() : b(10), a(b) {} // a先初始化,用的是未定义的b };因为a先声明,所以先初始化a,此时b还没被初始化,a拿到的是一个随机值。正确的做法是让初始化列表的顺序和成员声明顺序保持一致,或者干脆都用参数传进来的值。
2.3 析构函数:对象销毁时自动善后
析构函数名是~类名,没有参数、没有返回值,一个类只能有一个。它在对象生命周期结束时自动调用,不需要你手动调(手动调obj.~Foo()是极其罕见且危险的用法)。
三种典型场景:
栈对象:
void func() { Student s("Tom", 18); // 函数结束时,s的析构函数自动调用 }堆对象:
void func() { Student* s = new Student("Tom", 18); delete s; // 这里才调用析构函数 }如果你new了却不delete,析构函数永远不会执行,这就是经典的内存泄漏。
全局对象:
全局对象在程序启动时构造、程序退出时析构。如果构造函数依赖其他全局对象,顺序不确定时,程序可能在启动阶段就崩溃。所以我一直倾向于少用全局对象,尤其是带复杂逻辑的类。
析构函数最常见的用途是释放资源:堆内存、文件句柄、网络连接、互斥锁。你申请的资源,必须保证在析构里释放。
注意:如果你写了
delete,放心交给析构函数去做。不要担心性能损耗,编译器在大多数情况下会对你不可见、可内联的析构做优化。
3. 拷贝控制:浅拷贝的坑、深拷贝的代价、移动语义的优化
3.1 默认拷贝构造函数在做什么
如果你不写拷贝构造函数,编译器会生成一个默认版本,行为是逐成员拷贝。对内置类型就是直接赋值,对类类型成员会调用它的拷贝构造函数。这看起来很美好,但有致命缺陷:如果类里有裸指针,逐成员拷贝会让两个对象指向同一块内存。
class StringWrapper { private: char* data; public: StringWrapper(const char* str) { data = new char[strlen(str) + 1]; strcpy(data, str); } ~StringWrapper() { delete[] data; } };StringWrapper a("hello"); StringWrapper b = a;执行后,a.data和b.data指向同一块内存。程序结束,两个对象的析构函数都执行,同一块内存被delete[]两次,直接崩溃,术语叫double free。
3.2 深拷贝:自己管理资源时的正确姿势
要解决上面的问题,必须实现深拷贝,让拷贝后的对象拥有自己独立的资源:
StringWrapper(const StringWrapper& other) { data = new char[strlen(other.data) + 1]; strcpy(data, other.data); }同时还要实现拷贝赋值运算符。拷贝赋值比拷贝构造多一个步骤:先处理自身已有资源,防止内存泄漏。
StringWrapper& operator=(const StringWrapper& other) { if (this == &other) return *this; // 防止自赋值 delete[] data; data = new char[strlen(other.data) + 1]; strcpy(data, other.data); return *this; }注意那个if (this == &other),很多新手会漏掉。当你写a = a;时,没有自赋值检查,代码会先释放a.data,再访问other.data,此时内存已经没了,后果不可预知。
自赋值检查的代价很低,我建议在自定义拷贝赋值运算符时一律加上。
3.3 移动语义:减少不必要的拷贝
现代C++里,深拷贝虽然安全,但代价不小——数据量一大,频繁拷贝会拖慢程序。移动语义想的办法是:既然源对象马上要销毁了,不如把它的资源直接"偷"过来,而不是复制一份。
StringWrapper(StringWrapper&& other) noexcept : data(other.data) { other.data = nullptr; // 源对象置空,析构时不会释放已移交的资源 }&&表示右值引用,专门接收临时对象这类"快被销毁的值"。std::move的作用是把一个左值"伪装"成右值,好让编译器选择移动构造而不是拷贝构造。
移动构造的性能优势在大对象身上立竿见影。比如一个内部有vector、string甚至大量动态内存的类,拷贝要分配新内存、逐字节复制,移动只是搬个指针。我见过一个模拟项目,改用了移动语义后,某个密集调用路径的耗时下降了三分之一以上。
3.4 Rule of Three / Rule of Five
C++社区总结了经验法则:如果你需要自定义析构函数、拷贝构造函数、拷贝赋值运算符中的任何一个,通常三个都需要。C++11加入移动构造和移动赋值后,扩展成五个。
不需要死背,你只要理解背后的逻辑:这类"三件套"几乎都意味着类在管理某种资源。资源管理规则一旦自定义,编译器的默认逐成员拷贝就满足不了你的需求了。与其纠结,不如记住一条更现代的实践:优先用std::vector、std::string、std::unique_ptr这些管理资源的标准组件,尽量别自己写资源管理类。标准组件替你实现了拷贝、移动、析构的正常行为,很多崩溃和泄漏根本不会出现。
4. 继承与多态:从内存布局到虚表机制
4.1 继承的子对象布局
继承是一个类"包含"另一个类。最简单的理解:Derived对象内部,先有一块Base子对象,再加上自己新增的成员。
class Base { protected: int x; }; class Derived : public Base { private: int y; };Derived在内存里大致是[Base部分 | Derived新增部分]。这就是为什么可以用派生类对象给基类引用或指针赋值——基类部分确实排在对象开头,Base* b = &d;只需要取对象的起始地址即可。反过来不行:基类对象里没有派生类新增的成员,不能强转成派生类指针。
4.2 虚函数和动态绑定:vptr和vtable
看一个经典例子:
class Shape { public: virtual double area() const { return 0; } }; class Circle : public Shape { private: double r; public: Circle(double radius) : r(radius) {} double area() const override { return 3.14159265358979 * r * r; } };为什么要virtual?如果没有virtual,你用基类指针调用area,编译器看指针类型是Shape,直接静态绑定到Shape::area,永远调不到派生类版本。有virtual后,事情变成:编译器在每个含有虚函数的对象里,暗插一个指针vptr,它指向该类的虚函数表(vtable)。vtable是一张函数指针数组,里面按声明顺序存着这个类所有虚函数的实际地址。
Circle对象创建时,vptr被设置为指向Circle类的vtable,所以Shape* s = &circle; s->area();执行时,程序顺着vptr查到Circle::area的地址并调用。这正是动态绑定(多态)的底层原理。
我当初理解这个机制后,很多疑惑迎刃而解。比如为什么虚函数调用比普通成员函数慢一点点?因为多了一次间接寻址;为什么不能把构造函数设为虚函数?因为对象还没创建,vptr还不存在。
4.3 虚析构函数不是选项,是必须
一旦类里有虚函数,析构函数也建议声明为virtual。否则你干这种事:
Shape* s = new Circle(1.0); delete s;如果Shape的析构函数不是虚函数,delete s只会根据静态类型Shape*调用~Shape(),Circle的析构函数不会被调用。如果Circle持有资源,就会泄漏,严格来说这是未定义行为。
virtual ~Shape() = default;一行代码,就能保证通过基类指针删除派生类对象时,先调派生类析构,再调基类析构,链条完整。这不是优化建议,是必须做的事。
4.4 构造和析构期间调用虚函数:不会多态
这是一个容易让人困惑的坑。在构造函数里调用虚函数,不会触发动态绑定。比如:
class Base { public: Base() { log(); } virtual void log() const { std::cout << "Base"; } }; class Derived : public Base { public: void log() const override { std::cout << "Derived"; } }; Derived d; // 输出 "Base"原因前面已经铺垫过了:构造Derived时,先构造Base部分,此时vptr指向Base的虚表,虚调用自然到不了Derived::log。基类构造函数执行完之后,vptr才被重新赋值为指向Derived的虚表。析构时的顺序反过来,先析构派生类部分,再析构基类部分,所以基类析构函数里的虚调用同样不会多态。
这个行为看起来反直觉,其实是对的。构造期间,派生类部分还没初始化完成,如果此时调用了派生类版本的虚函数,它可能访问尚未初始化的成员,反而更危险。
4.5 纯虚函数与抽象类:强制子类实现
class Shape { public: virtual double area() const = 0; };= 0表示纯虚函数,拥有纯虚函数的类是抽象类,不能直接实例化。子类必须实现area,否则自己也是抽象类。这用来表达"你必须有面积,但具体怎么算我不关心"的契约。
实际项目中,我喜欢把抽象类当接口用,只声明纯虚函数,不带数据成员。这样解耦清晰,依赖倒置做起来也干净。但别过度设计,一个只有两个子类的简单继承,硬套抽象接口反而增加理解成本。
5. 我的踩坑笔记:类和对象最容易出问题的几个瞬间
5.1 返回局部对象的引用,悬垂引用崩溃
我见过最多的问题之一:
const std::string& getName() { Student s(...); return s.getName(); // 返回一个指向局部对象的引用/指针 }函数返回后,局部对象Student已经析构,返回的引用指向一块已经失效的内存。调用方访问它,未定义行为,有时候碰巧能打印出正确内容,有时候直接乱码或崩溃。这种bug难排查就难在"时好时坏"。
正确做法是返回值,而不是引用:
std::string getName() const { return name; }C++有返回值优化(copy elision),返回小型对象通常不会有多余的拷贝开销,不需要担心性能。你担心性能的话,考虑移动语义,而不是返回悬垂引用。
5.2 空指针调用成员函数:编译能过,但它是UB
你可能碰到过这种代码:
Student* s = nullptr; s->printInfo();奇怪的是它没崩溃。为什么不崩?因为printInfo如果没有访问任何成员变量,编译器根本不需要解引用s,函数体内只用到了this指针本身,而this恰好是nullptr,你说它是UB,但很多实现下确实能跑。
千万别依赖这种"侥幸能跑"的行为。只要printInfo内部访问了任何成员,必然解引用空指针,大概率崩溃。这种代码属于典型的"改了编译器说不定就崩"。
我的建议很简单:调用成员函数前,永远确保对象有效,尤其是指针从别的模块传过来时。
5.3 拷贝构造 vs 拷贝赋值,分不清楚
不少初学者混淆这两个:
Student a("Tom"); Student b = a; // 拷贝构造,生成了新对象 b = a; // 拷贝赋值,b已存在,进行赋值拷贝构造发生在"对象还不存在,拿另一个对象做模板"的时候。拷贝赋值发生在"对象已经存在,把另一个对象的状态抄过来"的时候。写拷贝赋值运算符时,要按前面说的步骤处理:自赋值检查、释放旧资源、拷贝新资源、返回引用。写拷贝构造函数时,不需要释放旧资源,因为新对象还没资源。
两者搞混,最典型的症状就是:某个类里资源被释放两次,或新对象和旧对象共享同一块内存后,修改一个连带改了另一个。
5.4 运算符重载的隐式转换陷阱
看到explicit关键字时,有人会问:为什么单参数构造函数最好是explicit?因为不加的话,编译器会在你不经意的时候做隐式转换。
class Money { public: Money(double amount) {} }; void pay(const Money& m) {} pay(100.0); // 如果不加explicit,编译器自动构造Money(100.0)有时候这是便利,比如字符串类允许const char*隐式转换。但更多时候,它掩盖了类型错误。假如你有个Money类,只想接受金额,传100.0也许就通了,但哪天你写pay(某个对象),如果那个对象恰好定义了到double的转换,隐式转换会悄悄发生,错误在编译期完全看不出来。我建议所有单参数构造函数默认加explicit,除非你明确需要隐式转换。
5.5 类定义放头文件的编译期成本
这是工程层面的坑。把完整的类定义写在头文件里,每个包含它的.cpp文件都要编译一遍类内联函数的定义;要是在类内写了体积很大的函数体,编译时间会明显拉长,稍微改一下,一堆文件重新编译。
经验做法:类定义放头文件,函数声明放类里,复杂实现放.cpp。只有非常短小、需要高频调用的函数(比如getter)才在类内定义,让编译器有机会内联。另外,析构函数和关键构造函数放到.cpp实现还有一个额外好处:可以让pimpl之类的隐藏实现变得可行,减少头文件依赖。
我在实际项目里,经常用这个思路控制模块之间的编译耦合。某个同事改一个内部实现,以前要重编译几乎整个项目,后来把类定义和实现分开,重编译范围缩小到几个文件,节省的时间非常可观。
最后分享一点个人体会
类和对象这一块,理论学习是一回事,真正把它用对是另一回事。我自己的经验是:不要一口气把特性全用上,刚开始就老老实实写简单的类,该用const的地方用const,该加explicit的地方加explicit,能交给标准库管理的资源就别自己手动new/delete。等这些习惯内化了,再慢慢研究继承和多态的高级技巧,效率会高很多。这套笔记是我从一次次编译报错、一次次莫名崩溃里提炼出来的核心内容,你按这个顺序复习一遍,再回到项目里对照着改代码,很多当时的疑惑会豁然开朗。