从实现 Go 语言的接口出发入门 C++ 模板元编程
在 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
的调用时,编译器是这么工作的:
- 首先根据实参类型,得到
test
的模板实参(即T
的值)为CustomQueue<int>
。 - 求
DerivedIQueue<CustomQueue<int>, int>
的值(这里把DerivedIQueue
视为一个编译期求值的bool
),这里DerivedIQueue
的第二个模板实参是int
,这是因为test
的模板形参列表里写的是DerivedIQueue<int> T
。 - 如果
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>::value
为 true
,否则 std::is_convertible<From, To>::value
为 false
”。在上面这个 concept
中,From
我们使用了 decltype()
语法来模拟真实调用的情况,得到实际调用情况下的返回值,std::declval<T>()
用来创建一个 T
类型的假的右值,在编写模板时我们经常使用它与 decltype()
结合得到特定表达式的类型。To
我们使用了接口期望的函数返回值类型,不难发现如果使用我们的接口定义一个返回 double
的函数,那么如果有一个类包含一个返回 int
的同名方法,它也满足了接口的要求(因为 int
可以隐式转换为 double
, std::is_convertible_v<int, double>
为 true
),如果你需要约束性更强的接口,可以使用 std::is_assignable
、std::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();
}
};