C++ + SLAM 高频面试问题整理

1. 指针和引用有什么区别?

指针和引用都是 C++ 中间接访问对象的方式,但它们的语义不一样。指针本质上是一个变量,里面保存的是某个对象的地址,所以指针本身也占内存,可以被重新赋值,可以指向不同对象,也可以是 nullptr。引用更像是某个对象的别名,定义时必须初始化,并且初始化之后不能再绑定到其他对象。比如 int* p = &a; 表示 p 是一个指针,它保存 a 的地址;而 int& r = a; 表示 ra 的别名,后面对 r 的操作就是对 a 的操作。指针使用时需要注意空指针、野指针、悬空指针等问题;引用因为不能为空,所以语义上更强,表示调用方必须传入一个有效对象。

从函数参数角度看,如果一个函数需要修改外部变量,可以使用指针或引用;如果对象一定存在,引用更自然;如果对象可能不存在,指针更合适。例如 void update(Pose& pose) 表示一定会传入一个有效 pose,并且函数会修改它;而 void setMap(Map* map) 可以表达 map 可能为空。只读大对象通常用 const &,比如 void process(const Data& data),既避免拷贝,又防止函数内部修改数据。

如果在 SLAM 中用到,最常见的位置是点云、位姿、地图、关键帧等数据传参。比如一帧点云数据量很大,不适合值传递,一般会用 const pcl::PointCloud<PointType>& 或点云指针传入;当前位姿如果一定存在,可以用引用传递;地图对象如果可能还没初始化,就可以用指针或智能指针表示可空状态。这样做的原因是 SLAM 数据量大、模块多,合理区分指针和引用可以减少拷贝,同时让接口语义更清楚。


2. 为什么 C++ 中经常使用 const & 传参?

const & 是 C++ 工程里非常常见的传参方式,它同时解决了两个问题:第一,引用传参可以避免对象拷贝;第二,const 可以保证函数内部不会修改传入对象。对于 intdouble 这种小对象,值传递没有明显问题;但对于 std::vector、点云、图像、矩阵、配置结构体这类较大的对象,如果值传递,就会产生额外拷贝,影响性能。比如 void func(std::vector<int> v) 会复制整个 vector,而 void func(const std::vector<int>& v) 只是引用原对象,不会拷贝,同时函数内部不能修改 v

const & 还有一个重要作用是表达接口语义。调用者看到 const &,就知道这个参数只是输入,不会被函数修改;如果看到非 const 引用,比如 void solve(Result& result),就知道这个参数可能会被当作输出结果修改。大型工程中,接口语义非常重要,否则函数之间的数据流会很混乱。需要注意的是,如果函数要保存这个引用到函数外部,就要特别小心对象生命周期,因为引用本身不拥有对象,原对象销毁后引用会悬空。

如果在 SLAM 中用到,const & 基本随处可见。比如 scan matching 函数读取当前帧点云、局部地图、初始位姿时,一般都应该用 const &:当前 scan 只是输入,局部地图只是输入,初始位姿也是输入,不应该在函数内部随便改。这样一方面避免复制整帧点云或大矩阵,另一方面保证数据链路安全。SLAM 前端通常要求实时运行,减少大对象拷贝是很重要的工程优化。


3. const 修饰指针时有哪些情况?

const 修饰指针时,主要看 const* 的左边还是右边。第一种是 const int* pint const* p,表示指针指向的内容不能通过 p 修改,但是指针本身可以改变指向。比如 p = &b 可以,但 *p = 10 不行。第二种是 int* const p,表示指针本身不能改变指向,但是可以通过指针修改内容。也就是说 p = &b 不行,但 *p = 10 可以。第三种是 const int* const p,表示指针本身不能改,指向的内容也不能通过它改。

理解这个问题的关键是:const 修饰的是它右边的东西,如果右边没有东西,就修饰左边的东西。工程中不建议只机械背写法,而要理解语义:到底是不允许改指向,还是不允许改内容。现代 C++ 里,裸指针一般不直接负责资源释放,更多只是表达"观察"或"可空参数";真正管理资源一般用智能指针。

如果在 SLAM 中用到,一般体现在点云或地图数据的只读访问上。比如某个模块拿到一帧点云只做匹配,不应该修改点云内容,可以使用 const Cloud*Cloud::ConstPtr。如果多个模块共享同一帧数据,更推荐 std::shared_ptr<const Frame>,表示这帧数据可以共享生命周期,但内容只读。这样可以避免前端、后端、可视化线程之间互相改同一份数据,降低数据竞争和调试难度。


4. 深拷贝和浅拷贝有什么区别?

浅拷贝是指只复制对象本身的成员值,如果成员里有指针,那么只复制指针地址,不复制指针指向的真实数据。深拷贝则会重新申请资源,并把指针指向的数据也完整复制一份。对于只包含普通变量的类,默认拷贝一般没问题;但如果类中包含裸指针、动态数组、文件句柄、线程句柄等资源,默认浅拷贝就可能带来严重问题。

例如一个类里有 int* data,默认拷贝后,两个对象的 data 会指向同一块内存。一个对象析构释放了 data,另一个对象再访问就会变成悬空指针;如果两个对象都析构并释放同一块内存,还会 double free。解决方式包括:自己实现深拷贝、使用智能指针、使用标准容器管理资源,或者直接禁止拷贝。比如资源类可以写 ClassName(const ClassName&) = delete;,明确不允许复制。

如果在 SLAM 中用到,主要出现在地图、点云、关键帧、优化器对象这些资源类上。比如一个局部地图对象内部保存点云指针和 KD-Tree 指针,如果默认浅拷贝,两个地图对象可能共享同一份底层资源,后续释放或修改都会出问题。因此 SLAM 工程里常用 std::shared_ptr 管共享点云,用 std::unique_ptr 管独占模块,用 delete 禁止系统类拷贝。这样做是为了让资源生命周期更明确,避免运行一段时间后出现偶发崩溃。


5. 什么是 RAII?

RAII 是 Resource Acquisition Is Initialization,意思是"资源获取即初始化"。它的核心思想是:资源的申请和释放应该绑定到对象生命周期中,对象构造时获取资源,对象析构时释放资源。这样就不需要程序员在每个分支里手动释放资源,可以减少内存泄漏、忘记解锁、文件未关闭等问题。C++ 中典型的 RAII 有智能指针、文件流、锁管理器等。

比如 std::unique_ptr 构造后拥有一个堆对象,离开作用域时自动释放;std::lock_guard<std::mutex> 构造时加锁,析构时自动解锁。相比手写 mtx.lock()mtx.unlock(),RAII 更安全,因为即使函数中途 return 或抛异常,析构函数仍然会执行。大型工程中,RAII 是保证资源安全的基本方式。

如果在 SLAM 中用到,最常见的是多线程锁和资源对象管理。比如 LiDAR 回调线程把点云放入队列时,可以用 std::lock_guard 自动加锁解锁;后端优化器、地图管理器、匹配器对象可以用 unique_ptr 管理,系统析构时自动释放。SLAM 程序经常长时间运行,如果资源释放依赖手动管理,很容易出现内存泄漏或死锁。RAII 可以让资源释放更加稳定可靠。


6. new/delete 和智能指针有什么区别?

new/delete 是手动内存管理,程序员需要自己保证每次 new 出来的对象最终都被 delete,否则会内存泄漏;如果重复 delete,或者对象已经释放后继续访问,又会出现未定义行为。智能指针是基于 RAII 的自动资源管理方式,可以在智能指针生命周期结束时自动释放对象。常见智能指针有 unique_ptrshared_ptrweak_ptr

unique_ptr 表示独占所有权,不能拷贝,只能移动;shared_ptr 表示共享所有权,通过引用计数管理对象生命周期;weak_ptr 不拥有对象,用来观察 shared_ptr 管理的对象,常用于打破循环引用。现代 C++ 工程中,除非有特殊原因,一般不建议裸 new/delete 管理资源,而是优先使用智能指针或标准容器。

如果在 SLAM 中用到,一般是模块对象和大数据对象的生命周期管理。比如前端类独占一个 scan matcher,可以用 std::unique_ptr<ScanMatcher>;一帧关键帧数据可能被前端、后端、回环和可视化共享,可以用 std::shared_ptr<const KeyFrame>。这样可以避免点云或关键帧被提前释放,也避免手动 delete 出错。SLAM 系统模块多、线程多,智能指针能让所有权关系更清晰。


7. unique_ptrshared_ptrweak_ptr 分别适合什么场景?

unique_ptr 适合独占资源。它不能拷贝,只能移动,表示某个对象只有一个拥有者。比如一个类内部有一个优化器对象、匹配器对象、配置对象,如果这些资源只属于这个类,就适合用 unique_ptrshared_ptr 适合多个对象共享同一份资源。它通过引用计数决定什么时候释放对象,当最后一个 shared_ptr 销毁时,对象才会释放。weak_ptr 不增加引用计数,主要用于观察对象是否还存在,常用于解决 shared_ptr 循环引用。

选择智能指针时,关键不是"哪个更高级",而是所有权语义。能明确独占就不要用 shared_ptr,因为共享所有权会让生命周期变复杂;只是临时传参也不一定要用智能指针,const & 可能更合适;如果是反向引用或非拥有关系,应该考虑 weak_ptr 或裸指针。

如果在 SLAM 中用到,典型场景是:SlamSystem 独占 FrontendBackendLoopDetector,可以用 unique_ptr;一帧点云或关键帧需要被多个线程共享,可以用 shared_ptr;地图持有关键帧,关键帧反向指向地图时,反向关系可以用 weak_ptr,避免循环引用。这样设计能减少内存泄漏,也能让模块之间的生命周期更清楚。


8. shared_ptr 的循环引用是什么?

shared_ptr 通过引用计数管理对象生命周期。当引用计数变成 0 时,对象才会释放。循环引用是指两个对象互相持有对方的 shared_ptr,导致引用计数永远不能归零。比如 A 中有 shared_ptr<B>,B 中又有 shared_ptr<A>,即使外部已经不再使用 A 和 B,它们内部仍然互相引用,所以都不会析构,造成内存泄漏。

解决循环引用的方法是把其中一方改成 weak_ptrweak_ptr 不增加引用计数,只是观察对象。使用时需要调用 lock() 转成 shared_ptr,如果对象还存在就能访问,如果对象已经释放就返回空。循环引用问题本质上是所有权设计不清楚:到底谁拥有谁,谁只是知道对方存在。

如果在 SLAM 中用到,常见于地图和关键帧、关键帧和地图点、因子图节点和约束边之间的关系。比如 Map 拥有多个 KeyFrame,但 KeyFrame 只是想访问所属 Map,这时 Map -> KeyFrame 可以是 shared_ptr,而 KeyFrame -> Map 应该是 weak_ptr。这样地图释放后关键帧不会强行延长地图生命周期,也不会形成内存泄漏。SLAM 长时间运行时,如果循环引用处理不好,内存会越来越大。


9. 构造函数、析构函数、拷贝构造和赋值运算符什么时候调用?

构造函数在对象创建时调用,用于初始化对象;析构函数在对象生命周期结束时调用,用于释放资源;拷贝构造函数在用一个已有对象创建新对象时调用,比如 A b = a;;赋值运算符在两个已经存在的对象之间赋值时调用,比如 b = a;。这几个函数和对象生命周期密切相关,是 C++ 资源管理的基础。

如果一个类只包含普通成员,编译器默认生成的构造、析构、拷贝和赋值通常够用。但如果类里管理动态资源,比如裸指针、线程、文件、锁、硬件句柄等,就要认真考虑拷贝语义。资源类如果允许默认拷贝,可能导致浅拷贝、重复释放或悬空指针。因此常见做法是禁止拷贝,或者自己实现深拷贝和移动语义。

如果在 SLAM 中用到,最典型的是系统类、地图类、优化器类、线程管理类。比如 SlamSystem 内部有后台线程、传感器队列、地图对象、优化器对象,这种类一般不应该允许默认拷贝,否则两个系统对象可能共享同一个线程或地图资源,后果很严重。因此可以写 SlamSystem(const SlamSystem&) = delete;SlamSystem& operator=(const SlamSystem&) = delete;。这样从编译阶段禁止错误复制,保证资源管理安全。


10. 什么是移动语义?std::move 真的会移动数据吗?

移动语义是 C++11 引入的机制,用来减少大对象拷贝。它允许把一个对象内部资源转移给另一个对象,而不是重新复制一份。比如 std::vector 内部有一块堆内存,拷贝 vector 需要复制所有元素,而移动 vector 通常只需要转移内部指针、大小和容量信息,开销很小。移动语义依赖右值引用和移动构造函数、移动赋值函数。

std::move 本身不会移动任何数据,它只是把一个左值强制转换成右值引用,告诉编译器这个对象可以被移动。真正的移动发生在移动构造或移动赋值中。使用 std::move 后,原对象仍然有效,但内容处于有效但未指定状态,不能继续依赖它原来的数据。比如 vector move 后,原 vector 可能为空,也可能处于其他可析构状态。

如果在 SLAM 中用到,主要用于减少大数据对象在队列和模块之间传递时的拷贝。比如一帧点云或关键帧对象从回调线程放入处理队列,可以使用移动语义减少复制;处理线程从队列取出数据时,也可以 move 到局部变量,然后释放锁再处理。这样既减少拷贝,又缩短加锁时间。但如果数据还要被多个模块共享,不应该随便 move,而应该用 shared_ptr<const Frame>。移动语义适合所有权转移,不适合共享读场景。


11. push_backemplace_back 有什么区别?

push_back 是把一个已经构造好的对象插入到容器尾部,可能发生拷贝或移动;emplace_back 是在容器内部直接根据传入参数构造对象,可能减少临时对象创建。比如一个类 Frame(int id, double t),使用 frames.push_back(Frame(id, t)); 会先构造临时对象,再移动或拷贝进 vector;而 frames.emplace_back(id, t); 会直接在 vector 尾部构造对象。

emplace_back 不一定总是比 push_back 好。如果你已经有一个对象,比如 Frame frame;,那么 frames.push_back(std::move(frame)); 就很清楚。如果你只有构造参数,emplace_back 更自然。另外,无论 push 还是 emplace,如果 vector 容量不够,都可能触发扩容,导致已有元素移动,原来的指针、引用、迭代器失效。

如果在 SLAM 中用到,常见于关键帧列表、特征点列表、匹配候选列表、轨迹数组等容器插入。比如根据 id、时间戳、位姿直接构造关键帧,可以用 keyframes.emplace_back(id, timestamp, pose);。如果已经处理好一帧点云对象,要放进队列,可以用 push_back(std::move(frame))。这样做的原因是减少临时对象和不必要拷贝,提高实时处理效率。


12. vector 的扩容机制是什么?

std::vector 底层是连续内存。当元素数量超过当前容量时,vector 会重新申请一块更大的连续内存,把旧元素拷贝或移动到新内存,然后释放旧内存。这个过程叫扩容。扩容会带来两个影响:第一,重新分配和搬移元素有性能开销;第二,原来指向 vector 元素的指针、引用、迭代器可能全部失效。

为了减少扩容,可以使用 reserve() 提前预留容量。reserve() 只改变 capacity,不改变 size;resize() 会改变 size,并构造元素。比如预计要插入 10000 个元素,可以先 v.reserve(10000),这样后续 push 时可以减少多次重新分配。需要注意,不要长期保存 vector 元素引用,尤其这个 vector 后续还会继续增长。

如果在 SLAM 中用到,点云特征、局部地图点、轨迹、关键帧索引、候选匹配结果通常都会用 vector 保存。如果每帧处理时频繁 push_back 而不 reserve,会增加动态内存分配,影响前端实时性。比如提取角点和平面点时,可以根据点云大小提前 reserve。这样做的原因是 SLAM 前端对耗时敏感,减少 vector 扩容能降低处理抖动。


13. vectordequelist 有什么区别?

vector 是连续内存,随机访问快,遍历缓存友好,适合大量顺序遍历和按索引访问;缺点是中间或头部插入删除代价高,扩容会导致指针、引用、迭代器失效。deque 是双端队列,支持头部和尾部高效插入删除,也支持随机访问,但内存不是完全连续,缓存性能一般不如 vector。list 是双向链表,任意位置插入删除效率高,迭代器稳定,但不支持随机访问,内存不连续,遍历性能通常较差。

选择容器要看访问模式。大多数情况下,vector 是默认首选,因为它简单、缓存友好、遍历快。需要频繁从头部弹出、尾部插入时,deque 更合适。只有确实需要频繁在中间插入删除,且不需要随机访问时,list 才有优势。

如果在 SLAM 中用到,vector 常用于点云数组、轨迹、关键帧列表、特征点列表;deque 常用于 IMU、LiDAR、Odom 等时间队列,因为这些数据不断从尾部进入,又从头部清理旧数据;list 在 SLAM 中相对少见,因为点云和关键帧通常需要频繁遍历,链表缓存不友好。这样选择容器可以兼顾实时性和代码清晰度。


14. mapunordered_map 有什么区别?

std::map 底层通常是红黑树,元素按 key 有序,查找、插入、删除复杂度是 O(logN)std::unordered_map 底层是哈希表,元素无序,平均查找复杂度接近 O(1),但最坏情况下可能退化。map 的优势是有序,可以做范围查询,比如查找某个 key 附近的数据;unordered_map 的优势是按 key 快速查找,不关心顺序时通常更快。

选择时要看需求。如果需要有序遍历、范围查询、找前驱后继,用 map;如果只是根据 ID 快速查找对象,用 unordered_map。自定义 key 使用 unordered_map 时,需要提供哈希函数;使用 map 时,需要提供比较规则。

如果在 SLAM 中用到,关键帧 ID 到关键帧对象的查询可以用 unordered_map<int, KeyFrame>;如果是时间戳到数据的索引,并且需要查找某个时间附近的数据,map<double, Data> 可能更合适。比如做传感器同步时,按时间有序的数据结构更方便查找前后两帧。这样选择是因为 SLAM 既有大量 ID 查询,也有大量时间窗口查询,不同需求适合不同容器。


15. static 关键字有哪些用法?

static 的含义取决于使用位置。函数内部的 static 局部变量只初始化一次,生命周期持续到程序结束,但作用域仍然在函数内部。类中的 static 成员属于类本身,不属于某个具体对象,所有对象共享同一份 static 成员。static 成员函数没有 this 指针,只能直接访问 static 成员。文件作用域下的 static 变量或函数只在当前编译单元可见,可以限制符号可见性。

static 常用于计数器、单例、全局配置、工具函数等。但 static 变量生命周期长,容易造成隐藏状态,多线程读写时也需要同步。函数内 static 在 C++11 之后初始化是线程安全的,但后续访问如果涉及修改,仍然要考虑线程安全。

如果在 SLAM 中用到,常见的是配置管理器、日志系统、帧 ID 计数器、全局工具函数等。比如关键帧 ID 可以用 static 计数器生成,但多线程下应使用 std::atomic<int> 或加锁;配置管理器可以用函数内 static 实现单例。需要注意的是,不建议把当前位姿、地图状态这类核心可变数据做成 static 全局状态,否则模块之间耦合严重,调试困难。


16. 全局变量有什么问题?

全局变量的优点是访问方便、生命周期长,但缺点也明显。它破坏封装,使模块之间产生隐式依赖;任何地方都可以修改它,状态变化难以追踪;多线程环境下还容易产生数据竞争。小项目里用全局变量可能很方便,但大型工程中,全局可变状态会让调试和维护变得困难。

一般来说,全局只读常量可以接受,比如数学常量、固定字符串、只读配置等;但全局可变状态要谨慎使用。更好的做法是把状态封装到类里,通过明确接口访问,比如 getPose()updateMap(),并在类内部做同步保护。这样可以控制谁能读、谁能写,也能方便调试。

如果在 SLAM 中用到,常见错误是把当前位姿、地图、传感器队列、初始化状态都做成全局变量。这样前端、后端、可视化、规划模块都可能修改同一份状态,很容易出现定位跳变、数据竞争、状态不一致。更好的方式是用 SlamSystemMapManager 管理状态,并提供线程安全接口。这样做的原因是 SLAM 模块多、线程多,核心状态必须封装清楚。


17. 虚函数和多态有什么作用?

虚函数用于实现运行时多态。基类中声明虚函数,派生类重写该函数,当通过基类指针或引用调用时,会根据对象真实类型调用对应派生类实现。多态的核心价值是让主流程依赖抽象接口,而不是依赖具体实现。这样可以降低耦合,提高扩展性。

例如可以定义一个基类 Matcher,里面有虚函数 match(),然后派生出 ICPMatcherNDTMatcherGridMatcher。主流程只保存 std::unique_ptr<Matcher>,运行时根据配置创建不同派生类。这样以后新增算法,不需要重写主流程,只要实现同一个接口即可。使用多态时,要注意基类析构函数应为虚函数,避免通过基类指针删除派生类对象时资源释放不完整。

如果在 SLAM 中用到,常见位置是匹配器、地图类型、后端优化器、回环检测器、传感器模型等模块。比如 scan matching 可以有 ICP、NDT、GICP、栅格匹配多种实现;后端可以有 Ceres、GTSAM 或自定义优化器。用多态的原因是 SLAM 算法模块经常需要替换和对比,接口化设计能让工程更容易扩展和维护。


18. 为什么基类析构函数要写成虚函数?

如果一个类作为基类使用,并且可能通过基类指针或智能指针删除派生类对象,那么基类析构函数应该写成虚函数。否则通过基类指针释放派生类对象时,可能只调用基类析构函数,不调用派生类析构函数,导致派生类资源没有释放。比如 Base* p = new Derived(); delete p;,如果 Base 析构函数不是 virtual,Derived 的析构可能不会执行。

现代 C++ 中即使使用 std::unique_ptr<Base>,也同样存在这个问题,因为 unique_ptr 析构时也是通过基类指针删除对象。接口类一般都应该写 virtual ~Base() = default;。如果一个类根本不打算作为多态基类使用,就不一定需要虚析构。

如果在 SLAM 中用到,匹配器、优化器、地图、回环检测器这类模块经常用基类指针管理派生类对象。比如 std::unique_ptr<ScanMatcher> matcher = std::make_unique<NDTMatcher>();,如果 ScanMatcher 没有虚析构,NDTMatcher 内部的 KD-Tree、缓存、线程等资源可能释放不完整。因此 SLAM 框架中的接口类基本都应该有虚析构。


19. 重载、重写、隐藏有什么区别?

重载发生在同一作用域中,函数名相同但参数列表不同,编译器根据参数类型和数量选择调用哪个函数。重写发生在继承关系中,基类有虚函数,派生类提供相同函数签名的实现,运行时根据对象真实类型调用。隐藏是指派生类中定义了和基类同名的函数,即使参数不同,也可能隐藏基类中所有同名函数。

例如 void add(int)void add(double) 是重载;派生类实现 void run() override 是重写;派生类定义了一个同名但参数不同的函数,可能隐藏基类函数。工程中建议派生类重写虚函数时加 override,这样如果函数签名不一致,编译器会报错,而不是悄悄变成隐藏。

如果在 SLAM 中用到,常见于模块接口设计。比如传感器输入可以重载 addMeasurement(const ImuData&)addMeasurement(const LidarData&);不同匹配器实现统一 match() 接口则是重写。使用 override 可以避免接口写错导致派生类函数没有真正覆盖基类函数。这样做能减少模块替换时的隐藏 bug。


20. 抽象类和接口类有什么用?

抽象类是包含至少一个纯虚函数的类,不能直接实例化,只能作为基类。接口类通常是一种主要只定义行为、不保存复杂状态的抽象类。抽象类的作用是把"做什么"和"怎么做"分开。基类定义统一接口,派生类实现具体逻辑。这样主流程依赖接口,不依赖具体实现。

例如可以定义 class Optimizer { virtual bool optimize() = 0; };,然后派生出不同优化器。接口类的好处是降低耦合、方便替换实现、方便单元测试,也适合插件式架构。但接口不能设计得太大,否则派生类会被迫实现很多不需要的方法。接口类通常应该有虚析构函数。

如果在 SLAM 中用到,常见于前端匹配器、后端优化器、地图表示、传感器模型、回环检测器等。比如地图可以有点云地图、栅格地图、体素地图;优化器可以有 Ceres 和 GTSAM;匹配器可以有 ICP、NDT、CSM。用接口类的原因是 SLAM 算法经常需要替换和测试,抽象接口能让主流程更稳定,具体算法实现更灵活。


21. 左值、右值和右值引用是什么?

左值通常是有名字、可以取地址、生命周期明确的对象,比如变量 a;右值通常是临时对象或表达式结果,比如 a + b、临时构造对象等。左值引用写作 T&,通常绑定左值;右值引用写作 T&&,通常绑定右值。右值引用的主要价值是支持移动语义,让临时对象的资源可以被转移,而不是复制。

需要注意,一个有名字的右值引用变量本身仍然是左值。如果想把它继续当作右值传递,需要使用 std::move。这个概念容易绕,但实际工程中重点理解即可:右值往往代表临时对象,它的资源可以被安全转移;左值是仍然可能被继续使用的对象,不能随便偷走资源。

如果在 SLAM 中用到,主要体现在大对象传递和队列操作。比如一帧点云处理结果临时生成后,可以移动进队列,避免复制整帧数据;从队列取出数据时,也可以 move 到局部变量。这样做的原因是点云、关键帧、地图块数据量较大,移动语义可以减少拷贝,提高实时性。但如果对象还要继续被多个模块使用,就不能随便 move,应考虑 shared_ptr。


22. std::movestd::forward 有什么区别?

std::move 是无条件把对象转换成右值引用,表示这个对象可以被移动。它不真正移动数据,真正移动发生在移动构造或移动赋值中。std::forward 主要用于模板中的完美转发,它会根据参数原来的值类别进行转发:原来是左值就转发为左值,原来是右值就转发为右值。

普通业务代码中更常见的是 std::move;泛型模板、工厂函数、包装函数中更常见的是 std::forward。如果在模板中错误地对所有参数使用 std::move,可能会把调用者传入的左值也强制移动,导致调用者对象内容被意外转走。std::forward 的目的就是避免这种问题。

如果在 SLAM 中用到,std::move 常用于传感器数据队列、大对象转移、关键帧插入等;std::forward 则更多出现在通用工厂函数或模板封装中,比如根据配置创建不同模块对象并把构造参数完美转发进去。用它们的原因是减少不必要拷贝,同时保持接口泛型能力。不过实际写业务逻辑时,std::move 用得更多,std::forward 主要出现在框架和模板代码中。


23. auto 使用时要注意什么?

auto 可以让编译器自动推导类型,减少冗长代码,但它也可能隐藏拷贝和类型细节。最常见的问题是遍历容器时写 for (auto item : items),这会复制每个元素;如果元素很大,会有性能开销。只读遍历应该写 for (const auto& item : items);需要修改元素时写 for (auto& item : items)

另一个问题是 auto 默认不会保留引用语义。如果函数返回引用,但你写 auto x = getRef();,可能会复制一份;如果想保持引用,应该写 auto& x = getRef();。此外,auto 也可能掩盖智能指针、迭代器、Eigen 表达式等真实类型,降低可读性。使用 auto 时要知道推导结果是什么。

如果在 SLAM 中用到,遍历点云、关键帧、地图点、约束边时经常使用 auto。比如遍历点云时,如果写 for (auto p : cloud.points),每个点都会复制;更合理的是 for (const auto& p : cloud.points)。这样做的原因是 SLAM 数据量大,不小心的隐藏拷贝会影响实时性。auto 可以用,但要配合引用和 const 正确使用。


24. Lambda 表达式是什么?捕获方式有什么风险?

Lambda 是匿名函数,常用于回调、排序、过滤、异步任务等场景。它可以捕获外部变量,捕获方式包括按值捕获 [=]、按引用捕获 [&]、指定变量捕获等。按值捕获会复制变量,lambda 内部使用副本;按引用捕获不复制,但要求外部变量在 lambda 执行时仍然存在。

风险主要在异步场景。如果 lambda 按引用捕获了局部变量,但 lambda 被放进线程中延迟执行,函数返回后局部变量已经销毁,lambda 再访问就会产生悬空引用。比如 std::thread([&]{ use(frame); }).detach(); 如果 frame 是局部变量,就很危险。更安全的做法是按值捕获,或者用 shared_ptr 管理生命周期。

如果在 SLAM 中用到,lambda 常用于候选匹配排序、回调封装、线程任务、条件变量谓词等。比如条件变量等待时常写 cv.wait(lock, [&]{ return !queue.empty() || !running; });。如果是异步线程处理点云,不建议随便 [&] 捕获局部变量,而应该明确捕获 shared_ptr 或数据副本。这样做是为了避免跨线程生命周期错误。


25. std::thread 创建线程后为什么要 joindetach

std::thread 对象创建线程后,在它析构前必须处于不可 join 状态,也就是说必须调用过 join()detach()。如果一个仍然 joinable 的 thread 对象析构,程序会调用 std::terminate() 直接终止。join() 表示当前线程等待子线程执行结束;detach() 表示子线程和 thread 对象分离,后台独立运行。

工程中一般更推荐 join(),因为线程生命周期可控。detach() 看似方便,但风险很大:线程脱离管理后,如果它还访问某个对象,而该对象已经析构,就会崩溃。一个类管理后台线程时,通常在析构函数中设置退出标志,通知条件变量,然后 join 线程。

如果在 SLAM 中用到,常见于前端处理线程、后端优化线程、回环检测线程、可视化发布线程等。比如系统启动时创建后台线程,析构时先设置 running_ = false,再 notify_all(),最后 join()。这样做的原因是 SLAM 程序有多个长期运行线程,如果退出顺序不对,很容易在线程还没结束时销毁地图、队列或匹配器,造成崩溃。


26. mutexlock_guardunique_lock 有什么区别?

std::mutex 是互斥锁本身,用来保护共享数据,保证同一时刻只有一个线程进入临界区。std::lock_guard 是 RAII 封装,构造时加锁,析构时自动解锁,适合简单的加锁场景。std::unique_lock 更灵活,可以延迟加锁、提前解锁、转移锁所有权,并且可以配合 condition_variable 使用。

如果只是保护一个短临界区,比如往队列里 push 一个数据,用 lock_guard 就够了。如果需要条件变量等待,必须用 unique_lock,因为 condition_variable::wait() 需要在等待时释放锁,唤醒后重新加锁。手动 mutex.lock()mutex.unlock() 不推荐,因为中途 return 或异常可能导致忘记 unlock。

如果在 SLAM 中用到,最常见的是保护传感器队列、当前位姿、地图、关键帧列表等共享数据。LiDAR 回调线程写队列,处理线程读队列,需要 mutex;当前位姿被前端更新、可视化读取,也需要 mutex。简单读写用 lock_guard,生产者消费者队列等待数据时用 unique_lock + condition_variable。这样做是为了避免数据竞争和偶发崩溃。


27. 什么是数据竞争?怎么避免?

数据竞争是指多个线程同时访问同一块共享数据,其中至少一个线程在写,并且没有同步机制保护。在 C++ 中,数据竞争属于未定义行为,程序可能偶发崩溃、结果错误,或者 Debug 正常 Release 出问题。比如一个线程往 vector 里 push,另一个线程同时遍历这个 vector,就可能出问题;一个线程更新矩阵,另一个线程同时读取,也可能读到不一致状态。

避免数据竞争的方式包括:共享可变数据用 mutex 保护;简单状态标志用 atomic;尽量减少共享数据;使用消息队列传递数据;读写都要加锁,而不是只有写加锁。还要注意,不要把锁加得太大,锁内只做必要的数据访问,耗时计算放在锁外。

如果在 SLAM 中用到,典型数据竞争包括 LiDAR 队列一边 push 一边 pop、IMU 队列一边写一边清理、当前位姿一边更新一边读取、地图一边优化一边可视化。避免方式是给队列、位姿、地图分别设计锁,读取时复制快照,释放锁后再做计算。这样做是为了保证实时系统长时间运行时稳定,不出现偶发定位异常或崩溃。


28. 什么是死锁?怎么避免?

死锁是多个线程互相等待对方释放资源,导致程序无法继续执行。最常见原因是多把锁的加锁顺序不一致。比如线程 A 先锁 mutex1 再锁 mutex2,线程 B 先锁 mutex2 再锁 mutex1,当 A 拿到第一把锁、B 拿到第二把锁时,两者就会互相等待,形成死锁。

避免死锁的方法包括:统一加锁顺序;尽量减少同时持有多把锁;使用 std::scoped_lock 同时锁多把 mutex;缩短临界区;不要在持锁时调用外部回调或执行耗时任务。死锁往往是偶发的,可能运行很久才出现,所以设计阶段就要规避。

如果在 SLAM 中用到,常见场景是地图锁、位姿锁、队列锁之间的顺序问题。比如前端线程先锁位姿再锁地图,可视化线程先锁地图再锁位姿,就可能死锁。更好的做法是规定统一顺序,或者读取时分别复制快照,避免同时持有多把锁。ICP、NDT、Ceres 优化这类耗时计算不要放在锁内,否则会阻塞其他线程,增加死锁和延迟风险。


29. 条件变量 condition_variable 有什么用?

条件变量用于线程之间的等待和通知,常见于生产者-消费者模型。没有条件变量时,消费者线程可能不断循环检查队列是否为空,这会浪费 CPU;使用条件变量后,队列为空时消费者线程可以睡眠,生产者放入数据后调用 notify_one() 唤醒它。条件变量通常配合 std::unique_lock<std::mutex> 使用。

正确写法一般是 cv.wait(lock, [&]{ return !queue.empty() || !running; });。这里的谓词很重要,因为条件变量可能虚假唤醒,也就是线程醒来时条件不一定满足。必须醒来后再次检查条件,不能认为 wait 返回就一定有数据。退出线程时,也要修改 running 标志并 notify,否则线程可能永远阻塞。

如果在 SLAM 中用到,最常见的是传感器数据队列。LiDAR 回调线程是生产者,把点云放入队列;前端处理线程是消费者,等待队列有数据后处理。IMU、里程计、GPS 队列也类似。使用条件变量的原因是避免处理线程空转,同时让新数据到来时能及时唤醒计算线程,提高实时性和 CPU 利用率。


30. 多线程中如何设计一个线程安全队列?

线程安全队列一般由普通队列、互斥锁、条件变量和退出标志组成。生产者 push 数据时,加锁,把数据放入队列,释放锁,然后通知条件变量;消费者 pop 数据时,使用 unique_lock 等待队列非空,醒来后取出数据,再释放锁处理。等待时要用谓词判断,防止虚假唤醒。队列还应该考虑最大长度,避免生产速度大于消费速度时内存无限增长。

一个好的线程安全队列要注意几个细节:第一,push 和 pop 都必须加锁;第二,锁内只做队列操作,不做耗时计算;第三,取大对象时可以使用移动语义减少拷贝;第四,停止线程时要设置退出标志并 notify_all();第五,必要时限制队列长度,超过后丢弃旧数据或新数据,防止延迟越来越大。

如果在 SLAM 中用到,典型就是 LiDAR、IMU、Odom 等传感器队列。回调线程负责接收数据并 push,处理线程负责 pop 并进行同步、去畸变、匹配或融合。这样做的原因是传感器回调不能被耗时计算阻塞,而计算线程也不能一直空转。线程安全队列可以把数据接收和数据处理解耦,是实时 SLAM 工程里非常常用的结构。

31. std::atomic 是什么?它和 mutex 有什么区别?

std::atomic 是 C++ 提供的原子类型,用来保证某些简单变量的读写操作是不可分割的。所谓不可分割,就是一个线程正在写这个变量时,另一个线程不会读到"写了一半"的状态。常见的 atomic 类型有 std::atomic<bool>std::atomic<int>std::atomic<size_t> 等。比如多个线程共享一个退出标志 running,如果它只是普通 bool,一个线程写、另一个线程读,在 C++ 里可能产生数据竞争;如果写成 std::atomic<bool> running{true};,读写就是线程安全的。

atomicmutex 都可以解决多线程同步问题,但适用范围不同。atomic 适合非常简单的变量,比如 bool 标志、计数器、状态枚举等;mutex 适合保护复杂数据结构,比如 std::dequestd::vector、地图对象、点云对象、位姿矩阵等。不能把一个复杂对象简单地套成 atomic,比如 Eigen::Matrix4dstd::vector<Point> 不能直接靠 atomic 保护。还有一点是,atomic 通常比 mutex 轻量,但它只能解决简单读写同步,不能保护一组操作的整体一致性。

如果在 SLAM 中用到,atomic 常用于线程退出标志、初始化状态、是否收到第一帧数据、是否开启回环检测、当前系统模式等。例如后台建图线程里可以写 while (running_) { ... },主线程退出时把 running_ = false,然后通知线程退出。这样用 atomic 的原因是这些状态很简单,只需要保证读写安全,不需要加一把 mutex。但如果是 LiDAR 队列、IMU 队列、当前位姿、局部地图这些复杂共享数据,仍然应该用 mutex 或读写锁保护。


32. 什么是读写锁?shared_mutex 适合什么场景?

读写锁是一种比普通互斥锁更细分的同步机制。普通 mutex 不区分读和写,只要一个线程拿到锁,其他线程都要等待;而读写锁允许多个读线程同时访问共享数据,但写线程必须独占访问。C++17 中提供了 std::shared_mutex,读操作可以用 std::shared_lock<std::shared_mutex>,写操作用 std::unique_lock<std::shared_mutex>。这样当很多线程只是读取数据时,它们可以并发执行,不会互相阻塞。

读写锁适合"读多写少"的场景。如果一个数据大部分时间被读取,偶尔才被更新,用读写锁可以提高并发性能。但如果写操作非常频繁,读写锁不一定比普通 mutex 好,因为它更复杂,开销也更大,而且某些实现中还可能出现写线程饥饿问题。也就是说,读写锁不是万能优化,只有读操作明显多于写操作时才值得使用。

如果在 SLAM 中用到,读写锁常见于地图、关键帧列表、轨迹结果、定位状态等读多写少的数据。例如可视化线程、规划线程、状态发布线程都需要读取当前地图或轨迹,而后端优化线程偶尔更新地图或关键帧位姿。这种情况下可以用 shared_mutex:多个读取线程用 shared lock,同时读取;后端更新时用 unique lock,独占修改。这样做的原因是避免普通 mutex 让多个只读线程互相阻塞,提高系统并发读取效率。


33. 如何设计一个线程安全的当前状态读取接口?

线程安全接口设计的核心是:不要把内部可变数据直接暴露给外部。比如一个类里有成员变量 Pose current_pose_,如果提供 Pose& getCurrentPose(),外部拿到引用后可以不加锁直接修改或长期持有,这就破坏了类内部的线程安全。更好的方式是读取时在类内部加锁,复制一份快照返回:

复制代码
复制代码
Pose getCurrentPose() const {
    std::lock_guard<std::mutex> lock(pose_mutex_);
    return current_pose_;
}

写入时也通过接口加锁:

复制代码
复制代码
void updatePose(const Pose& pose) {
    std::lock_guard<std::mutex> lock(pose_mutex_);
    current_pose_ = pose;
}

这样外部只能通过接口读写,锁逻辑集中在类内部,避免多个模块各自随便操作共享变量。如果对象很大,返回拷贝可能有开销,可以考虑返回不可变快照、shared_ptr、双缓冲,或者让调用方传输出参数。但原则仍然是:不要返回内部可变引用,不要让外部绕过锁直接访问状态。

如果在 SLAM 中用到,当前位姿就是典型例子。前端线程会更新当前位姿,后端线程可能修正全局位姿,可视化线程、规划模块、日志模块可能读取位姿。如果直接共享 current_pose_,很容易数据竞争。更合理的做法是提供 getCurrentPose() 返回一份位姿快照,读取时短暂加锁,复制完马上释放锁。这样做的原因是位姿是高频共享状态,接口必须保证读写安全,同时不能让锁持有时间太长。


34. 什么是双缓冲?它解决什么问题?

双缓冲是一种常见的数据同步和性能优化方法,核心思想是准备两份缓冲区:一份给写线程更新,另一份给读线程使用。当写线程更新完成后,只需要短时间交换两个缓冲区的指针或索引,读线程就能读取新的数据。这样可以避免读线程和写线程长时间争抢同一份数据。双缓冲常用于图像渲染、传感器数据发布、大地图可视化等场景。

普通加锁方式是读写都访问同一份数据,写线程更新时读线程要等,读线程长时间遍历时写线程也要等。如果数据很大,比如一张地图或一帧大点云,读写过程都可能比较耗时。双缓冲可以把耗时操作放到各自缓冲区中,只在交换指针时加短锁,从而减少阻塞。它的缺点是会多占一份内存,而且读线程读到的可能不是最新实时数据,而是一个快照。

如果在 SLAM 中用到,双缓冲常用于地图可视化、局部地图发布、轨迹发布、代价地图更新等。比如后端线程在后台缓冲区构建新的局部地图,构建完成后短暂加锁交换前后台地图指针;可视化线程一直读取前台地图,不会阻塞后端构图。这样做的原因是点云地图可能很大,如果可视化线程持锁遍历地图,会阻塞建图线程;双缓冲可以减少锁竞争,让系统更稳定。


35. 为什么不要在锁里做耗时操作?

锁的作用是保护共享数据,但锁的持有时间越长,其他线程等待时间就越长。一个好的多线程程序应该让临界区尽可能短,锁内只做必要的数据访问,例如 push、pop、复制指针、复制小对象等;耗时计算应该放到锁外执行。如果在锁内做复杂计算,其他线程可能长时间拿不到锁,导致系统延迟增加,甚至出现类似"卡死"的现象。

常见错误是:加锁后直接做大量计算,比如遍历大数组、进行优化、写文件、网络发送、调用外部回调等。这些操作时间不可控,而且可能再次触发其他锁,增加死锁风险。更好的模式是:

复制代码
复制代码
Data data;
{
    std::lock_guard<std::mutex> lock(mtx_);
    data = std::move(queue_.front());
    queue_.pop_front();
}
// 锁外处理
process(data);

这样锁只保护队列操作,真正耗时的 process() 不占用锁。

如果在 SLAM 中用到,这个原则非常重要。比如 LiDAR 队列加锁后,只应该取出点云,不应该在锁里做 ICP、NDT、点云滤波、Ceres 优化;地图锁里也不应该做大规模地图构建。这样做的原因是 SLAM 有多个线程同时运行,回调线程、前端线程、后端线程、可视化线程都会访问共享数据。如果锁内做重计算,会导致传感器数据堆积、定位延迟增加,甚至影响实时性。


36. 什么是生产者-消费者模型?

生产者-消费者模型是多线程中非常常见的一种结构。生产者负责产生数据,并把数据放入共享队列;消费者负责从队列中取出数据并处理。为了保证线程安全,队列需要用 mutex 保护;为了避免消费者一直空转等待数据,通常使用 condition_variable 通知。生产者放入数据后调用 notify_one(),消费者在队列为空时睡眠,收到通知后醒来处理。

这个模型的好处是把数据接收和数据处理解耦。生产者不需要关心消费者什么时候处理完,只负责快速放入队列;消费者不需要关心数据从哪里来,只负责按顺序处理队列中的数据。实际工程中还要考虑队列容量,如果生产速度大于消费速度,队列会越来越大,所以常常需要设置最大长度、丢弃旧数据或做降频处理。

如果在 SLAM 中用到,传感器处理几乎都是生产者-消费者模型。LiDAR 回调线程生产点云帧,前端线程消费点云做匹配;IMU 回调线程生产 IMU 数据,同步模块消费 IMU 做积分和去畸变;后端线程消费关键帧进行优化。这样做的原因是传感器回调必须轻量,不能被耗时计算阻塞,而处理线程可以独立运行,保证数据流更稳定。


37. 如何安全地停止一个后台线程?

安全停止线程需要几个步骤。第一,提供一个线程共享的退出标志,比如 std::atomic<bool> running_{true};。第二,线程循环中定期检查这个标志,如果变成 false 就退出循环。第三,如果线程可能阻塞在 condition_variable 上,停止时要调用 notify_all() 唤醒它。第四,主线程或对象析构函数中调用 join() 等待后台线程真正结束。不能直接销毁线程正在访问的对象,否则会出现悬空访问。

一个常见结构是:

复制代码
复制代码
running_ = false;
cv_.notify_all();
if (worker_.joinable()) {
    worker_.join();
}

顺序很重要:先告诉线程退出,再唤醒线程,然后等待线程结束,最后释放资源。不要轻易使用 detach(),因为 detach 后线程生命周期不可控,它可能在对象析构后继续访问成员变量。

如果在 SLAM 中用到,后台线程包括前端处理线程、后端优化线程、回环检测线程、地图发布线程等。退出程序或重置系统时,如果先释放地图、队列、匹配器,再停止线程,线程可能访问已经销毁的资源而崩溃。正确做法是在 SlamSystem 析构中先停止所有线程并 join,再销毁地图和模块对象。这样做的原因是 SLAM 程序线程多、资源共享多,退出顺序不对很容易出现偶发崩溃。


38. std::asyncstd::future 有什么用?

std::async 用来异步执行一个任务,它会返回一个 std::future,之后可以通过 future 获取异步任务的返回结果。比如:

复制代码
复制代码
auto fut = std::async(std::launch::async, [] {
    return heavyCompute();
});
auto result = fut.get();

future.get() 会等待任务完成并返回结果。相比手动创建 thread,async 更适合"启动一个任务,稍后拿结果"的场景。它可以让代码更简洁,也能自动传递异常:如果异步任务里抛异常,调用 get() 时会重新抛出。

不过 std::async 的线程调度不一定完全可控,如果频繁创建大量 async 任务,可能造成线程数量过多或调度开销。因此对于长期运行的系统,固定线程或线程池有时更合适。async 更适合临时并行任务,而不是持续实时处理主流程。

如果在 SLAM 中用到,async/future 可以用于回环候选验证、多个匹配候选并行评分、地图保存、离线统计等任务。比如对多个历史关键帧并行做匹配验证,每个任务返回一个匹配分数,最后通过 future 收集结果。这样做的原因是这些任务相对独立,可以并行加速。但实时前端通常不建议频繁 async 创建任务,否则线程调度不可控,可能影响实时性。


39. 什么是线程池?为什么比频繁创建线程更好?

线程池是预先创建一组工作线程,并维护一个任务队列。当有任务到来时,把任务放入队列,空闲线程取出任务执行。这样可以避免每来一个任务就创建一个新线程。频繁创建和销毁线程会有系统开销,而且线程数量不可控时可能导致 CPU 被打满,反而降低整体性能。线程池通过固定线程数量,可以更稳定地控制并发度。

线程池适合任务数量多、单个任务相对独立、执行时间中等或较短的场景。它的基本组成包括任务队列、worker 线程、mutex、condition_variable、退出标志等。工程中还要考虑任务优先级、队列长度、异常处理、线程安全退出等问题。

如果在 SLAM 中用到,线程池可以用于回环检测中的多个候选帧验证、点云分块处理、局部地图并行滤波、批量描述子计算等。比如回环候选有 20 个,不应该为每个候选临时创建线程,而可以把匹配任务丢给线程池。这样做的原因是控制并发数量,避免辅助任务抢占前端实时计算资源。SLAM 前端通常要稳定实时,线程池更适合放在后端、回环、地图后处理这些非每帧强实时模块。


40. 什么是未定义行为?为什么它很危险?

未定义行为是指 C++ 标准没有规定程序应该产生什么结果的行为。程序可能看起来正常,也可能崩溃,也可能在不同编译器、不同优化级别、不同机器上表现完全不同。常见未定义行为包括数组越界、空指针解引用、悬空引用、使用未初始化变量、数据竞争、重复释放内存、返回局部变量引用等。

未定义行为最危险的地方是它不一定马上崩溃。有时 Debug 模式正常,Release 模式出错;有时运行一小时才出现;有时只是结果偶尔错误。这会让调试非常困难。避免未定义行为需要良好习惯,比如变量显式初始化、访问数组前检查范围、使用智能指针管理生命周期、用 mutex 保护共享数据、不要返回局部变量引用。

如果在 SLAM 中用到,未定义行为可能表现为定位突然跳变、地图飞点、匹配结果异常、程序偶发崩溃。比如 Eigen::Matrix4d T; 未初始化就用于点云变换,点云可能被变到奇怪位置;点云为空时访问 points[0] 会越界;多个线程无锁访问地图也属于数据竞争。这样的问题看起来像算法不稳定,其实可能是 C++ 基础错误导致的。


41. 为什么变量要显式初始化?

C++ 中很多变量默认不会自动初始化为 0,尤其是局部基础类型和某些矩阵对象。如果直接使用未初始化变量,它里面可能是随机值,程序结果不可预测。比如 int x; 后直接使用 x,它的值是不确定的;Eigen::Matrix4d T; 也不会自动变成单位矩阵。正确做法是定义变量时尽量立即初始化,比如 int x = 0;double score = 0.0;Eigen::Matrix4d T = Eigen::Matrix4d::Identity();

显式初始化能减少很多隐藏 bug。大型工程中,一个未初始化变量可能只在某些路径下被使用,平时很难发现。一旦在 Release 模式下优化后,结果可能更不稳定。初始化不仅是安全问题,也是代码可读性问题,读者能知道这个变量的初始状态是什么。

如果在 SLAM 中用到,位姿矩阵、旋转矩阵、协方差矩阵、误差累计值都必须显式初始化。比如位姿变换通常应该初始化为单位变换,而不是随机矩阵;协方差矩阵应该初始化为零矩阵或指定对角矩阵;匹配误差累计值应该初始化为 0。这样做的原因是 SLAM 是数值计算密集型系统,一个未初始化矩阵可能直接导致点云飞掉、优化发散或地图错乱。


42. Eigen::Matrix4d T;Eigen::Matrix4d::Identity() 有什么区别?

Eigen::Matrix4d T; 只是声明了一个 4×4 矩阵对象,但它的内容默认不一定初始化,里面可能是随机值。Eigen::Matrix4d::Identity() 会生成一个单位矩阵,也就是对角线为 1,其余元素为 0。对于位姿变换矩阵,单位矩阵表示"不做任何旋转和平移",这是一个安全的初始状态。

很多初学者会误以为 Eigen 矩阵默认是零矩阵或单位矩阵,这是错误的。Eigen 为了性能,默认构造通常不做初始化。如果需要零矩阵,应写 Eigen::Matrix4d::Zero();如果需要单位矩阵,应写 Eigen::Matrix4d::Identity()。同理,Eigen::Vector3d v; 也不会自动变成 (0,0,0),应写 Eigen::Vector3d::Zero()

如果在 SLAM 中用到,这个问题非常关键。位姿 T 一般表示坐标系变换,如果未初始化就拿来变换点云,点云会被变到随机位置;如果优化初值未初始化,优化器可能直接发散。SLAM 里经常写 Eigen::Isometry3d T = Eigen::Isometry3d::Identity();,然后再设置旋转和平移。这样做的原因是单位位姿是合理默认值,可以避免随机矩阵造成严重错误。


43. Eigen 为什么要注意内存对齐?

Eigen 为了提高矩阵和向量计算性能,会使用 SIMD 向量化指令,而这些指令对内存地址可能有对齐要求。固定大小的 Eigen 类型,比如 Eigen::Vector4dEigen::Matrix4dEigen::Quaterniond,在某些平台和编译选项下,如果内存没有按要求对齐,可能导致运行时崩溃。这个问题在 Debug 下不一定出现,在 Release 或开启优化后更容易暴露。

常见解决方式是:如果一个类里包含固定大小 Eigen 成员,可以在类中加入 EIGEN_MAKE_ALIGNED_OPERATOR_NEW;如果 STL 容器里直接存 Eigen 固定大小类型,可以使用 Eigen::aligned_allocator。不过新版本 Eigen 和 C++ 标准库对齐支持已经改善,但面试和工程中仍然经常提到这个问题。

如果在 SLAM 中用到,Eigen 几乎无处不在:位姿、旋转、平移、协方差、雅可比、IMU 状态、优化变量都可能用 Eigen 表示。比如 std::vector<Eigen::Vector3d>、关键帧类中保存 Eigen::Isometry3d,都可能涉及内存对齐。注意对齐的原因是避免很隐蔽的运行时崩溃,尤其在优化编译和多平台部署时更重要。


44. 四元数、旋转矩阵、欧拉角有什么区别?

欧拉角用 roll、pitch、yaw 三个角表示旋转,直观易懂,适合显示和调试,但存在万向锁问题,不适合做复杂插值和优化。旋转矩阵是 3×3 矩阵,能直接用于点坐标变换,比如 p' = R p + t,但它有 9 个元素,并且必须满足正交约束 R^T R = Idet(R)=1。四元数用 4 个数表示旋转,避免万向锁,适合姿态插值和计算,但不如欧拉角直观,并且需要保持归一化。

工程中通常不会只用一种表示。内部计算常用四元数或旋转矩阵,输出日志或界面显示时常转成欧拉角。优化中更常用李群李代数或四元数局部参数化,避免直接优化旋转矩阵的 9 个元素。

如果在 SLAM 中用到,IMU 姿态通常以四元数表示,点云坐标变换常用旋转矩阵,调试定位结果时常看 roll、pitch、yaw。比如 LiDAR 点从雷达坐标系变到地图坐标系时,实际计算常用 R * p + t;但日志里为了方便判断车体是否转弯,会输出 yaw。这样做的原因是不同表示各有优劣,SLAM 工程通常根据场景切换表示方式。


45. 为什么不能直接优化旋转矩阵的 9 个元素?

旋转矩阵不是任意 3×3 矩阵,它必须满足特殊约束:R^T R = I,表示矩阵列向量正交且长度为 1;det(R)=1,表示它是合法旋转而不是反射或缩放。如果在优化中把旋转矩阵的 9 个元素当作普通变量直接更新,很容易破坏这些约束,最后得到的矩阵就不再是旋转矩阵。比如点变换后可能出现缩放、拉伸、非正交等问题。

更合理的做法是在旋转流形上优化。常见方式是使用李代数小扰动,比如用一个 3 维旋转增量更新当前旋转;或者使用四元数并配合归一化或局部参数化。这样优化变量更少,也能保证更新后的旋转仍然合法。

如果在 SLAM 中用到,位姿优化、回环优化、视觉重投影优化、ICP 点到面优化都涉及旋转更新。SLAM 位姿属于 SE(3),一般用 6 维扰动表示一次小更新,其中 3 维是平移,3 维是旋转。这样做的原因是既符合旋转的几何约束,又能让优化器稳定求解。如果直接优化矩阵 9 个元素,结果可能不再是合法位姿。


46. 浮点数为什么不能直接用 == 比较?

浮点数在计算机中通常不能精确表示很多小数,比如 0.1、0.2 这类十进制小数转换成二进制会有误差。经过多次计算后,误差还会累积。因此两个理论上应该相等的浮点数,在程序中可能只是非常接近,而不是完全相等。如果直接用 a == b,可能得到错误判断。更常见的做法是比较差值是否小于一个阈值:

复制代码
复制代码
std::abs(a - b) < eps

这里 eps 是允许误差,比如 1e-6。阈值不能随便固定,要根据数据尺度选择。比如比较角度、距离、时间戳、误差值时,合适的 eps 可能不同。

如果在 SLAM 中用到,浮点比较非常常见。比如判断两个时间戳是否接近、判断优化是否收敛、判断两帧位姿变化是否小于阈值、判断匹配误差是否足够低,都不应该直接用 ==。尤其 LiDAR-IMU 同步中,时间戳通常是 double,如果直接比较相等,很可能失败。使用阈值比较的原因是 SLAM 中大量数值计算存在误差,判断条件必须考虑浮点精度。


47. floatdouble 怎么选择?

float 是单精度浮点数,通常占 4 字节,精度大约 6~7 位有效数字;double 是双精度浮点数,通常占 8 字节,精度大约 15~16 位有效数字。float 内存占用小,适合大量数据存储;double 精度高,适合数值计算和优化。选择时要看数据规模和精度需求。

如果一个数组非常大,比如百万级点坐标,用 double 会让内存占用翻倍,缓存压力也更大;如果是状态估计、优化、积分、协方差传播等对精度敏感的计算,用 double 更稳妥。工程中常见做法是存储用 float,计算用 double,尤其在高精度估计中。

如果在 SLAM 中用到,PCL 点云常见 PointXYZ 的 x、y、z 是 float,因为点数很多,float 足够表达厘米级或毫米级空间位置;但位姿、IMU 积分、雅可比、协方差、优化残差通常用 double,因为这些计算会累积误差,对精度更敏感。这样选择的原因是平衡内存、速度和精度:大规模点云存储偏 float,核心状态估计和优化偏 double。


48. 时间戳为什么不建议用 float 存?

时间戳通常需要较高精度,尤其系统运行时间变长后,秒级时间戳的整数部分会越来越大,而 float 有效位数有限,小数部分精度会变差。比如一个时间戳是几万秒,如果用 float 保存,毫秒甚至微秒级差异可能无法准确表示。对于需要精确同步的系统,这会造成严重问题。

更常见做法是用 double 保存秒,或者用 int64_t 保存纳秒、微秒等整数时间。整数时间戳可以避免浮点误差,适合精确比较和排序;double 秒用起来方便,但比较时也要考虑误差。

如果在 SLAM 中用到,时间戳用于 LiDAR、IMU、相机、轮速计等多传感器同步。IMU 频率高,点云去畸变还可能需要点级时间,如果时间戳精度不够,会导致 IMU 插值错误、运动补偿错误、外推失败。使用 double 或 int64 纳秒的原因是保证时间同步精度,尤其在 LiDAR-IMU 融合中非常关键。


49. constexprconst 有什么区别?

const 表示变量初始化后不能被修改,但它不一定是编译期常量;constexpr 表示这个值可以在编译期求值。比如 const int x = getValue(); 如果 getValue() 是运行时函数,那么 x 是运行时常量;而 constexpr int y = 10; 是编译期常量,可以用于数组大小、模板参数等需要编译期值的地方。

constexpr 适合数学常量、固定转换系数、编译期计算函数等。const 适合运行时初始化后不希望修改的值。比如从配置文件读取的参数,虽然运行后不想改,但它是在运行时读取的,不能用 constexpr,只能用 const 或普通变量保存。

如果在 SLAM 中用到,constexpr 可以用于角度转换系数、固定维度、数学常量,比如 constexpr double kDeg2Rad = M_PI / 180.0;;配置参数如体素滤波大小、地图分辨率、匹配阈值通常来自配置文件,应该用 const 成员或配置结构体保存。这样做的原因是区分编译期常量和运行时参数,既提高代码语义,也避免把可配置参数写死。


50. inline 函数有什么作用?

inline 最初的含义是建议编译器把函数体展开到调用处,减少函数调用开销。但现代编译器是否真正内联,主要由编译器优化策略决定,不完全听 inline 关键字。现在 inline 还有一个重要作用:允许函数或变量在多个编译单元中定义而不违反 ODR 规则,因此常用于头文件中的小函数定义。

适合 inline 的通常是非常短小、频繁调用的函数,比如简单 getter、数学小工具、角度归一化函数等。不适合把复杂算法函数都写成 inline,因为这可能导致编译时间变长、代码膨胀,而且编译器未必真的内联。模板函数通常定义在头文件中,也经常天然具有类似 inline 的使用方式。

如果在 SLAM 中用到,inline 常用于小型数学函数,比如角度归一化、弧度角度转换、简单坐标转换、状态 getter。比如 normalizeAngle() 这类函数可能在前端和后端频繁调用,写成 inline 比较自然。但 ICP、NDT、Ceres 优化、地图构建这类复杂函数不应该为了"快"就写 inline。这样做的原因是小函数内联能减少调用开销,大函数滥用 inline 会影响编译和可维护性。


51. 模板有什么用?有什么缺点?

模板是 C++ 实现泛型编程的重要机制,可以让同一份代码适用于不同类型,而不需要重复写多份函数或类。比如可以写 template<typename T> T add(T a, T b),让它支持 int、double 等类型。模板在编译期实例化,因此性能通常很好,没有虚函数的运行时分发开销。

模板的缺点是编译错误信息可能很复杂,代码膨胀,编译时间变长。模板实现通常需要放在头文件中,因为编译器实例化模板时必须看到完整定义。过度模板化也会降低代码可读性,让接口变得难理解。因此模板适合类型真正需要泛化的工具代码,不适合所有业务逻辑都模板化。

如果在 SLAM 中用到,模板常见于点云处理和数学库。PCL 的 pcl::PointCloud<PointT> 就是模板,可以支持 PointXYZPointXYZI、带法向量点等不同点类型。滤波器、特征提取器、坐标变换工具函数也可能写成模板。这样做的原因是点类型在不同雷达和算法中可能不同,模板能复用代码,同时保持编译期类型安全。


52. 为什么模板函数通常要写在头文件里?

模板函数和普通函数不同。普通函数可以在头文件声明、cpp 文件定义,然后链接器找到实现;而模板函数只有在使用具体类型时才会实例化。编译器在实例化模板时必须看到模板完整定义,所以模板函数通常要写在头文件里。如果只把模板实现放在 cpp 文件里,其他编译单元使用这个模板时可能看不到定义,导致链接错误。

当然也可以使用显式实例化,在 cpp 中手动实例化某些具体类型,但这只适合类型集合固定的情况。对于通用模板库,通常还是把实现放在头文件或 .hpp 文件里。

如果在 SLAM 中用到,点云模板函数很常见,比如 template<typename PointT> void transformCloud(...)voxelFilter<PointT>(...)。因为不同工程可能使用不同点类型,模板实现一般放在头文件里。PCL、Eigen 这类库大量头文件化,也是因为它们广泛使用模板。这样做的原因是让编译器能根据具体点类型和矩阵类型生成高效代码。


53. std::optional 有什么用?

std::optional<T> 表示一个值可能存在,也可能不存在。它比用特殊值、空对象或额外 bool 标志更清晰。比如一个函数可能找不到结果,可以返回 std::optional<Result>;调用方通过 if (result) 判断是否有值,再用 *resultresult.value() 取值。optional 适合表达"计算可能失败,但失败不是异常"的情况。

相比返回 nullptr,optional 更适合值类型;相比返回 bool 加输出参数,optional 接口更简洁。但如果失败原因很多,需要返回错误码或错误信息,std::expected 或自定义结果结构可能更合适。optional 不应该用来包很大的对象频繁拷贝,可以考虑 optional 中放轻量结果,或配合移动语义。

如果在 SLAM 中用到,匹配结果、回环候选、时间同步结果都可能不存在。比如 scan matching 可能失败,函数可以返回 std::optional<MatchResult>;查找某个时间戳附近的 IMU 数据可能失败,也可以返回 optional。这样做的原因是 SLAM 中很多算法步骤都有失败可能,接口应该显式表达"没有结果",避免返回默认位姿导致后续模块误用。


54. std::variant 有什么用?

std::variant 可以保存多个类型中的一种,是类型安全的联合体。比如 std::variant<int, double, std::string> 当前可能保存 int,也可能保存 double 或 string。使用时可以通过 std::visit 访问。variant 适合类型集合固定、但运行时可能是其中一种的场景。

它和多态的区别是:多态适合可扩展的类层次结构,通过虚函数调用;variant 适合有限类型集合,所有可能类型在编译期就列出来。variant 是值语义,不需要堆分配,但如果类型很多,访问逻辑可能变复杂。

如果在 SLAM 中用到,可以用 variant 表示不同类型的传感器数据或事件,比如 std::variant<ImuData, LidarData, OdomData>。统一事件队列中可以存不同消息类型,然后用 std::visit 分发处理。这样做的原因是某些系统希望用一个队列管理多源事件,而这些事件类型是固定的。相比基类指针,variant 避免了动态分配和虚函数,但扩展新类型时需要修改 variant 类型列表。


55. std::span 是什么?为什么传数组时要带长度?

C++ 原生数组作为函数参数传递时会退化成指针,函数内部不知道数组长度,所以容易越界。std::span 是 C++20 提供的连续内存视图,它不拥有数据,只是表示"从某个地址开始的一段连续元素",并且携带长度信息。比如 std::span<const double> 可以表示一段只读 double 数组。

span 的好处是避免拷贝,同时比裸指针更安全,因为它知道 size。它适合函数临时读取一段数组、vector、普通数组的数据,但不负责管理生命周期。使用 span 时仍然要保证原始数据在 span 使用期间有效。

如果在 SLAM 中用到,span 可以用于传递一段 IMU 数据、一段点索引、一段残差数组、一段时间窗口数据等。比如一个函数只需要读取某个时间范围内的 IMU 序列,可以接收 std::span<const ImuData>,而不是复制 vector。这样做的原因是 SLAM 中大量数据是连续数组,span 能表达非拥有、只读、带长度的高效访问方式,减少拷贝和越界风险。


56. 异常和错误码有什么区别?

异常是一种错误处理机制,可以在错误发生处抛出,在上层捕获处理;错误码则是函数返回一个状态值,调用方根据状态判断成功或失败。异常适合处理不常见、非正常流程的问题,比如文件打开失败、配置格式错误;错误码适合高频、可预期失败的流程,比如匹配失败、没有找到数据、队列为空等。

异常的优点是能把错误处理和正常逻辑分开,缺点是控制流不明显,在实时系统中可能带来额外开销和不可控路径。错误码简单直接,但容易被调用方忽略。现代 C++ 里也可以用 optional 或自定义 Result 类型表达失败。

如果在 SLAM 中用到,配置文件读取、模型加载、严重初始化失败可以用异常;而 scan matching 失败、回环候选不存在、时间同步数据不足,一般更适合返回 bool、optional 或状态码。这样做的原因是 SLAM 前端是高频实时流程,不希望频繁用异常控制正常失败路径;但初始化阶段错误需要明确上报,可以使用异常或清晰错误信息。


57. Debug 和 Release 有什么区别?

Debug 模式通常关闭或降低优化,保留调试信息,方便断点调试;Release 模式开启优化,运行速度更快,但调试信息较少。两者在性能、内存布局、变量优化、断言开关等方面都可能不同。很多程序 Debug 能跑,Release 崩溃,往往说明代码里有未定义行为,比如未初始化变量、数组越界、数据竞争、悬空指针等。

Debug 模式下内存布局可能更"宽松",一些错误不容易暴露;Release 模式优化后,编译器会基于"程序没有未定义行为"的假设做激进优化,所以隐藏 bug 可能被放大。还有一种常见问题是 Debug/Release 库混用,尤其在 Windows C++ 工程中,不同运行时库混用可能导致链接或运行问题。

如果在 SLAM 中用到,Release 模式通常用于实际运行,因为前端匹配和后端优化计算量大;Debug 用于排查逻辑问题。如果 Debug 正常 Release 异常,要重点查 Eigen 矩阵未初始化、数组越界、点云空访问、多线程数据竞争、库版本混用等。这样做的原因是 SLAM 工程依赖多、数据大、线程多,Release 下更容易暴露隐藏的 C++ 问题。


58. 静态库和动态库有什么区别?

静态库在链接时被打包进可执行文件,程序运行时不再依赖单独的库文件。Linux 下静态库通常是 .a,Windows 下可能是 .lib。动态库在运行时加载,可执行文件只保存对动态库的引用。Linux 下动态库通常是 .so,Windows 下是 .dll,同时可能有对应的导入 .lib

静态库优点是部署简单,运行时不容易缺库;缺点是可执行文件变大,多个程序使用同一库时不能共享内存,更新库需要重新链接。动态库优点是可以共享、可单独更新,缺点是运行时必须能找到正确版本,否则会出现找不到库或符号不匹配问题。C++ 工程中还要注意 ABI、编译器版本、Debug/Release 版本一致性。

如果在 SLAM 中用到,PCL、OpenCV、Ceres、GTSAM、ROS、Sophus 等库都涉及链接问题。比如程序编译通过,但运行时报找不到 .so.dll,就是动态库路径问题;Windows 下有 .lib 不代表运行时不需要 .dll。这样做的原因是 SLAM 工程依赖库多,理解静态库和动态库能帮助排查编译链接和部署问题。


59. 头文件和源文件应该怎么分?

头文件通常放声明,比如类声明、函数声明、类型定义、模板实现、inline 小函数等;源文件放具体实现。这样可以让接口和实现分离,减少编译依赖。头文件被多个 cpp 包含,所以头文件里不应该随便定义非 inline 的全局变量或普通函数,否则可能导致重复定义。头文件还应该使用 include guard 或 #pragma once 防止重复包含。

大型工程中,头文件包含关系会影响编译速度。如果一个头文件 include 了很多重量级库,那么任何包含它的源文件都会被迫依赖这些库。合理使用前向声明可以减少依赖。如果类里只保存某个类型的指针或引用,可以在头文件中前向声明;只有在源文件中使用具体成员函数时再 include 完整头文件。

如果在 SLAM 中用到,工程通常依赖 Eigen、PCL、OpenCV、Ceres 等重库,头文件乱 include 会导致编译很慢。比如 Frontend.h 如果只需要声明 MapManager* map_,可以前向声明 class MapManager;,不要直接 include 很大的地图头文件。这样做的原因是 SLAM 工程文件多、依赖多,减少头文件耦合能明显改善编译速度和模块维护性。


60. 前向声明有什么用?什么时候不能用?

前向声明是提前告诉编译器"有这样一个类型存在",但不提供完整定义。例如 class Map;。如果你只需要使用某个类型的指针或引用,编译器不需要知道它的完整大小,就可以使用前向声明。这样可以减少头文件 include,降低编译依赖。比如类成员是 Map* map_;std::shared_ptr<Map> map_;,头文件中通常可以只写前向声明。

但是如果你需要直接定义对象成员,比如 Map map_;,编译器必须知道 Map 的完整大小,这时不能只用前向声明,必须 include Map.h。如果你要调用对象成员函数,也需要在 cpp 中 include 完整定义。模板和继承场景下也常常需要完整定义。

如果在 SLAM 中用到,前向声明适合降低模块依赖。比如 SlamSystem 里持有 FrontendBackendMapManager 的智能指针,头文件可以前向声明这些类,具体创建和调用放到 cpp 中 include。这样做的原因是 SLAM 系统模块多,如果头文件互相 include,很容易循环依赖、编译变慢。前向声明能让接口更轻量,也让模块边界更清楚。

61. #include 的作用是什么?尖括号和双引号有什么区别?

#include 是预处理指令,作用是在编译前把指定头文件的内容包含到当前文件中。C++ 编译过程大致可以理解成预处理、编译、汇编、链接几个阶段,#include 发生在预处理阶段。比如你写了 #include <vector>,预处理器会把 vector 相关声明引入当前文件,这样后面才能使用 std::vector。如果没有 include 对应头文件,编译器就不知道某个类、函数、模板的声明是什么。

尖括号和双引号主要区别在于搜索路径。#include <xxx> 通常用于系统库或第三方库头文件,编译器会从系统 include 路径、编译参数指定的 include 路径中查找;#include "xxx" 通常用于项目自己的头文件,编译器一般会先从当前文件所在目录查找,再去系统路径查找。比如:

复制代码
复制代码
#include <vector>
#include <memory>
#include "MapManager.h"
#include "ScanMatcher.h"

工程中要注意,不要在头文件里随便 include 很重的头文件。因为头文件会被很多 .cpp 间接包含,如果一个公共头文件 include 了 PCL、OpenCV、Ceres 这类大库,会让编译依赖变重,改一个头文件可能触发大量文件重新编译。能用前向声明时尽量用前向声明,把完整 include 放到 .cpp 文件里。

如果在 SLAM 中用到,#include 管理非常重要。SLAM 工程通常依赖 Eigen、PCL、OpenCV、Ceres、GTSAM、ROS 等大量库,如果头文件依赖混乱,很容易出现编译慢、循环包含、符号冲突、平台移植困难等问题。比如 Frontend.h 只需要持有 MapManager 指针时,可以前向声明 class MapManager;,不要直接 include 整个地图管理头文件。这样做的原因是 SLAM 工程模块多、依赖重,良好的 include 管理能明显提升编译速度和代码可维护性。


62. #pragma once 和 include guard 有什么区别?

#pragma once 和 include guard 都是为了防止头文件被重复包含。C++ 中一个头文件可能被多个文件间接 include,如果没有防重机制,同一个类或函数声明可能被重复展开,导致重定义错误。传统 include guard 写法是:

复制代码
复制代码
#ifndef MAP_MANAGER_H
#define MAP_MANAGER_H

class MapManager {
};

#endif

它通过宏判断这个头文件是否已经被包含过。如果第一次包含,会定义宏并展开内容;后续再次包含时,因为宏已经存在,内容就不会重复展开。

#pragma once 写法更简单:

复制代码
复制代码
#pragma once

class MapManager {
};

它告诉编译器这个头文件在同一次编译中只包含一次。大多数现代编译器都支持 #pragma once,写起来简洁,不容易因为宏名重复或复制粘贴错误出问题。但它不是 C++ 标准的一部分,虽然实际工程中已经非常常见。include guard 是标准写法,兼容性更强;#pragma once 更方便。

如果在 SLAM 中用到,建议项目内部头文件统一使用一种风格。很多现代 SLAM 工程会直接用 #pragma once,简单清楚;一些对兼容性要求很高的库可能用 include guard。这样做的原因是 SLAM 工程头文件之间依赖复杂,如果没有防重包含,容易出现类重复定义、编译错误,尤其在地图、前端、后端、传感器模块互相 include 时更明显。


63. 宏 #defineconstexprinline 有什么区别?

#define 是预处理阶段的文本替换,不受作用域和类型检查约束。比如 #define PI 3.14159,预处理时会把代码里的 PI 替换成 3.14159。它的优点是简单,缺点是没有类型安全,调试困难,容易产生命名冲突。比如宏没有命名空间概念,如果不同头文件定义了相同宏名,就可能互相影响。

constexpr 是 C++ 的编译期常量表达方式,有类型检查、作用域规则,更安全。比如:

复制代码
复制代码
constexpr double kPi = 3.141592653589793;
constexpr double kDeg2Rad = kPi / 180.0;

inline 函数则适合替代函数式宏。比如以前可能写:

复制代码
复制代码
#define SQUARE(x) ((x) * (x))

但这种宏有副作用风险,例如 SQUARE(i++) 会导致 i++ 执行两次。更安全的写法是:

复制代码
复制代码
inline double square(double x) {
    return x * x;
}

现代 C++ 中,除非做条件编译、日志宏、平台兼容这类必须预处理的场景,否则更推荐用 constexprinlineenum class、模板等替代宏。

如果在 SLAM 中用到,宏常见于日志开关、编译平台判断、调试开关,比如 #ifdef ENABLE_DEBUG_LOG;数学常量、角度转换、固定阈值更推荐用 constexpr;小型工具函数比如角度归一化更推荐 inline 函数。这样做的原因是 SLAM 代码里参数和数学计算很多,使用有类型检查的 constexpr 和 inline 函数比宏更安全、更容易调试。


64. 命名空间 namespace 有什么作用?

命名空间用于避免名字冲突。大型 C++ 工程里,不同模块、不同库可能定义相同名字的类或函数,比如 MapFramePoseConfig 这些名字都很常见。如果没有命名空间,就容易冲突。使用 namespace 可以把名字放到一个作用域中,比如:

复制代码
复制代码
namespace slam {

class Map {};
class Frame {};

}

使用时写 slam::Map,这样就能和其他库里的 Map 区分开。

不要在头文件里写 using namespace std;using namespace xxx;,因为头文件会被很多文件包含,会把命名空间污染扩散到所有包含者,可能引发冲突。更好的做法是在 .cpp 文件内部局部使用,或者直接写完整命名空间。

命名空间还可以按模块划分,比如:

复制代码
复制代码
namespace slam {
namespace frontend {}
namespace backend {}
namespace mapping {}
}

现代 C++ 也可以写成:

复制代码
复制代码
namespace slam::frontend {
}

如果在 SLAM 中用到,命名空间很重要。SLAM 工程会同时依赖 ROS、PCL、Eigen、OpenCV、Ceres、GTSAM 等库,这些库里也有很多常见类名。如果项目自己的类也叫 MapFrameOptimizer,没有命名空间很容易冲突。一般会把项目代码放在自己的命名空间下,比如 mairui::slamlio_sam。这样做的原因是减少符号冲突,让模块边界更清晰,也方便大型工程维护。


65. usingtypedef 有什么区别?

typedefusing 都可以给类型起别名。传统写法是:

复制代码
复制代码
typedef std::vector<int> IntVector;

现代 C++ 更推荐 using:

复制代码
复制代码
using IntVector = std::vector<int>;

两者对普通类型别名效果类似,但 using 更清晰,尤其在模板别名中更方便。比如:

复制代码
复制代码
template<typename T>
using Vec = std::vector<T>;

如果用 typedef 写模板别名,会更麻烦。using 还常用于引入基类函数、引入命名空间中的某个名字等。

类型别名的作用是让代码更简洁、更表达语义。比如 std::shared_ptr<pcl::PointCloud<PointType>> 很长,可以起别名:

复制代码
复制代码
using CloudPtr = std::shared_ptr<pcl::PointCloud<PointType>>;
using ConstCloudPtr = std::shared_ptr<const pcl::PointCloud<PointType>>;

但也不能滥用别名。如果别名太多,或者名字起得不清楚,反而会让代码难读。别名应该表达业务意义,而不是单纯缩短所有类型。

如果在 SLAM 中用到,类型别名非常常见。比如点云类型、位姿类型、时间戳类型、关键帧指针类型都可以用 using 简化:

复制代码
复制代码
using PointType = pcl::PointXYZI;
using Cloud = pcl::PointCloud<PointType>;
using CloudPtr = Cloud::Ptr;
using Pose = Eigen::Isometry3d;

这样做的原因是 SLAM 代码里模板类型和库类型很长,合理使用 using 可以让函数接口更清楚,也方便后续替换点类型或位姿类型。


66. enumenum class 有什么区别?

传统 enum 会把枚举值暴露到外层作用域,并且可以隐式转换成整数,这容易引发命名冲突和类型错误。比如:

复制代码
复制代码
enum State {
    INIT,
    RUNNING,
    STOPPED
};

这里 INITRUNNING 会进入当前作用域。如果另一个 enum 也有 INIT,就可能冲突。

enum class 是强类型枚举,枚举值不会污染外层作用域,也不会隐式转换成 int。比如:

复制代码
复制代码
enum class State {
    Init,
    Running,
   Stopped
};

使用时必须写 State::Init。这让代码更清晰,也更类型安全。如果需要转成整数,要显式转换:

复制代码
复制代码
int value = static_cast<int>(State::Init);

现代 C++ 更推荐使用 enum class,尤其是工程代码中状态很多时。

如果在 SLAM 中用到,系统状态、传感器类型、匹配状态、初始化状态、地图状态都适合用 enum class。比如:

复制代码
复制代码
enum class SlamState {
    Uninitialized,
    Initializing,
    Tracking,
    Lost
};

这样比用一堆 int 或宏更安全。SLAM 系统状态很多,如果用普通 enum 或 int,容易传错类型;用 enum class 可以让状态语义清晰,减少错误分支判断。


67. structclass 有什么区别?

在 C++ 中,structclass 本质上非常相似,都可以有成员变量、成员函数、构造函数、继承、访问控制。主要区别是默认访问权限不同:struct 默认 public,class 默认 private;继承时 struct 默认 public 继承,class 默认 private 继承。除此之外,它们能力基本一致。

工程习惯上,struct 常用于简单数据聚合,主要保存数据,不包含复杂行为;class 常用于有封装、有不变量、有复杂成员函数的对象。比如一个传感器数据包可以用 struct:

复制代码
复制代码
struct ImuData {
    double timestamp;
    Eigen::Vector3d acc;
    Eigen::Vector3d gyro;
};

而一个地图管理器更适合 class:

复制代码
复制代码
class MapManager {
public:
    void update();
private:
    std::mutex mutex_;
};

这不是语法强制,而是代码风格和语义表达。

如果在 SLAM 中用到,传感器数据结构、匹配结果、配置参数、小型状态结构通常用 struct,比如 LidarFrameImuDataMatchResult;前端、后端、地图管理器、优化器这种有复杂行为和资源管理的模块通常用 class。这样做的原因是让代码读起来更符合直觉:struct 偏数据,class 偏对象和行为。


68. 什么是对象切片?

对象切片发生在把派生类对象按值赋给基类对象时,派生类独有的部分会被"切掉",只保留基类部分。例如:

复制代码
复制代码
class Base {
public:
    virtual void run() {}
};

class Derived : public Base {
public:
    int extra;
    void run() override {}
};

Derived d;
Base b = d;   // 对象切片

这里 b 是一个真正的 Base 对象,不再具有 Derived 的额外成员和多态行为。对象切片会导致派生类信息丢失,因此多态对象不能按值传递或保存。正确方式是使用基类指针或引用:

复制代码
复制代码
Base& ref = d;
Base* ptr = &d;

如果需要动态管理生命周期,可以用:

复制代码
复制代码
std::unique_ptr<Base> ptr = std::make_unique<Derived>();

如果在 SLAM 中用到,对象切片常见于算法接口设计。比如 ScanMatcher 是基类,ICPMatcherNDTMatcher 是派生类,如果写 ScanMatcher matcher = ICPMatcher();,派生类部分会被切掉,虚函数行为也不符合预期。正确做法是用 std::unique_ptr<ScanMatcher> 或引用。这样做的原因是 SLAM 中经常用基类接口管理不同算法实现,必须避免按值保存派生类对象。


69. 什么是单例模式?有什么优缺点?

单例模式保证一个类在程序中只有一个实例,并提供一个全局访问点。常见写法是函数内 static:

复制代码
复制代码
class Config {
public:
    static Config& instance() {
        static Config inst;
        return inst;
    }

private:
    Config() = default;
};

C++11 之后,函数内 static 初始化是线程安全的。单例适合全局唯一、生命周期贯穿程序、状态相对稳定的对象,比如配置管理、日志系统、资源管理器等。

单例的缺点是容易形成全局状态,增加模块耦合,不利于测试。如果很多模块都直接访问单例并修改内部状态,程序行为会变得难追踪。单例还要注意析构顺序问题,尤其多个单例之间互相依赖时,程序退出阶段可能出现问题。

如果在 SLAM 中用到,单例适合日志系统、配置读取、全局参数表等。例如 Config::instance().getDouble("voxel_size")。但不建议把当前位姿、地图、前端、后端核心状态做成单例,因为这些状态经常变化,又被多个线程访问,容易造成耦合和数据竞争。这样做的原因是单例适合"全局服务",不适合滥用成"全局可变状态"。


70. 工厂模式有什么用?

工厂模式用于根据条件创建不同类型的对象,把对象创建逻辑集中起来,避免主流程直接依赖具体类。比如:

复制代码
复制代码
std::unique_ptr<Matcher> createMatcher(const std::string& type) {
    if (type == "ICP") {
        return std::make_unique<ICPMatcher>();
    } else if (type == "NDT") {
        return std::make_unique<NDTMatcher>();
    }
    return nullptr;
}

主流程只关心 Matcher 接口,不关心具体创建哪个派生类。这样以后新增算法时,只需要新增类并修改工厂,不需要到处改主流程。

工厂模式适合"同一接口,多种实现"的场景。它的优点是解耦和可扩展;缺点是如果类型很多,简单 if-else 工厂会变长,可以进一步用注册表方式优化。

如果在 SLAM 中用到,工厂模式非常常见。比如根据配置创建不同 scan matcher:ICP、NDT、GICP、栅格匹配;创建不同地图类型:点云地图、栅格地图、体素地图;创建不同后端优化器:Ceres、GTSAM。这样做的原因是 SLAM 算法经常需要对比和切换,工厂模式能让算法替换通过配置完成,而不影响主流程。


71. 观察者模式有什么用?

观察者模式是一种事件通知机制。一个对象作为被观察者,内部状态变化时通知多个观察者。观察者可以注册回调函数,被观察者在合适时机调用这些回调。这样可以降低模块之间的直接依赖。比如地图更新后,不需要地图模块直接知道可视化模块、保存模块、网络模块分别是谁,而是触发一个 "map updated" 事件,感兴趣的模块自己注册回调。

C++ 中可以用 std::function 实现回调:

复制代码
复制代码
using Callback = std::function<void(const Map&)>;

void setMapUpdateCallback(Callback cb) {
    callback_ = std::move(cb);
}

要注意回调生命周期和线程安全。如果回调里访问对象,而对象已经销毁,就会出问题;如果在锁内调用回调,也可能死锁。

如果在 SLAM 中用到,观察者模式适合地图更新通知、轨迹更新通知、定位状态变化通知、回环成功通知等。比如后端优化完成后通知可视化线程更新轨迹,或前端定位丢失时通知上层模块。这样做的原因是 SLAM 模块之间不应该强耦合,事件回调能让模块解耦。但要注意不要在持锁状态下调用外部回调,避免死锁。


72. 什么是回调函数?使用时要注意什么?

回调函数是把一个函数传给另一个模块,由那个模块在某个事件发生时调用。C++ 中回调可以用函数指针、成员函数绑定、lambda、std::function 实现。回调的优点是灵活,可以让调用方自定义行为;缺点是控制流不如普通函数调用直观,调试时要注意回调什么时候执行、在哪个线程执行、捕获的数据是否还有效。

使用回调时要注意三点:第一,回调执行时间不能太长,否则会阻塞触发回调的线程;第二,回调捕获对象时要保证生命周期有效;第三,不要在持锁时调用外部回调,避免回调内部再访问同一资源造成死锁。

如果在 SLAM 中用到,传感器订阅、地图更新、状态发布、可视化通知都可能使用回调。ROS 订阅 LiDAR、IMU 消息时,本质就是消息到达触发 callback。回调中一般只做轻量工作,比如检查时间戳、转换格式、放入队列,不建议直接做耗时匹配或优化。这样做的原因是传感器回调线程需要及时处理数据,如果回调太慢,会造成消息堆积和延迟。


73. 为什么不要在构造函数中启动复杂线程?

构造函数的职责是初始化对象。如果在构造函数中启动线程,而线程立即访问对象成员,此时对象可能还没有完全构造完成,存在风险。尤其当构造函数后面还有成员初始化或可能抛异常时,线程可能看到一个半初始化对象。更安全的做法是构造函数只完成成员初始化,提供单独的 start() 函数启动线程。

例如:

复制代码
复制代码
class System {
public:
    System() {
        // 初始化成员
    }

    void start() {
        running_ = true;
        worker_ = std::thread(&System::run, this);
    }
};

这样对象构造完成后,再显式启动后台任务。析构时则先 stop,再 join。

如果在 SLAM 中用到,SlamSystemFrontendBackend 这类对象可能管理线程。最好不要在构造函数中直接启动建图线程、回环线程、优化线程,尤其这些线程会访问地图、配置、队列等成员。更安全的结构是:构造对象 -> 加载配置 -> 初始化地图和传感器 -> 调用 start 启动线程。这样做的原因是 SLAM 模块初始化顺序复杂,分离构造和启动可以减少半初始化访问和异常退出风险。


74. 为什么析构函数中要谨慎抛异常?

析构函数通常在对象生命周期结束时自动调用。如果析构函数抛出异常,而此时程序正在处理另一个异常,就可能导致 std::terminate(),程序直接终止。因此 C++ 中析构函数一般不应该抛异常,默认也被认为是 noexcept。资源释放失败时,析构函数内部应该尽量捕获异常并记录日志,而不是继续向外抛出。

比如文件关闭、线程停止、资源释放这些操作,即使失败,也应该在析构函数中处理掉:

复制代码
复制代码
~Logger() noexcept {
    try {
        close();
    } catch (...) {
        // 记录或忽略,不能抛出
    }
}

如果某个操作确实可能失败并需要上层处理,最好提供显式 close()stop() 函数,让调用者在对象析构前主动处理错误。

如果在 SLAM 中用到,析构函数常用于停止线程、关闭日志、释放地图、关闭传感器接口等。比如系统退出时,析构函数里要通知线程退出并 join。如果这些操作抛异常,可能导致程序退出过程崩溃。更好的做法是提供 shutdown(),显式返回状态;析构函数只做兜底清理并保证不抛异常。这样做的原因是 SLAM 程序资源多,退出阶段必须稳定。


75. 什么是 noexcept?什么时候使用?

noexcept 表示一个函数承诺不抛出异常。比如:

复制代码
复制代码
void reset() noexcept;

如果一个 noexcept 函数内部真的抛出了异常,程序会调用 std::terminate()noexcept 的作用有两个:第一,表达接口语义,让调用者知道这个函数不会抛异常;第二,帮助编译器和标准库做优化。例如 vector 在扩容时,如果元素的移动构造是 noexcept,就可以优先使用移动而不是拷贝。

析构函数通常应该是 noexcept。移动构造函数和移动赋值函数如果确实不会抛异常,也建议标记 noexcept。但不要为了性能随便给可能抛异常的函数加 noexcept,否则一旦异常发生,程序会直接终止。

如果在 SLAM 中用到,资源清理函数、简单状态查询、移动构造等可以考虑 noexcept。比如一个轻量数据帧的移动构造如果只移动 vector 和智能指针,可以声明 noexcept,有利于容器优化。析构函数、线程停止兜底清理也应该保证不抛异常。这样做的原因是 SLAM 长时间运行,对稳定性要求高,异常边界要清楚,不能让析构或移动过程意外中断系统。


76. explicit 关键字有什么用?

explicit 用于修饰构造函数,防止发生不希望的隐式类型转换。比如:

复制代码
复制代码
class Time {
public:
    explicit Time(double sec) : sec_(sec) {}
private:
    double sec_;
};

如果没有 explicit,Time t = 1.0; 可能会隐式调用构造函数,把 double 转成 Time。有些情况下这很方便,但大型工程中容易产生误调用。加上 explicit 后,必须写 Time t(1.0);Time t{1.0};,语义更清楚。

单参数构造函数尤其应该考虑 explicit。多参数构造函数在 C++11 列表初始化下也可能涉及隐式转换,也可以使用 explicit。它的核心价值是避免编译器自动做你不希望的转换。

如果在 SLAM 中用到,时间戳、角度、距离、坐标系 ID、帧 ID 等封装类型适合使用 explicit。比如 Timestamp(double)Angle(double)FrameId(int),不希望一个普通 double 或 int 被随便当作这些强语义类型。这样做的原因是 SLAM 中单位和坐标系很容易混淆,explicit 可以减少隐式转换导致的错误。


77. deletedefault 关键字在函数声明中有什么用?

= default 表示让编译器生成默认实现,比如默认构造函数、析构函数、拷贝构造、移动构造等。= delete 表示明确禁止某个函数被调用。比如:

复制代码
复制代码
class System {
public:
    System() = default;
    System(const System&) = delete;
    System& operator=(const System&) = delete;
};

这表示默认构造可以使用,但不允许拷贝和赋值。

delete 常用于禁止资源类拷贝,避免默认浅拷贝带来的问题;default 常用于明确表达"我就是要编译器默认实现",提高可读性。相比把拷贝构造声明为 private 但不实现,= delete 的错误信息更清楚。

如果在 SLAM 中用到,管理线程、锁、地图资源、优化器资源的类通常应该禁止拷贝。比如 SlamSystemBackendThreadMapManagerSensorDriver 这类类对象不应该被复制。使用 = delete 可以在编译阶段阻止错误用法。这样做的原因是 SLAM 核心对象通常管理独占资源和线程,默认拷贝会导致资源重复管理或数据竞争。


78. 什么是 Rule of Three / Five / Zero?

Rule of Three 指的是:如果一个类需要自定义析构函数、拷贝构造函数、拷贝赋值运算符中的任意一个,通常也需要考虑另外两个。因为这说明类可能管理资源,默认拷贝和析构可能不安全。C++11 后加入移动语义,扩展成 Rule of Five:还要考虑移动构造和移动赋值。

Rule of Zero 是更现代的思想:尽量不要自己管理裸资源,而是使用标准库容器、智能指针、RAII 类型,让编译器自动生成正确的特殊成员函数。比如用 std::vector 管数组,用 std::unique_ptr 管堆对象,用 std::thread 封装线程,再根据需要禁止拷贝。

如果在 SLAM 中用到,资源类设计经常涉及这个规则。比如一个类管理点云缓存、KD-Tree、后台线程,如果使用裸指针,就必须考虑 Rule of Five,很容易出错;如果用智能指针和标准容器,可以更接近 Rule of Zero。对于 SlamSystem 这种管理线程的类,通常禁止拷贝,只允许明确启动和停止。这样做的原因是 SLAM 工程资源复杂,遵守这些规则可以减少内存和生命周期 bug。


79. 什么是 PIMPL 惯用法?

PIMPL 是 Pointer to Implementation 的缩写,也叫编译防火墙。它把类的实现细节隐藏到一个内部实现类中,头文件只暴露接口和一个指向实现类的指针。例如:

复制代码
复制代码
class SlamSystem {
public:
    SlamSystem();
    ~SlamSystem();

    void start();

private:
    class Impl;
    std::unique_ptr<Impl> impl_;
};

具体成员变量和实现放在 .cpp 文件里的 SlamSystem::Impl 中。这样外部用户不需要知道内部依赖哪些库,也不会因为内部成员变化而重新编译所有包含头文件的文件。

PIMPL 的优点是降低编译依赖、隐藏实现细节、保持 ABI 稳定;缺点是多一层指针间接访问,代码稍复杂。

如果在 SLAM 中用到,PIMPL 适合对外发布的 SDK 或大型模块接口。比如一个定位 SDK 不想在公共头文件暴露 PCL、Ceres、GTSAM、ROS 依赖,就可以用 PIMPL 把这些细节放到实现文件中。这样做的原因是 SLAM 工程依赖很重,PIMPL 能减少用户编译负担,也能隐藏内部算法实现。


80. 什么是内存泄漏?怎么排查?

内存泄漏是指程序申请了内存,但不再使用后没有释放,导致内存占用持续增长。传统原因是 new 后没有 delete、异常路径提前返回未释放资源、循环引用导致对象无法析构等。现代 C++ 中使用智能指针和标准容器可以减少泄漏,但并不能完全避免,比如 shared_ptr 循环引用仍然会泄漏。

排查内存泄漏可以使用工具,比如 Linux 下 Valgrind、AddressSanitizer,Windows 下 Visual Studio Diagnostic Tools。也可以通过日志观察关键容器大小是否持续增长。要区分真正的泄漏和业务缓存增长。有些程序内存增长不是因为忘记释放,而是因为一直保存历史数据,没有设计清理策略。

如果在 SLAM 中用到,内存增长很常见。原因可能是关键帧无限增加、历史点云全部保存、局部地图没有裁剪、传感器队列处理不过来、shared_ptr 循环引用等。排查时可以打印 keyframes_.size()cloud_queue_.size()、地图点数、回环候选数量。这样做的原因是 SLAM 是长期运行系统,内存不受控会导致性能下降甚至程序崩溃。


81. 什么是悬空指针?

悬空指针是指指针指向的对象已经被释放或生命周期结束,但指针仍然保存原来的地址。继续通过悬空指针访问对象会产生未定义行为,可能崩溃,也可能读到错误数据。常见场景包括:返回局部变量地址、delete 后继续使用指针、vector 扩容后继续使用旧元素指针、对象析构后异步线程还访问它。

比如:

复制代码
复制代码
int* getPtr() {
    int x = 10;
    return &x;   // 错误,x 离开函数后销毁
}

解决方法是避免返回局部对象地址,使用智能指针管理动态对象,跨线程传递数据时明确生命周期,delete 后将指针置空只能减少误用,但不能根治所有问题。

如果在 SLAM 中用到,悬空指针常见于点云、关键帧、地图、线程任务之间。比如回调函数里创建局部点云对象,然后把它地址放入队列,函数返回后队列里的指针就悬空了;或者回环线程保存了某个关键帧裸指针,但关键帧被地图清理掉。这样做的原因是 SLAM 多线程和异步处理多,数据生命周期必须用 shared_ptr、拷贝或明确所有权来保证。


82. 什么是野指针?和空指针有什么区别?

空指针是明确不指向任何对象的指针,通常是 nullptr。访问空指针会崩溃,但它至少是可判断的。野指针是未初始化的指针,里面是随机地址,可能指向任意位置。野指针比空指针更危险,因为它不一定马上崩溃,可能误写某块内存,导致更隐蔽的问题。

例如:

复制代码
复制代码
int* p;      // 未初始化,野指针
int* q = nullptr; // 空指针

良好习惯是指针定义时初始化为 nullptr,使用前判断是否为空;更推荐用智能指针和引用减少裸指针使用。指针释放后如果还保留变量,也可以置为 nullptr,但如果还有其他指针指向同一块内存,它们仍然可能悬空。

如果在 SLAM 中用到,野指针和空指针可能出现在地图未初始化、匹配器创建失败、点云读取失败、传感器对象未连接等场景。比如 map_ptr 为空时直接 map_ptr->update() 会崩溃;未初始化的点云指针更危险。这样做的原因是 SLAM 模块初始化流程复杂,应该使用智能指针、初始化检查和状态判断,避免空指针和野指针访问。


83. 什么是内存碎片?为什么频繁申请释放大对象不好?

内存碎片是指程序频繁申请和释放不同大小的内存块后,堆内存中出现很多不连续的小空洞,虽然总空闲内存可能足够,但难以找到连续大块内存。频繁动态分配还会带来分配器开销,影响实时性。对于高频处理系统,如果每一帧都创建大量临时大对象,会造成性能抖动。

优化方法包括:复用 buffer,提前 reserve,使用对象池,减少临时对象,尽量让内存分配发生在初始化阶段而不是实时循环中。比如 vector 可以 clear() 后复用容量,而不是每帧重新构造大 vector。需要注意 clear() 不会释放 capacity,正好适合复用;如果确实要释放内存,可以用 shrink_to_fit(),但它本身也可能触发重新分配,不适合高频使用。

如果在 SLAM 中用到,点云处理每帧都会产生滤波点云、特征点云、局部地图、匹配缓存。如果每帧都频繁 new/delete 大点云对象,会影响实时性。更好的做法是复用点云对象,使用 points.clear(),提前 reserve(),或维护缓存池。这样做的原因是 SLAM 前端需要稳定帧率,内存分配抖动会直接影响定位延迟。


84. 什么是缓存友好?为什么连续内存通常更快?

现代 CPU 访问内存不是一个字节一个字节随机取,而是按缓存行加载一块连续数据。连续内存结构,比如 vector,遍历时能很好利用 CPU cache;链表虽然插入删除灵活,但每个节点分散在堆上,遍历时容易 cache miss,性能可能很差。很多时候算法复杂度相同,缓存友好程度也会造成明显性能差异。

例如遍历 std::vector<Point> 通常比遍历 std::list<Point> 快很多,因为 vector 的点在内存中连续排列。链表每访问一个节点都可能跳到新的内存位置,CPU 很难预取。

如果在 SLAM 中用到,点云、轨迹、关键帧、残差数组通常需要大量遍历,所以 vector 这类连续容器更适合。比如点云滤波、特征提取、残差计算都属于遍历密集型操作。这样做的原因是 SLAM 不只是算法复杂度问题,工程实时性还依赖内存访问效率。选择缓存友好的数据结构可以提升整体性能。


85. 什么是对象池?适合什么场景?

对象池是一种预先创建或复用对象的技术。程序需要对象时从池里取,用完后归还,而不是频繁 new/delete。对象池适合对象创建销毁频繁、大小较固定、实时性要求高的场景。它可以减少动态内存分配开销和内存碎片。

对象池设计要注意线程安全、对象重置、最大容量和生命周期。归还对象时必须清理状态,否则下次使用会带着旧数据。多线程对象池需要加锁或使用无锁结构,但实现复杂度会上升。不是所有场景都需要对象池,过早使用会让代码复杂。

如果在 SLAM 中用到,对象池可以用于点云帧对象、图像帧对象、临时匹配缓存、残差块缓存等。比如每帧都需要临时点云 buffer,可以从池中取一个,处理完归还。这样做的原因是 LiDAR/视觉前端是高频循环,频繁分配大对象可能造成延迟抖动。对象池能让内存使用更稳定,但要注意重置对象状态和线程安全。


86. 什么是 std::shared_ptr<const T>?为什么有用?

std::shared_ptr<const T> 表示共享所有权,但通过这个指针不能修改对象内容。它和 const std::shared_ptr<T> 不一样。const std::shared_ptr<T> 是指这个智能指针变量本身不能改指向,但仍然可能修改指向对象;而 std::shared_ptr<const T> 是指对象内容只读,更适合共享不可变数据。

例如:

复制代码
复制代码
std::shared_ptr<const Frame> frame;

多个模块可以共享同一个 frame,引用计数保证生命周期,但任何拿到这个指针的模块都不能修改 frame 内容。这样可以减少误修改和数据竞争风险。

如果在 SLAM 中用到,关键帧、原始点云帧、去畸变后的点云帧、图像帧等一旦生成后,通常希望作为只读数据传给多个模块。比如前端、后端、回环、可视化都要读同一帧点云,但不应该互相修改。使用 shared_ptr<const Frame> 的原因是同时解决生命周期共享和只读安全,非常适合跨线程共享大数据。


87. 什么是拷贝省略 RVO / NRVO?

RVO 是 Return Value Optimization,返回值优化;NRVO 是 Named Return Value Optimization,命名返回值优化。它们是编译器优化技术,可以在函数返回对象时避免不必要的拷贝或移动。比如:

复制代码
复制代码
Frame createFrame() {
    Frame f;
    return f;
}

编译器可能直接在调用方的存储位置构造 f,而不是先构造局部对象再移动出去。C++17 之后,某些返回临时对象的场景保证拷贝省略。

这意味着现代 C++ 中返回对象不一定很昂贵,尤其对象支持移动或编译器能做 RVO 时,直接返回值可能比使用输出参数更清晰。但如果对象非常大且返回路径复杂,也要结合性能测试。

如果在 SLAM 中用到,函数返回 MatchResultPoseResult、小型状态结构体,可以直接值返回,代码清晰且通常不会有明显开销。对于大点云对象,仍然要谨慎,可能用移动语义、智能指针或输出参数更合适。这样做的原因是 SLAM 代码既要性能,也要可读性,理解 RVO 可以避免过度使用复杂指针接口。


88. std::optional、返回 bool 加输出参数,应该怎么选?

返回 bool 加输出参数是传统 C++ 写法:

复制代码
复制代码
bool match(MatchResult& result);

函数返回 true 表示成功,结果写入 resultstd::optional<MatchResult> 是现代写法:

复制代码
复制代码
std::optional<MatchResult> match();

如果成功返回结果,失败返回空。optional 语义更清楚,调用方不容易忘记检查返回状态。但如果失败原因复杂,optional 不够表达错误信息,此时可以用自定义 Result 结构,包含状态码、错误信息和结果。

bool 加输出参数适合性能敏感或对象很大、不想构造 optional 的场景;optional 适合结果较轻、接口语义清晰的场景。现代代码更倾向 optional 或 Result 类型,但具体要看项目风格。

如果在 SLAM 中用到,scan matching、时间同步、回环检测都可能失败。比如匹配失败不能返回一个默认位姿继续用,否则会导致定位错误。可以用 optional<MatchResult> 明确表达"没有有效结果"。这样做的原因是 SLAM 流程中失败是正常情况,接口必须让调用方显式处理失败,而不是误用无效数据。


89. C++ 中如何处理配置参数?

配置参数可以来自命令行、yaml、json、ros param、ini 文件等。好的配置系统应该做到:集中读取、类型明确、默认值清晰、范围检查、错误提示清楚。不要在代码中到处写硬编码常量,比如直接写 0.051000.3,否则后期调参困难。更好的方式是定义配置结构体:

复制代码
复制代码
struct FrontendConfig {
    double voxel_size = 0.2;
    int max_iterations = 20;
    double score_threshold = 0.5;
};

模块构造时传入配置。这样模块依赖哪些参数一目了然。读取配置后还应该做检查,比如分辨率必须大于 0,迭代次数不能小于 1。

如果在 SLAM 中用到,配置参数非常多:地图分辨率、体素滤波大小、匹配最大迭代次数、回环阈值、外参、IMU 噪声、优化权重等。使用配置结构体的原因是方便调参、部署不同传感器和场景,也避免硬编码导致代码难维护。参数读取后如果运行中不改,可以作为 const 配置使用;如果支持动态调参,要考虑线程安全。


90. 什么是日志系统?高频日志有什么问题?

日志系统用于记录程序运行状态、错误信息、调试信息。一个好的日志系统应该支持等级,比如 error、warning、info、debug;支持模块名、时间戳;支持开关和输出目标。日志的作用是帮助排查问题,但日志本身也有性能成本,尤其是高频循环中打印大量字符串,会严重拖慢程序。

高频日志问题包括:控制台输出很慢,磁盘写入有开销,字符串拼接也消耗 CPU;多线程同时写日志还可能需要锁;日志太多会淹没有用信息。工程中通常会给细节日志加开关,或者降频打印,比如每秒打印一次统计,而不是每个点、每次迭代都打印。

如果在 SLAM 中用到,日志用于记录时间戳、位姿、匹配得分、迭代次数、队列长度、优化耗时、初始化状态、异常原因等。比如定位跳变时,可以查看匹配分数是否下降、队列是否堆积、IMU 时间是否覆盖。这样做的原因是 SLAM 问题复杂,日志是工程调试的重要手段。但高频点云处理里不能无限打印,否则会影响实时性,所以要有日志等级和开关。


91. 如何用 std::chrono 统计耗时?

std::chrono 是 C++ 标准库提供的时间工具,可以用于计时、睡眠、时间间隔计算。统计耗时时常用 steady_clock,因为它是单调时钟,不受系统时间调整影响。示例:

复制代码
复制代码
auto t0 = std::chrono::steady_clock::now();

// do something

auto t1 = std::chrono::steady_clock::now();
auto cost_ms = std::chrono::duration_cast<std::chrono::milliseconds>(t1 - t0).count();
std::cout << "cost = " << cost_ms << " ms\n";

如果需要更细粒度,可以用 microseconds。不要用系统时间做耗时统计,因为系统时间可能被 NTP 或用户修改。

如果在 SLAM 中用到,chrono 常用于统计点云预处理耗时、scan matching 耗时、KD-Tree 构建耗时、后端优化耗时、回环检测耗时等。这样做的原因是 SLAM 实时性非常关键,必须知道时间花在哪里。如果 LiDAR 10Hz,一帧处理最好小于 100ms;如果某个模块耗时突然增加,就可能导致队列堆积和定位延迟。


92. 如何排查程序偶发崩溃?

偶发崩溃通常比稳定复现的 bug 更难查。C++ 中常见原因包括空指针、悬空指针、数组越界、未初始化变量、数据竞争、迭代器失效、重复释放、栈溢出等。排查思路一般是:先看崩溃栈,用 gdb 或 Visual Studio 定位崩溃位置;再检查该位置的数据是否为空、越界、生命周期是否有效;如果怀疑内存问题,可以用 AddressSanitizer;如果怀疑多线程竞争,可以用 ThreadSanitizer;同时增加关键日志,记录对象大小、指针是否为空、时间戳、线程 ID 等。

偶发崩溃不要只看崩溃那一行,因为真正破坏内存的地方可能更早。比如 vector 越界写可能很久后才崩溃。要结合工具和日志一起排查。

如果在 SLAM 中用到,偶发崩溃常见于多线程队列、点云空访问、地图被更新时可视化遍历、关键帧被删除后回环线程访问旧指针、Eigen 未初始化等。这样做的原因是 SLAM 系统线程多、数据大、流程长,很多 C++ 错误会伪装成算法不稳定。排查时要同时看 C++ 内存线程问题和 SLAM 数据逻辑。


93. 如何排查实时性下降?

实时性下降可以从几个方面排查:模块耗时、队列长度、锁等待、内存分配、CPU 占用、数据规模。首先要统计每个主要模块耗时,比如预处理、匹配、地图更新、优化、发布。其次观察输入队列是否持续增长,如果队列增长说明处理速度低于输入速度。再看是否有高频日志、频繁内存分配、锁内耗时操作、线程抢占等问题。

不要盲目优化,要先测量。可以用 std::chrono 打点,也可以用 profiler。优化方向包括减少点云数量、降采样、复用内存、减少拷贝、缩短锁时间、降低低优先级模块频率、限制队列长度等。

如果在 SLAM 中用到,实时性下降可能表现为定位延迟、点云积压、轨迹滞后、控制不稳定。比如局部地图太大导致 KD-Tree 构建耗时增加;回环线程占满 CPU 影响前端;日志过多导致每帧耗时增加。这样做的原因是 SLAM 是实时系统,算法准确性之外,还必须保证处理速度跟上传感器输入。


94. 如何排查内存持续增长?

内存持续增长可能是真正泄漏,也可能是业务数据没有上限。排查时先观察增长趋势,是线性增长还是某些阶段突然增长;再打印关键容器大小,比如队列长度、缓存数量、对象池数量。使用工具如 Valgrind、ASan、heap profiler 可以检查真正泄漏。还要检查 shared_ptr 循环引用,因为它不会被传统手动 delete 问题覆盖。

业务层面的增长也很常见,比如历史数据一直保存不清理。此时不是 C++ 泄漏,而是设计上没有滑窗或淘汰机制。解决方式包括限制队列长度、关键帧筛选、地图裁剪、降采样、定期释放缓存等。

如果在 SLAM 中用到,内存增长可能来自关键帧越来越多、每个关键帧保存完整点云、全局地图无限增长、回环候选缓存不清理、传感器队列堆积。这样做的原因是 SLAM 本身会不断积累地图数据,必须设计上限和压缩策略,否则长时间运行必然内存变大。排查时要区分"泄漏"和"地图增长"。


95. 什么是迭代器失效?常见场景有哪些?

迭代器失效是指原来指向容器元素的迭代器、指针或引用因为容器操作变得不再有效。vector 扩容会导致所有元素地址变化,旧迭代器全部失效;vector erase 会导致被删除位置之后的迭代器失效;unordered_map rehash 会导致迭代器失效;list 删除某个元素只会让该元素迭代器失效,其他一般不受影响。

错误示例是在 range-for 中直接 erase vector 元素,或者保存 vector 元素引用后继续 push_back。正确做法要根据容器选择,比如 vector 批量删除用 erase-remove 惯用法:

复制代码
复制代码
v.erase(std::remove_if(v.begin(), v.end(), pred), v.end());

如果在 SLAM 中用到,清理低质量特征点、删除旧关键帧、裁剪局部地图、删除过期候选时都可能遇到迭代器失效。比如遍历关键帧 vector 时同时删除元素,如果写法不对,会崩溃。这样做的原因是 SLAM 中缓存数据经常需要动态清理,容器操作必须符合迭代器规则。


96. erase-remove 惯用法是什么?

erase-remove 是从 vector 中批量删除满足条件元素的常见写法。std::remove_if 并不会真正删除元素,它只是把不需要删除的元素移动到前面,并返回新的逻辑结尾;真正删除尾部无效元素需要再调用 erase

复制代码
复制代码
points.erase(
    std::remove_if(points.begin(), points.end(),
                   [](const Point& p) { return p.invalid; }),
    points.end());

这种写法比在循环中一个个 erase 更高效,因为 vector 单次 erase 中间元素会移动后续元素,多次 erase 可能代价很高,还容易写出迭代器失效 bug。

如果在 SLAM 中用到,erase-remove 可以用于删除无效点、过滤距离过远的点、删除低分匹配候选、清理过期轨迹点。比如局部地图中删除超出范围的点,可以用 remove_if 根据距离判断。这样做的原因是 SLAM 数据量大,批量删除要尽量高效和安全,erase-remove 是 vector 清理元素的经典写法。


97. 什么是 std::priority_queue?适合什么场景?

std::priority_queue 是优先队列,底层通常基于堆结构,每次可以快速取出优先级最高的元素。默认是大顶堆,也就是 top() 返回最大元素。如果需要小顶堆,可以自定义比较器。它适合"不断插入候选,并且每次取最高优先级"的场景。

示例:

复制代码
复制代码
struct Candidate {
    int id;
    double score;
};

struct Compare {
    bool operator()(const Candidate& a, const Candidate& b) const {
        return a.score < b.score; // 分数高优先
    }
};

std::priority_queue<Candidate, std::vector<Candidate>, Compare> pq;

priority_queue 不适合需要遍历所有元素或随机删除中间元素的场景,因为它只保证 top 是最高优先级。

如果在 SLAM 中用到,优先队列可以用于回环候选排序、匹配候选评分、路径规划 A*、分枝定界搜索等。比如回环检测找到多个候选关键帧,可以优先验证得分最高的几个。这样做的原因是很多时候不需要处理所有候选,优先处理最可能成功的候选能减少计算量。


98. std::sort 自定义比较器要注意什么?

std::sort 可以传入自定义比较器,用来定义排序规则。比较器必须满足严格弱序关系,也就是说不能写出前后矛盾的比较逻辑。常见写法是:

复制代码
复制代码
std::sort(candidates.begin(), candidates.end(),
          [](const Candidate& a, const Candidate& b) {
              return a.score > b.score; // 分数从高到低
          });

比较器中不要修改被比较对象,也不要依赖会变化的外部状态,否则排序结果可能不稳定甚至出错。如果比较浮点数,还要注意 NaN 问题,因为 NaN 和任何数比较都为 false,可能破坏排序逻辑。

如果在 SLAM 中用到,sort 常用于按时间戳排序传感器数据、按匹配分数排序候选、按距离排序关键帧、按残差大小筛选外点。比如回环候选按相似度从高到低排序,或者局部地图关键帧按距离从近到远排序。这样做的原因是 SLAM 中很多步骤需要从大量候选中优先选择最相关、最近或最高分的数据。


99. 如何设计一个匹配结果 MatchResult 结构体?

一个好的结果结构体应该包含结果是否有效、估计值、质量指标和调试信息。比如匹配结果可以包含最终位姿、匹配分数、迭代次数、是否收敛、内点数量、耗时、失败原因等。不要只返回一个位姿,因为调用方无法判断这个位姿是否可靠。

示例:

复制代码
复制代码
struct MatchResult {
    bool success = false;
    Eigen::Isometry3d pose = Eigen::Isometry3d::Identity();
    double score = 0.0;
    int iterations = 0;
    int inlier_count = 0;
    std::string message;
};

如果使用现代 C++,也可以返回 std::optional<MatchResult>,失败时返回空;如果需要失败原因,就让 MatchResult 中包含状态码。

如果在 SLAM 中用到,scan matching、回环验证、重定位都应该有类似结果结构。比如匹配成功但 score 很低,不能直接信任;迭代次数达到上限,可能说明没有收敛;内点太少,说明约束不可靠。这样做的原因是 SLAM 决策不能只看"有没有输出位姿",还要看结果质量,否则容易把错误匹配传给后端,造成地图或轨迹跳变。


100. 如何设计一个 C++ SLAM 模块的接口?

设计模块接口时,要先明确输入、输出、所有权、线程安全和失败处理。输入大对象通常用 const &shared_ptr<const T>,避免拷贝和误修改;输出可以用返回值、输出参数或结果结构体;模块内部资源用 unique_ptr 或成员对象管理;接口要表达失败,比如返回 bool、optional 或 Result;如果模块会被多线程访问,要说明哪些接口线程安全。

比如一个匹配器接口可以设计成:

复制代码
复制代码
class ScanMatcher {
public:
    virtual ~ScanMatcher() = default;

    virtual MatchResult match(
        const Cloud& scan,
        const Cloud& map,
        const Eigen::Isometry3d& initial_pose) = 0;
};

这个接口清楚表达:scan、map、initial_pose 都是输入;返回 MatchResult,里面包含成功状态和质量指标;基类有虚析构,方便多态删除。模块接口不应该暴露内部可变数据引用,也不应该让调用方知道太多实现细节。

如果在 SLAM 中用到,前端、后端、地图、回环、传感器同步都需要良好接口。比如前端接口可以是 addLidarFrame()addImuData()getCurrentPose();后端接口可以是 addKeyFrame()addLoopConstraint()optimize()。这样做的原因是 SLAM 系统链路长、模块多,接口设计清晰才能减少耦合,方便替换算法、排查问题和多人协作开发。

相关推荐
想要成为糕糕手1 小时前
前端必修课:JavaScript 数组与数据结构底层逻辑全解析
javascript·数据结构·面试
牛油果子哥q1 小时前
【C++ STL string 】C++ STL string 终极精讲:底层原理、内存机制、全套API、深浅拷贝、易错坑点与工程实战规范
数据库·c++
Cloud_Shy6181 小时前
解读《Effective Python 3rd Edition》:从练气到老魔(第五章 Item 30 - 32)
开发语言·人工智能·笔记·python·学习方法
天佑木枫2 小时前
15天Python入门系列 · 序
开发语言·python
swipe2 小时前
做多轮对话 Agent,为什么我建议把短期记忆放到 Redis
后端·面试·llm
宋拾壹3 小时前
同时添加多个类目
android·开发语言·javascript
swipe3 小时前
别再把关系库和向量库拆开了:PostgreSQL 搭建 AI 长期记忆层实战
面试·langchain·llm
凡人叶枫3 小时前
Effective C++ 条款04:确定对象被使用前已先被初始化
java·linux·开发语言·c++·嵌入式开发
不想写代码的星星3 小时前
std::move 根本不移动,就像老婆饼里没有老婆
c++