在 c++ 的工程实践中,如何在保证资源安全管理的同时,又避免头文件污染和不必要的编译依赖?这个问题贯穿了现代 c++ 库设计的核心。本文将沿着一条清晰的技术演进路径,探讨从 raii 封装出发,历经值语义、裸指针、智能指针等阶段,最终走向 pimpl(pointer to implementation) 这一成熟且优雅的解决方案。
1. raii——资源管理的基石
c++ 的核心哲学之一是 raii(resource acquisition is initialization):资源(内存、文件句柄、网络连接等)的生命周期应由对象的构造与析构自动管理。例如:
class filehandle {
file* fp;
public:
filehandle(const char* path) : fp(fopen(path, "r")) {}
~filehandle() { if (fp) fclose(fp); }
};
raii 让资源管理变得安全:利用类对象的生命周期,在构造函数中申请资源,在析构函数中释放资源。如果这个类对象是基于栈的值对象,那么就可以自动实现资源的管理。因此,在现代 c++ 中,相比传统的指针语义,更加提倡使用基于 raii 的值语义。
2. 值语义的诱惑与代价
但是,当我们把这种思想用于封装复杂组件(如 onnx 模型会话、数据库连接池)时,问题出现了。理想情况下,我们希望像使用 std::string 一样,用“值语义”操作一个封装对象:
class embedder {
ort::session session; // 值成员
public:
std::vector<float> embed(const std::string& text);
};
这看起来非常简洁、高效、符合现代 c++ 风格。但也有另外一个问题:破坏了封装,导致不必要的环境依赖。最直观的问题就是 ort::session 的完整定义必须出现在头文件中,这意味着使用者必须包含 onnxruntime ,而这个头文件可能重达数 mb ,依赖数十个系统库。这就会造成如下问题:
- 编译时间暴增,微小的改动都需要编译很长的时间。
- 头文件耦合严重,调用者使用不方便,甚至造成环境污染。
- abi 极其脆弱,内部改动导致所有用户重编译。
3. 指针语义的回退
为了解耦,一个比较好的办法就是使用前置声明 + 指针语义:
// header
class sessionimpl; // 前置声明
class embedder {
sessionimpl* pimpl;
public:
embedder();
~embedder(); // 必须手动 delete
};
这样做确实切断了编译依赖,但也引入了新的问题。那就是需要按照 raii 原则写好构造函数和析构函数。而一旦要写析构函数,也往往意味着需要写另外四个特殊的成员函数:
- 拷贝构造函数(copy constructor)
- 拷贝赋值运算符(copy assignment operator)
- 移动构造函数(move constructor)
- 移动赋值运算符(move assignment operator)
这样做要写非常多的样板代码,而且也很容易出问题。为了封装牺牲安全,得不偿失。
4. 使用智能指针
使用裸指针又麻烦又不安全,那么就可以使用 c++11 引入的智能指针:std::unique_ptr 和 std::shared_ptr;智能指针同样是基于 raii 的:
class sessionimpl;
class embedder {
std::unique_ptr<sessionimpl> pimpl;
};
这里为什么使用 std::unique_ptr 而不使用 std::shared_ptr 呢?其实也可以,不过在现代 c++ 中,更推荐使用 std::unique_ptr 。std::shared_ptr 是用来共享资源的所有权,会对引用资源进行计数,但是有可能会造成相互循环引用造成不能释放资源的问题;而std::unique_ptr 则表示独占资源的所有权,不仅开销更低(无引用计数),也更加安全(只能通过 std::move 转移所有权 )。
不过有一点需要注意:std::unique_ptr 和 std::shared_ptr 在处理不完整类型(incomplete type)时的行为截然不同。具体来说,当在头文件中使用前置声明(如 class impl;)并用智能指针持有它时,impl 是一个不完整类型。
std::shared_ptr可以安全地在头文件中默认析构,因为它在构造时(通常在.cpp文件中)会捕获一个完整的删除器(deleter),即使析构发生在头文件上下文中,也能正确调用delete。- 而
std::unique_ptr的删除器是其类型的一部分(通常是默认的std::default_delete<impl>),它要求在析构点(即类的析构函数被实例化的地方)impl必须是完整类型。如果在头文件中写~embedder() = default;,此时impl仍是不完整的,编译器可能不会报错,但会导致未定义行为(通常是链接失败或运行时崩溃)。
因此,使用 std::unique_ptr<impl> 时,必须将主类的析构函数定义移到 .cpp 文件中,确保 impl 已被完整定义:
// embedder.cpp
class embedder::impl {
// 完整定义...
};
embedder::~embedder() = default; // ✅ 此时 impl 完整,安全析构
5. 封装与效率的平衡:pimpl
使用智能指针虽然好,但是总归是比不上值语义方便。当类中只有一个需要隐藏的成员还好,如果有很多个需要隐藏的成员,每一个都写前置声明,并用智能指针来管理,那就实在太繁琐了。并且,从编程品味上来说,c++ 智能指针的写法说不上优雅:智能指针是由传染性的,当满屏都是 std::shared_ptr 或者 std::unique_ptr 的时候,实在很影响阅读性。
另外,作为对外的接口,最好是提供像 java / c# 那样的接口,c++ 的纯虚函类也行,隐藏掉所有的细节,包括私有函数和数据成员。这样有非常多的好处:
- 最小化依赖环境,提升编译速度。
- 调用者使用方便,不会污染环境。
- abi 稳定,可以只更新库而不用更新整个程序。
那么要怎么进行优化呢?很简单,我们可以实现一个名为 impl 的类中类 ,使用std::unique_ptr进行管理。impl 是实现在 cpp 中的,可以将一切实现的细节,比说私有函数和数据成员,都放在这个 impl 中。更重要的是,impl 中的数据成员完全可以使用值类型!如下所示:
// 头文件
class embedder {
class impl;
std::unique_ptr<impl> impl;
public:
embedder(const std::string& model);
~embedder(); // 声明但不在头文件定义!
std::vector<float> embed(std::string_view text) const;
};
// 源文件
class embedder::impl {
ort::session session;
hf::tokenizer tokenizer;
int64_t dim;
public:
impl(const std::string& path, const hf::tokenizer& tok)
: session(...), tokenizer(tok) { /* init */ }
std::vector<float> embed(std::string_view text) const { /* ... */ }
};
embedder::embedder(const std::string& path)
: impl(std::make_unique<impl>(path, global_tokenizer)) {}
embedder::~embedder() = default; // 此时 impl 完整,安全!
这个实现,就是所谓的 pimpl(pointer to implementation)惯用法,也常被称作 “编译防火墙”(compilation firewall) 或 “opaque pointer” 模式。不得不说,这种 pimpl 设计模式确实精妙——它在安全性、封装性、编译效率与接口简洁性之间取得了近乎完美的平衡,既坚守了 raii 的资源管理原则,又有效隔离了实现细节,堪称现代 c++ 工程实践中“高内聚、低耦合”的典范。
6. 没有银弹,只有权衡
pimpl 使用了前置声明。是否使用前置声明一直是 c++ 中比较争议的一点,qt 遵循前置声明的原则实现了非常强大、优雅且高效的 c++ 运行时框架。google 则经历了从推荐使用前置声明到不推荐使用前置声明的转变。个人认为,pimpl 解决的就是 c++ 中两个重要原则矛盾的问题:
- 推荐使用值语义,但是会引入更多环境依赖
- 封装需要尽可能隐藏不必要的细节
如果两者只能选择其中一个,那么还是尽量使用值语义的原则更加重要,毕竟这涉及到安全问题,而资源管理的安全问题贯穿 c++ 程序的始终。事实上,如果不是提供对外接口,或者实现比较小,那么直接使用值语义即可(第2节中的内容)——值语义永远是最简洁安全的实现。
另外,如果实现 c++20 modules ,那么就不必要使用 pimpl 了,完全可以回归值语义实现,因为 c++20 modules 在语言层面已经实现了 pimpl 的诸多优点。
7. 示例代码
最后放出笔者自己实现的基于 pimpl 的嵌入器的完整代码供读者参考:
// bgeonnxembedder.h
#pragma once
#include <memory>
#include <string>
#include <vector>
namespace embedding {
namespace hf {
class tokenizer;
}
class bgeonnxembedder {
public:
explicit bgeonnxembedder(const std::string& modelpath,
const hf::tokenizer& tokenizer);
~bgeonnxembedder();
const int64_t& embeddingdim() const;
std::vector<float> embed(const std::string& text) const;
private:
class impl; // 前向声明
std::unique_ptr<impl> impl;
};
} // namespace embedding
//bgeonnxembedder.cpp
#include "bgeonnxembedder.h"
#include <onnxruntime_cxx_api.h>
#include "hftokenizer.h"
#include "util/stringencode.h"
namespace embedding {
class bgeonnxembedder::impl {
public:
ort::env& getortenv() {
static ort::env env(ort_logging_level_warning, "bgeonnxembedder");
return env;
}
const int64_t& embeddingdim() const { return embeddingdim; }
explicit impl(const std::string& modelpath, const hf::tokenizer& tokenizer)
: session{getortenv(),
#ifdef _win32
util::stringencode::utf8stringtowidestring(modelpath).c_str(),
#else
modelpath.c_str(),
#endif
ort::sessionoptions()},
meminfo{ort::memoryinfo::createcpu(ortdeviceallocator, ortmemtypecpu)},
tokenizer(tokenizer),
embeddingdim(0) {
//
const auto& outputinfo = session.getoutputtypeinfo(0);
const auto& tensorinfo = outputinfo.gettensortypeandshapeinfo();
const auto& shape = tensorinfo.getshape();
// 假设输出是 [batch, seq, dim] 或 [batch, dim]
// 我们取最后一个非 -1 的维度
for (auto it = shape.rbegin(); it != shape.rend(); ++it) {
if (*it != -1) {
embeddingdim = *it;
break;
}
}
if (embeddingdim == 0) {
throw std::runtime_error(
"failed to infer embedding dimension from onnx model.");
}
}
std::vector<float> embed(const std::string& text) const {
hf::tokenizer::resultptr result = tokenizer.encode(text);
if (!result) {
throw std::runtime_error("tokenizer_encode failed");
}
// 定义张量维度
int64_t seqlen = static_cast<int64_t>(result->length);
std::vector<int64_t> inputshape = {1, seqlen};
size_t databytecount = sizeof(int64_t) * seqlen;
ort::value inputidstensor = ort::value::createtensor(
meminfo.getconst(), result->input_ids, databytecount, inputshape.data(),
inputshape.size(),
onnxtensorelementdatatype::onnx_tensor_element_data_type_int64);
ort::value attentionmasktensor = ort::value::createtensor(
meminfo.getconst(), result->attention_mask, databytecount,
inputshape.data(), inputshape.size(),
onnxtensorelementdatatype::onnx_tensor_element_data_type_int64);
ort::value tokentypeidstensor = ort::value::createtensor(
meminfo.getconst(), result->token_type_ids, databytecount,
inputshape.data(), inputshape.size(),
onnxtensorelementdatatype::onnx_tensor_element_data_type_int64);
// 输入名必须与模型定义一致
const char* inputnames[] = {"input_ids", "attention_mask",
"token_type_ids"};
const char* outputnames[] = {"last_hidden_state"};
// 把三个输入张量放进数组
std::vector<ort::value> inputs;
inputs.push_back(std::move(inputidstensor));
inputs.push_back(std::move(attentionmasktensor));
inputs.push_back(std::move(tokentypeidstensor));
// 执行推理
auto outputs = session.run(ort::runoptions(), // 运行选项(通常 nullptr)
inputnames, // 输入名数组
inputs.data(), // 输入张量数组
inputs.size(), // 输入数量(3)
outputnames, // 输出名数组
1 // 输出数量(1)
);
// 获取输出信息
auto& output_tensor = outputs[0];
auto output_shape = output_tensor.gettensortypeandshapeinfo().getshape();
if (output_shape.size() != 3 || output_shape[0] != 1) {
throw std::runtime_error("unexpected output shape");
}
// 获取输出张量的原始 float 指针
const float* outputdata = outputs[0].gettensordata<float>();
// 提取 [cls] token 的 embedding(第0个token)
int64_t hiddensize = output_shape[2];
std::vector<float> embedding(outputdata, outputdata + hiddensize);
// l2 归一化(bge 要求)
float norm = 0.0f;
for (float v : embedding) norm += v * v;
norm = std::sqrt(norm);
if (norm > 1e-8) {
for (float& v : embedding) v /= norm;
}
return embedding;
}
private:
mutable ort::session session;
ort::memoryinfo meminfo;
const hf::tokenizer& tokenizer;
int64_t embeddingdim;
};
bgeonnxembedder::bgeonnxembedder(const std::string& modelpath,
const hf::tokenizer& tokenizer)
: impl(std::make_unique<impl>(modelpath, tokenizer)) {}
bgeonnxembedder::~bgeonnxembedder() = default; // 此时 impl 已定义,可安全析构
const int64_t& bgeonnxembedder::embeddingdim() const {
return impl->embeddingdim();
}
std::vector<float> bgeonnxembedder::embed(const std::string& text) const {
return impl->embed(text);
}
} // namespace embedding到此这篇关于为什么现代 c++ 库都用 pimpl?一场关于封装、依赖与安全的演进的文章就介绍到这了,更多相关c++ 库都用 pimpl内容请搜索代码网以前的文章或继续浏览下面的相关文章希望大家以后多多支持代码网!
发表评论