项目中这样去隐藏类的真正实现 是种好的做法吗?

2022-01-30 16:01:40 +08:00
 amiwrong123
//ModuleBase.h
class ModuleBase
{
public:
    ModuleBase();
    ModuleBase(ModuleBase* _impl);
    virtual ~ModuleBase() {}
    //省略了很多方法声明。比如开启定时器的函数声明、通过传一个 lambda 表达式来起一个异步的任务
private:
    android::sp<ModuleBase> impl;
};

项目里写了一个框架,每个子模块都可以直接继承 ModuleBase 来使用定时器等功能,比如class ModuleA : public ModuleBase

//ModuleBaseImpl.h
class ModuleBaseImpl : public ModuleBase
{
public:
    ModuleBaseImpl(ModuleBase& owner);
    virtual ~ModuleBaseImpl();
    //省略了很多方法声明。比如开启定时器的函数声明、通过传一个 lambda 表达式来起一个异步的任务
private:
    ModuleBase& mOwner;
};

ModuleBaseImpl 则是模块通用功能的真正实现。

// ModuleBase 的实现
ModuleBase::ModuleBase()
    : impl(new ModuleBaseImpl(*this)) {
}

ModuleBase::ModuleBase(ModuleBase* _impl)
    : impl(_impl) {
}
// ModuleBaseImpl 的实现
ModuleBaseImpl::ModuleBaseImpl(ModuleBase& owner)
    : ModuleBase(this),
    mOwner(owner)
{

}

上面我省略了很多函数的实现,实际上,ModuleBase 的很多函数都是通过它的android::sp<ModuleBase> impl成员来间接调用的,因为真正的实现都在 ModuleBaseImpl ,比如:

void ModuleBase::startTimer(int32_t timerId, uint64_t msec) {
    if (NULL != impl.get()) {
        static_cast<ModuleBaseImpl*>(impl.get())->startTimer(timerId, msec);
    }
}

目前看来,这么写是为了隐藏 ModuleBaseImpl 的实现,使用者原则上只需要 include ModuleBase.h 即可,但这么写是一种比较好的写法吗?(而且理解起来比较费心智)

class  ModuleA : public ModuleBase{};
ModuleA a;

比如上面这个,执行了ModuleA a;实际上生成了两个对象,第一个是我们能看到的 a 对象。第二个是ModuleBase::ModuleBase(): impl(new ModuleBaseImpl(*this)) {}里生成的 ModuleBaseImpl 对象(因为子模块 ModuleA 总是隐式地去调用父类 ModuleBase 的默认构造函数),但是使用者感觉不到这个 ModuleBaseImpl 对象的存在。

而且是:a 对象通过 sp 指针来指向这个隐藏 ModuleBaseImpl 对象,这个隐藏 ModuleBaseImpl 对象通过 owner 引用来指向 a 对象。(理解起来好费脑细胞啊)

(很尽力描述清楚了 QAQ )

3658 次点击
所在节点    C++
13 条回复
amiwrong123
2022-01-30 16:30:12 +08:00
![]( https://i.bmp.ovh/imgs/2022/01/b278977e8b169435.png)
我画了个图,希望能方便理解😂
amiwrong123
2022-01-30 16:40:36 +08:00
![]( https://i.bmp.ovh/imgs/2022/01/8971e3381336fb2c.png)
这个图才是对的,下面的是 ModuleBase 的子类。
GPIO
2022-01-30 17:22:03 +08:00
ModuleBaseImpl 和 ModuleBase 的继承关系我觉得没什么问题,比如你要支持多种数据库,那必然要多种不同的 implemention ,但是后面的 class ModuleA : public ModuleBase{};这个继承感觉没必要啊,还不如直接用组合。
amiwrong123
2022-01-30 18:03:40 +08:00
@GPIO #3
之所以一定要用`class ModuleA : public ModuleBase{};`这样的继承,而不用组合。是因为:
```cpp
class ModuleBase
{
ModuleBase();
ModuleBase(ModuleBase* _impl);
virtual ~ModuleBase() {}

//这三个函数都需要每个模块,自己去实现的
virtual void onTimer(int timerId) {};//启动定时器后,之后会异步调用到的函数
virtual void onStart() {};//模块初始化
virtual void onStop() {};

android::sp<ModuleBase> impl;
};
```
ModuleBase 有三个虚函数是默认实现,需要每个模块去重写。比如 ModuleA 去重写 onStart ,实现自己模块需要初始化的事情。所以各个模块都需要实现 onStart 这个函数实现。会由一个线程分别调用 ModuleA 、ModuleB 、ModuleC…… 的 onStart (调用的时候 使用到了多态,通过 ModuleBase 指针来调用 ModuleA 的实现)。
tool2d
2022-01-30 22:51:40 +08:00
C++有 interface 关键词的,不一定非要继承,组合模式也挺好的。
ysc3839
2022-01-31 01:02:13 +08:00
@tool2d C++ 没有 interface 关键词 https://en.cppreference.com/w/cpp/keyword
zzxxisme
2022-01-31 01:50:38 +08:00
先说我认为的结论:这种写法的主要目的 并不是 为了隐藏实现,而是为了方便转移、复制、释放一个 ModuleBase 子类的对象。

首先我们有 ModuleBase 这个基类,这个基类可能有很多种不同的子类实现,例如 ModuleBaseImpl 是其中一种实现。但是我用这个 Module 的时候我可能不关心具体实现是怎么样,不关心具体的子类是什么。我只想拿到 ModuleBase 的基类的指针(或者引用)进而能够访问子类的具体实现就好了。如果只是这个需求,我的设计可能就是令到 ModuleBase 的虚函数全部都弄成 纯虚函数 (也就是没有默认实现),然后子类 ModuleBaseImpl 必须实现所有纯虚函数。也就是

```c++
class ModuleBase {
...
virtual void startTimer(int32_t timerId, uint64_t msec) = 0; // 纯虚函数
};

class ModuleBaseImpl : public ModuleBase {
...
void startTimer(int32_t timerId, uint64_t msec) override {
... // 具体实现
}
};
```

然后这样会有一个小问题,就是我用一个 Module 的时候,我必须用 ModuleBase 的指针(或者引用,但如果能用引用,可能就没有我说的这个小问题)。而且用这个指针我还要注意适当的时候 delete 这个指针。但是如果这个指针是被其他人用的,在其他人的函数里面他们未必会 delete 这个指针。所以这个时候我需要弄多一个类,来包装这个 ModuleBase 指针,也就是

```c++
class ModuleBaseWrapper {
~ModuleBaseWrapper() { delete module; ​}
private:
​ModuleBase* module;
};

```

有了这个 ModuleBase Wrapper 之后,这个 Wrapper 在析构的时候会帮忙析构这个 ModuleBase 指针。

最后,回到一开始你说的 ModuleBase 里面包含了一个 android::sp<ModuleBase>impl 的事,这个 android::sp<ModuleBase>使得 ModuleBase 可以当做上面的 ModuleBaseWrapper 来用。
pezy
2022-01-31 15:19:27 +08:00
自从尝试了这个方案: https://yairchu.github.io/posts/the-priv-idiom
后 我觉得 PIMLP 舒服多了。
zwzmzd
2022-02-01 14:00:58 +08:00
所谓的“编译器防火墙”,这种类主要是库的提供者作为接口类给下游使用的,避免升级库 so 的时候踩到二进制兼容问题。另一个好处是 impl 改动的时候,同项目中下游引用的类可以不用重新编译,提升开发效率。

看需求了,写起来有点麻烦,一般用的不多
crackhopper
2022-02-01 15:33:44 +08:00
PIMPL 通常来说是个好的做法,但一般都是写库的时候用,方便提供简化的头文件,分离一些依赖。另外对加快编译速度也有帮助。
FrankHB
2022-02-01 15:49:17 +08:00
看这代码,不好。虽然思路不见得错。

首先,ModuleBase(ModuleBase*) 的参数含义就不明确,没文档还得看实现:谁有所有权?
即使.h 里的 private 成员类型能让用户看得到,但正常情况下就不应该看这个(需要看的 private 成员仅限虚函数)。

其次,“开启定时器”这么具体的功能可以“通用”到和 module 有什么关系?
没套个命名空间限定,这 module 的说法就可疑到要猜到底有没清楚从哪些具体逻辑中抽象出什么。
如果这类具体功能必须在公开 API 中体现,那么要么 ModuleBase 这命名不够清晰,要么这更适合在类外以自由函数的方式提供(所谓“扩展方法”)。
虽然你可以辩解说这样纯粹只是作为虚指的例子,不具有实际含义;但这里要强调,是否放在类的继承体系中,和具体功能通常强相关。
总体原则是便利接口放类外而类只提供必要接口(避免 std::basic_string 膨胀到阻碍易用和扩展性的教训),但什么功能算“必要”的判断标准依赖具体业务逻辑。

第三,不是说这样实现不好,而是用户的这种心智包袱有问题。
跟 OO 意义两回事,C++对象不一定是类的实例,建模上可能没意义;运行时开销也是原则上可以预测的(反正你没折腾虚基类),除非真有证据发现或者高度怀疑构成性能瓶颈,实现不应患得患失。
在多出来的对象仅用于实现时,就应该是“使用者感觉不到”的。(反过来因为感觉不到过头了,才会纠结 std::launder 这种。)
而作为设计者,你自己都搞不定自己的心智包袱,那么设计的确可能是不稳定的(可能改变主意返工,白写,给用户添乱)。
通过指针的 pImpl 是个标准做法(另一个不需要指针的做法是 @Pezy 提到的 Priv )。除具体逻辑外,是否合适的确存在一些实现上的计较,但你都没感觉到,那么大可忽略。
这些做法最明显的实现问题是对被隐藏对象类型的完整性要求,所以直接= default 的特殊成员函数就没法直接用。(当然,类的用户无需关心。)光是因为这个原因我就会考虑减少这些隐藏的使用,直到考虑清楚这确实是逻辑上有必要隐藏的边界。
更要紧的:过早引入兼容性保证会让以后发现不适合维护这种兼容性时更加蛋疼。如果代码被分发再返工,用户也会感觉到疼。这时候就不只是心智包袱的问题了。

第四,需要虚函数不是非得避免组合的借口。
你这里的 pImpl 还是挺简单的,才一层而已……
要我写:

class ModuleBase /* final ,放这里一般不太好,可能有非 virtual 用途的继承比如当 mixin ;想要禁止的一般只是 public 继承*/
{
private:
function<void(int)> onTimer_Impl; // 反正就当例子,这看不出用途的签名我就不多吐槽了……
public:
//激进一点,onTimer 也可以暴露成 public ,如果你敢承(甩)担(锅) bad_function_call 之类的风险。
//...
};

避免虚函数的原因是大多数设计者根本没有考虑到 virtual 能够做什么他们能预料的破事。
具体地,dtor 外的虚函数的心智包袱远远比一般函数以及可调用对象更大,除非是实现中完全能自己控制 overrider ,否则用户能用各种瞎继承(对 public ,还有 C::f()和 x.f()的调用姿势;对非 void 返回类型,还有协变)混起来让你破防。
特别注意,private 的也可被重写。

另外,“需要重写”和“能重写”是两回事。如果要求重写,那可能更适合用虚函数,但同时应该用= 0;做成纯虚函数。
好处也就是这种= 0 强制静态检查,不需要多用文档约定用户接锅 bad_function_call 之类的而已。
注意纯虚函数仍可有定义提供默认实现。
(要求纯虚函数是 Java 等习惯上要求 interface 的同义词,即便是这样作死花样不那么多的语言也有一些类似的坑;但 C++中用 virtual 整个就是个可选项,不那么死板,所以不需要那么强调。)

第六,startTimer 不是虚函数的决策就通常逻辑讲是对的(但是,是不是非得访问 impl 而不是 module 的其它公开 API 实现仍然存疑;另见以上第二点)。
然而 NULL 判断说明你很可能做错了,因为典型情况下 pImpl 的 p 仅仅是要间接使用而不蕴含 nullable ,非空是个不变量(跟上面的 function 实现正常不可能 bad_function_call 一样)。
例外是你这个 Module 是真能 move 出空状态的不寻常设计。但你都要用 virtual 了,逻辑上更没法随便要求派生的实现能 move 。

第七,@zwzmzd 提到的是特定于 pImpl 的一个应用——应对 ABI 兼容问题,但实际上经常不那么顶用,也确实不常用;但这不仅是 C++实现的问题,还有更多整体原因。
一般来讲,UNIXy 下更多用 soname 而不是替换掉 .so 。(虽然我不喜欢这些选项;我更乐意多版本共存部署,但是包管理都比较坑就是了,除了 nix 等少数方案外都得手动,自动也不省心。)
而且都 android::sp 了,这问题就更没显著性了。Android 尤其不注重跨 apk 共享 .so (甚至某些著名 app 还没事多复制包括.so 的文件备份),也不需要你太关心升级时的问题,大不了整个替换掉。

最后,是不是真值得来个 Impl/Priv 隐藏,这就跟具体业务逻辑全面相关了。
不知道 Module 具体应该干什么,变更需求有多频繁时,没法知道收益是否够大。提供个别具体函数的实现也看不出个大概。
c0xt30a
2022-02-04 03:09:20 +08:00
@pezy 这种方案跟什么都没做似乎没区别?那个 `struct priv` 完全可以省去的。只要随便在一个匿名的空间里定义一个实现,然后 `bool SpamFilter::isSpam`直接调用就成了。所以没有必要在这个类里放一个 struct 啊。替代的可以是这样 :

```

// header file:

struct SpamFilter{
bool isSpam(string const&);
};


// implementation file:

namespace
{
template< typename S >
bool is_spam( S const& s, string const& str ){ ... }
}

bool SpamFilter::isSpam(string const& str )
{
for ( ... )
if ( is_spam( *this, str ) )
return true;
return false;
}
```

比链接里的实现清爽多了。
c0xt30a
2022-02-04 04:07:32 +08:00
对楼上 @FrankHB (帝球)的发言强行补充两点:

1 ) `ModuleBase(ModuleBase* _impl);` 这里传进去的指针如果外边被析构了怎么办?
2) `ModuleBaseImpl(ModuleBase& owner);` 这里的引用出了作用域了怎么办?譬如某个函数返回一个 ModuleBaseImpl 实例的时候


所以我大概会这么实现:

https://godbolt.org/z/1Y7Yxn9Yf

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/831423

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX