类型擦除与Pimpl


概述

PIMPLPointer to IMPLementation的缩写,意思是指向实现的指针。 是一个Cpp独有的设计模式(严格说也不是,是桥接模式的一种特例),该技巧可以避免在头文件中暴露私有细节,同时通过类型擦除的技巧,不必麻烦用户编写继承相关代码,并能包装任意对象,在STL标准库中,这类设施典型的代表就是std::function

Cpp语境下的类型擦除,技术上来说,是编写一个类,它提供模板的构造函数和非虚函数接口提供功能;隐藏了对象的具体类型,但保留其行为。简单地说,就是库作者把面向对象的代码写了,而不是推给用户写。

Pimpl惯用法

struct task_base {
   virtual ~task_base() {}
   virtual void operator()() const = 0;
};

template <typename F>
struct task_model : public task_base {
    F functor_;

    template <typename U> // 构造函数是函数模板
    task_model(U&& f) :
      functor_(std::forward<U>(f)) {}

    void operator()() const override {
        functor_();
    }
};

首先,抽象基类task_base作为公共接口不变;其子类task_model(角色同上文中的task_impl)写成类模板的形式,其把一个任意类型F的函数对象function_作为数据成员。

子类写成类模板的具体用意是,对于用户提供的一个任意的类型FF不需要知道task_base及其继承体系,而只进行语法上的duck typing检查。 这种方法避免了继承带来的侵入式设计。换句话说,只要能合乎语法地对F调用预先定义的接口,代码就可以编译,这个技巧就能运作。此例中,预先定义的接口是void(),以functor_();的形式调用。

然后,我们把它包装起来:

class my_task {
 std::unique_ptr<task_base> ptr_;

public:
    template <typename F>
    my_task(F&& f) {
        using model_type = task_model<F>;
        ptr_ = std::make_unique<model_type>(std::forward<F>(f));  
    }

    void operator()() const {
        ptr_->operator()();
    } 

    // 其他部分略
};

why?

首先,初始动机是用一个类型包装不同的函数对象。然后,考虑这些函数对象需要提供的功能(affordance),此处为使用括号运算符进行函数调用。最后,把这个功能抽取为一个接口,此处为my_task,我们在在这一步擦除了对象具体的类型。这便是类型擦除的本质:切割类型与其行为,使得不同的类型能用同一个接口提供功能。

my_task进行简单测试的代码如下:

// 普通函数
void foo() {
    std::cout << "type erasure 1";
}
my_task t1{ &foo };
t1(); // 输出"type erasure 1"

// 重载括号运算符的类
struct foo2 {
    void operator()() {
        std::cout << "type erasure 2";
    }
};
my_task t2{ foo2{} };
t2(); // 输出"type erasure 2"

// Lambda
my_task t3{
    [](){ std::cout << "type erasure 3"; } 
}; 
t3(); // 输出"type erasure 3"

接口简洁,类型参数全部由编译器推断,实现了上文中的需求,当然这里只是一个简单的核心概念demo,实际上的实现需要考虑的比这多,比如自动类型退化,模板构造函数不匹配或者错误的情况,以及怎么配合SFINAE与concepts都是需要深入的。

注意的点

编写一个类型擦除类,当然也离不开编写它的构造函数、赋值函数和析构函数,而其中大部分纠结都可归为资源所有权的问题:一个对象是否拥有(own)其资源的生命周期;如果拥有,是唯一还是共享地拥有?支持复制,还是只支持移动?

my_task有一个std::unique_ptr数据成员,这个智能指针拥有具体任务对象的生命周期;而一个任务也没有创造副本的需求。因此,my_task不需支持复制,仅支持移动构造或赋值。

析构函数的责任是正确地释放资源。my_task使用了智能指针,智能指针会正确地调用delete,所以使用编译器合成的默认析构函数即可,这里强调一下,当手动实现析构函数的时候,一定要自己实现或者删除掉移动构造,否则就会出现二次析构的问题。

task_model的析构函数会递归调用F的析构函数,这依赖于用户正确编写了F的析构函数,因此task_model也用编译器合成的默认析构函数。

优缺点

Pimpl优点

  • 信息隐藏:实现细节可以隐藏到Impl类实现中,保护闭源API专有性。同时,接口头文件也能更干净、清晰表达真正的公有接口,易于阅读和理解。
  • 降低耦合:接口类只用知道Impl类即可,不用包含私有成员变量所需头文件,也不必包含平台依赖的windows.h或sys/time.h。
  • 加速编译:将实现相关头文件移入.cpp,API的引用层次降低,会导致编译时间减少。
  • 更好的二进制兼容性:采用Pimpl的对象大小从不改变,因为对象总是单个指针大小。对私有成员变量做任何修改,都只影响隐藏在cpp文件内的实现类大小。而对象的二进制表示可以不变。
  • 惰性分配:Impl类可以在需要时再构造,而不必在接口类构造时立即构造。

Pimpl的缺点

  • 必须为你创建的每个对象分配并释放实现对象。这使得对象增加了一个指针(Impl* impl_),同时增加了通过指针访问成员的开销,增加了new和delete对象的开销。_
  • 必须通过impl->的形式访问私有成员。
  • 编译器不能捕获接口类中const对成员变量修改。因为成员变量现在存在于独立的对象(impl_指针所指对象)中。编译器仅检查impl_指针是否发生变化,而不会检查其成员。

实战篇

假设我们目前有这么几个类:

#pragma once

#include <iostream>

struct MoveMsg {
    int x;
    int y;

    void speak() {
        std::cout << "Move " << x << ", " << y << '\n';
    }
};

struct JumpMsg {
    int height;

    void speak() {
        std::cout << "Jump " << height << '\n';
    }
};

struct SleepMsg {
    int time;

    void speak() {
        std::cout << "Sleep " << time << '\n';
    }
};

struct ExitMsg {
    void speak() {
        std::cout << "Exit" << '\n';
    }
};

然后在main中,我们需要用使用到多态,但又不能修改源文件,这时候我们就可以使用P-impl,再配合上工厂模式就可以消灭if-else舒服使用了。

struct MsgBase {
    virtual void speak() = 0;
    virtual void load() = 0;
    virtual ~MsgBase() = default;

    using Ptr = std::shared_ptr<MsgBase>;
};

namespace msg_extra_funcs { // 无法为 Msg 们增加成员函数,只能以重载的形式,外挂追加
    void load(MoveMsg &msg) {
        std::cin >> msg.x >> msg.y;
    }

    void load(JumpMsg &msg) {
        std::cin >> msg.height;
    }

    void load(SleepMsg &msg) {
        std::cin >> msg.time;
    }

    void load(ExitMsg &) {
    }
}

template <class Msg>
struct MsgImpl : MsgBase {
    Msg msg;

    void speak() override {
        msg.speak();
    }

    void load() override {
        msg_extra_funcs::load(msg);
    }
};

struct MsgFactoryBase {
    virtual MsgBase::Ptr create() = 0;
    virtual ~MsgFactoryBase() = default;

    using Ptr = std::shared_ptr<MsgFactoryBase>;
};

template <class Msg>
struct MsgFactoryImpl : MsgFactoryBase {
    MsgBase::Ptr create() override {
        return std::make_shared<MsgImpl<Msg>>();
    }
};

template <class Msg>
MsgFactoryBase::Ptr makeFactory() {
    return std::make_shared<MsgFactoryImpl<Msg>>();
}

struct RobotClass {
    inline static const std::map<std::string, MsgFactoryBase::Ptr> factories = {
        {"Move", makeFactory<MoveMsg>()},
        {"Jump", makeFactory<JumpMsg>()},
        {"Sleep", makeFactory<SleepMsg>()},
        {"Exit", makeFactory<ExitMsg>()},
    };

    void recv_data() {
        std::string type;
        std::cin >> type;

        try {
            msg = factories.at(type)->create();
        } catch (std::out_of_range &) {
            std::cout << "no such msg type!\n";
            return;
        }

        msg->load();
    }

    void update() {
        if (msg)
            msg->speak();
    }

    MsgBase::Ptr msg;
};

int main() {
    RobotClass robot;
    robot.recv_data();
    robot.update();
    return 0;
}

文章作者: JoyTsing
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 JoyTsing !
评论
  目录