Home 使用 CRTP 实现编译期接口定义
Post
Cancel

使用 CRTP 实现编译期接口定义

前言

有一种类型叫 pure virtual,不仅多态,而且强制要求 Base 类不做实现(子类必须 override),只声明为接口,实现了接口语义

但有时候并不需要多余的多态,我就需要一个类做接口声明

C++ 作为追求 zero overhead 的语言,能不能做到完全不必承担 virtual 开销的接口语义?

那当然可以,只要你能折腾

PS. 全文废话非常多(如果不愿意看这个东西是怎么来的),解决方案只在正文最后一段

期望

期望自然是做到接口语义,伪代码如下

class Interface {
    DEFINE int func();
    DEFINE void func2(int val);
};

class Impl: public Interface {
    // 不实现 func 和 func2,你就得死
};

初探

既然是编译时,又是继承,还是接口,很容易想到用 CRTP 惯用法

为了简单起见,目前接口只有 void func()

template <typename Derived>
class Interface {
private:
    void __require() {
        auto that = static_cast<Derived*>(this);
        that->func();
    }
};

然后随便写一个测试

// Impl "is-an" Interface
class Impl: public Interface<Impl> {}; // 不写实现,直观上应该编译不了

int main() {
    Impl test;
    return 0;
}

直观上来说,由于 Impl 继承了奇异递归的 Interface

Interface 声明的 __require 中调用了子类 Implfunc

目前显然是没有 func 符号,编译期就会无法通过,起到接口约束的作用

要不编译试试?

坑 1:模板

很不幸,这段代码是完全可以编译的,这个所谓的 CRTP 完全没有实现接口语义的作用

那是因为踩了__模板实例化__的坑:你不用,编译期间就不会实例化,哪怕你调用了 Interface<Impl>,由于没有任何一处调用到 __require(),那么 __require() 部分就不会实例化

也就是说,你需要在每一个子类中显式地调用到 __require() 才能起到约束作用

template <typename Derived>
class Interface {
protected: // 需要开放权限为 protected
    void __require() {
        auto that = static_cast<Derived*>(this);
        that->func();
    }
};

class Impl: public Interface<Impl> {
public:
    void func() {}
private:
    void checkInterface() { // 需要添加一个丑陋的函数
        Interface<Impl>::__require();
    }
};

int main() {
    Impl test;
    return 0;
}

开放权限不是大事,但是每一个子类都要加一个极具侵入性的函数是非常丑陋的行为

能不能再改进一点?我不希望子类有任何额外的添加

构造即检查

其实思路很简单,把检查的流程写到接口类的构造函数即可

template <typename Derived>
class Interface {
public:
    Interface() { __require(); }
private: // 回归到 private 了
    void __require() {
        auto that = static_cast<Derived*>(this);
        that->func();
    }
};

class Impl: public Interface<Impl> {
public:
    void func() {}
};

实现类一下子清爽多了!而且做到了接口约束的效果

坑 2:副作用

上面这段代码忽略的一个很大问题是调用 that->func() 是有__副作用__的

  1. 不仅有运行时开销
  2. 而且可能会导致整个流程在逻辑上是错误的

怎么解决?

最简单的方法是效仿 linux kernel 里的小技巧:sizeof 是编译阶段的求值,并不会真的执行

template <typename Derived>
class Interface {
public:
    Interface() { __require(); }
private:
    void __require() {
        auto that = static_cast<Derived*>(this);
        sizeof (that->func()); // 严格确保是编译时的行为
    }
};

但是仍有不足之处:

  1. 代码丑陋
  2. sizeof(void) 并不是特别合法的行为

既然如此那就用稍微 modern C++ 的做法:decltype

template <typename Derived>
class Interface {
public:
    Interface() { __require(); }
private:
    void __require() {
        auto that = static_cast<Derived*>(this);
        decltype(that->func()) * _ { }; // 一段只有谜语人才敢写的玩意
    }
};

这里的意思是说

  1. 任何类型都有它的指针形式
  2. 任何指针都能赋值 nullptr
  3. decltype 同样只是编译时推导类型

虽然代码有点令人不适,但它确实是类型安全的

但这代码确实是令人不适,那我们还能换种形式

template <typename Derived>
class Interface {
public:
    Interface() { __require(); }
private:
    void __require() {
        auto that = static_cast<Derived*>(this);
        using RequireFunc = decltype(that->func()); // 稍微体面一点
    }
};

那么,这样就能结束了?一个完美的接口类型?

并没有

坑 3:参数类型

假设现在要求实现另一个接口

int func2(int, double);

它现在有两个问题

  1. 怎么在 decltype 中表达出 intdouble 参数
  2. 怎么得知返回类型真的是 int

先来解决 2:使用 std::is_same<> 再一次判断

再来解决 1:反正是编译时推导的东西,可以通过默认构造函数解决,能塞进去就算成功

总结如下

template <typename Derived>
class Interface {
public:
    Interface() { __require(); }
private:
    void __require() {
        auto that = static_cast<Derived*>(this);

        using RequireFunc = decltype(that->func());
        static_assert(std::is_same<RequireFunc, void>::value, "please check func");

        // 使用默认构造函数,这种统一的方式可以把整个过程封装为 template
        // 使用 STL 提供的类型萃取轻松完成返回值类型判断
        using RequireFunc2 = decltype(that->func2(int{}, double{}));
        static_assert(std::is_same<RequireFunc2, int>::value, "please check func2");
    }
};

class Impl: public Interface<Impl> {
public:
    void func() {}
    int func2(int, double) { return 1; }
};

似乎又解决了一个难题?还没完! 假设现在要求实现另外一个接口

void func3(NoDefaultCtor);

// NoDefaultCtor 的定义
struct NoDefaultCtor {
    NoDefaultCtor() = delete;
    NoDefaultCtor(int) {}
};

前面那种基于默认构造函数的做法是无效的

这不是只要传入一个 NoDefaultCtor(1) 就能解决的问题

它导致的更严重的后果是:无法使用 template 来封装整个静态检查过程

一个类被限制了只能用某一种特定的构造函数的情况,是非常难通过模板来推导的

模板参数推导

既然类型是个坎,那就得正面从类型去考虑,不要停留在值或者对象的角度

对类型最有表达能力的方式自然是模板的推导

已知:给出函数,通过推导可以得出它的返回类型和参数

怎么做的:见 sRpc/FunctionTraits.h at master · Caturra000/sRpc (github.com)

我在上述链接实现了一个简单的 FunctionTraits,可以推导出各种不同的函数(普通函数、成员函数、lambdastd::function)的元信息

另一方面:希望通过注册的形式来完成接口检查,由使用方主动告知函数需要的信息,然后对比一下是否匹配(IsSameInterface)即可

template <typename T>
struct FunctionTraits;

// 用于推导函数的 meta-data,包括返回值和函数参数
template <typename Ret, typename ...Args>
struct FunctionTraits<Ret(Args...)> {
    constexpr static size_t ArgsSize = sizeof...(Args);
    using ReturnType = Ret;
    using ArgsTuple = std::tuple<Args...>;
};

// 针对成员函数的偏特化
template <typename Ret, typename C, typename ...Args>
struct FunctionTraits<Ret(C::*)(Args...)>: public FunctionTraits<Ret(Args...)> {};

// 这里的意思是说,如果返回值和函数参数完全一致,那它是 meta-data 匹配了
// 这里 FT 是 FunctionTraits 的实例化
// 此时我们不知道双方的 function 的名字是什么,需要在 Interface 类中主动提供
template <typename FT1, typename FT2>
struct IsSameInterface {
    constexpr static bool value =
        std::is_same<typename FT1::ReturnType, typename FT2::ReturnType>::value
        && std::is_same<typename FT1::ArgsTuple, typename FT2::ArgsTuple>::value;
};

struct NoDefaultCtor {
    NoDefaultCtor() = delete;
    NoDefaultCtor(int) {}
};

template <typename Derived>
class Interface {
public:
    Interface() { __require(); }
private:
    void __require() {
        constexpr bool check = IsSameInterface<
            FunctionTraits<decltype(&Interface::_func3)>,
            FunctionTraits<decltype(&Derived::func3)>>
        ::value;
        static_assert(check, "check func3");
    }

    // 注册一个主动提供的信息,告知 func3 需要什么样的返回值和参数
    void _func3(NoDefaultCtor);
};


class Impl: public Interface<Impl> {
public:
    void func3(NoDefaultCtor) {};
};

从这里我们已经到了使用模板的方案,除了显得繁琐

但这个可以用宏来缓解一下 __require 流程,毕竟过程是很单调重复的(详略)

现在,前面的问题全部都解决了,通过当前的模板甚至能扩展到处理左右值、const 语义等细节,已经很能打了

坑 4:死于重载

在上述模板方案中,有一点是模板无法解决的(或者很难处理)

对于 FunctionTraits<decltype(&Interface::_func3)> 这种形式的调用,模板无法区分 _func3 到底是什么

比如要求实现含有重载的接口

void func3(NoDefaultCtor);
void func3(int);

这时候就无法解析了,很显然,因为存在二义性

用重载解决重载

这里提供一种新的处理思路:

如果给出成员函数指针 ReturnType Class::*fptr

只要有对应的函数参数列表,那么 fptr 都是能匹配上的

相比上述的解决方案,这里能匹配的原因是多提供了参数的信息,这是基本语法就提供的特性,不需要额外的模板推导

因此换一种方式:不是从函数推导出参数,而是提供参数和利用上述重载特性找出匹配函数

这里重新定义一套 require-define 的步骤,如下所示

template <typename Ret, typename ...Args>
struct Require;

template <typename Ret, typename ...Args>
struct Require<Ret(Args...)> {
    template <typename C>
    constexpr static void define(Ret (C::*fp)(Args...)) {}
};

struct NoDefaultCtor {
    NoDefaultCtor() = delete;
    NoDefaultCtor(int) {}
};

template <typename Derived>
class Interface {
public:
    constexpr Interface() { __require(this); }

private:
    // 加上 Interface 参数防止潜在签名冲突(虽然没多大必要)
    constexpr void __require(Interface*) {
        Require<void(int, double&)>::define(&Derived::func);
        Require<void(int, double)>::define(&Derived::func);
        Require<long()>::define(&Derived::func);
        Require<int()>::define(&Derived::func2);
        Require<int(const NoDefaultCtor&)>::define(&Derived::func2);
    }
};

// Impl "is-an" Interface
class Impl: public Interface<Impl> {
public:
    void func(int, double&) {}
    void func(int, double) {}
    long func() { return 1; }
    int func2() { return 1; }
    int func2(const NoDefaultCtor&) { return 1; }
};

是不是非常简洁明了?只要 define 匹配上的,那么接口就是合法

同样的,它处理了前面所有的问题

THE END

至此,总算是折腾出了一套 CRTP 下的静态接口,总结有以下优点:

  1. 没有 virtual 开销,完全编译时处理
  2. 耦合度非常低,子类只有继承接口,无需额外操作
  3. 底层实现简洁且紧凑,它并没有多少代码
This post is licensed under CC BY 4.0 by the author.
Contents