为什么现代 C++ 库都用 PIMPL?一场关于封装、依赖与安全的演进

 更新时间:2026年02月15日 14:53:30   作者:Charlee44  
这篇文章主要介绍了为什么现代 C++ 库都用 PIMPL?一场关于封装、依赖与安全的演进的相关资料,需要的朋友可以参考下

在 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内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

相关文章

  • C语言驱动开发之内核使用IO/DPC定时器详解

    C语言驱动开发之内核使用IO/DPC定时器详解

    本章将继续探索驱动开发中的基础部分,定时器在内核中同样很常用,在内核中定时器可以使用两种,即IO定时器,以及DPC定时器,感兴趣的可以了解一下
    2023-04-04
  • C++程序设计-五子棋

    C++程序设计-五子棋

    本文将以简单的存储结构及简单的运算,条件语句,分支语句,循环语句结合,带来一个双人对战版五子棋,这是一个简单的模型,实现了五子棋最最基本的功能。具有很好的参考价值,下面跟着小编一起来看下吧
    2017-02-02
  • C语言如何实现Unix时间戳与本地时间转化

    C语言如何实现Unix时间戳与本地时间转化

    这篇文章主要介绍了C语言如何实现Unix时间戳与本地时间转化的相关资料,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2021-03-03
  • QT中QByteArray与char、int、float之间的互相转化

    QT中QByteArray与char、int、float之间的互相转化

    本文主要介绍了QT中QByteArray与char、int、float之间的互相转化,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2022-05-05
  • C/C++实现快速排序算法的两种方式实例

    C/C++实现快速排序算法的两种方式实例

    快速排序是一种采用分治思想,在实践中通常运行较快一种排序算法,这篇文章主要给大家介绍了关于C/C++实现快速排序的两种方式的相关资料,文中给出了详细的示例代码,需要的朋友可以参考下
    2021-08-08
  • C++图文并茂讲解继承

    C++图文并茂讲解继承

    继承是C++面向对象编程中的一门。继承是子类继承父类的特征和行为,或者是继承父类得方法,使的子类具有父类得的特性和行为。重写是子类对父类的允许访问的方法实行的过程进行重新编写,返回值和形参都不能改变。就是对原本的父类进行重新编写,但是外部接口不能被重写
    2022-05-05
  • C++中vector容器的用法

    C++中vector容器的用法

    在c++中,vector是一个十分有用的容器。这篇文章主要介绍了C++ vector容器的用法的相关资料,非常不错具有参考借鉴价值,需要的朋友可以参考下
    2016-10-10
  • C++ 中引用与指针的区别实例详解

    C++ 中引用与指针的区别实例详解

    这篇文章主要介绍了C++ 中引用与指针的区别实例详解的相关资料,需要的朋友可以参考下
    2017-06-06
  • C语言实现计算双色球的中奖率

    C语言实现计算双色球的中奖率

    这篇文章主要为大家详细介绍了如何利用C语言实现计算双色球的中奖率,文中的示例代码讲解详细,感兴趣的小伙伴可以跟随小编一起学习一下
    2022-12-12
  • C语言详解数据结构与算法中枚举和模拟及排序

    C语言详解数据结构与算法中枚举和模拟及排序

    枚举和模拟其实是没什么算法可言的,大多数都是按照题目意思去写,这里提供快排和归并的两个模板,感兴趣的朋友来看看吧
    2022-04-04

最新评论