C++ Pimpl(Pointer to Implementation)设计思想(转载)
C++ Pimpl(Pointer to Implementation)设计思想
背景
最近都在写C++库,可以说是库库地写库。
只能说这库得写啊,不写不行。真有了下游使用方,才能真正理解很多工程实践。不然就只知其然,而不知其所以然。本文的灵感就由此而来,大家就看个乐呵,要是一不小心真学了点儿东西,嘿,那血赚。
本文使用的代码保存在这个repo里:
https://gitee.com/fataswellassad/clean-header
下载的时候建议使用命令:
git clone --recurse-submodules https://gitee.com/fataswellassad/clean-header.git
因为这样可以把子模块一起clone下来,比较省事喔。这个repo的使用方法都在README里了,就不多赘述了。欢迎大家扒下来自己build跑跑。
我们选取的例子是一个自然语言处理和大语言模型中都必不可少的组件:分词器(tokenizer)。
它有三个核心功能:
- 读入预先训练好的分词器文件。一般叫xxx.model。
- 编码(encode):输入一个字符串(string),输出一串数字(vector),对应着组成这个字符串的token们。
- 解码(decode):输入一串数字,输出一个字符串。即encode的逆操作
多说无益,我们直接看这个东西的使用范例便是,以下内容来自main.app,及其运行输出。
可以看到encode再decode,原文就回来了。和咱们预期的一样。
那么这么酷炫的功能是怎么整出来的呢。当然是通过调包儿,总不能是我自己搓出来的吧。不过这调包也是小有说法的。我们先来检阅一下最朴素的实现(位于repo的vanilla分支):
tokenizer.hpp
tokenizer.cpp
CMakeLists.txt
这个实现方案的优点我们已经看到了,能build,能跑。不过也就到此为止了。
我们来列举一下缺点。所有缺点的根源都在于:
#include<sentencepiece_processor.h>出现在了tokenizer.hpp这个头文件里!
- 下游用户拿到你build好的.so,和头文件.hpp,乐呵呵想用来build自己的东西。然后就发现:
2. 你猥琐调包儿的真相在hpp文件里一目了然。到时候你财务出身的老板和客户说:“啊,对,百分百纯!自!研!”然后对面对接的技术人员一拿到头文件,你猜他能不能乐出来。
这有问题,咱得解决。咋解决呢,核心思想是要把那个include给搬到tokenizer.cpp文件里雪藏起来。但是那样的话,SentencePieceProcessor sp作为一个成员变量,当场就会死给你看。成员函数可以拿到cpp文件里来实现,但是成员变量不可以,因为C/C++要求任何一个struct/class的大小,都要能在编译时确定。那怎么办,阿巴阿巴…
不要慌!编程的时候要秉承一个信念,越是容易遇到的问题,越有成熟的解决方案。事实上,这个问题的历史几乎和C/C++语言的历史一样久远。解决方案不仅有,而且还有两种。一个就是著名的pImpl idiom (Pointer to IMPLementation),另一个是借助抽象类和工厂函数。以下分别介绍。
pImpl Idiom
本实现请见repo中的pimpl分支。
tokenizer.hpp
tokenizer.cpp
CMakeLists.txt改动细微,只是把那个include_dir去掉了,毕竟已经不需要了。main.cpp更是完全不用更改。
需要讲解的主要有两点。
- 我们通过提前声明引入了一个TokenizerImpl类,然后把指向它的一个指针(unique_ptr)放进了Tokenizer类里。之后在cpp文件里,我们把原先Tokenizer的工作都代理给了TokenizerImpl。
- 一切实现都必须在cpp文件中进行,包括析构函数。不然仅有提前声明,C++默认生成的析构函数不知道TokenizerImpl的size,无法进行析构。
抽象基类 + 工厂函数
tokenizer.hpp
tokenizer.cpp
main.cpp (与pimpl不同,这个会改变接口)
我们实际上利用的是C++多态的特性。create_tokenizer这个工厂函数返回的是一个指针(unique_ptr),因为多态只对指针或引用生效。真正干活的TokenizerImpl是这个指针所指对象的运行时类型,从头文件是无法感知到的。
这和我们的需求不谋而合:你就知道这东西能decode/encode就行了,别的不需要管
总结
我们可以比较这两种实现的异同/优劣。
相同:
- 都能完成对类实现细节的封装,保证一个清爽纯净的头文件。
- 都要求进行堆内存的分配。这个应该没有办法解决,因为我们的需求是把成员变量藏起来,但C++编译器需要从头文件里读出sizeof(Tokenizer)才能把它放在栈上。
不同:
- pImpl idiom能保证使用侧用法与封装前完全相同。这对于重构某些祖传代码是十分重要的。这点上远胜抽象类。
- 如果我们本来就有几种不同的Tokenizer,比如现在这个是sentencepiece的,旁边还有一个Huggingface的,那抽象基类就变成了一个很自然的方案,工厂函数的逻辑也可以完美复用。这点,抽象类胜。
参考文献
C++小寄巧-10:儒雅随和头文件
PImpl
C++ Pimpl(Pointer to Implementation)设计思想
Pimpl(Pointer to Implementation)模式详解