工程经验(1):文件组织与数据结构设计


前言

从这一篇博客开始,笔者会总结工程经验。笔者曾经考虑过在“编程基础”一个栏目当中记录编程知识,例如智能指针等用法,以及再开一个栏目专门讲“编程哲学”,但是那样仍然摆脱不了学了就忘的历史周期律。因此,这些比较高级的知识,将会直接融入到“工程经验”这个栏目当中。借用C++之父Bjarne的说法,编程语言从抽象到具体就像切洋葱,最外面一定是很抽象的一个“问题”,一种思考的结果。往下慢慢的拆分,才是一个一个具体的实现。如果直接切到最里面,势必会辣到眼睛。笔者本科时学习C语言差点挂科,就是因为老师出于自己的任务考虑,一个一个的知识点讲授,完全不教怎么使用、如何分析问题,甚至连编译器的调试都很少介绍,直接导致对编程失去信心。(因此强烈建议取消基础编程课,增加专业课程的课时量并且改善授课方式hhh)。我们应当学习那些厉害的人,学他们是怎么思考问题并且实现的,有针对性的去寻找相关的知识,这样事半功倍。本专栏以一个简单的SLAM系统实现为例子,源码来自高翔博士的《视觉SLAM十四讲》,记录下笔者补充的知识与感悟。

提示:以下是本篇文章正文内容,下面案例可供参考

一、工程文件如何组织与包含?

一个典型的C++工程,可以按照如下方式进行划分:
(1)include文件夹——自己写的库(包含头文件)和第三方库(分别建文件夹来存放,以免混淆);
(2)src文件夹——头文件对应的cpp文件;
(3)cmake_modules文件夹——存放需要的第三方库cmake文件;
(4)config文件夹——存放配置文件;
(5)bin文件夹——存放已经编译好的二进制文件;
(6)lib文件夹——存放已经编译好的库文件;
(7)test文件夹——存放用于测试的代码(cpp)。
在Linux系统下,include的默认路径是系统的usr/local/include(参考笔者的另一篇博客:《编程基础(1):总结编译基本过程》),如果要使用自己的include文件夹的话,也需要在工程菜单内指定,这一点和windows配置附加库的方式相同。因为我们没写完的代码肯定是不能作为系统标准库使用的(笔者的电脑上甚至没有权限修改这个硬盘上的数据,虽然是电脑配置问题,但即便能配置,也是十分麻烦的)。
——————————
工程经验1:使用一个统一的common_include.h,来避免大量的包含。
这主要是为了防止在编写每一个头文件时都反复写包含。一般而言,这个通用包含头主要包括:通用的一些库(例如C++标准库),第三方库中的常见部分(例如Core)以及所有的类型定义typedef:代码如下(示例):

//common_include.h
// std
#include <atomic>
#include <condition_variable>
#include <iostream>
#include <list>
#include <map>
#include <memory>
#include <mutex>
#include <set>
#include <string>
#include <thread>
#include <typeinfo>
#include <unordered_map>
#include <vector>

// start 3rdParty
// Eigen
#include <eigen3/Eigen/Core>
#include <eigen3/Eigen/Geometry>

//typedef for Eigen
typedef Eigen::Matrix<double,Eigen::Dynamic,Eigen::Dynamic> MatXX;
typedef Eigen::Matrix<double, 10, 10> Mat1010;
typedef Eigen::Matrix<double, 13, 13> Mat1313;
typedef Eigen::Matrix<double, 8, 10> Mat810;
typedef Eigen::Matrix<double, 8, 3> Mat83;
typedef ........

//for sophus
#include <sophus/se3.hpp>
#include <sophus/so3.hpp>

typedef Sophus::SE3d SE3;
typedef Sophus::SO3d SO3;

//for opencv
#include <opencv2/core/core.hpp>
using cv::Mat;

//for glog
#include <glog/logging.h>

这样一来,我们只需要维护这一个头文件,而且在每一个自己编写的头文件和cpp当中,只需要引入自己写的对应h文件即可。头文件夹中不需要包含CMakeList,而cpp文件夹中的CMakeList应该和主文件夹中的保持联动:
————————————
工程经验2:在CMakeLists.txt中设置第三方库合集
根目录下的CMakeLists.txt片段如下:

set(THIRD_PARTY_LIBS
        ${OpenCV_LIBS}
        ${Sophus_LIBRARIES}
        ${Pangolin_LIBRARIES} GL GLU GLEW glut
        g2o_core g2o_stuff g2o_types_sba g2o_solver_csparse g2o_csparse_extension
        ${GTEST_BOTH_LIBRARIES}
        ${GLOG_LIBRARIES}
        ${GFLAGS_LIBRARIES}
        pthread
        ${CSPARSE_LIBRARY}
        )

enable_testing()

############### source and test ######################
include_directories(${PROJECT_SOURCE_DIR}/include)
add_subdirectory(src)
add_subdirectory(test)
add_subdirectory(app)

我们希望把带有头文件和cpp的,全部编译为库,因此在src文件夹当中,CMakeList这样写:

add_library(myslam SHARED
        frame.cpp
        mappoint.cpp
        map.cpp
        camera.cpp
        config.cpp
        feature.cpp
        frontend.cpp
        backend.cpp
        viewer.cpp
        visual_odometry.cpp
        dataset.cpp)

target_link_libraries(myslam
        ${THIRD_PARTY_LIBS}) //用到了前面的第三方库合集

二、刻画基本数据结构之间的关系

1.定义基本数据结构

对于一个典型的SLAM问题,抽象一点的数据类型有:相机、物方空间的三维点(又叫路标点)、图像(又叫关键帧)、图像上的特征点。其中,相机是一个独立的存在,如果设备有限,从头到尾我们只使用一部相机,那么相机就不和后面发生联系。路标点、图像和特征点三者紧密相关,这是我们需要重点考虑的;
(1)对于图像(Frame),其属性有id、是否为关键帧、关键帧id、左图像、右图像(如果是双目的);
(2)对于特征(Feature),其属性有id、是否为离群点、二维位置、是否在左图像上;
(3)对于三维点(MapPoint),其属性有id,是否为离群点、被观测的数量(有很多特征点对应)
在这里插入图片描述
这是一个粗略的图,实际上单张图像上不会出现2个特征对应同一个路标点。但做出这样的图很重要,因为他显示了我们的基本数据结构之间的“一对一”、“一对多”还是“多对多”的关系。可以看出,一张图像上包含多个特征,换句话说,一张图像“持有”“一连串的特征;每一个特征只能对应一个路标,换句话说,一个特征可以持有一个路标;而一个路标可以对应多个特征,换句话说,一个路标可以持有一串特征。为什么要换说法呢,因为我们要跨越从“自然语言”到“机器语言”的“语义鸿沟”(科学中完成这一任务是神经网络,而现实中完成这一工作的也是神经网络——我们的大脑)。

2.使用标准容器与智能指针实现关系刻画

有了上面的说法,如何刻画一对一和一对多的关系呢?很明显,我们需要在类型成员中说明。最简单的想法,“一连串”可以使用vector这样的容器进行,但是里面要是存放很多的对象吗?这样是否合适?如何确定哪些对象需要被new出来放进去?即便我们不考虑内存的泄露与溢出问题,如果我们要对一组固定的对象进行多线程维护,而每个线程却使用着不同的对象,那岂不是南辕北辙。因此我们才会想到“指针”这个东西,我可以让多个线程复制指向对象的指针,而对象其实是没有变的,从而愉快的实现“系统般高大上的维护”(这可比单纯讲指针的那些C语言知识更让人激动而非昏昏欲睡),因此我们有了工程经验3,并以工程经验4的方式给出代码:
——————————
工程经验3:使用智能指针+C++标准库容器作为成员变量.
工程经验4:对于较为简单的数据结构,使用结构体来定义,利于区分

//图像类数据结构Frame片段
struct Frame{
    public:
        EIGEN_MAKE_ALIGNED_OPERATOR_NEW;
        typedef std::shared_ptr<Frame> Ptr;
        unsigned long id_ = 0;
        unsigned long keyframe_id_ = 0;
        bool is_keyframe_ = false;
        double time_stamp_;
        SE3 pose_;//TCW
        std::mutex pose_mutex_;
        cv::Mat left_img_,right_img_;
        
        //one to many
        std::vector<std::shared_ptr<Feature>> features_left_;
        std::vector<std::shared_ptr<Feature>> features_right_;
 }

//特征点类结构Feature片段
struct Feature{
    public:
        EIGEN_MAKE_ALIGNED_OPERATOR_NEW;
        typedef std::shared_ptr<Feature> Ptr;
        std::weak_ptr<Frame> frame_;
        std::weak_ptr<MapPoint> map_point_;
        cv::KeyPoint position_;
        bool is_outlier_ = false;
        bool is_on_left_img_ = false;
}

//地图点类结构MapPoint片段
struct MapPoint{
    public:
        EIGEN_MAKE_ALIGNED_OPERATOR_NEW;
        typedef std::shared_ptr<MapPoint> Ptr;
        
        unsigned long id_ = 0;
        bool is_outlier = false;
        Vec3 pos_ = Vec3::Zero(); //The  postion in the world
        int observed_times_ = 0;
        
        std::list<std::weak_ptr<Feature>> observations_;
        std::mutex data_mutex_;
 }

首先考虑图像对feature的持有,采用shared_ptr智能指针,存放进vector里面。而feature对图像的持有,则是只能用1个weak_ptr,因为不能互相引用(参考智能指针的基础知识)。而后,路标点对feature的持有也应当是一连串的。但是这里采用了list(C++中list的含义是链表而不是Python当中的list),这主要是因为路标点的这个属性经常需要insert和erase操作,不适合采用vector。关于这些现代C++容器的基本使用方法,请参阅笔者的另一篇博客(编程基础(6):C++标准库容器总结)。在成员变量中指定了这些指针之后,就可以以熟悉的逻辑思路来处理代码了。要注意的是,这里的路标点和图像之间并没有直接的关联,因此无需在路标点结构体和图像结构体之中指定他们的相互关系(那样的也太复杂了,我们只需要考虑“有直接关系的成员”,而非所有有联系的内容)
——————————
工程经验5:我们只需要考虑“有直接关系的变量”,而非所有有联系的内容。

3.使用线程锁保障安全

对于这些简单的数据结构,在并发维护的时候都要加上线程锁。一个简单的线程锁使用案例如下:

#include <mutex>
struct Frame{
	std::mutex data_mutex_; //在成员中添加锁,以备后来占用
	SE3 pose_;
	
	SE3 getPose(){
		std::unique_lock<std::mutex> lck(data_mutex_);
		return pose_;
	}
}

我们声明了一个锁,在具体函数调用的时候,只需要声明一个独占对象,即可完成对锁的独占,以避免并发异常。
——————————
工程经验6:使用线程锁以避免并发异常。

4.使用工厂函数生产需要的智能指针

一个直观的需求是,我们需要让系统记录“产生了多少对象”,这样一个id很明显应该使用静态方式声明。哪些需要工厂函数呢?就是那些“不依赖其他类而产生的原生结构”。例如Frame,只能往里面新加入,MapPoint也只能往里面加入新的。Feature则不是,因为有了Frame之后可以采用特征提取算法产生Feature。以Frame为例,工厂函数实现如下:

struct Frame{
	typedef std::shared_ptr<Frame> Ptr;
	unsigned long id_ = 0;
	.....
	static Frame::Ptr CreateFrame();
}

Frame::Ptr CreateFrame(){ //这里不需要加static
	static long factory_id = 0;
	Frame::Ptr newFrame = std::shared_ptr<Frame>(new Frame);
	newFrame->id_ = factory_id++;
	return newFrame;
}

——————————
工程经验7:使用工厂函数创建需要的智能指针,并维护序号id。

总结

本文从数据关联分析的角度,结合智能指针进行数据的实际构造,有助于真正的理解智能指针的场景和用途,以及各种C++标准容器的使用。事实上,将智能指针放在容器中是很不错的选择,不仅是因为需求,也是因为一种内存管理的优势(参考吴咏炜《现代C++实战30讲》)。本文中总结了使用工厂函数和线程锁的相关简单方法,日后如有新的体会,亦会保持更新。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值