為什么現(xiàn)代?C++?庫都用?PIMPL?一場關(guān)于封裝、依賴與安全的演進(jìn)
在 C++ 的工程實(shí)踐中,如何在保證資源安全管理的同時(shí),又避免頭文件污染和不必要的編譯依賴?這個(gè)問題貫穿了現(xiàn)代 C++ 庫設(shè)計(jì)的核心。本文將沿著一條清晰的技術(shù)演進(jìn)路徑,探討從 RAII 封裝出發(fā),歷經(jīng)值語義、裸指針、智能指針等階段,最終走向 PIMPL(Pointer to Implementation) 這一成熟且優(yōu)雅的解決方案。
1. RAII——資源管理的基石
C++ 的核心哲學(xué)之一是 RAII(Resource Acquisition Is Initialization):資源(內(nèi)存、文件句柄、網(wǎng)絡(luò)連接等)的生命周期應(yīng)由對象的構(gòu)造與析構(gòu)自動管理。例如:
class FileHandle {
FILE* fp;
public:
FileHandle(const char* path) : fp(fopen(path, "r")) {}
~FileHandle() { if (fp) fclose(fp); }
};
RAII 讓資源管理變得安全:利用類對象的生命周期,在構(gòu)造函數(shù)中申請資源,在析構(gòu)函數(shù)中釋放資源。如果這個(gè)類對象是基于棧的值對象,那么就可以自動實(shí)現(xiàn)資源的管理。因此,在現(xiàn)代 C++ 中,相比傳統(tǒng)的指針語義,更加提倡使用基于 RAII 的值語義。
2. 值語義的誘惑與代價(jià)
但是,當(dāng)我們把這種思想用于封裝復(fù)雜組件(如 ONNX 模型會話、數(shù)據(jù)庫連接池)時(shí),問題出現(xiàn)了。理想情況下,我們希望像使用 std::string 一樣,用“值語義”操作一個(gè)封裝對象:
class Embedder {
Ort::Session session; // 值成員
public:
std::vector<float> embed(const std::string& text);
};
這看起來非常簡潔、高效、符合現(xiàn)代 C++ 風(fēng)格。但也有另外一個(gè)問題:破壞了封裝,導(dǎo)致不必要的環(huán)境依賴。最直觀的問題就是 Ort::Session 的完整定義必須出現(xiàn)在頭文件中,這意味著使用者必須包含 onnxruntime ,而這個(gè)頭文件可能重達(dá)數(shù) MB ,依賴數(shù)十個(gè)系統(tǒng)庫。這就會造成如下問題:
- 編譯時(shí)間暴增,微小的改動都需要編譯很長的時(shí)間。
- 頭文件耦合嚴(yán)重,調(diào)用者使用不方便,甚至造成環(huán)境污染。
- ABI 極其脆弱,內(nèi)部改動導(dǎo)致所有用戶重編譯。
3. 指針語義的回退
為了解耦,一個(gè)比較好的辦法就是使用前置聲明 + 指針語義:
// header
class SessionImpl; // 前置聲明
class Embedder {
SessionImpl* pimpl;
public:
Embedder();
~Embedder(); // 必須手動 delete
};
這樣做確實(shí)切斷了編譯依賴,但也引入了新的問題。那就是需要按照 RAII 原則寫好構(gòu)造函數(shù)和析構(gòu)函數(shù)。而一旦要寫析構(gòu)函數(shù),也往往意味著需要寫另外四個(gè)特殊的成員函數(shù):
- 拷貝構(gòu)造函數(shù)(Copy Constructor)
- 拷貝賦值運(yùn)算符(Copy Assignment Operator)
- 移動構(gòu)造函數(shù)(Move Constructor)
- 移動賦值運(yùn)算符(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 呢?其實(shí)也可以,不過在現(xiàn)代 C++ 中,更推薦使用 std::unique_ptr 。std::shared_ptr 是用來共享資源的所有權(quán),會對引用資源進(jìn)行計(jì)數(shù),但是有可能會造成相互循環(huán)引用造成不能釋放資源的問題;而std::unique_ptr 則表示獨(dú)占資源的所有權(quán),不僅開銷更低(無引用計(jì)數(shù)),也更加安全(只能通過 std::move 轉(zhuǎn)移所有權(quán) )。
不過有一點(diǎn)需要注意:std::unique_ptr 和 std::shared_ptr 在處理不完整類型(incomplete type)時(shí)的行為截然不同。具體來說,當(dāng)在頭文件中使用前置聲明(如 class Impl;)并用智能指針持有它時(shí),Impl 是一個(gè)不完整類型。
std::shared_ptr可以安全地在頭文件中默認(rèn)析構(gòu),因?yàn)樗跇?gòu)造時(shí)(通常在.cpp文件中)會捕獲一個(gè)完整的刪除器(deleter),即使析構(gòu)發(fā)生在頭文件上下文中,也能正確調(diào)用delete。- 而
std::unique_ptr的刪除器是其類型的一部分(通常是默認(rèn)的std::default_delete<Impl>),它要求在析構(gòu)點(diǎn)(即類的析構(gòu)函數(shù)被實(shí)例化的地方)Impl必須是完整類型。如果在頭文件中寫~Embedder() = default;,此時(shí)Impl仍是不完整的,編譯器可能不會報(bào)錯(cuò),但會導(dǎo)致未定義行為(通常是鏈接失敗或運(yùn)行時(shí)崩潰)。
因此,使用 std::unique_ptr<Impl> 時(shí),必須將主類的析構(gòu)函數(shù)定義移到 .cpp 文件中,確保 Impl 已被完整定義:
// Embedder.cpp
class Embedder::Impl {
// 完整定義...
};
Embedder::~Embedder() = default; // ? 此時(shí) Impl 完整,安全析構(gòu)
5. 封裝與效率的平衡:PIMPL
使用智能指針雖然好,但是總歸是比不上值語義方便。當(dāng)類中只有一個(gè)需要隱藏的成員還好,如果有很多個(gè)需要隱藏的成員,每一個(gè)都寫前置聲明,并用智能指針來管理,那就實(shí)在太繁瑣了。并且,從編程品味上來說,C++ 智能指針的寫法說不上優(yōu)雅:智能指針是由傳染性的,當(dāng)滿屏都是 std::shared_ptr 或者 std::unique_ptr 的時(shí)候,實(shí)在很影響閱讀性。
另外,作為對外的接口,最好是提供像 Java / C# 那樣的接口,C++ 的純虛函類也行,隱藏掉所有的細(xì)節(jié),包括私有函數(shù)和數(shù)據(jù)成員。這樣有非常多的好處:
- 最小化依賴環(huán)境,提升編譯速度。
- 調(diào)用者使用方便,不會污染環(huán)境。
- ABI 穩(wěn)定,可以只更新庫而不用更新整個(gè)程序。
那么要怎么進(jìn)行優(yōu)化呢?很簡單,我們可以實(shí)現(xiàn)一個(gè)名為 Impl 的類中類 ,使用std::unique_ptr進(jìn)行管理。Impl 是實(shí)現(xiàn)在 cpp 中的,可以將一切實(shí)現(xiàn)的細(xì)節(jié),比說私有函數(shù)和數(shù)據(jù)成員,都放在這個(gè) Impl 中。更重要的是,Impl 中的數(shù)據(jù)成員完全可以使用值類型!如下所示:
// 頭文件
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; // 此時(shí) Impl 完整,安全!
這個(gè)實(shí)現(xiàn),就是所謂的 PIMPL(Pointer to IMPLementation)慣用法,也常被稱作 “編譯防火墻”(Compilation Firewall) 或 “Opaque Pointer” 模式。不得不說,這種 PIMPL 設(shè)計(jì)模式確實(shí)精妙——它在安全性、封裝性、編譯效率與接口簡潔性之間取得了近乎完美的平衡,既堅(jiān)守了 RAII 的資源管理原則,又有效隔離了實(shí)現(xiàn)細(xì)節(jié),堪稱現(xiàn)代 C++ 工程實(shí)踐中“高內(nèi)聚、低耦合”的典范。
6. 沒有銀彈,只有權(quán)衡
PIMPL 使用了前置聲明。是否使用前置聲明一直是 C++ 中比較爭議的一點(diǎn),Qt 遵循前置聲明的原則實(shí)現(xiàn)了非常強(qiáng)大、優(yōu)雅且高效的 C++ 運(yùn)行時(shí)框架。Google 則經(jīng)歷了從推薦使用前置聲明到不推薦使用前置聲明的轉(zhuǎn)變。個(gè)人認(rèn)為,PIMPL 解決的就是 C++ 中兩個(gè)重要原則矛盾的問題:
- 推薦使用值語義,但是會引入更多環(huán)境依賴
- 封裝需要盡可能隱藏不必要的細(xì)節(jié)
如果兩者只能選擇其中一個(gè),那么還是盡量使用值語義的原則更加重要,畢竟這涉及到安全問題,而資源管理的安全問題貫穿 C++ 程序的始終。事實(shí)上,如果不是提供對外接口,或者實(shí)現(xiàn)比較小,那么直接使用值語義即可(第2節(jié)中的內(nèi)容)——值語義永遠(yuǎn)是最簡潔安全的實(shí)現(xiàn)。
另外,如果實(shí)現(xiàn) C++20 Modules ,那么就不必要使用 PIMPL 了,完全可以回歸值語義實(shí)現(xiàn),因?yàn)?C++20 Modules 在語言層面已經(jīng)實(shí)現(xiàn)了 PIMPL 的諸多優(yōu)點(diǎn)。
7. 示例代碼
最后放出筆者自己實(shí)現(xiàn)的基于 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();
// 假設(shè)輸出是 [batch, seq, dim] 或 [batch, dim]
// 我們?nèi)∽詈笠粋€(gè)非 -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"};
// 把三個(gè)輸入張量放進(jìn)數(shù)組
std::vector<Ort::Value> inputs;
inputs.push_back(std::move(inputIdsTensor));
inputs.push_back(std::move(attentionMaskTensor));
inputs.push_back(std::move(tokenTypeIdsTensor));
// 執(zhí)行推理
auto outputs = session.Run(Ort::RunOptions(), // 運(yùn)行選項(xiàng)(通常 nullptr)
inputNames, // 輸入名數(shù)組
inputs.data(), // 輸入張量數(shù)組
inputs.size(), // 輸入數(shù)量(3)
outputNames, // 輸出名數(shù)組
1 // 輸出數(shù)量(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個(gè)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; // 此時(shí) Impl 已定義,可安全析構(gòu)
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到此這篇關(guān)于為什么現(xiàn)代 C++ 庫都用 PIMPL?一場關(guān)于封裝、依賴與安全的演進(jìn)的文章就介紹到這了,更多相關(guān)C++ 庫都用 PIMPL內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
IOS開發(fā)之UIScrollView實(shí)現(xiàn)圖片輪播器的無限滾動
這篇文章主要介紹了IOS開發(fā)之UIScrollView實(shí)現(xiàn)圖片輪播器的無限滾動的相關(guān)資料,需要的朋友可以參考下2017-07-07
c++基礎(chǔ)語法:構(gòu)造函數(shù)初始化列表
構(gòu)造函數(shù)需要初始化的數(shù)據(jù)成員,不論是否顯示的出現(xiàn)在構(gòu)造函數(shù)的成員初始化列表中,都會在該處完成初始化,并且初始化的順序和其在聲明時(shí)的順序是一致的,與列表的先后順序無關(guān)2013-09-09
C語言輸入一個(gè)數(shù)判斷是否為素?cái)?shù)的多種方法
素?cái)?shù)是只能被1和它自己本身整除,不能被其他自然數(shù)整除的大于1的正整數(shù),下面這篇文章主要給大家介紹了關(guān)于C語言輸入一個(gè)數(shù)判斷是否為素?cái)?shù)的多種方法,文中通過實(shí)例代碼介紹的非常詳細(xì),需要的朋友可以參考下2023-04-04
Ubuntu16.04下配置VScode的C/C++開發(fā)環(huán)境
這篇文章主要介紹了Ubuntu16.04下配置VScode的C/C++開發(fā)環(huán)境的教程,本文通過圖文并茂的形式給大家介紹的非常詳細(xì),對大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2020-03-03

