c++ 类之间的依赖问题:impl、代理模式

参考

Effective_C++第三版.pdf
Effective_Modern_C__.pdf

描述

类似于托管的方式来解决几个问题:

  1. 减少编译时间
  2. 解决循环引用
  3. 屏蔽内部实现

减少编译时间,本质降低依赖

​ 因为c++是静态编译语言,他看的就是文件和文件之间的依赖,如果是实例 type a,那么就一定需要include type相关头文件,这样导致一件事情:当多重依赖的时候,很可能基层类的小改动,导致所有包括这个类的大类都需要重新编译,注意:(Run和Date是随意写的两个类)

例子1:

#include "Run.h"
#include "Date.h"

class Test
{
public:
    Test(Run& run, Date& date);
    std::string g
    Run run;
    Date date;
};

​ 对于这种带来的就是 当Test这个类依赖的头文件改变,或者这些头文件依赖的其他头文件改变的时候,每一个含有Test的文件都要重新编译,使用Test的文件也要重新编译,这将带来的是连串编译依存关系

解决办法 : 前置声明

​ 首先对于标准库无法使用类似 class string的方式,因为 string不是class,是一个typedef, 涉及到 template,但是你应该不对标准库进行前置声明,因为他们几乎不会改变

​ 其次,c++这种语言编译的时候需要知道对象的大小,也就是 sizeof你得出来准确的值,那么要求你类中的变量类型都是确定的

​ 解决办法就是:

​ 使用引用/指针,因为引用/指针的大小是固定的 指针大小,并且对于java这些他的成员变量其实也是指向地址的类型

class Run;
class Date;
class Test
{
public:
    Test(Run& run, Date& date);
    Run& run;
    Date& date;
};
  1. 当然你使用者肯定要包含Run和Date类,但是还是推荐智能指针,想不出来不用的理由

成员变量的IMPL

​ 上面的用引用保留变量其实不常见,一般都是用指针,但是指针又有释放的问题,那么就使用智能指针,类中定义Impl的结构体,包含所有必要的成员变量,但是这里不去体现,头文件中仅仅进行必要的class 声明

​ weight.h

#ifndef WEIGHT_H
#define WEIGHT_H
#include <memory>
class Weight
{
public:
    Weight();
private:
    struct Impl;
    std::unique_ptr<Impl> m_impl;
};

#endif // WEIGHT_H

weight.cpp

#include "Weight.h"
#include <vector>
#include <string>

struct Weight::Impl {
    std::string name;
    std::vector<double> data;
};

Weight::Weight()
    : m_impl(new Impl())
{

}

将所有需要实例化的成员变量创建一个结构体结构体指针使用unique_ptr管理!!!

但是这种方式在实例化weight的时候会出问题,因为unique_ptr内部默认析构器会对指针类型进行判断如果是不完全的类型会进行报错,为啥会不完全呢,因为编译器默认的析构函数是在头文件隐式内联的,在头文件中当然看不到具体类型

解决办法是:

让析构的时候看到完整类型呗,也就是析构实现的时候看到结构体是完成的,所以将weight的析构函数移到.cpp中

#include "Weight.h"
#include <vector>
#include <string>

struct Weight::Impl {
    std::string name;
    std::vector<double> data;
};

Weight::Weight()
    : m_impl(new Impl())
{

}
Weight::~Weight() {

}

​ 也可以使用 ~Weight() = default; 相当于实现使用默认的编译器生成代码

#include "Weight.h"
#include <vector>
#include <string>

struct Weight::Impl {
    std::string name;
    std::vector<double> data;
};

Weight::Weight()
    : m_impl(new Impl())
{

}

Weight::~Weight() = default;

那么析构有影响,拷贝构造和赋值操作符呢?

我们都知道,当声明了析构函数,编译器就不会给我们默认生成移动操作符函数,需要我们显示声明

那么对于下面的

#ifndef WEIGHT_H
#define WEIGHT_H
#include <memory>
class Weight
{
public:
    Weight();
    ~Weight();

    Weight(Weight&& rhs) = default;
    Weight& operator=(Weight&& rhs) = default;
private:
    struct Impl;
    std::unique_ptr<Impl> m_impl;
};

#endif // WEIGHT_H

因为unique_ptr的原因,我们只能使用默认的移动操作符

然而在

#include <iostream>     // std::streambuf, std::cout
#include "Weight.h"
int main () {
    Weight w;
    Weight c;
    w = std::move(c);
    return 0;
}

报错了,原因是在 移动操作符的默认实现中 会对原有的进行delete处理,这就和析构函数相同了,不完整类型

解决办法就是换个地方,在.h中统一声明

#ifndef WEIGHT_H
#define WEIGHT_H
#include <memory>
class Weight
{
public:
    Weight();
    ~Weight();

    Weight(Weight&& rhs);
    Weight& operator=(Weight&& rhs);
private:
    struct Impl;
    std::unique_ptr<Impl> m_impl;
};

#endif // WEIGHT_H

#include "Weight.h"
#include <vector>
#include <string>

struct Weight::Impl {
    std::string name;
    std::vector<double> data;
};

Weight::Weight()
    : m_impl(new Impl())
{

}

Weight::~Weight() = default;
Weight::Weight(Weight&& rhs) = default;
Weight& Weight::operator=(Weight&& rhs) = default; 

为了保证赋值操作符可以正常使用,我们必须手工自己进行实现

Weight& Weight::operator=(const Weight& rhs) {
    if (this != &rhs) {
        *m_impl = *rhs.m_impl;
    }
    return *this;
}  

我们使用这种赋值方式,让结构体内部进行赋值,注意的是 内存是两块内存,只不过现在内容是一样的了

值得一提的shared_ptr和unique_ptr

​ 上面例子2中的unique_ptr的种种,换成shared_ptr后都不需要了

#ifndef WEIGHT_H
#define WEIGHT_H
#include <memory>
class Weight
{
public:
    Weight();   
private:
    struct Impl;
    std::shared_ptr<Impl> m_impl;
};

#endif // WEIGHT_H

#include "Weight.h"
#include <vector>
#include <string>

struct Weight::Impl {
    std::string name;
    std::vector<double> data;
};

Weight::Weight()
    : m_impl(new Impl())
{

}

但是呢,是有代价的。对于unique_ptr他的析构器是智能指针的一部分,因为一开始就可以确定下来,这让编译器可以快速执行代码,这就要求编译时候看到的指针类型是完全的;对于shared_ptr,他的内部析构器不是智能指针的一部分,属于control Block的一部分,所以这也带来的编译器无法优化、减少代码大小

注意一点,我们使用类变量托管的方式解决依赖,但是客户端有的时候就是要Impl里面变量的具体实现,比如

循环引用

​ 这里的循环引用说的不是shared_ptr的那个,说的是头文件的互相包含,这时候可以选择的是 A include B,B 头文件中 class A,进行声明

从类之间的角度进行IMPL

​ 上面我们说了 单个成员变量使用指针减少依赖、类中定义结构体较少所有成员变量的具体依赖,那么类之间的IMPL呢?

其实这个作用在设计模式上还有 叫 代理模式,还有的叫不和陌生人说话,本质就是在 两者之间 加入第三个类来解决两个类的互相依赖

将类分成两部分,一个负责提供接口一个负责提供实现,注意了我们这里说的可没有带继承这种类关系

那么例子:还是上面的Weight,但是使用WeightProxy进行IMPL

WeightProxy头文件

#ifndef WEIGHTPROXY_H
#define WEIGHTPROXY_H
#include <string>
#include <memory>
class Weight;

class WeightProxy
{
public:
    WeightProxy();
    ~WeightProxy();
    std::string GetInfo();
    std::unique_ptr<Weight> m_proxy;
};

#endif // WEIGHTPROXY_H

WeightProxy实现文件

#include "Weightproxy.h"
#include "Weight.h"
WeightProxy::WeightProxy()
    : m_proxy(new Weight())
{
}

WeightProxy::~WeightProxy() = default;

std::string WeightProxy::GetInfo() {
    return m_proxy->GetInfo();
}

Main函数

#include <iostream>     // std::streambuf, std::cout
#include "Weightproxy.h"
int main () {
    WeightProxy w;
    std::cout << w.GetInfo() << std::endl;
    return 0;
}

对于客户端完全看不到类的具体实现,这也就 面对接口编程

基本原则是:

  1. 如果使用指针或者引用可以实现,就不要用Object,因为定义某类型的Object需要类型的定义式,而前者只需要声明

  2. 尽量用声明替换定义,当你声明一个函数,并且他用到某个class时,不需要该class的定义,即使函数是 Object传递参数或者返回值

    class Test;
    Test getTest();
    void setTest(Test obj);
    

    注意了,变量只有是指针我们才能使用声明,但是函数却没有这个限制,即使是对象也可使用声明,本质是函数编译不依赖于实现,但是调用函数之前,Test定义式一定要存在,重要的目的是把这种include形式传递到客户调用函数的那个文件中,将类型定义和客户端依赖去除,说白了库的提供者一个类中会提供很多函数,因为库的提供者选择 class形式,那么对于客户端只有需要 知道Test的具体定义的才去包含Test头文件,减少不必要的依赖

  3. 为声明式和定义式提供不同头文件

    因为定义式里面包含的头文件的真实实现,客户端不应该自己手工class声明,而是库实现侧自己提供两种头文件一个是声明、一个是定义声明文件就是给客户端像include的形式使用声明,也就是声明文件的内容就仅仅是 class Test

所以 接口实现的文件中的函数要和 接口头文件相同,什么以来具体实现都是 接口实现文件Proxy考虑的事情

构造函数中依然传递 Weight需要的参数(使用class声明),但是这个依赖就丢给了客户端,这就表示事情还是会做,只不过方式改变了,这是一种Handle class的方式

4. 另一种方式:

纯虚类

在java 和 .NET中,就有专门的interface定义,里面不能有成员变量,实现的成员函数等

因为这样,使用这个纯虚类的客户,必须用指针/引用使用应用程序,因为无法定义实例,那么这样的话除非接口修改否则客户也不需要重新编译

并且,一般考虑使用工厂模式来创建这种类型的对象,这种工厂函数一般在接口类中定义为静态,通过参数不同生成不同的函数

class Test
{
public:
    static Test* getInstanse(std::string type);
    virtual std::string getRunString() const = 0;
};

还是推荐返回智能指针,这样返回不同的派生类实例

最后总结

  1. 使用IMPL方式来较少类之间的依赖,减少编译时间
  2. 变量可以使用指针,一大推变量使用结构体,类可以使用一个托管类,大致这三类型来实现减少依赖
  3. 其实本质上来说,头文件之间就不应该有定义的依赖,所以java中统一使用了指针,实现cpp中才是真正包含所有具体定义,头文件是用来声明这个类长什么样子,实现cpp中用来实现这个类内部怎么实现的
posted @ 2021-06-20 19:20  make_wheels  阅读(6440)  评论(2编辑  收藏  举报