指针与C++:引用与指针的区别、this 指针、虚函数表与指针、智能指针的原理
说实话,很多C语言老手刚转到C++时,最困惑的就是指针和引用到底有什么区别。我当年也一样,看着&符号,心想这不就是取地址吗?怎么又冒出来个引用?今天咱们就把这些概念彻底理清楚。
一、引用与指针:看似相似,实则不同
引用和指针都能间接访问对象,但本质上有区别。指针是一个变量,存储的是地址;引用是别名,是某个已存在对象的另一个名字。
核心区别:
- 指针可以为空(nullptr),引用必须初始化且不能为空
- 指针可以重新赋值,引用一旦绑定就不能改变
- 指针有自身的地址和大小,引用没有独立的内存空间
- 指针支持算术运算,引用不支持
int a = 10;
int* p = &a; // 指针,存储a的地址
int& r = a; // 引用,a的别名
p = nullptr; // 合法,指针可以为空
// int& r2; // 错误!引用必须初始化
int b = 20;
p = &b; // 合法,指针可以指向别处
// r = b; // 不是重新绑定!这是把b的值赋给a
cout << sizeof(p); // 8(64位系统),指针本身的大小
cout << sizeof(r); // 4,引用的大小就是对象的大小
我在项目中遇到过这样一个坑:有个同事用引用作为函数返回值,结果返回了一个局部变量的引用。程序跑起来时好时坏,调试了半天才发现是悬空引用的问题。嗯,这里要注意——引用虽然用起来像值类型,但底层实现其实就是指针。所以返回局部变量的引用,本质上和返回局部变量的指针一样危险。
我的建议:
- 函数参数传递:能用引用就用引用,避免指针的null检查
- 需要重新绑定或允许为空:用指针
- 实现容器或迭代器:用指针,因为需要算术运算
二、this 指针:每个对象都知道自己是谁
你想想看,当你在成员函数里访问成员变量时,编译器怎么知道是哪个对象的变量?答案就是 this 指针。
this 指针是每个非静态成员函数内部隐含的一个指针,指向调用该函数的对象。它本质上就是 ClassName* const this——一个常量指针,你不能改变它指向的对象。
class Student {
private:
string name;
int age;
public:
Student(const string& name, int age) {
// 当参数名和成员变量名冲突时,必须用this
this->name = name;
this->age = age;
}
Student& setAge(int age) {
this->age = age;
return *this; // 返回对象本身,支持链式调用
}
void print() const {
// 在const成员函数中,this的类型是 const Student*
cout << this->name << ": " << this->age << endl;
}
};
我曾经在写一个链表类时,利用 this 指针实现了链式调用:list.insert(1).insert(2).insert(3)。这种写法在配置类或建造者模式中特别常见,代码读起来很流畅。
注意:
- 静态成员函数没有 this 指针,因为它们不属于某个对象
- 不要在成员函数中保存 this 指针供后续使用,除非你清楚对象的生命周期
- this 指针本身不能为 null,但如果对象是通过空指针调用的成员函数,this 就是空指针
三、虚函数表与指针:多态的底层实现
C++ 的多态是怎么实现的?说白了就是通过虚函数表(vtable)和虚函数指针(vptr)。每个包含虚函数的类都有一个虚函数表,每个对象都有一个指向该表的虚函数指针。
class Base {
public:
virtual void func1() { cout << "Base::func1" << endl; }
virtual void func2() { cout << "Base::func2" << endl; }
void func3() { cout << "Base::func3" << endl; } // 非虚函数
};
class Derived : public Base {
public:
void func1() override { cout << "Derived::func1" << endl; }
virtual void func4() { cout << "Derived::func4" << endl; }
};
这个结构可以用一张图来表示:
为什么会有这样的设计?因为编译器在编译时不知道基类指针到底指向哪个派生类对象,所以只能在运行时通过 vptr 去查虚函数表,找到实际要调用的函数。这就是动态绑定的本质。
避坑指南:
- 构造函数中调用虚函数不会触发多态,因为此时 vptr 可能还没初始化完成
- 不要在析构函数中调用虚函数,因为派生类的部分已经被销毁了
- 虚函数表是类级别的,所有对象共享同一个表,但每个对象有自己的 vptr
四、智能指针:告别手动 delete
手动管理内存是 C 和 C++ 最容易出问题的地方。我早期做嵌入式开发时,就因为在中断处理函数里忘记释放内存,导致系统跑几天就崩了。智能指针就是为了解决这个问题而生的。
4.1 unique_ptr:独占所有权
unique_ptr 是独占所有权的智能指针。同一时刻只能有一个 unique_ptr 指向某个对象,不能拷贝,只能移动。这就像一把钥匙,只有一个人能持有。
#include <memory>
std::unique_ptr<int> p1(new int(42));
// std::unique_ptr<int> p2 = p1; // 错误!不能拷贝
std::unique_ptr<int> p2 = std::move(p1); // 可以移动,p1变为空
// 推荐使用 make_unique(C++14)
auto p3 = std::make_unique<int>(100);
// 自定义删除器
auto deleter = [](FILE* f) {
if (f) fclose(f);
cout << "文件已关闭" << endl;
};
std::unique_ptr<FILE, decltype(deleter)> filePtr(fopen("test.txt", "r"), deleter);
我在项目中用 unique_ptr 管理硬件资源特别顺手。比如打开一个串口设备,用 unique_ptr 包装起来,函数退出时自动关闭,再也不用担心忘记释放了。
4.2 shared_ptr:共享所有权
shared_ptr 使用引用计数来管理共享所有权。多个 shared_ptr 可以指向同一个对象,当最后一个 shared_ptr 被销毁时,对象才被释放。
std::shared_ptr<int> sp1 = std::make_shared<int>(42);
std::shared_ptr<int> sp2 = sp1; // 引用计数变为2
cout << sp1.use_count(); // 输出2
sp1.reset(); // 引用计数减1
sp2.reset(); // 引用计数变为0,对象被销毁
// 循环引用问题
struct Node {
std::shared_ptr<Node> next;
// std::weak_ptr<Node> next; // 用weak_ptr打破循环
};
auto n1 = std::make_shared<Node>();
auto n2 = std::make_shared<Node>();
n1->next = n2;
n2->next = n1; // 循环引用!内存泄漏
我曾经踩过的坑:循环引用导致的内存泄漏。两个 shared_ptr 互相引用,引用计数永远到不了0。解决办法是用 weak_ptr 打破循环。weak_ptr 不增加引用计数,只是观察者,需要使用时通过 lock() 获取 shared_ptr。
3.3 智能指针对比
| 特性 | unique_ptr | shared_ptr | weak_ptr |
|---|---|---|---|
| 所有权 | 独占 | 共享 | 不拥有 |
| 引用计数 | 无 | 有 | 有(不增加计数) |
| 拷贝 | 禁止 | 允许 | 允许 |
| 移动 | 允许 | 允许 | 允许 |
| 性能开销 | 几乎为零 | 有(引用计数) | 较小 |
| 适用场景 | 资源独占 | 资源共享 | 观察者、打破循环引用 |
说实话,智能指针是现代 C++ 的基石之一。我建议你在新项目中尽量使用智能指针,而不是裸指针。但也要注意,智能指针不是万能的——比如在嵌入式系统中,内存受限且实时性要求高,智能指针的引用计数操作可能带来不确定的开销。这时候,手动管理内存反而更可控。
总结一下:
- 引用是别名,指针是变量——用引用传参,用指针实现多态
- this 指针是成员函数的隐含参数,指向当前对象
- 虚函数表是多态的底层实现,vptr 指向 vtable,运行时动态绑定
- unique_ptr 独占所有权,shared_ptr 共享所有权,weak_ptr 辅助观察