在编译时有效配置类层次结构

Efficient configuration of class hierarchy at compile-time

本文关键字:层次结构 配置 有效 编译      更新时间:2023-10-16

这个问题是关于嵌入式、硬实时系统上的C++架构的。这意味着大部分数据结构以及确切的程序流都是在编译时给出的,性能很重要,而且很多代码都可以内联。解决方案最好只使用C++03,但也欢迎使用C++11输入

我正在寻找已建立的设计模式和架构问题的解决方案,其中相同的代码库应该重新用于几个密切相关的产品,而某些部分(例如硬件抽象)必然会有所不同。

我可能最终会得到一个封装在类中的模块的层次结构,然后可能看起来像这样,假设有4层:

Product A                       Product B
Toplevel_A                      Toplevel_B                  (different for A and B, but with common parts)
    Middle_generic                  Middle_generic          (same for A and B)
        Sub_generic                     Sub_generic         (same for A and B)
            Hardware_A                      Hardware_B      (different for A and B)

这里,一些类从公共基类继承(例如,Toplevel_AToplevel_base继承),而其他类则根本不需要专门化(例如Middle_generic)。

目前我可以想到以下方法:

  • (A):如果这是一个常规的桌面应用程序,我会使用虚拟继承并在运行时创建实例,例如使用抽象工厂。

    缺点:但是*_B类永远不会在产品A中使用,因此在运行时取消引用所有未链接到地址的虚拟函数调用和成员将导致相当大的开销。

  • (B)使用模板专门化作为继承机制(例如CRTP)

    template<class Derived>
    class Toplevel  { /* generic stuff ... */ };
    class Toplevel_A : public Toplevel<Toplevel_A> { /* specific stuff ... */ };
    

    缺点:很难理解。

  • (C):使用不同的匹配文件集,并让构建脚本包含正确的

    // common/toplevel_base.h
    class Toplevel_base { /* ... */ };
    // product_A/toplevel.h
    class Toplevel : Toplevel_base { /* ... */ };
    // product_B/toplevel.h
    class Toplevel : Toplevel_base { /* ... */ };
    // build_script.A
    compiler -Icommon -Iproduct_A
    

    缺点:令人困惑,难以维护和测试。

  • (D):一个大的typedef(或#define)文件

    //typedef_A.h
    typedef Toplevel_A Toplevel_to_be_used;
    typedef Hardware_A Hardware_to_be_used;
    // etc.
    // sub_generic.h
    class sub_generic {
        Hardware_to_be_used the_hardware;
        // etc.
    };
    

    缺点:一个文件将包含在各处,但仍需要另一个机制才能在不同配置之间进行实际切换。

  • (E):类似的"基于策略"配置,例如

    template <class Policy>
    class Toplevel { 
        Middle_generic<Policy> the_middle;
        // ...
    };
    // ...
    template <class Policy>
    class Sub_generic {
        class Policy::Hardware_to_be_used the_hardware;
        // ... 
    };
    // used as
    class Policy_A {
        typedef Hardware_A Hardware_to_be_used;
    };
    Toplevel<Policy_A> the_toplevel;
    

    缺点:现在一切都是模板;每次都需要重新编译大量代码。

  • (F):编译器开关和预处理器

    // sub_generic.h
    class Sub_generic {
        #if PRODUCT_IS_A
            Hardware_A _hardware;
        #endif
        #if PRODUCT_IS_B
            Hardware_B _hardware;
        #endif
    };
    

    缺点:Brrr。。。,只有在其他一切都失败的情况下。

是否有任何(其他)已建立的设计模式或更好的解决方案来解决这个问题,以便编译器可以静态地分配尽可能多的对象,并内联大部分代码,知道正在构建哪个产品,将使用哪些类?

我会选择A。在证明这还不够好之前,请做出与桌面相同的决定(当然,在堆栈上分配几千字节,或者使用兆字节大的全局变量可能"明显"不起作用)。是的,调用虚拟函数会有一些开销,但我会首先选择最明显、最自然的C++解决方案,然后在它"不够好"的情况下重新设计(显然,尽早尝试确定性能等,并使用采样分析器等工具来确定你在哪里花时间,而不是"猜测"——事实证明,人类是非常糟糕的猜测者)。

如果A被证明不起作用,我会选择B。这确实并不完全显而易见,但大致来说,LLVM/Cang是如何为硬件和操作系统的组合解决这个问题的,请参阅:https://github.com/llvm-mirror/clang/blob/master/lib/Basic/Targets.cpp

首先,我想指出的是,您基本上在问题中回答了自己的问题:-)

接下来我想指出的是,在C++

在编译时给出了确切的程序流程,性能为重要的并且很多代码可以内联

称为模板。与构建系统功能相比,利用语言功能的其他方法只能作为在项目中构建代码的一种逻辑方式,从而为开发人员带来好处。

此外,正如在其他答案中所指出的,C在硬实时系统中比C++更常见,并且在C中,通常在编译时依靠MACROS进行这种优化。

最后,您在上面的B解决方案中指出,模板专业化很难理解。我认为这取决于你如何做到这一点,也取决于你的团队在C++/模板方面有多少经验。我发现许多"模板充斥"的项目非常难阅读,它们产生的错误信息充其量也是邪恶的,但我仍然能够在自己的项目中有效地使用模板,因为我在做这件事时尊重KISS原则。

因此,我对您的回答是,选择B或放弃C++用于C

我知道您有两个重要的要求:

  1. 数据类型在编译时是已知的
  2. 程序流在编译时已知

CRTP不会真正解决您试图解决的问题,因为它会允许HardwareLayer调用Sub_genericMiddle_genericTopLevel上的方法,我认为这不是您想要的。

使用Trait模式(另一个参考)可以满足您的两个要求。下面是一个例子,证明这两个要求都得到了满足。首先,我们定义了表示您可能想要支持的两个Hardwares的空壳。

class Hardware_A {};
class Hardware_B {};

然后让我们考虑一个类,它描述了一个对应于Hardware_A的一般情况。

template <typename Hardware>
class HardwareLayer
{
public:
    typedef long int64_t;
    static int64_t getCPUSerialNumber() {return 0;}
};

现在让我们来看看Hardware_B:的专业化

template <>
class HardwareLayer<Hardware_B>
{
public:
    typedef int int64_t;
    static int64_t getCPUSerialNumber() {return 1;}
};

现在,下面是Sub_generic层中的一个用法示例:

template <typename Hardware>
class Sub_generic
{
public:
    typedef HardwareLayer<Hardware> HwLayer;
    typedef typename HwLayer::int64_t int64_t;
    int64_t doSomething() {return HwLayer::getCPUSerialNumber();}
};

最后,一个执行两种代码路径并使用两种数据类型的简短main:

int main(int argc, const char * argv[]) {
    std::cout << "Hardware_A : " << Sub_generic<Hardware_A>().doSomething() << std::endl;
    std::cout << "Hardware_B : " << Sub_generic<Hardware_B>().doSomething() << std::endl;
}

现在,如果您的HardwareLayer需要维护状态,这里有另一种实现HardLayer和Sub_generic层类的方法。

template <typename Hardware>
class HardwareLayer
{
public:
    typedef long hwint64_t;
    hwint64_t getCPUSerialNumber() {return mySerial;}
private:
    hwint64_t mySerial = 0;
};
template <>
class HardwareLayer<Hardware_B>
{
public:
    typedef int hwint64_t;
    hwint64_t getCPUSerialNumber() {return mySerial;}
private:
    hwint64_t mySerial = 1;
};
template <typename Hardware>
class Sub_generic : public HardwareLayer<Hardware>
{
public:
    typedef HardwareLayer<Hardware> HwLayer;
    typedef typename HwLayer::hwint64_t hwint64_t;
    hwint64_t doSomething() {return HwLayer::getCPUSerialNumber();}
};

这里是最后一个变体,其中只有Sub_generic实现发生了变化:

template <typename Hardware>
class Sub_generic
{
public:
    typedef HardwareLayer<Hardware> HwLayer;
    typedef typename HwLayer::hwint64_t hwint64_t;
    hwint64_t doSomething() {return hw.getCPUSerialNumber();}
private:
    HwLayer hw;
};

按照与F类似的思路,您可以有这样的目录布局:

Hardware/
  common/inc/hardware.h
  hardware1/src/hardware.cpp
  hardware2/src/hardware.cpp

简化接口,只假设存在一个硬件:

// sub_generic.h
class Sub_generic {
        Hardware _hardware;
};

然后只编译包含该平台硬件的.cpp文件的文件夹。

这种方法的好处是:

  • 了解正在发生的事情并添加硬件很简单3
  • 硬件.h仍然充当您的API
  • 它去掉了编译器的抽象(出于速度考虑)
  • 编译器1不需要编译hardware2.cpp或hardware3.cpp,它们可能包含编译器1不能做的事情(如内联程序集或其他编译器2特定的事情)
  • hardware3可能会因为一些你还没有考虑过的原因而变得复杂得多。。所以给它一个完整的目录结构可以封装它

由于这是针对硬实时嵌入式系统的,通常您会选择C类型的解决方案,而不是C++。

对于现代编译器,我想说c++的开销并没有那么大,所以这并不完全是性能问题,但嵌入式系统倾向于使用c而不是c++。你试图构建的将类似于一个经典的设备驱动程序库(比如ftdi芯片库)。

那里的方法(因为它是用C写的)类似于你的F,但没有编译时选项-你会在运行时根据PID、VID、SN等来专门化代码

现在,如果您要使用c++来实现这一点,那么模板可能是您的最后一个选择(代码可读性通常比模板带来的任何优势都要高)。所以你可能会选择类似于A的东西:一个基本的类继承方案,但不需要特别花哨的设计模式。

希望这能帮助。。。

我假设这些类只需要创建一次,并且它们的实例在整个程序运行时都会持续存在。

在这种情况下,我建议使用对象工厂模式,因为工厂只会运行一次来创建类。从那时起,专门的类都是已知的类型。