在 Java、C#、C++ 等很多高级语言中,接口(C++ 没有专门的接口语法,这里我们指只含有纯虚方法的类)通常可以定义几个类通用的方法,之后这些类的对象就可以以接口的名义进行操作,大大降低了程序的耦合性。然而,这些语言通常需要显式地声明类实现了某个接口,类对接口的依赖依然存在,如果我们希望先开发的库实现后开发的库里面的接口,通常就只能通过继承的方式实现,比较麻烦:

// 先开发的库
public class SimpleQueue<T> {
    public void push(T obj) { ... }
    public T pop() { ... }
}

// 后开发的库
public interface IQueue<T> {
    public void push(T obj);
    public T pop();
}
public class SimpleQueueDerived<T> extends SimpleQueue<T> implements IQueue<T> {
    // 这个类仅仅是实现了IQueue的SimpleQueue,里面什么也不用写,后写的项目全程使用这个类
}

Go 语言提出了接口的隐式实现的概念,在 Go 语言中,一个结构体只要事实上实现了一个接口里的所有方法,它的实例就可以通过接口来操作,不再需要实现类显示地声明自己实现了接口:

// 先开发的库
type SimpleQueue[T any] struct { ... }
func (q *SimpleQueue[T]) push(obj T) { ... }
func (q *SimpleQueue[T]) pop() T { ... }

// 后开发的库
type IQueue[T any] interface {
    push(obj T)
    pop() T
}
// 此时*SimpleQueue已经满足了IQueue的实现
// 即使SimpleQueue并没有显式说明自己实现了IQueue
// 所有SimpleQueue对象的指针也可以作为IQueue使用

实际上,通过合理的使用 C++ 的模板语法,C++ 也可以实现类似 Go 语言的接口。在 C++20 中,我们可以使用概念(concept)语法构造一个具有特定方法的类型:

template <typename T, typename Type>
concept DerivedIQueue =
        std::is_convertible_v<decltype(std::declval<T>().push(std::declval<Type>())), void> &&
        std::is_convertible_v<decltype(std::declval<T>().top()), Type> &&
        std::is_convertible_v<decltype(std::declval<T>().pop()), void>;

template <DerivedIQueue<int> T>
void test(T&& testList) {
    for (int i = 0; i < 5; i++)
        testList.push(i);
    for (int i = 0; i < 5; i++) {
        std::cout << testList.top() << ' ';
        testList.pop();
    }
    std::cout << '\n';
}

template <typename T>
class CustomQueue : public std::queue<T> {
public:
    decltype(auto) top() { return this->front(); }
    decltype(auto) top() const { return this->front(); }
};

int main() {
    test(CustomQueue<int>());
    test(std::stack<int>());
}

运行结果:

0 1 2 3 4
4 3 2 1 0

觉得很冗余不好看?可以善用宏定义格式化接口声明:

#define interface(...)           template <typename T, ##__VA_ARGS__> concept
#define M(RET_TYPE, NAME, ...)   std::is_convertible_v<decltype(std::declval<T>().NAME(__VA_ARGS__)), RET_TYPE>
#define A(TYPE)                  std::declval<TYPE>()

template <typename... Args>
constexpr bool DEFINE_INTERFACE(Args&&... args) { return (args && ...); }

interface(typename Type) DerivedIQueue = DEFINE_INTERFACE(
    M(void,     push,   A(Type)     ),
    M(Type,     top                 ),
    M(void,     pop                 )
);

这里函数的形参类型必须使用宏定义 A()包起来,这是因为 C++ 目前的版本貌似没办法实现 __VA_ARGS__像形参包那样展开,如果有人知道怎么把这个宏定义去掉的话也欢迎发出来。

concept有些类似于一个 constexpr bool类型的模板对象(它在声明时,等号右边的表达式一定是一个可以在编译期求值的 bool值),它接收不少于一个模板形参,其中第一个模板形参一定是类型模板形参,它表示在 template<>中使用它定义一个类型模板形参时,被定义出来的模板形参,从第二个模板形参开始才是它真正的模板形参。

上述代码在编译到第一个对 test的调用时,编译器是这么工作的:

  1. 首先根据实参类型,得到 test的模板实参(即 T的值)为 CustomQueue<int>
  2. DerivedIQueue<CustomQueue<int>, int>的值(这里把 DerivedIQueue视为一个编译期求值的 bool),这里 DerivedIQueue的第二个模板实参是 int,这是因为 test的模板形参列表里写的是 DerivedIQueue<int> T
  3. 如果 DerivedIQueue<CustomQueue<int>, int>的值为 true,那么重载决议会应用 test的这个重载,反之则会放弃这个重载。如果 test有多个 concept类型模板形参,则要求所有这样的求值得到的结果均为 true

定义 DerivedIQueue时,等号右边是一系列 std::is_convertible_v相与的结果,这表明 std::is_convertible_v是一个 bool类型的模板对象,实际上,元编程库中以 _t_v结尾的模板大多都是这样定义的(包括后文要用到的 std::enable_if_t):

template <typename... Args>   // 实际情况肯定不会用可变模板参数,这里只表示省略模板形参列表
struct xxx { ... }            // 不带_t和_v的原始模板

template <typename... Args>
using xxx_t = xxx<Args...>::type;

template <typename... Args>
using xxx_v = xxx<Args...>::value;

std::is_convertible<From, To>的定义是“当一个From类型的值可以隐式转换为To类型,那么 std::is_convertible<From, To>::valuetrue,否则 std::is_convertible<From, To>::valuefalse”。在上面这个 concept中,From我们使用了 decltype()语法来模拟真实调用的情况,得到实际调用情况下的返回值,std::declval<T>()用来创建一个 T类型的假的右值,在编写模板时我们经常使用它与 decltype()结合得到特定表达式的类型。To我们使用了接口期望的函数返回值类型,不难发现如果使用我们的接口定义一个返回 double的函数,那么如果有一个类包含一个返回 int的同名方法,它也满足了接口的要求(因为 int可以隐式转换为 doublestd::is_convertible_v<int, double>true),如果你需要约束性更强的接口,可以使用 std::is_assignablestd::is_same等更严格的模板,但是要注意 C++ 有 cv 限定符和引用类型等其它语言不存在的衍生类型,可能需要使用 std::remove_cvref来清除这些衍生类型的差别。

在 “T有满足要求的方法”和“T有满足要求的方法重载,但其返回值不满足要求”这两种情况下,decltype()内的表达式都可以正常编译并取类型,但还有一种情况,那就是“T不含有满足特定重载的方法”,比如 T内压根没有名为 top的方法,那么 std::declval<T>().top()就会出现编译错误,也就没有办法求类型了。但这种情况并不需要我们特别说明,C++ 中有一种特性叫做 SFINAE,它的意思是“如果在模板重载决议过程中发生了编译错误,编译器不会直接报错,而是直接放弃这种重载”,看如下代码:

template <bool Cond>
struct EnableIf {};

template <>
struct EnableIf<true> {
    using type = void;
};

void test(...) {   // 省略号形参总是在重载集中,但具有最低的优先级
    std::cout << 0;
}

template <typename T, typename = typename T::type>
void test(T, int = 1) {
    std::cout << 1;
}

int main() {
    test(EnableIf<true>());   // 调用特化模板,实参有成员::type
    test(EnableIf<false>());  // 调用非特化模板,实参没有成员
}

运行结果:

10

我们首先观察 test(T, int = 1)的第二个模板参数,很多初学者看到 typename = typename就头大了,但其实这只是缩写了没有使用到的形参,就像 test的两个形参一样,第一个形参 T因为在函数中没有使用到,所以我们没有写变量名,只写了类型,第二个 int = 1看起来更抽象一些,但它也是一个因为没有用到,所以没有写变量名的形参,区别于第一个形参的点是它有一个默认值 1,这么写是编译器允许的。

看懂了 int = 1我们再来看 typename = typename T::type,首先它是一个类型模板形参,这是显而易见的,因为这个形参的类型是 typename,然后它有一个默认值 typename T::type,这里 T::type是一个用 using声明的类型没有问题,那这第二个 typename呢?好吧,它其实没什么含义,单纯就是语法要求,如果要引用模版类型内的子类型,那就得在前面加一个 typename,没有为什么,不然编译过不了。

test(EnableIf<true>())语句选择了 test(T, int = 1)重载,一切正常。

但在 test(EnableIf<false>())语句,重载决议在尝试使用 test(T, int = 1)重载时,由于它的第二个模板形参引用了类型 T::type,而这个类型是不存在的,因此发生了编译错误,按照 SFINAE 特性,发生在重载期间的编译错误不是错误,因而重载决议只是放弃了 test(T, int = 1)重载,调用了 test(...)重载。

另外需要注意的是,SFINAE 只会发生在重载决议期间,而重载决议只关心函数的声明,不会关系函数体的内容,以下代码不会触发 SFINAE,因而会发生反预期的编译错误:

void test(...) {}

template <typename T>
void test(T) {
    std::cout << std::is_same_v<typename T::type, void>;
}

int main() {
    test(EnableIf<true>());
    test(EnableIf<false>());
}

本段代码编写的 EnableIf其实就是标准库中 std::enable_if的原理,区别是 std:enable_if可以用第二个模板形参来改变其 type成员的值。

即使没有概念语法,在 C++17 及以前我们也可以使用 SFINAE 特性实现类似的效果,从这里我们也能更清楚地看明白 concept 的本质:

template <typename T, typename Type>
constexpr bool DerivedIQueue =
        std::is_convertible_v<decltype(std::declval<T>().push(std::declval<Type>())), void> &&
        std::is_convertible_v<decltype(std::declval<T>().top()), Type> &&
        std::is_convertible_v<decltype(std::declval<T>().pop()), void>;

template <typename T, typename = typename std::enable_if_t<DerivedIQueue<T, int>>>
void test(T&& testList) {
    ...
}

还记得我们前面说的吗?std::enable_if_t<...>就是 std::enable_if<...>::type

善用模板引用不定类型的特性还可以实现很多依赖反转的效果,比如让项目中所有实现了 to_string方法的类都可以被输出到流中,为项目中所有实现了 hashcode方法的类自动生成 std::hash的特化版本:

template <typename T>
concept ImplementedHashcode = std::is_same_v<size_t, decltype(std::declval<T>().hashcode())>;

template <typename T> requires ImplementedHashcode<T>
struct std::hash<T> {
    size_t operator()(const T& t) const {
        return t.hashcode();
    }
};

template <ImplementedHashcode T>
bool operator==(const T& a, const T& b) {
    return a.hashcode() == b.hashcode();
}

template <typename T>
concept Hashable = std::is_same_v<size_t, decltype(std::declval<std::hash<std::remove_cvref_t<T>>>()(std::declval<T>()))>;

size_t generateComplexHashcode() {
    return 1;
}

template <Hashable T, Hashable... Args>
size_t generateComplexHashcode(const T& t, Args&&... args) {
    return std::hash<T>()(t) + 31 * generateComplexHashcode(args...);
}

template <typename T, typename U>
auto operator<<(T&& t, U&& u) -> decltype(t << u.to_string()) {
    return t << u.to_string();
}

class Data1 {
private:
    int a{1};
    size_t b{2};
public:
    [[nodiscard]] size_t hashcode() const {
        return generateComplexHashcode(a, b);
    }
    [[nodiscard]] const char* to_string() const {
        return "Data1 ";
    }
};

class Data2 {
private:
    int a{1};
    double b{2};
    Data1 c{};
public:
    [[nodiscard]] size_t hashcode() const {
        return generateComplexHashcode(a, b, c);
    }
    [[nodiscard]] std::string to_string() const {
        return std::string("Data2 ") + c.to_string();
    }
};

int main() {
    Data2 d;
    std::unordered_set<Data2> s;
    s.insert(d);
    std::cout << Data1{} << *s.begin();
}

运行结果:

Data1 Data2 Data1 

以上代码第4行 requires关键字是使用 concept的另一种方法,requires后接一个 bool类型的值,为 true时重载生效。注意跟 requires子句语法区别,requires子句的语法如下:

requires (/*可选形参,没有形参时可以不写小括号*/) {
    // 一些代码
}

requires子句是一个表达式,其值类型为 bool,含义是 requires子句中的代码是否均能通过编译,如果 requires关键字和 requires子句同时使用的话代码是这种连写两个 requires的,有点丑:

template <typename T> requires requires (T t, size_t h) { h = t.hashcode(); }
struct std::hash<T> {
    size_t operator()(const T& t) const {
        return t.hashcode();
    }
};

封面:https://upyun.kircute.top/cover/7_44873217_p0.jpg

文章作者: 卡比三卖萌KirCute
本文链接:
版权声明: 本站所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 卡比三卖萌KirCute的博客
语言特性杂谈
喜欢就支持一下吧