最近学习Rust时,对于其模式匹配印象颇为深刻,隐约记得C++似乎也有过类似的提案,翻来覆去还是找到了C++23模式匹配提案。不过等提案到编译器落地估计要个几年,所以这里先通过std::variant做一个简单模拟。
先展示以下Rust的模式匹配:
enum Coin {
Penny,
Nickel,
Dime,
Quarter,
}
fn value_in_cents(coin: Coin) -> u8 {
match coin {
Coin::Penny => 1,
Coin::Nickel => 5,
Coin::Dime => 10,
Coin::Quarter => 25,
}
}
上述代码首先声明了一个enum对象,然后根据enum对象的实际类型分派到不同的动作。除此之外,还有解包、逻辑运算等功能,不在这里多加描述。主要关注Rust根据一个enum对象的实际类型分派不同动作这样的行为。
在C++17后,可以使用std::variant
定义一个类型安全的union,以模拟Rust中的enum。如下:
int main()
{
using CommonStr = std::variant<std::string, std::string_view, const char*>;
CommonStr str1 = "str1";
CommonStr str2 = "str2"s;
CommonStr str3 = "str3"sv;
}
接下来要做根据类型派发,一是可以通过万能的decltype,这里没必要搞这么复杂;二是通过std::variant的配套的一些api,std::holds_alternative
与std::get
:
void dispatch(CommonStr str)
{
if(std::holds_alternative<std::string>(str))
std::cout << "std string:" << std::get<std::string>(str) << std::endl;
else if(std::holds_alternative<std::string_view>(str))
std::cout << "std string_view:" << std::get<std::string_view>(str) << std::endl;
else if(std::holds_alternative<const char*>(str))
std::cout << "c string:" << std::get<const char*>(str) << std::endl;
else
std::cout << "invalid string" << std::endl;
}
这个方法可以写的很通用,不过有一个问题是类型派发是在运行期间进行的。实际上标准库提供了更完善的套件,也就是std::visit
。直接来看它怎么使用的:
std::visit([](auto&& str){
std::cout << str << std::endl;
}, str1);
&emsp 第一个参数是一个可调用对象,该可调用对象必须要能接受一个std::variant
作为参数,第二个参数就是std::variant
变量了。这里省去了我们检查类型是否存在等操作,从std::variant中解包并把可调用对象直接施加在解包后的std::variant
中。但这样显然不够。为了做到类型分派,我们需要一个辅助类型,如下:
struct helper {
void operator()(string_view str) {
std::cout << "std string_view:" << str << std::endl;
}
void operator()(const std::string& str) {
std::cout << "std string:" << str << std::endl;
}
void operator()(const char* str) {
std::cout << "c string:" << str << std::endl;
}
};
int main()
{
CommonStr str1 = "str1";
CommonStr str2 = "str2"s;
CommonStr str3 = "str3"sv;
std::visit(helper{}, str1);
std::visit(helper{}, str2);
std::visit(helper{}, str3);
}
代码运行结果如下:
c string:str1
std string:str2
std string_view:str3
上面的helper结构体通过函数重载进行自动的派发,这样也不需要我们手动写相关类型判断逻辑了。更重要的是,决定函数调用哪个重载是静态决议的,以降低运行期的负荷。但是每次都需要写一个辅助结构体,不太合理。在cppreference上给出了一个更优雅的解决方案,有以下辅助结构:
// helper type for the visitor #4
template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
// explicit deduction guide (not needed as of C++20)
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;
int main()
{
std::vector<CommonStr> vec{"str1", "str2"s, "str3"sv};
for(const auto& str : vec)
std::visit(overloaded{
[](std::string_view str){std::cout << "std string_view:" << str << std::endl;},
[](const std::string& str){std::cout << "std string:" << str << std::endl;},
[](const char* str){std::cout << "c string:" << str << std::endl;}
}, str);
}
唔嗯,这样复用性和简洁性都比之前好了不少。这里传入一个overloaded结构体,并且通过多个针对不同类型参数的lambda去初始化该结构体。
解释一下上面的代码,由于lambda的实现实际上重载了匿名类的operator()
运算符,所以通过using Ts::operator()...;
用于将传入的lambda表达式重载后的operator()
引入到结构体overloaded中,这就起到了我们手动编写结构体并实现不同重载函数的作用。后面的语句template
用于限定结构体的类型推断原则。一般而言,要使用实例化的类模板或者结构体模板,必须显示给出模板参数的类型。在C++17之后,可以根据构造函数实际参数的类型来推导模板参数类型,从而减少了一些程序员的代码工作。所以这里也是出于类似的考虑,限定了参数推导模式,使得可以从构造函数参数类型中推导模板参数类型。具体内容参考User-defined deduction guides。
不过还有一些不满意的地方,除去上面奇怪的语法,在Rust中模式匹配对于Option类型也是可用的。那么既然C++也有optional类型,没有理由不让他也使用模式匹配,于是乎有:
using CommonStr = std::variant<std::string, std::string_view, const char*, std::monostate>;
// helper type for the visitor #4
template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
// explicit deduction guide (not needed as of C++20)
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;
template <typename T>
concept is_variant = requires (T value) {
std::holds_alternative<int>(value);
};
template <typename T>
concept is_optional = requires (T value) {
value = std::nullopt;
};
template <typename ValueType, typename...FuncTypes>
void match(ValueType&& v, FuncTypes&&...funcs)
{
constexpr auto optioal_match = [](auto v, auto&& f1, auto&& f2) {
if(v) f1(v.value());
else f2();
};
if constexpr (is_variant<std::decay_t<ValueType>>)
std::visit(overloaded {std::forward<FuncTypes>(funcs)...}, std::forward<ValueType>(v));
else if constexpr (is_optional<std::decay_t<ValueType>>)
optioal_match(v, std::forward<FuncTypes>(funcs)...);
else
std::cerr << "error match type" << std::endl;
}
constexpr auto UnKnown = [](...) {
std::cerr << "match error because of unknown type!" << std::endl;
};
constexpr auto Err = [](...) {
std::cerr << "optional contains a invalid value!" << std::endl;
};
上面的代码首先把std::visit
函数通过match封装起来,看起来就更像Rust了。然后通过concept在编译期区分std::optional
与std::variant
类型,然后做出不同的处理。注意针对std::optional
的情况只需要接收两个lambda,多余或者不足都将导致编译报错,并且由于第二个lambda是针对optional为std::nullopt
的情况,这种情况下通过optional值已经无法获取更多错误信息,所以实际上传入的lambda不需要任何参数。具体使用如下:
int main()
{
std::vector<CommonStr> vec{"str1", "str2"s, "str3"sv, std::monostate()};
for(const auto& str: vec)
match(str,
[](std::string_view str) {std::cout << "std string_view :" << str << std::endl;},
[](const std::string& str) {std::cout << "std string :" << str << std::endl;},
[](const char* str) {std::cout << "ctype string :" << str << std::endl;},
UnKnown
);
std::cout << std::endl;
std::vector<std::optional<std::string>> vec2{"str1", std::nullopt};
for(auto var : vec2)
match(var,
[](const std::string& v) {std::cout << "optional value = " << v << std::endl;},
Err
);
}
至少从风格上说更贴近Rust的模式匹配了,也支持std::optional
。为了方便加了两个预定义的lambda用于处理错误情况。运行结构如下:
ctype string :str1
std string :str2
std string_view :str3
match error because of unknown type!
optional value = str1
optional contains a invalid value!