使用接口包装库而不需要向下转换

Posted

技术标签:

【中文标题】使用接口包装库而不需要向下转换【英文标题】:Wrapping a library using interfaces without needing downcasts 【发布时间】:2015-01-29 05:51:44 【问题描述】:

假设我的项目使用了一个库LibFoo,它通过多个类提供其功能,例如FooAFooB。现在,有许多类似的库(例如,提供BarABarBLibBar)提供与LibFoo 相同的功能,我希望我的项目的用户能够选择使用哪个库,最好在运行时。

为此,我创建了一个“包装层”,它定义了我期望从库中获得的接口。在我的示例中,该层包含两个接口:IfaceAIfaceB。然后,对于我想要支持的每个库,我创建一个“实现层”,使用其中一个库来实现接口。

我现在的问题在于如何很好地实现实现层。为了演示我的问题,考虑我们有以下接口(用 C++ 显示,但应该适用于类似的语言):

class IfaceA

public:
    virtual void doSomethingWith(IfaceB& b) = 0;
    ...
;

class IfaceB

    ...
;

LibFoo 的实现层中的类将持有来自LibFoo 对应类的对象。接口中的操作应该使用这些对象来实现。因此(请原谅我的可怕名字):

class ConcreteAForFoo : public IfaceA

public:
    void doSomethingWith(IfaceB& b) override
    
        // This should be implemented using FooA::doSomethingWith(FooB&)
    

private:
    FooA a;
;

class ConcreteBForFoo : public IfaceB

public:
    FooB& getFooB() const
    
        return b;
    

private:
    FooB b;
;

问题在于实现ConcreteAForFoo::doSomethingWith:它的参数类型为IfaceB&,但我需要访问ConcreteBForFoo 的实现细节才能正确实现该方法。我发现这样做的唯一方法是到处使用丑陋的向下转型:

void doSomethingWith(IfaceB& b) override

    assert(dynamic_cast<ConcreteBForFoo*>(&b) != nullptr);
    a.doSomethingWith(static_cast<ConcreteBForFoo&>(b).getFooB());

由于不得不向下转换通常被认为是代码异味,我不禁想到应该有更好的方法来做到这一点。还是我一开始就设计错了?

TL;DR

给定一层相互依赖的接口(其中一个接口中的方法接收对其他接口的引用)。这些接口的实现如何在不向下转换或在接口中暴露这些细节的情况下共享实现细节?

【问题讨论】:

但我需要访问 ConcreteBForFoo 的实现细节才能正确实现该方法 如果是这种情况,您是否可以考虑重写 IfaceB 以便您不要不需要知道实现细节? @stijn:我当然可以考虑这一点,但如果不在接口中公开来自LibFoo 的类,我看不出有什么办法。 我希望回答(4)downcasts 是好的也是可以的。如果不是,有什么理由不呢? :) 如果两个这样的库同时处于活动状态,并且您交叉传递实例(即,将库 1 中的 a b 传递到库 2 中的 a?)会发生什么? 这不是 COM 基本上试图成为的样子吗? 【参考方案1】:

看起来像一个经典的 double dispatch 用例。

class IfaceA

public:
    virtual void doSomethingWith(IfaceB& b) = 0;
    ...
;

class IfaceB

    virtual void doSomethingWith(IfaceA & a) = 0;
    ...
;

struct ConcreteAForFoo : public IfaceA 
    virtual void doSomethingWith (IfaceB &b) override 
        b.doSomethingWith(*this);
    
;

struct ConcreteBForFoo : public IfaceB

    virtual void doSomethingWith(IfaceA & a) override 
        //b field is accessible here
    

private:
    FooB b;
;

可以改变 IfaceB::doSomethingWith 签名以获得耦合和访问级别之间的最佳平衡(例如,ConcreteAForFoo 可以用作参数以通过紧密耦合来访问其内部)。

【讨论】:

我认为双重分派有点矫枉过正,因为对象的唯一有效组合是 ConcreteAForFoo + ConcreteBForFoo 或 ConcreteAForBar + ConcreteBForBar。如果您得到无效的类型组合,则需要引发运行时错误。 dynamic_cast 同样清晰地表达了这一点。 @TimLovell-Smith,如果你得到无效的类型组合,你会在设计时看到这个。这就是类型系统的全部意义所在。 我认为只有在IfaceB 中暴露ConcreteAForFoo 才能解决我的问题,这是我绝对不想要的。【参考方案2】:

正确答案是:

    让您的界面真正具有互操作性和可互换性,或者 不要改变任何东西。继续进行动态转换。 在您的类型系统中添加其他内容,这样用户就不可能做错事并混合不兼容的具体实现。

你现在的基本问题是接口/继承是一个“过于简单化的谎言”,我的意思是你的接口实际上并没有遵循LSP。

如果你想修复这个遵循 LSP 并使库可混合,你需要做 1:修复你的代码不使用偷偷摸摸的实现细节,并真正遵循 LSP。但是这个选项基本上被问题陈述排除在外,明确类的实现是不兼容的,我们可能应该假设这种情况总是如此。

假设这些库永远是可混合的,那么正确答案是 2。继续使用动态转换。让我们想想为什么:

OP 的接口定义字面意思是ConcreteAForFoo 可以成功doSomethingWith any IFaceB 对象。但是 OP 知道这不是真的 - 它确实必须是 ConcreteBForFoo 因为需要使用某些在 IFaceB 的接口中找不到的实现细节

在这种特定的描述场景中,向下转换是最好的方法

记住:向下转换是在你知道对象的类型,但编译器不知道的时候。编译器只知道方法签名。您比编译器了解运行时类型更好,因为您知道一次只加载一个库

您希望对编译器隐藏真正的类型,因为您希望将您的库抽象到一个接口中,并对库的用户隐藏底层类型,我认为这是一个很好的设计决策。向下转型是抽象的实现部分,你告诉编译器“相信我,我知道这会起作用,那只是一个抽象”。

(并且使用 dynamic_cast 而不是 static_cast 会说'哦,是的,因为我有点偏执,如果我碰巧对此有误,请让运行时抛出错误',例如,如果有人滥用库试图混合不兼容的 ConcreteImplementations。)

选项 3 被扔在那里,即使我认为这不是 OP 真正想要的,因为它意味着打破“接口兼容性”约束,这是停止违反 LSP 并满足强类型粉丝的另一种方式。

【讨论】:

向下转型制动了接口给出的低耦合和收缩。我认为这不是一个好习惯。 接口可能是松耦合的,但实现是紧耦合的......所以低耦合是一种谎言? 是的,你是对的,所以我说肯定有受孕问题。【参考方案3】:

首先,我要感谢你,因为你的问题问得很好。

我的第一感觉是,如果你想传递一个IfaceB&amp;,但需要使用具体类型,你就有架构问题。

但是,我了解您想要做的事情的复杂性,因此我会尽力为您提供比沮丧的解决方案更好的解决方案。

架构的开头是一个不错的选择,因为这是 adapter pattern(链接是 C#,但即使我有一段时间不使用这种语言,它仍然是我在这个主题上找到的最好的!)。这正是您想要做的。

在以下解决方案中,您添加另一个对象c 负责ab 之间的交互:

class ConcreteAForFoo : public IfaceA

    private:
        FooA a;
;

class ConcreteBForFoo : public IfaceB

    private:
        FooB b;
;

class ConcreteCForFoo : public IfaceC

    private:
        ConcreteAForFoo &a;
        ConcreteBForFoo &b;

    public:
        ConcreteCForFoo(ConcreteAForFoo &a, ConcreteBForFoo &b) : a(a), b(b)
        
        

        void doSomething() override
        
            a.doSomethingWith(b);
        
;

class IfaceC

    public:
        virtual void doSomething() = 0;
;

您应该使用factory 来获取您的对象:

class IFactory

    public:
        virtual IfaceA* getA() = 0;
        virtual IfaceB* getB() = 0;
        virtual IfaceC* getC() = 0;
;

class IFactory

    public:
        virtual IfaceA* getA() = 0;
        virtual IfaceB* getB() = 0;
        virtual IfaceC* getC() = 0;
;

class FooFactory : public IFactory

    private:
        IfaceA* a;
        IfaceB* b;
        IfaceC* c;

    public:
        IfaceA* getA()
        
            if (!a) a = new ConcreteAForFoo();

            return a;
        

        IfaceB* getB()
        
            if (!b) b = new ConcreteBForFoo();

            return b;
        

        IfaceC* getC()
        
            if (!c)
            
                c = new ConcreteCForFoo(getA(), getB());
            

            return c;
        
;

当然,你肯定要修改这段代码,因为你可能有很多 b 或 a。

此时,您将可以像这样处理方法doSomething

factory = FooFactory();
c = factory.getC();

c->doSomething();

也许有更好的解决方案,但我需要一个真实的代码来告诉你。我希望这会对你有所帮助。

最后,我想为我的 C++(和我的英语)道歉,我很久没有用 C++ 编码了(而且我知道我至少犯了一些错误(这意味着 null == this. a 没有指针??))。

编辑:

另一种避免在您想要具体类型时提供传递接口的可能性的歧义的可能性是使用一种与命名相关联的调解器(负责AB 的实例之间的交互)注册表:

class FooFactory : public IFactory

    private:
        IMediator* mediator;

    public:
        IfaceA* getNewA(name)
        
            a = new ConcreteAForFoo();
            mediator = getMediator();
            mediator->registerA(name, *a);

            return a;
        

        IfaceB* getNewB(name)
        
            b = new ConcreteBForFoo();
            mediator = getMediator();
            mediator->registerB(name, *b);

            return b;
        

        IMediator* getMediator()
        
            if (!mediator) mediator = new ConcreteMediatorForFoo();

            return mediator;
        
;

class ConcreteMediatorForFoo : public IMediator

    private:
        std::map<std::string, ConcreteAForFoo> aList;
        std::map<std::string, ConcreteBForFoo> bList;

    public:
        void registerA(const std::string& name, IfaceA& a)
        
            aList.insert(std::make_pair(name, a));
        

        void registerB(const std::string& name, IfaceB& b)
        
            bList.insert(std::make_pair(name, b));
        

        void doSomething(const std::string& aName, const std::string& bName)
        
            a = aList.at(aName);
            b = bList.at(bName);

            // ...
        

然后您可以像这样处理AB 实例的交互:

factory = FooFactory();
mediator = factory.getMediator();
a = factory.getNewA('plop');
bPlap = factory.getNewB('plap');
bPlip = factory.getNewB('plip');
// initialize a, bPlap and bPlip.

mediator->doSomething('plop', 'plip');

【讨论】:

“我的第一感觉是你有架构问题”我同意。 OP 的IfaceA::doSomethingWith(IfaceB&amp;) 有一个隐含的要求/前提条件:传递的对象必须ConcreteBForFoo。此要求不能表示为类型,因为这需要在客户端处理不同类型(针对不同库)的代码。但是,您可以将此保证表示为同时存储 ConcreteAForFooConcreteBForFoo 的类的不变量(这似乎是您的解决方案)。 您根本没有以任何有用的方式公开IFaceAIFaceB,只要您可以将一个IfaceA 与一个IFaceB 配对,这很好而且很漂亮。一旦您的用户想要拥有 vector&lt;IFaceA*&gt;map&lt;string,IFaceB*&gt; 以及任何数量的其他事先未知的组合,您就无法再提供美观且安全的 IFaceC 这就是为什么我说肯定有更好的方法来做到这一点。如果您想做好,在许多相关文件上制作适配器模式可能会很痛苦。您真的应该尝试使用 SOA 模式进行开发以避免这种复杂性。我没有足够的信息来提供其他建议,但另一种可能的解决方案(我无法评估)是通过将 ConcreteAForFoo FooA FooA 去相关来添加外观模式(当然对于 B 也是同样的事情)。 虽然我有点喜欢这个解决方案,但恐怕它不适用于我的情况,原因是@n.m。给出:我可以让多个接口对象相互交互。 据我了解,外观只会将IfaceAIfaceB 混为一谈,这绝对不是我想要的。还是我在这里误解了什么?【参考方案4】:

这不是一件容易的事。 C++ 之外的类型系统还不够。原则上没有什么可以(静态地)阻止您的用户从一个库实例化 IFaceA 和从另一个库实例化 IFaceB,然后在他们认为合适的情况下混合和匹配它们。您的选择是:

    不要让库动态可选,即不要让它们实现相同接口。相反,让他们实现一系列接口的实例。

    template <typename tag>
    class IfaceA;
    template <typename tag>
    class IfaceB;
    
    template <typename tag>
    class IfaceA
    
       virtual void doSomethingWith(IfaceB<tag>& b) = 0;
    ;
    

    每个库都使用不同的标签实现接口。用户可以在编译时轻松选择标签,但不能在运行时选择。

    使接口真正可互换,以便用户可以混合和匹配不同库实现的接口。 在一些漂亮的界面后面隐藏演员表。 使用访问者模式(双重调度)。它消除了强制转换,但有一个众所周知的循环依赖问题。非循环访问者通过引入一些动态转换消除了这个问题,所以这是 #3 的变体。

这里是双重调度的基本示例:

//=================

class IFaceBDispatcher;
class IFaceB 

   IFaceBDispatcher* get() = 0;
;

class IfaceA

public:
    virtual void doSomethingWith(IfaceB& b) = 0;
    ...
;

// IFaceBDispatcher is incomplete up until this point

//=================================================

// IFaceBDispatcher is defined in these modules

class IFaceBDispatcher

  virtual void DispatchWithConcreteAForFoo(ConcreteAForFoo*)  throw("unimplemented"); 
  virtual void DispatchWithConcreteAForBar(ConcreteAForBar*)  throw("unimplemented"); 

;
class ConcreteAForFoo : public IfaceA

   virtual void doSomethingWith(IfaceB& b)  b.DispatchWithConcreteAForFoo(this); 


class IFaceBDispatcherForFoo : public IFaceBDispatcher

   ConcreteBForFoo* b;
   void DispatchWithConcreteAForFoo(ConcreteAForFoo* a)  a->doSomethingWith(*b); 
;   

class IFaceBDispatcherForBar : public IFaceBDispatcher

   ConcreteBForBar* b;
   void DispatchWithConcreteAForBar(ConcreteAForBar* a)  a->doSomethingWith(*b); 
;   

class ConcreteBForFoo : public IFaceB 

   IFaceBDispatcher* get()  return new IFaceBDispatcherForFoothis; 
;

class ConcreteBForBar : public IFaceB 

   IFaceBDispatcher* get()  return new IFaceBDispatcherForBarthis; 
;

【讨论】:

让我单独谈谈你的观点。 1. 我想过一个类似的解决方案,但我认为它并不真正可行,因为所有使用该接口的代码都需要“模板化”。 2. 这将是理想的,但对于我正在使用的库来说是完全不可能的。但是,我认为这一点可能说明我的设计完全有问题(因为方法签名有点“谎言”)。 3. 这就是我现在正在做的,但它并没有解决问题。 4. 如果不暴露接口中的实现类,我认为这是不可能的,是吗? 1.是的。 2.方法签名存在,但这是类型系统无法充分表达所需签名的必然结果。设计本身没有任何问题。 4. 在所有图书馆都可以看到的界面中,但不一定对其客户可见。 我仍然不确定您将如何在没有向下转换的情况下实施第 4 项。能举个小例子吗? Double dispatch。它回答了问题吗? 不,它没有 :-) 我只是不明白如何避免对您在之前的评论中谈到的这个“中间接口”感到沮丧。 AFAIU,双重分派需要接口包含所有涉及的类型的方法重载。如果你将这些重载转移到这个中间接口,你仍然需要向下转换到这个接口,对吧?【参考方案5】:

在您的设计中,可以同时实例化 libFoo 和 libBar。也可以将 IFaceB 从 libBar 传递给 libFoo 的 IFaceA::doSomethingWith()。

因此,您被强制向下动态转换,以确保 libFoo 对象不会传递给 libBar 对象,并且反之亦然。需要验证用户没有搞砸。

我只看到两件事你真的可以做:

接受 dynamic_cast 以验证您在每个功能基础上的接口 重新设计接口,让函数参数都不是接口

您可能能够完成第二个任务,只允许从其他对象内部创建对象,并且每个创建的对象都保持对创建它的对象的引用。

例如对于 libFoo:

IFaceA* libFooFactory (void)

    return new libFoo_IFaceA ();


IFaceB* libFoo_IFaceA::CreateIFaceB (void)

    return new libFoo_IFaceB (this);


.
.
.

libFoo_IFaceB::libFoo_IFaceB (IFaceA* owner)

    m_pOwner = owner;


libFoo_IFaceB::doSomething (void)

    // Now you can guarentee that you have libFoo_IFaceA and libFoo_IFaceB
    m_pOwner-> ... // libFoo_IFaceA

使用方式如下:

IFaceA* libFooA = libFooFactory ();
IFaceB* libFooB = libFooA->CreateIFaceB();

libFooB->doSomething();

【讨论】:

【参考方案6】:

如果目标是从接口中删除对具体实现的向下转换,则需要将具体类型暴露给接口(而不是它的实现)。这是一个例子:

class LibFoo;
class LibBar;

class IfaceA;
class IfaceB;

template <typename LIB> class BaseA;
template <typename LIB> class BaseB;

struct IfaceA

    virtual ~IfaceA () 
    virtual operator BaseA<LibFoo> * ()  return 0; 
    virtual operator BaseA<LibBar> * ()  return 0; 
    virtual void doSomethingWith (IfaceB &) = 0;
;

struct IfaceB 
    virtual ~IfaceB () 
    virtual operator BaseB<LibFoo> * ()  return 0; 
    virtual operator BaseB<LibBar> * ()  return 0; 
;

BaseABaseB 的实现会覆盖相应的转换运算符。他们还知道它将与之交互的类型。 BaseA 依赖于 IfaceB 的转换运算符到达匹配的 BaseB,然后分派到适当匹配的方法。

template <typename LIB>
struct BaseA : IfaceA 
    operator BaseA * () override  return this; 

    void doSomethingWith (IfaceB &b) override 
        doSomethingWithB(b);
    

    void doSomethingWithB (BaseB<LIB> *b) 
        assert(b);
        doSomethingWithB(*b);
    

    virtual void doSomethingWithB (BaseB<LIB> &b) = 0;
;

template <typename LIB>
struct BaseB : IfaceB 
    operator BaseB * () override  return this; 
;

然后具体的实现就可以做需要做的事情了。

struct LibFoo 
    class FooA ;
    class FooB ;
;

struct ConcreteFooA : BaseA<LibFoo> 
    void doSomethingWithB (BaseB<LibFoo> &) override 
        //...
    

    LibFoo::FooA a_;
;

struct ConcreteFooB : BaseB<LibFoo> 
    LibFoo::FooB b_;
;

将另一个库添加到组合中时,将导致编译时错误,除非Ifaces 被适当扩展(但Bases 不一定需要任何扩展)。您可能会认为该结果是有用的功能,而不是有害的功能。

struct ConcreteBazA : BaseA<LibBaz>  // X - compilation error without adding
                                      //     conversion operator to `IfaceA`

Working example.


如果更新所有接口对象不是一个选项,那么阻力最小的路径是利用动态向下转换,因为它旨在解决这个问题。

struct IfaceA

    virtual ~IfaceA () 
    template <typename LIB> operator BaseA<LIB> * () 
        return dynamic_cast<BaseA<LIB> *>(this);
    
    virtual void doSomethingWith (IfaceB &) = 0;
;

struct IfaceB 
    virtual ~IfaceB () 
    template <typename LIB> operator BaseB<LIB> * () 
        return dynamic_cast<BaseB<LIB> *>(this);
    
;

由于转换不再是虚拟的,BaseABaseB 模板不再需要覆盖转换方法。

template <typename LIB>
struct BaseA : IfaceA     
    void doSomethingWith (IfaceB &b) override 
        doSomethingWithB(b);
    

    void doSomethingWithB (BaseB<LIB> *b) 
        assert(b);
        doSomethingWithB(*b);
    

    virtual void doSomethingWithB (BaseB<LIB> &b) = 0;
;

template <typename LIB>
struct BaseB : IfaceB 
;

这个答案可以看作是 n.m. 建议的访问者模式的说明

【讨论】:

【参考方案7】:

@Job,我不能把它放在评论中,但它应该在那里。您尝试使用哪些技术来解决此问题?为什么这些技术不起作用?了解这一点将有助于其他人解释为什么一项技术可能真正有效或避免重复工作。

我认为对您的问题的最大挑战是您期望IfaceA 的实现需要调用IfaceB 的实现的未知函数。这将始终导致您必须强制转换传入的对象以获取所需的方法,除非您正在调用的函数位于 IfaceB 接口中。

class IfaceB

public:
    virtual void GetB() = 0; // Added function so I no longer have to cast.

现在,我想说您的初始实现类似于“如果您拥有的只是一把锤子,那么一切看起来都像钉子”。但是,我们已经知道您有木螺钉、金属螺钉、螺栓、方头螺栓等。因此,您需要为“锤子”提供一种通用方式,使其不总是锤子,而是钻头、扳手、棘轮等。这就是为什么我提出通过强制实现通用功能来扩展您的界面。

我还注意到你们的 cmets:

我不确定这是否真的很重要,但它是一个库,开发人员应该能够选择使用哪个后端。

这实际上对您的设计产生了巨大影响。我看到你在这里发生了两件事:

    尝试高内聚,但通过允许实现相互调用来强制耦合。 看来您正在做的是构建数据抽象层 (DAL)。

我最近实现了一个 DAL,用于处理切换出创建图像的对象(JPEG、PNG、BMP 等)。我为基本功能使用了一个接口,并使用了一个工厂来处理创建对象。效果很好。我不需要更新任何东西,只需添加一个新类来处理不同的图像类型。您可以使用Templated Factory 对您的Iface# 实现执行相同的操作。它将允许您在工厂中注册一个类,并且调用代码会根据注册的名称获取一个对象。

【讨论】:

【参考方案8】:

从接口中删除 doSomethingWith() 方法。提供免费功能:

void doSomethingWith(ConcreteAForFoo & a, ConcreteBForFoo & b);

让类型代表它可以做什么和不能做什么是很重要的。你的不能处理任意子类型的接口。

【讨论】:

这个免费功能应该在哪里提供呢?在行为上,doSomethingWith 必须是界面的一部分,因为客户端需要该功能。 把它放在提供实际实现的库中。您的类型系统看起来是对称的,因此可以将其改写为“互相做某事”。 @Job,对不起,我的意思是,把它放在 ConcreteAForFoo 附近。 但是客户端需要使用这个函数,他们不能,因为它的签名包含实现类。【参考方案9】:

这是一个使用泛型的 Java 解决方案。我对C++不是很熟悉,所以我不知道是否有类似的解决方案。

public interface IfaceA<K extends IfaceB> 

  void doSomething(K b);




public interface IfaceB 




public class ConcreteAForFoo implements IfaceA<ConcreteBForFoo> 

  private FooA fooA;

  @Override
  public void doSomething(ConcreteBForFoo b) 
    fooA.fooBar(b.getFooB());
  




public class ConcreteBForFoo implements IfaceB 

  private FooB fooB;

  public FooB getFooB() 
    return fooB;
  




public class FooA 

  public void fooBar(FooB fooB) 
  




public class FooB 


【讨论】:

以上是关于使用接口包装库而不需要向下转换的主要内容,如果未能解决你的问题,请参考以下文章

适配器模式

GitHub - 如何在共享服务器上克隆存储库而不授予我所有存储库的访问权限

返回一个已知大小的向量而不需要额外的包装器?

如何在离子应用程序中使用库而不将其发布到 npm

如何使用 fmt 库而不获取“架构 x86_64 的未定义符号”

从“node_modules”以外的位置引用 NodeJS 库而不使用相对路径