前几天跟一个解释代码,他怎么都理解不了我写的使用overload转换QOverload的代码。最后我只能跟他说你就记住我是这么写的,你在上面改就是了。今天有时间,把这个东西详细地写一下。

C++17中的overload

C++17中提供了overload模式,在std::visit的示例中。不注意还真的注意不到。

1
2
3
4
5
6
7
8
template <typename... Ts>
struct overload : Ts...
{
using Ts::operator()...;
};

template <typename ... Ts>
overload(Ts...) -> overload<Ts...>;

按照上面的观点,它应该是作为一个visitor和std::visit配合使用的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
void overload_test()
{
auto twice = overload{
[](std::string v) { std::cout << "twice string: " << v+v << std::endl; },
[](int v) { std::cout << "twice int value: " << 2 * v << std::endl; },
};

using MultiVal = std::variant<int, std::string>;

std::cout << "和visit配合使用的用法\n";
std::visit(twice, MultiVal(1));
std::visit(twice, MultiVal("hellow"));

std::cout << "把overload当作可调用对象直接使用:\n";
twice(12);
twice("world");

std::cout << "直接定义临时对象并使用\n";
overload{
[](std::string v) { std::cout << "twice string: " << v + v << std::endl; },
[](int v) { std::cout << "twice int value: " << 2 * v << std::endl; },
}(12);

std::cout << "直接在visit中使用:\n";
std::visit(overload{
[](std::string v) { std::cout << "twice string: " << v + v << std::endl; },
[](int v) { std::cout << "twice int value: " << 2 * v << std::endl; },
},
MultiVal("goodbye"));
}

当然,我们也可以不用std::visit,可以直接用overload——它就是一个callable object。

1
2
twice(12);
twice("world");

当然,我们也可以不创建twice这个对象实例,直接在std::visist中定义overload,甚至直接定义overload并调用它,都可以。

后面本质上是创建了一个临时对象而已。

我们真正关心的是overload这个东西,正如前面看到的,它不仅仅是可以用于std::visit

我们先跳过支持任意类型,只看一个要支持两种类型:intstd::string的一个overload的东西怎么实现。

最古老的实现

在最古老的C++时代,比C++98或更老的C++,我们要写一个可以处理多种类型的callable object,最普通的是这种实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct VeryOldOverload
{
void operator()(int v) { std::cout << "twice int value: " << 2 * v << std::endl; }
void operator()(std::string v) { std::cout << "twice string value: " << v + v << std::endl; }
};

void veryOldOverload_test()
{
VeryOldOverload a1;
std::cout << __func__ << std::endl;
a1(11);
a1("hellow");
}

利用继承实现

进一步,利用继承实现这个类,这样,我们可以把数据处理逻辑给提取出来共享:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
struct IntWraper
{
void operator()(int v) { std::cout << "twice int value: " << 2 * v << std::endl; }
};

struct StringWraper
{
void operator()(std::string v) { std::cout << "twice string value: " << v + v << std::endl; }
};

struct DoubleWrapter
{
void operator()(double v) { std::cout << "power double value: " << v * v << std::endl; }
};

// 一个能支持整数和字符串的
struct DerivedOverload : public IntWraper, StringWraper
{
// 这里要使用using语句将基类的符号拿进来。不然,严格的编译器会报错
using IntWraper::operator();
using StringWraper::operator();
};

void TemplateOverload_test()
{
DerivedOverload a;
a("hell");
a(120);
}

其中,在DerivedOverload中的using IntWraper::operator()是将IntWrapter::operator()放到DerivedOverload的命名空间下面。这个using用法是什么时候的我已经不记得了(好像是C++98才有的)

但是,这个问题在于,我们定义了DerivedOverload,它能够支持的类就定死了。如果我们还需要一个支持double的怎么办?我们就需要再顶一个新的overload类。所以,才会有模板技术。

利用模板支持

接下来,我们考虑使用模板生成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
struct IntWraper
{
void operator()(int v) { std::cout << "twice int value: " << 2 * v << std::endl; }
};

struct StringWraper
{
void operator()(std::string v) { std::cout << "twice string value: " << v + v << std::endl; }
};

struct DoubleWrapter
{
void operator()(double v) { std::cout << "power double value: " << v * v << std::endl; }
};

template <typename T1, typename T2>
struct TemplateOverload : public T1, T2
{
using T1::operator();
using T2::operator();
};

void TemplateOverload_test()
{
TemplateOverload<IntWraper, StringWraper> t;
t("Hellow");
t(123);
}

使用模板的改进不大,它只能支持两个类,如果要支持一个,三个怎么办?我们还是需要再定义一个类的overload类,三个类的overload。所以,C++11引入了变参模板。

任意基类的模板

接下来就是如何支持任意的基类了。这个需要使用变参模板。我们先看C++17之前该怎么实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
template<typename T1, typename... Ts>
struct VarOverload : T1, VarOverload<Ts...>
{
using T1::operator();
using VarOverload<Ts...>::operator();
};

template<typename T>
struct VarOverload<T> : public T
{
using T::operator();
};

void VarOverload_test()
{
VarOverload<IntWraper, StringWraper> a;
a(12);
a("Hellow world");
}

从C++11开始支持模板变参,在C++17之前,我们必须使用递归的形式来定义,并且要注意,必须将递归推出条件写在后面而不是前面(至少MSVC编译器是这样要求,我没有仔细看过规范)。

其实我觉得递归形式更好理解:-)。也许是我用得还不够多,不像递归,还没有形成直觉😊

变参折叠和单行using支持的实现

在C++17种,引入了两个对这个问题很重要的特性:
第一个是模板变参的折叠,第二个是支持一行中多个逗号隔开的using。在这里,就是这种写法成为可能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template <typename... Ts>
struct FoldOverload : Ts...
{
using Ts::operator()...;
};

void FoldOverload_test()
{

FoldOverload<IntWraper, StringWraper, DoubleWrapter> a2;
a2(22);
a2(std::string("Hellow"));
a2(2.0);
}

它实际上将FoldOverload<IntWraper, StringWrapter, DoubleWrapter>展开后得到:

1
2
3
4
struct FoldOverload : IntWraper, StringWrapter, DoubleWrapter
{
using IntWraper::operator(), StringWrapter::operator(), DoubleWrapter::operator();
};

而在我们前面的递归版本中的最终是:

1
2
3
4
5
6
struct FoldOverload : IntWraper, StringWrapter, DoubleWrapter
{
using IntWraper::operator();
using StringWrapter::operator();
using DoubleWrapter::operator();
};

主要的差别 就是using是否在一行中。

现在,我们已经可以很容易做一个overload了。但是和前面的形式还是有很大的区别的。这是怎么回事?

C++对初始化列表的扩展支持

从C++11开始,支持大括号形式的初始化列表。例如,考虑下面的代码:

1
2
3
4
5
6
7
8
9
10
11
struct B
{
int b1;
int b2;
};

void test_initialize()
{
B b{ 1,2 };
std::cout << "b::b1=" << b.b1 << ", b::b2=" << b.b2 << std::endl;
}

但是,如果我们从B派生出类D,就不行了:

1
2
3
4
5
6
7
8
9
10
11
12
struct D : B
{
int vd;
};
void test_initialize()
{
B b{ 1,2 };
D d{ 1,2,3 };

std::cout << "b::b1=" << b.b1 << ", b::b2=" << b.b2 << std::endl;
std::cout << "d::b1=" << d.b1 << ", d::b2=" << d.b2 << ",d::vd=" << d.vd;
}

将编译器标准设置为C++14,编译下面的代码,编译器会报错:error C2440: “初始化”: 无法从“initializer list”转换为“D”.

我们改成D d{ {1,2},3 };一样也不行。

将C++标准改为C++17,则上面的代码能编译通过。

这种使用方式,在C++中的约束是:

  • 无用户定义的构造函数
  • 无private或protected的非静态数据成员
  • 没有虚函数
  • 没有基类

C++17将最后一条限制去掉了。通过这个,就为通过大括号直接初始化一个类扫平了道路。

类模板参数推导

在C++17之前,当使用模板时,必须指定模板类的参数。例如,

1
2
std::mutex _mutex;
std::lock_guard<std::mutex> lock{_mutex};

在C++17之后,可以根据实际参数推导出模板参数,从而,上面的代码可以写成这样:

1
2
3
4
std::mutex _mutex;
std::lock_guard lock{_mutex};
std::vector lst{1,2,3}; // std::vector<int>
std::vector lst2{"hellow", "world"}; // std::vector<const char *>

这样可以让我们在声明类的时候,不用再将模板参数名称写一遍。

在C++17之前,因为不支持类模板参数的推导,我们需要借助函数参数推导来进行推导。例如,常用的std::make_pair()函数,就是在无法直接使用std::pair做推导的时候提供的:

1
2
3
std::pair<double, int> m1{2.5, 5};	// OK
std::pair m2{2.4, 5}; // 在C++17之前出错
auto m3 = std::make_pair(2.4, 5); // 自动推导为std::pair<double, int>

这个有啥用呢?它有助于我们推导出Lambda的类型。比如,下面的类,封装了一个回调函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
template <typename CB>
class CountCalls
{
private:
CB _callback;
long _call_count{ 0 };
public:
CountCalls(CB cb) : _callback{ cb } {};

template<typename... Args>
auto operator() (Args&&... args)
{
++_call_count;
return _callback(std::forward<Args>(args)...);
}

long callCount() const { return _call_count; }
};

void callCount_tester()
{
CountCalls sc([](auto x) { std::cout << "call once: " << x << std::endl; });

sc(1);
cout << "call count is: " << sc.callCount();
sc(2);
cout << "call count is: " << sc.callCount();
}

这个类和我们在前面的overload原理是一样的,只不过它只有一个模板参数。

在C++17之前,这个是编译不过的,我们需要定义一个辅助模板函数来返回一个CountCalls对象使用。在C++17中就不需要了。我们可以把编译器版本改为C++14,就会发现编译不过了。

我们需要写一个辅助函数:

1
2
3
4
template <typename T>
constexpr auto make_callcount(T&& t) {
return CountCalls<T>{std::forward<T>(t)};
}

然后:

1
2
3
4
5
6
7
8
9
void callCount_tester()
{
//CountCalls sc([](auto x) { std::cout << "call once: " << x << std::endl; });
auto sc = make_callcount([](auto x) { std::cout << "call once: " << x << std::endl; });
sc(1);
cout << "call count is: " << sc.callCount();
sc(2);
cout << "call count is: " << sc.callCount();
}

这样就可以了。

注意,这里的constexpr并不是必须的。

最后,对于前面的FoldOverload,如果想要编译成功,也需要一个辅助函数;我们重新完整的写一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
template <typename... Ts>
struct FoldOverload : Ts...
{
using Ts::operator()...;
};

template <typename ...Ts>
constexpr auto make_foldoverload(Ts&&... args)
{
return FoldOverload<Ts...>{std::forward<Ts>(args)...};
}

void FoldOverload_test()
{
auto s = make_foldoverload(
[](std::string v) { std::cout << "twice string: " << v + v << std::endl; },
[](int v) { std::cout << "twice int value: " << 2 * v << std::endl; }
);
}

我们看到,这个make函数其实纯粹是千篇一律的脚手架代码,而C++17于是又提供了一个新特性,叫做Deduction Guide,使得我们可以免去写这个函数,而是只需要指明它的推导规则就可以。

我们将Deduction Guid和make函数都写在一起,比较一下它们:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
template <typename... Ts>
struct FoldOverload : Ts...
{
using Ts::operator()...;
};

// make函数
template <typename ...Ts>
constexpr auto make_foldoverload(Ts&&... args)
{
return FoldOverload<Ts...>{std::forward<Ts>(args)...};
}
// deduciton guide
template<typename ...Ts>
FoldOverload(Ts...) -> FoldOverload<Ts...>;

void FoldOverload_test()
{
// 使用make辅助函数的写法
auto o1 = make_foldoverload(
[](std::string v) { std::cout << "twice string: " << v + v << std::endl; },
[](int v) { std::cout << "twice int value: " << 2 * v << std::endl; }
);
// 直接构造
auto o2 = FoldOverload{
[](std::string v) { std::cout << "twice string: " << v + v << std::endl; },
[](int v) { std::cout << "twice int value: " << 2 * v << std::endl; }
};
}

C++20的overload

到了C++20,类模块的推导规则又得到了进一步的增强,现在连推导规则也不需要了。只要这样写就够了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

template <typename... Ts>
struct FoldOverload : Ts...
{
using Ts::operator()...;
};

//template<typename ...Ts>
//FoldOverload(Ts...) -> FoldOverload<Ts...>;

void FoldOverload_test()
{
auto o2 = FoldOverload{
[](std::string v) { std::cout << "twice string: " << v + v << std::endl; },
[](int v) { std::cout << "twice int value: " << 2 * v << std::endl; }
};
...
}

至于说这个东西到底有没有价值?我也不好说,这种技巧不是必须的。C++很多的特性更多的可能只是炫技,人生艰难,从中找点乐趣罢了。