从今天开始进入现代C++的世界。智能指针是C++11引入的最重要的特性之一,面试必考。

在机器人开发里,shared_ptr用得非常多。传感器对象、通信节点、算法模块……这些需要共享所有权的场景,几乎都用shared_ptr管理。

面试官问智能指针,通常从shared_ptr开始。因为它是三个智能指针里最复杂的,涉及引用计数、线程安全、循环引用等问题。

为什么需要智能指针

先看个问题。

void processSensor() {
    Sensor* s = new LidarSensor();
    s->init();
    if (someError()) {
        return;  // 忘了delete,内存泄漏!
    }
    process(s);
    delete s;
}

手动管理new/delete,很容易出问题。异常、提前return、复杂的控制流……任何一个地方忘了delete,就是内存泄漏。

智能指针的思路是:用栈上的对象管理堆上的内存。栈上的shared_ptr析构时,自动delete它管理的堆内存。这就是RAII思想。

void processSensor() {
    auto s = std::make_shared<LidarSensor>();
    s->init();
    if (someError()) {
        return;  // s自动释放,不会泄漏
    }
    process(s);
    // 函数结束,s自动释放
}

shared_ptr的基本用法

// 推荐用make_shared创建
auto p1 = std::make_shared<Sensor>();

// 也可以直接new(不推荐)
std::shared_ptr<Sensor> p2(new Sensor());

// 查看引用计数
cout << p1.use_count();  // 1

// 共享所有权
auto p3 = p1;
cout << p1.use_count();  // 2

// 重置
p3.reset();
cout << p1.use_count();  // 1

p1.reset();
// 此时Sensor对象被销毁

make_shared比直接new好在哪?两个原因。第一,make_shared只分配一次内存(对象和控制块一起),直接new要分配两次(对象一次,控制块一次)。第二,make_shared异常安全——如果参数构造过程中抛异常,直接new可能泄漏。

引用计数的底层实现

面试深挖:"shared_ptr的引用计数是怎么实现的?"

每个shared_ptr关联一个控制块(control block),控制块里存着:强引用计数、弱引用计数、析构器、分配器等。

shared_ptr<Sensor> p1 ──→ 控制块 ──→ Sensor对象
                           │
                           ├── 强引用计数: 2
                           ├── 弱引用计数: 0
                           └── 析构器: delete
                           
shared_ptr<Sensor> p2 ───┘

拷贝shared_ptr的时候,控制块的强引用计数+1。析构或reset的时候,计数-1。当强引用计数变为0,销毁对象。当弱引用计数也变为0,销毁控制块。

控制块是线程安全的——引用计数的增减操作是原子的。但注意:被管理的对象本身不是线程安全的。两个线程同时通过shared_ptr修改同一个对象,还是需要加锁。

循环引用:shared_ptr最大的坑

shared_ptr有个致命的问题:循环引用会导致内存泄漏。

class Node {
public:
    std::shared_ptr<Node> next;
    std::shared_ptr<Node> prev;
    ~Node() { cout << "Node destroyed" << endl; }
};

auto n1 = std::make_shared<Node>();
auto n2 = std::make_shared<Node>();
n1->next = n2;
n2->prev = n1;

// 离开作用域时,n1和n2的引用计数都是2
// n1析构后计数变为1(n2->prev还指着它)
// n2析构后计数变为1(n1->next还指着它)
// 两个对象的引用计数永远到不了0,永远不会被销毁!

"Node destroyed"永远不会被打印。这就是循环引用导致的内存泄漏。

解决方案是用weak_ptr打破循环。这个下篇详细讲。

自定义删除器

shared_ptr支持自定义删除器,这在机器人开发里很实用。

// 管理C语言的资源
FILE* fp = fopen("data.txt", "r");
std::shared_ptr<FILE> file_ptr(fp, [](FILE* f) {
    fclose(f);
});

// 管理ROS节点
auto node = std::shared_ptr<rclcpp::Node>(
    rclcpp::Node::create("my_node"),
    [](rclcpp::Node* n) {
        n->shutdown();
        delete n;
    }
);

自定义删除器不影响shared_ptr的类型——shared_ptr<T>的删除器类型不是模板参数的一部分。这和unique_ptr不同(下篇会讲)。

enable_shared_from_this

还有个容易踩的坑:在类的成员函数里需要获取指向自身的shared_ptr。

class Sensor {
public:
    std::shared_ptr<Sensor> getPtr() {
        return shared_from_this();  // 安全地获取自身的shared_ptr
    }
};

// 必须继承enable_shared_from_this
class Sensor : public std::enable_shared_from_this<Sensor> {
public:
    std::shared_ptr<Sensor> getShared() {
        return shared_from_this();
    }
};

auto s = std::make_shared<Sensor>();
auto p = s->getShared();  // p和s共享所有权,引用计数为2

如果不继承enable_shared_from_this,直接shared_ptr<Sensor>(this)会创建新控制块,导致double free。

在机器人开发里,这个模式常用于回调注册。传感器对象需要把自己注册到数据处理管线里时,就需要传一个shared_ptr给管线。

shared_ptr在机器人开发里的实践建议

几个实际项目中的经验:

第一,工厂函数返回shared_ptr。传感器创建、算法模块初始化,都返回shared_ptr而不是裸指针,调用方不需要关心内存释放。

第二,函数参数用const shared_ptr&传递。不要按值传shared_ptr,否则会多一次引用计数的原子增减操作。

// 不好:按值传递,引用计数+1再-1,有原子操作开销
void process(std::shared_ptr<Sensor> s);

// 好:const引用传递,零开销
void process(const std::shared_ptr<Sensor>& s);

// 更好:如果函数不需要共享所有权,传裸指针或引用
void process(Sensor& s);

第三,不要滥用shared_ptr。所有权明确的时候用unique_ptr,shared_ptr的引用计数有性能开销,高频场景不可忽视。

给正在准备面试的你一点建议

shared_ptr在面试里考得非常细。

必须掌握的:make_shared的优势、引用计数的机制、循环引用问题。

加分项:知道控制块的结构、引用计数的线程安全性、自定义删除器的用法。

面试有个经典问题:"shared_ptr和裸指针相比有什么缺点?"答案是引用计数的原子操作有性能开销。在对性能极度敏感的场景(比如每帧执行几万次的热路径),可能需要考虑用裸指针或weak_ptr来避免引用计数的开销。

下篇讲unique_ptr和weak_ptr——三种智能指针的对比和选型。


如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。 你的支持是我持续更新的最大动力。

「机器人软件开发面试·从入门到精通」连载系列 上一篇:第34篇 迭代器模式——STL的灵魂设计思想 下一篇预告:第36篇 智能指针unique_ptr与weak_ptr——三种智能指针的选型

有任何问题欢迎评论区留言,我会尽量回复。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐