mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4mobile wallpaper 5mobile wallpaper 6
2974 字
8 分钟
从CloudCompare源码中学习C++工程化接口设计
2026-04-30

0. 前言#

主管扔给我一个 PointStartMap 的项目目录,让我扒拉下来看看 NsCCLibHeader Files/CCDb 下的一堆 .h 文件,说”着重学习一下里面的接口是怎么做的”。我点开一个文件头,看到版权声明:

This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License…

再看到 CLOUDCOMPAREEDF R&D / TELECOM ParisTech 的字样,我知道了——这是开源点云处理软件 CloudCompare(简称 CC)的核心数据库层

主管让我看这些,是让我观察一个成熟开源项目是怎么设计 C++ 接口的:导出宏怎么包装、基类怎么抽象、Qt 类型怎么融入、对象生命周期怎么管理。这些正是我接下来要把 Python 算法封装成 C++ DLL 时最缺的经验。

这篇笔记会记录我从 CC 源码中”考古”接口设计模式的过程。不求吃透 CC 的全部架构,只求把 CCDb 这一层的接口骨架扒清楚,然后照猫画虎地用到自己的车厢分割模块里。


1. CloudCompare与我的项目#

1.1 什么是 CloudCompare#

CloudCompare 是一款由法国电力集团研发部门(EDF R&D)和巴黎 Telecom 联合维护的开源 3D 点云与网格处理软件,采用 GPL v2 许可证。它提供了点云可视化、分割、测量、配准、重建等一整套功能,在测绘、BIM、工业检测领域用得极广。

它的核心架构大致可以拆成几块:

模块对应目录/库职责
CCDb(数据库层)libs/qCC_db/定义所有可管理对象的基类与数据容器
CCCoreLib(算法库)CCCoreLib/提供点云处理的基础算法(KNN、PCA、采样等)
qCC_io(IO 层)libs/qCC_io/读写 PCD、LAS、PLY 等格式
qCC_gl(渲染层)libs/qCC_gl/OpenGL 可视化相关
Main App + PluginsqCC/ + plugins/主界面与插件系统

1.2 我们项目里的 NsCCLib 是什么#

在公司项目中,NsCCLib 看起来是把 CloudCompare 的 CCDb 以及部分核心库抽出来、重新命名空间后封装成的内部基础库。文件命名依然保留了 CC 的 cc 前缀(如 ccHObjectccGenericPointCloud),但可能经过了裁剪或增补,以适配业务需求。

这意味着:

  1. 接口风格要对齐:我后续写的车厢分割模块,如果也要挂进这个项目,最好遵循 ccXXX 的命名习惯和 QCC_DB_LIB_API 式的导出宏设计。
  2. 数据类型要兼容:点云输入可能不是裸的 PCL pcl::PointCloud,而是 CC 的 ccPointCloudccGenericPointCloud
  3. 许可证要注意:GPL v2 具有传染性。如果我的算法以独立进程或独立 DLL 的形式存在,不链接 CC 的 GPL 代码,则可以保持闭源;如果直接修改 CC 源码并随软件分发,则修改部分需开源。具体怎么集成,需要后续和主管确认架构方案。

1.3 主管让我学什么#

主管的原话是让我看 CCDb.h 文件的接口设计。翻译一下,核心就这几点:

  • 导出宏的包装:看 QCC_DB_LIB_API 是怎么用条件编译切换 dllexport/dllimport 的。
  • 基类与继承体系:看 ccHObject 怎么当”万物之父”,以及 ccGenericPointCloud 这类抽象基类怎么定义接口。
  • Qt 的融合:看 CC 怎么在算法层使用 QStringQRect 等 Qt 类型,而不是裸 std::string
  • 多继承与接口分离:看一个类怎么同时继承实体基类和交互接口(如 cc2DLabel : public ccHObject, public ccInteractor)。

2. 学习路线#

主管让我看 CCDb,本质是让我观摩一个成熟开源项目的接口设计规范,然后在自己的全新项目里复现。我不需要看懂 CloudCompare 的渲染管线,也不需要把我的车厢算法硬塞进 PointStartMap

学习目标就一条:看完 CC 的 .h 文件后,我能独立写出一份让主管觉得”专业、对齐、舒服”的 carriage_api.h

按这个目的,学习顺序如下:

步骤看什么学到什么产出物
Step 1cc2DLabel.h(或任意一个中小型 .h文件头规范:版权注释、#pragma once、include 顺序、导出宏 QCC_DB_LIB_API 的定义位置我自己的 carriage_api.h 文件头骨架
Step 2ccHObject.h基类设计:怎么定义一个所有对象都继承的根,怎么管理生命周期(构造函数/析构函数/克隆接口)我的 ISegmentor 基类声明
Step 3ccGenericPointCloud.h数据容器接口:点云类怎么抽象读写接口,怎么隐藏内部存储(PCL/Qt/裸数组)我的输入输出数据结构定义
Step 4带工厂函数的类(如 ccExternalFactory.h 或全局 CreateXXX对象创建模式:是暴露 new,还是用工厂函数隐藏实现类我的 CreateSegmentor() 声明
Step 5cc2DLabel.cpp(可选,看实现)接口与实现分离.h 里纯虚函数,.cpp 里具体类继承实现我的 carriage_impl.cpp 骨架

关键原则:每看完一个 CC 的头文件,立刻在自己的项目里写一个对应的仿制品。不要看完五个再动手,那样全忘了。


3. Step 1:看cc2DLabel.h#

打开 cc2DLabel.h

序号找什么cc2DLabel.h 里大概长什么样对应我的模块
1版权注释// CLOUDCOMPARE + GPL 声明我可以写公司/个人版权声明(暂时先不写)
2#pragma once第 1 行我也放第 1 行
3导出宏QCC_DB_LIB_API(可能在某个 ccLibDefines.h 里定义)我要定义 CARRIAGE_API
4include 顺序先本地头文件(ccHObject.h),再 Qt(<QRect>),再标准库(<array>我也按”本地 → 第三方 → 标准库”排
5类声明骨架class QCC_DB_LIB_API cc2DLabel : public ccHObject, public ccInteractor我要写 class CARRIAGE_API Segmentor : public ISegmentor

3.1 #pragma once是什么?#

防止头文件被重复包含,避免编译报错。


3.1.1 什么是”重复包含”#

我的 carriage_api.h 会被多个 .cpp 文件 #include

main.cpp
#include "carriage_api.h"
// carriage_impl.cpp
#include "carriage_api.h"
// test.cpp
#include "carriage_api.h"

编译时,这三个 .cpp 各自会把 carriage_api.h 的内容复制粘贴进来。如果没有保护,编译器会看到三份一模一样的类声明,直接报错:

error C2011: "ISegmentor": "class" 类型重定义

3.1.2 #pragma once 的作用#

它就是告诉编译器:

“这个文件,整个编译过程里只给我展开一次,后面再遇到 #include,跳过。”

相当于给文件贴了个”已读”标签。


3.1.3 另一种写法#

老代码或跨平台库常用这种:

#ifndef CARRIAGE_API_H // 如果没定义这个宏
#define CARRIAGE_API_H // 那就定义它
// ... 头文件内容 ...
#endif // 结束

#pragma once 效果一样,但:

  • #pragma once 更简洁,一行搞定,现代项目首选
  • #ifndef 更兼容(极少数老编译器不支持 #pragma once,但你碰不到)

3.1.4 放哪一行?#

永远放头文件第一行,前面连空行都别留:

#pragma once ← 第 1 行
// 版权注释
// include 其他头文件
// 类声明...

3.2 导出宏#

导出宏就是 Windows DLL 的”开关”:编译 DLL 时它把符号推出去,别人用 DLL 时它把符号拉进来。没有它,你的类/函数对别人不可见。


3.2.1 底层原理:__declspec(dllexport) vs __declspec(dllimport)#

这是 Windows 特有的机制(Linux/macOS 不需要)。

场景编译器看到什么效果
编译你的 DLL__declspec(dllexport)把这个类/函数名写进 .dll导出表,对外可见
主管编译主程序__declspec(dllimport)告诉链接器:这个符号不在当前 .exe 里,去 .dll 里找

如果不加:类/函数只在 DLL 内部可见,主管链接时报 LNK2019 无法解析的外部符号


3.2.2 完整代码#

通常放在单独一个头文件里,或每个公共头文件顶部:

// carriage_export.h (建议单独一个文件,或放在 carriage_api.h 顶部)
#pragma once
// 关键:编译你的 DLL 项目时,VS 项目属性里预处理器定义了 CARRIAGE_EXPORTS
// 所以编译 DLL 时走 #ifdef 分支,用 dllexport
// 主管编译主程序时,没定义 CARRIAGE_EXPORTS,走 #else 分支,用 dllimport
#ifdef CARRIAGE_EXPORTS
#define CARRIAGE_API __declspec(dllexport)
#else
#define CARRIAGE_API __declspec(dllimport)
#endif

3.2.3 命名规范#

项目规范你的例子
宏名项目名_API库名_APICARRIAGE_APIPOINTCLOUD_APINSCC_API
定义开关项目名_EXPORTSCARRIAGE_EXPORTSQCC_DB_LIB_EXPORTS
文件位置单独 xxx_export.h,或每个公共 .h 顶部carriage_export.h
作用对象所有要对外暴露的 classextern "C" 函数class CARRIAGE_API ISegmentor

3.3 具体怎么用#

Step 1:定义宏(一次)

创建 carriage_export.h

#pragma once
#ifdef CARRIAGE_EXPORTS
#define CARRIAGE_API __declspec(dllexport)
#else
#define CARRIAGE_API __declspec(dllimport)
#endif

Step 2:给要暴露的类/函数”挂牌”

carriage_api.h 里:

#include "carriage_export.h"
namespace CarriageSegment {
// 类前面挂 CARRIAGE_API,表示"这个类要导出到 DLL 外"
class CARRIAGE_API ISegmentor {
public:
virtual bool Initialize(const std::string& config_path) = 0;
virtual float Process(const std::string& pcd_path) = 0;
virtual void Release() = 0;
virtual ~ISegmentor() = default;
};
// 工厂函数也要挂牌
extern "C" CARRIAGE_API ISegmentor* CreateSegmentor();
} // namespace

3.4 CloudCompare 里怎么写的(对照)#

你之前看的 cc2DLabel.h 里:

class QCC_DB_LIB_API cc2DLabel : public ccHObject...

QCC_DB_LIB_API 就是 CC 的导出宏,定义在某个 ccLibDefines.h 里:

#ifdef QCC_DB_LIB_BUILD
#define QCC_DB_LIB_API __declspec(dllexport)
#else
#define QCC_DB_LIB_API __declspec(dllimport)
#endif

3.5 我的产出动作#

看完 cc2DLabel.h 的前 50 行后,立刻在我的 CarriageAlgoTest 项目里新建 carriage_api.h,照抄这个结构:

#pragma once
// 1. 导出宏(照抄 QCC_DB_LIB_API 的模式)
#ifdef CARRIAGE_EXPORTS
#define CARRIAGE_API __declspec(dllexport)
#else
#define CARRIAGE_API __declspec(dllimport)
#endif
// 2. include 顺序:本地 → 第三方 → 标准库
// (先空着,后续需要再加)
// 3. 命名空间(学 CC 的 namespace 风格)
namespace CarriageSegment {
// 4. 前向声明(如果有复杂类型,先声明不 include)
class IAlgorithm; // 占位
// 5. 你的接口类骨架(仿 cc2DLabel 的继承风格)
class CARRIAGE_API ISegmentor {
public:
// 构造函数/析构函数
ISegmentor() = default;
virtual ~ISegmentor() = default;
// 生命周期三段式(仿 CC 的 Init/Process/Release 套路)
virtual bool Initialize(const std::string& config_path) = 0;
virtual float Process(const std::string& pcd_path) = 0;
virtual void Release() = 0;
};
} // namespace CarriageSegment

4. Step 2:看 ccHObject.h 学基类设计#

继续 Step 2。 ccHObject.h 是 CloudCompare 的”万物之父”,所有点云、网格、标注、包围盒都继承自它。它很长,但我只需要抄3 个核心设计模式

4.1 ccHObject 的核心骨架#

打开 ccHObject.h,别被长度吓到,找这 4 个特征:

特征ccHObject.h 里长什么样为什么重要
1. 虚析构函数virtual ~ccHObject();基类指针 delete 时,能正确调用子类析构,防止内存泄漏
2. 纯虚接口virtual bool isA(const CC_CLASS_ENUM& flag) const = 0;强制所有子类必须实现,定义”契约”
3. 克隆接口virtual ccHObject* clone() const = 0;工厂模式,外部通过基类指针复制对象,不知道具体子类类型
4. 父子树系统ccHObject* getParent() const; void addChild(ccHObject* child);CC 是可视化软件,需要场景树管理。你的算法模块不需要这个

你的学习重点虚析构函数 + 纯虚接口 + 克隆接口。父子树是 CC 做界面用的,你不用抄。


4.2 映射到你的车厢模块:哪些要抄,哪些不要#

CC 的设计你要不要抄你的对应设计
虚析构函数必须抄virtual ~ISegmentor() = default;
纯虚接口(isA, toString抄模式Initialize(), Process(), Release() 设为纯虚
克隆接口 clone()⚠️ 可选如果主管需要”复制一份配置去跑另一帧”,就加
父子树 parent/child不抄你的算法模块是独立处理器,不是场景节点
唯一 ID 系统不抄算法模块不需要
元数据 metaData不抄struct CarriageConfig 代替

4.3 你的 ISegmentor 基类代码(直接复制用)#

carriage_api.h
#pragma once
#include "carriage_export.h"
#include <string>
namespace CarriageSegment {
// 配置结构体(仿 CC 的 metaData,但更简单)
struct CarriageConfig {
float voxel_size = 0.05f; // 体素大小
float min_height = 0.3f; // 车厢最低高度(过滤地面)
float max_height = 4.5f; // 车厢最高高度
int min_cluster_size = 100; // 最小聚类点数
std::string model_path; // 模型路径(如果有深度学习模型)
};
// 结果结构体
struct CarriageInfo {
float volume_m3 = 0.0f; // 体积
float length = 0.0f; // 长
float width = 0.0f; // 宽
float height = 0.0f; // 高
bool success = false;
std::string error_msg;
};
// 基类接口(仿 ccHObject 的纯虚接口设计)
class CARRIAGE_API ISegmentor {
public:
// 1. 虚析构函数(必须!抄 ccHObject 的 ~ccHObject())
virtual ~ISegmentor() = default;
// 2. 生命周期三段式(纯虚,强制子类实现)
virtual bool Initialize(const CarriageConfig& config) = 0;
virtual CarriageInfo Process(const std::string& pcd_file_path) = 0;
virtual void Release() = 0;
// 3. 克隆接口(可选,仿 ccHObject::clone)
// 如果你需要"保存当前配置,复制一份去处理下一帧",就加上
// virtual ISegmentor* Clone() const = 0;
// 4. 信息接口(仿 ccHObject::toString,调试用)
virtual std::string GetVersion() const { return "1.0.0"; }
};
// 工厂函数(仿 CC 的 Create 模式,外部用这行创建对象)
extern "C" CARRIAGE_API ISegmentor* CreateSegmentor();
} // namespace CarriageSegment

4.4 为什么这样设计(逐行解释)#

代码设计原因如果不这样写的后果
virtual ~ISegmentor() = default基类指针析构时,能正确调用子类析构函数子类有资源没释放,内存泄漏
= 0 纯虚函数强制任何继承 ISegmentor 的类都必须实现这 3 个函数有人继承后忘了实现 Process(),编译直接报错
const CarriageConfig& config传引用,避免拷贝大结构体;加 const 承诺不修改传值会复制整个结构体,浪费性能
extern "C" 工厂函数防止 C++ 编译器改名(mangling),方便主管调试时认函数名主管用 dumpbin 看 DLL 导出表,看到一堆乱码
GetVersion() 非纯虚有默认实现,子类可以选择性重写每个子类不用强制实现版本号,省事

4.5 实现类怎么写(对应 ccHObject 的子类如 ccPointCloud#

carriage_impl.h
#pragma once
#include "carriage_api.h"
namespace CarriageSegment {
// 实现类(仿 ccPointCloud 继承 ccHObject)
class CARRIAGE_API SegmentorImpl : public ISegmentor {
public:
SegmentorImpl() = default;
~SegmentorImpl() override; // override 关键字,编译器帮你检查是否真的覆盖了基类虚函数
bool Initialize(const CarriageConfig& config) override;
CarriageInfo Process(const std::string& pcd_file_path) override;
void Release() override;
std::string GetVersion() const override { return "1.0.0-pcl"; }
private:
CarriageConfig config_; // 保存配置
bool is_initialized_ = false; // 状态标记
// 后续加 PCL 成员变量
};
} // namespace

注意 override 关键字:这是现代 C++11 特性,写在子类虚函数后面,编译器会检查”基类里到底有没有这个虚函数”。如果基类函数签名写错了,override 会直接编译报错,防止你”以为覆盖了,其实没覆盖”的隐藏 bug。


4.6 你现在立刻做的#

  1. 在 VS/VS Code 里打开 ccHObject.h,找到:

    • virtual ~ccHObject(); 在哪一行?
    • clone() 函数怎么声明的?
    • 有没有 override 关键字?(CC 可能用旧标准没有,但你的项目应该用)
  2. 把上面的 carriage_api.h + carriage_impl.h 复制进你的项目

  3. 创建 carriage_impl.cpp,写空实现(返回假数据),编译测试:

    #include "carriage_impl.h"
    namespace CarriageSegment {
    SegmentorImpl::~SegmentorImpl() {
    Release();
    }
    bool SegmentorImpl::Initialize(const CarriageConfig& config) {
    config_ = config;
    is_initialized_ = true;
    return true;
    }
    CarriageInfo SegmentorImpl::Process(const std::string& pcd_file_path) {
    CarriageInfo info;
    info.volume_m3 = 42.0f; // 假数据
    info.success = true;
    return info;
    }
    void SegmentorImpl::Release() {
    is_initialized_ = false;
    }
    ISegmentor* CreateSegmentor() {
    return new SegmentorImpl();
    }
    } // namespace
  4. 修改 main.cpp 调用:

    #include "carriage_api.h"
    #include <iostream>
    int main() {
    CarriageSegment::ISegmentor* seg = CarriageSegment::CreateSegmentor();
    CarriageSegment::CarriageConfig cfg;
    cfg.voxel_size = 0.05f;
    if (seg->Initialize(cfg)) {
    auto result = seg->Process("test.pcd");
    std::cout << "Volume: " << result.volume_m3 << std::endl;
    }
    seg->Release();
    delete seg; // 工厂创建,外部释放。后续可改用智能指针
    return 0;
    }
  5. 编译Ctrl + Shift + B。如果报错,把错误信息发给我。

这套代码跑通后,你的接口骨架就完整了。后续只需要往 SegmentorImpl::Process() 里填 PCL 算法逻辑,接口纹丝不动。

跑通了告诉我,进入 Step 3:数据结构定义(看 ccGenericPointCloud 怎么抽象点云容器)。

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

从CloudCompare源码中学习C++工程化接口设计
https://fredsblog-2dc.pages.dev/posts/note-c-study-ccdb/
作者
Fredzhe
发布于
2026-04-30
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时