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





5.00/5 (3投票s)
在静态方法上强制执行接口契约的方法,类似于您期望从 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();
}
此时,您可能想知道为什么需要这个。
在我的情况下,它之所以出现,是因为我正在编写一个用于内存管理的内存分配器类。这需要具有特定签名的各种函数(allocate
、deallocate
等)。它们属于一起(因此它们应该逻辑上在一个类中),并且它们不依赖于特定的对象状态(因此它们可以是 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_cast
到 IContract::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日:第一版