65.9K
CodeProject 正在变化。 阅读更多。
Home

在 C++ 中强制执行静态接口

starIconstarIconstarIconstarIconstarIcon

5.00/5 (3投票s)

2023年2月2日

MIT

10分钟阅读

viewsIcon

15915

在静态方法上强制执行接口契约的方法,类似于您期望从 C++ 中存在的静态虚方法获得的效果

引言

C++ 语言没有 .NET 的接口概念。相反,您可以创建包含方法签名但没有实现的 abstract 类,就像这样

class IContract
{
public:
    virtual void DoStuff() = 0;
}

这有用有几个原因。首先,显而易见的原因是,您希望强制当调用基类时,执行一个依赖于实现的 DoStuff

class CUtility : public IContract
{
public:
    void DoStuff(int &val);
}

在此实现中,可以通过派生对象或基对象访问对象,并且在这两种情况下,都将执行相同的方法。

现在假设您希望 DoStuff 是一个 static 方法,因为它是一个处理所提供参数的辅助函数。那么这就成了一个问题,因为 static 方法不能是虚的。下面的代码将无法编译。

class IContract
{
public:
    static virtual void DoStuff(int &val) = 0;
};

class CUtility : public IContract
{
public:
    static void DoStuff(int &val);
};

class CSomeClass
{
private:
  int m_val = 0;
public:
    void Something(void) {
        CUtility ::DoStuff(m_val);
    }
};

int main()
{
    CSomeClass a;
    a::Something();
}

此时,您可能想知道为什么需要这个。

在我的情况下,它之所以出现,是因为我正在编写一个用于内存管理的内存分配器类。这需要具有特定签名的各种函数(allocatedeallocate 等)。它们属于一起(因此它们应该逻辑上在一个类中),并且它们不依赖于特定的对象状态(因此它们可以是 static)。

我正在开发一个模板类,可以提供不同类型的分配器,但它们都必须具有正确的方法签名。从 IContract 派生是确保这一点的一种方法。这不是唯一的方法,事实上,对于 static 方法,这甚至是不可能的。这不起作用,因为标准不允许它。

在本文中,我重点介绍了其他各种方法。请注意,其中一些方法有点牵强。我只是想探索不同的选项。

为什么我们需要接口契约

如果我们放弃定义接口契约的想法会发生什么。

class CUtility
{
public:
    static void DoStuff(int &val);
};

class CSomeClass
{
private:
    int m_val = 0;
public:
    void Something(void) {
       CUtility::DoStuff(m_val);
    };

int main()
{
    CSomeClass a;
    a.Something();
}

在这种情况下,我们没有实现显式接口定义。通常,这没问题,因为我们有意在代码中使用 CUtility::DoStuff,这意味着我们可能已经检查过 CUtility 正在实现 CSomeClass 中所需的任何内容。这两个类都是具体类型,因此经过测试后,您基本上可以放心了。

但在我的情况下,CSomeClass 是一个模板类,而 CUtility 是模板参数。IContract 的方法有多种实现,它们都实现了具有相同签名的方法

class CUtility
{
public:
    static void DoStuff(int &val);
};

template<typename T>
class CSomeClass 
{
private:
  int m_val = 0;
public:
    void Something(void) {
        T::DoStuff(m_val);
    }
};

int main()
{
    CSomeClass<CUtility> a;
    a.Something();
}

根据提供哪种实现,调用特定的 DoStuff

这意味着在不同时间点,可以在 CSomeClass 开发很久之后创建其他 IContract 实现。现在您可能会争辩说,如果 DoStuff 没有正确的签名,代码就不会编译。但这并不完全正确。在这个简单的例子中,我们通过引用传递一个 int

如果有人不小心提供了以下实现,它将正常编译。它只是不会做预期的工作,因为 int 是按值传递的。

class CUtility2
{
public:
    static void DoStuff(int val);
};

因此,显然,完全忘记接口契约的方法并不理想。

并非真正是变通方法

如果我们坚持使用基本的 C++,除了保留接口、将方法设为实例方法并在我们的类中有一个 static 实例之外,我们能做的事情并不多。

我想强调的是,这不是一个变通方法,因为我们不再有 static 方法。我们有在没有内部状态的实例上的实例方法。

class IContract
{
public:
    virtual void DoStuff(int &val) = 0;
};

class CUtility: public IContract
{
public:
    void DoStuff(int &val);
};

template<typename T>
class CSomeClass
{
private:
    static T t;
    int m_val = 0;
public:
    void Something(void) {
       static_cast<IContract&>(t).DoStuff(m_val);
    }
}; 

int main()
{
    CSomeClass<CUtility> a;
    a.Something();
}

这也行。通过将 t 强制转换为 IContract&,我们强制通过 IContract::DoStuff 进行调用。唯一非常烦人的是,即使我们的 static 变量仍然需要在某个 cpp 文件中声明。

CSomeClass<CUtility>::CUtility t;

如果我们使用多个派生类作为模板参数,我们需要在某处声明它们。

CSomeClass<CUtility>::CUtility t;
CSomeClass<CUtility2>::CUtility2 t;

对于非模板类,您可以在该类的 cpp 文件中执行此操作。因此,如果我们有一个 caller.h 和一个 caller.cpp,那么它就会在 cpp 文件中,我们可以忘记它。但是因为 caller 是一个模板类,所以我们不仅没有它的 cpp 文件,而且即使我们有,它也不会知道需要声明哪些 static 变量。

这很烦人,因为它意味着我们不能简单地更改模板类型而不更改 static 变量声明。当然,我们也可以将 static 变量转换为实例变量。这也行。但是,当然,如果我们这样做,解决方案就不再是 static 的了。

可以说,虽然我们使用 static 变量,但契约实现本身是非静态的。诚然,当我面临这个问题时,我只是决定将 IContract 作为一个空类上的非 static 接口契约,这是最简单的解决方案,没有真正的缺点,但出于好奇,我一直摆弄,直到找到了下一个变通方法。

一种强制机制

如果我们想确保方法以特定签名实现,我们需要一种强制机制。我找到了一种优雅的解决方案,可以确保契约的正确实现。

首先,我们稍微修改接口契约。我们不使用 virtual 函数,而是使用函数指针 typedef 来定义精确的接口。

class IContract
{
public:
    typedef void (*DoStuffFunc)(int& val);
};

函数指针 typedef 就像任何其他可以赋值的类型一样,这意味着我们可以这样做

IContract::DoStuffFunc funcdummy = T::DoStuff;

这很棒,因为编译器会尝试编译,如果两者不完全匹配,我们就得到了我们需要的东西。现在只是将它放在代码中的某个地方,将 CSomeClass 绑定到这个约束。

变通方法 0:每次方法调用都进行类型转换

最简单的方法是每次方法调用都进行类型转换

template<typename T>
class CSomeClass
{
private:
    int m_val = 0;
public:
    void Something(void) {
        static_cast<IContract::DoStuffFunc>(T::DoStuff)(m_val);
    }
};

我们简单地将方法强制转换为函数指针,然后调用它。这可行,但老实说,它看起来并不干净。此外,由于检查是在调用方法的地方实现的,它要求程序员在使用应该有接口契约的 static 方法时记住实现它。因此,它容易出错,并且在 CSomeClass 开发生命周期中,您需要记住它。

变通方法 1:静态内联变量

一个非常简单直接的方法是将其设置为先决条件,这样做

template<typename T>
class CSomeClass
{
    static inline IContract::DoStuffFunc funcdummy = T::DoStuff;
private:
    int m_val = 0;
public:
    void Something(void) {
        T::DoStuff(m_val);
    }
};

CSomeClass 中,我们有一个 static 变量,它是我们契约中 typedef 定义的函数指针类型。它使用指向由提供的模板类型实现的 DoStuff 方法的指针进行初始化。

任何没有完全相同签名的 DoStuff 实现都会导致编译错误。这里真正巧妙的是,我们甚至不必通过 funcdummy 调用 static 方法。我们可以继续通过 T::DoStuff 调用它。funcdummy 的唯一目的只是为了检查 T::DoStuff 是否可以赋值而存在。

我们需要 C++17,否则无法内联初始化 static 变量,我们将回到之前解决方案的问题,即需要一个显式的 static 变量。

变通方法 2:模板概念

在此变通方法中,我们通过 C++ 模板概念强制执行契约。这也是需要 C++20 的原因。模板概念是 C++20 的一个特性。

可以这样编写检查转换是否可能的概念。static_cast 不会被评估。编译器只检查代码是否编译。

template<typename T>
concept ImplementsContract =
    requires(T t) {
    static_cast<IContract::DoStuffFunc>(T::DoStuff);
};

然后实现变成

template<typename T> requires ImplementsContract<T>
class CSomeClass
{
    //...
};

这很好,可读性强。与前一个解决方案相比,一个额外的好处是不需要成员变量。

我确实研究了是否可以在概念中直接定义函数参数列表的约束,而不需要 static_cast<IContract::DoStuffFunc>(T::DoStuff) 类型转换,但没有找到解决方案。可以检查 T::DoStuff 是否接受 int 作为参数

template<typename T>
concept ImplementsContract =
    requires(T t, int& i) {
    T::DoStuff(i);
};

然而,这真正检查的不是 T::DoStuff 是否通过引用接受 int 参数,而是当我们提供 int 作为参数时 T::DoStuff 是否可以被调用。这从根本上来说是一个非常不同的问题!

如果我们提供带有 T::DoStuff( int i)T::DoStuff(float f) 签名的实现,而不是 T::DoStuff(int& i),它将编译成功而没有错误,因为 int 可以作为参数传递,并且编译器将认为概念已验证。因此,目前看来,使用函数指针 typedef 是确保 static 方法具有正确签名的唯一真正方法。

变通方法 3:模板参数化

正如我所提到的,概念只在 C++20 中有效。但是,如果我们受限于 C++14,我们仍然可以做类似的事情,但更丑陋,通过将函数指针作为类型定义的一部分

template<typename T,
         IContract::DoStuffFunc f = T::DoStuff>
class CSomeClass
{
    //...
};

基本上,我们的模板中有第二个参数,它是我们的函数指针类型。如果实现了正确的 DoStuff 方法,它将正常编译。如果 T 没有实现正确的 DoStuff,它将以 C++ 的方式失败:出现大量错误,并且没有真正的解释。

我不喜欢这种方法的原因是,这些构造使得代码可读性和直观性大大降低,尤其是在您需要追溯问题来源时。

变通方法 4:SFINAE

前面的例子之所以有效,是因为如果提供了错误的签名,它会导致编译失败,并导致一堆编译器错误。如果——在没有 C++20 概念的情况下(因为我们仍然使用 C++14)——我们至少能得到一个清晰的编译器错误来告诉我们哪里出了问题,那岂不是很好吗?

我们可以使用 static_assert 来做到这一点。基本上,如果满足条件,static_assert 允许我们生成一个编译错误。在我们的例子中,如果 T::DoStuff 不能 static_castIContract::DoStuffFunc。为了评估该条件,我们需要 SFINAE 来执行类型评估。没有标准的“is_static_castable”类型评估,但我们可以自己创建它。当我说“自己创建”时,我真正的意思是“使用别人的模式”(感谢 Pavel)。

template <class F, class T, class = T>
struct is_static_castable : std::false_type
{};

template <class F, class T>
struct is_static_castable<F, T, decltype(static_cast<T>
                         (std::declval<F>()))> : std::true_type
{};

基本上,is_static_castable 默认派生自 std::false_type,并且有一个部分特化,对于类型 F 的值可以转换为类型 T 的值的特化,派生自 std::true_type。编译器不能直接执行 static_cast,因为我们仍处于编译阶段,但它可以检查 static_cast 操作的类型(如果应该执行)。如果操作无法编译,则类型评估失败。

使用这种模式,我们可以这样做

template<typename T>
class CSomeClass
{
    static_assert(
        is_static_castable<decltype(T::DoStuff), IContract::DoStuffFunc>::value,
        "Interface contract IContract not implemented");
    
    //...
};

现在我们可以简单地编译 CSomeClass,如果提供了 T::DoStuff(int i),那么即使代码编译通过,仍然会有一个清晰的编译器错误,而不是一堆模板编译错误。

请注意,is_static_castable 接受两个类型参数,因此我们不能直接将 T::DoStuff 作为参数提供,但我们可以通过使用 decltype 关键字来提供“T::DoStuff 的类型”。

关注点

C++,特别是模板编程,功能非常强大,正如我在本文中描述的,我们可以用它以各种方式强制执行 static 方法的接口契约。通过前面的例子,我希望能涵盖各种不同选项的基础知识。毫无疑问,以同样的方式还有许多其他变体。

话虽如此,有时最好/最简单/最容易的做法是不去寻找真正的解决方案,而只是在空类上使用实例方法。这样做的成本可以忽略不计,您可以忽略所有这些问题。当您需要匆忙完成某件事时,不要过于有创意可能是一个好主意。尤其是当其他可能不熟悉模板元编程的人最终维护代码时。

尽管如此,在极少数情况下,当您确实需要检查 static 方法是否以特定签名实现时,工具箱中多一个工具总是好的。

历史

  • 2023年2月2日:第一版
© . All rights reserved.