C++17 中那些值得关注的特性

引用

       C++17标准在2017上半年已经讨论确定,正在形成ISO标准文档,今年晚些时候会正式发布。本文将介绍最新标准中值得开发者关注的新特新和基本用法。
       总的来说C++17相比C++11的新特性来说新特性不算多,做了一些小幅改进。C++17增加了数十项新特性,值得关注的特性大概有下面这些:

1
2
3
4
5
6
7
8
9
constexpr if
constexpr lambda
fold expression
void_t
structured binding
std::apply, std::invoke
string_view
parallel STL
inline variable

       剩下的有一些来自于boost库,比如variant,any、optional和filesystem等特性,string_view其实在boost里也有。还有一些是语法糖,比如if init、deduction guide、guaranteed copy Elision、template、nested namespace、single param static_assert等特性。我接下来会介绍C++17主要的一些特性,介绍它们的基本用法和作用,让读者对C++17的新特性有一个基本的了解。

fold expression

       C++11增加了一个新特性可变模版参数(variadic template),它可以接受任意个模版参数在参数包中,参数包是三个点…,它不能直接展开,需要通过一些特殊的方法才能展开,导致在使用的时候有点难度。现在C++17解决了这个问题,让参数包的展开变得容易了,Fold expression就是方便展开参数包的。

fold expression的语义

fold expression有4种语义:

1
2
3
4
unary right fold (pack op …)
unary left fold (… op pack)
binary right fold (pack op … op init)
binary left fold (init op … op pack)

       其中pack代表变参,比如args,op代表操作符,fold expression支持32种操作符: + - / % ^ & | = > += -= = /= %= ^= &= |= >= == != = && || , . ->

unary right fold的含义

       fold (E op …) 意味着 E1 op (… op (EN-1 op EN))。
       顾名思义,从右边开始fold,看它是left fold还是right fold我们可以根据参数包…所在的位置来判断,当参数包…在操作符右边的时候就是right fold,在左边的时候就是left fold。我们来看一个具体的例子:

1
2
3
4
5
6
7
template<typename... Args>
auto add_val(Args&&... args)
{
return (args + ...);
}
auto t = add_val(1,2,3,4); //10

       right fold的过程是这样的:(1+(2+(3+4))),从右边开始fold。

unary left fold的含义

       fold (… op E) 意味着 ((E1 op E2) op …) op EN。
       对于+这种满足交换律的操作符来说left fold和right fold是一样的,比如上面的例子你也可以写成left fold。

1
2
3
4
5
6
7
template<typename... Args>
auto add_val(Args&&... args)
{
return (... + args);
}
auto t = add_val(1,2,3,4); //10

       对于不满足交换律的操作符来说就要注意了,比如减法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template<typename... Args>
auto sub_val_right(Args&&... args)
{
return (args - ...);
}
template<typename... Args>
auto sub_val_left(Args&&... args)
{
return (... - args);
}
auto t = sub_val_right(2,3,4); //(2-(3-4)) = 3
auto t1 = sub_val_left(2,3,4); //((2-3)-4) = -5

       这次right fold和left fold的结果就不一样。

binary fold的含义

       Binary right fold (E op … op I) 意味着 E1 op (… op (EN-1 op (EN op I)))。
       Binary left fold (I op … op E) 意味着 (((I op E1) op E2) op …) op E2。
       其中E代表变参,比如args,op代表操作符,I代表一个初始变量。
       二元fold的语义和一元fold的语义是相同的,看一个二元操作符的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template<typename... Args>
auto sub_one_left(Args&&... args)
{
return (1 - ... - args);
}
template<typename... Args>
auto sub_one_right(Args&&... args)
{
return (args - ... - 1);
}
auto t = sub_one_left(234);// (((1-2)-3)-4) = -8
auto t1 = sub_one_right(234);//(2-(3-(4-1))) = 2

       相信通过这个例子大家应该对C++17的fold expression有了基本的了解。

comma fold

       在C++17之前,我们经常使用逗号表达式和std::initializer_list来将变参一个个传入一个函数。比如像下面这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
template<typename T>
void print_arg(T t)
{
std::cout << t << std::endl;
}
template<typename... Args>
void print2(Args... args)
{
//int a[] = { (printarg(args), 0)... };
std::initializer_list<int>{(print_arg(args), 0)...};
}

       这种写法比较繁琐,用fold expression就会变得很简单了。

1
2
3
4
5
template<typename... Args>
void print3(Args... args)
{
(print_arg(args), ...);
}

       这是right fold,你也可以写成left fold,对于comma来说两种写法是一样的,参数都是从左至右传入print_arg函数。

1
2
3
4
5
template<typename... Args>
void print3(Args... args)
{
(..., print_arg(args));
}

       你也可以通过binary fold这样写:

1
2
3
4
5
template<typename ...Args>
void printer(Args&&... args)
{
(std::cout << ... << args) << '\n';
}

       也许你会觉得能写成这样:

1
2
3
4
5
template<typename ...Args>
void printer(Args&&... args)
{
(std::cout << args << ...) << '\n';
}

       但这样写是不合法的,根据binary fold的语法,参数包…必须在操作符中间,因此上面的这种写法不符合语法要求。
       借助comma fold我们可以简化代码,假如我们希望实现tuple的for_each算法,像这样:

1
for_each(std::make_tuple(2.5, 10, 'a'),[](auto e) { std::cout << e<< '\n'; });

       这个for_each将会遍历tuple的元素并打印出来。在C++17之前我们如果要实现这个算法的话,需要借助逗号表达式和std::initializer_list来实现,类似于这样:

1
2
3
4
5
template <typename... Args, typename Func, std::size_t... Idx>
void for_each(const std::tuple& t, Func&& f, std::index_sequence<Idx...>)
{
(void)std::initializer_list<int> { (f(std::get<Idx>(t)), void(), 0)...};
}

       这样写比较繁琐不直观,现在借助fold expression我们可以简化代码了。

1
2
3
4
5
template <typename... Args, typename Func, std::size_t... Idx>
void for_each(const std::tuple<Args...>& t, Func&& f, std::index_sequence<Idx...>)
{
(f(std::get<Idx>(t)), ...);
}

       借助coma fold我们可以写很简洁的代码了。

constexpr if

       constexpr标记一个表达式或一个函数的返回结果是编译期常量,它保证函数会在编译期执行。相比模版来说,实现编译期循环或递归,C++17中的constexpr if会让代码变得更简洁易懂。比如实现一个编译期整数加法:

1
2
3
4
5
6
7
8
9
10
11
template<int N>
constexpr int sum()
{
return N;
}
template <int N, int N2, int... Ns>
constexpr int sum()
{
return N + sum<N2, Ns...>();
}

       C++17之前你可能需要像上面这样写,但是现在你可以写更简洁的代码了。

1
2
3
4
5
6
7
8
template <int N, int... Ns>
constexpr auto sum17()
{
if constexpr (sizeof...(Ns) == 0)
return N;
else
return N + sum17<Ns...>();
}

       当然,你也可以用C++17的fold expression:

1
2
3
4
5
template<typename ...Args>
constexpr int sum(Args... args)
{
return (0 + ... + args);
}

       constexpr还可以用来消除enable_if了,对于讨厌写一长串enable_if的人来说会非常开心。比如我需要根据类型来选择函数的时候:

1
2
3
4
5
6
7
8
9
10
11
template<typename T>
std::enable_if_t<std::is_integral<T>::value, std::string> to_str(T t)
{
return std::to_string(t);
}
template<typename T>
std::enable_if_t<!std::is_integral<T>::value, std::string> to_str(T t)
{
return t;
}

       经常不得不分开几个函数来写,还需要写长长的enable_if,比较繁琐,通过if constexpr可以消除enable_if了。

1
2
3
4
5
6
7
8
template<typename T>
auto to_str17(T t)
{
if constexpr(std::is_integral<T>::value)
return std::to_string(t);
else
return t;
}

       constexpr if让C++的模版具备if-else if-else功能了,是不是很酷,C++程序员的好日子来了。
       不过需要注意的是下面这种写法是有问题的。

1
2
3
4
5
6
7
8
template<typename T>
auto to_str17(T t)
{
if constexpr(std::is_integral<T>::value)
return std::to_string(t);
else
return t;
}

       这个代码把else去掉了,当输入如果是非数字类型时代码可以编译过,以为if constexpr在模版实例化的时候会丢弃不满足条件的部分,因此函数体中的前两行代码将失效,只有最后一句有效。当输入的为数字的时候就会产生编译错误了,因为if constexpr满足条件了,这时候就会有两个return了,就会导致编译错误。
       constexpr if还可以用来替换#ifdef宏,看下面的例子:

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
enum class OS { Linux, Mac, Windows };
//Translate the macros to C++ at a single point in the application
#ifdef __linux__
constexpr OS the_os = OS::Linux;
#elif __APPLE__
constexpr OS the_os = OS::Mac;
#elif __WIN32
constexpr OS the_os = OS::Windows;
#endif
void do_something()
{
//do something general
if constexpr (the_os == OS::Linux)
{
//do something Linuxy
}
else if constexpr (the_os == OS::Mac)
{
//do something Appley
}
else if constexpr (the_os == OS::Windows)
{
//do something Windowsy
}
//do something general
}

       代码变得更清爽了,再也不需要像以前一样写#ifdef那样难看的代码块了。

constexpr lambda

       constexpr lambda其实很简单,它的意思就是可以在constexpr 函数中用lambda表达式了,这在C++17之前是不允许的。这样使用constexpr函数和普通函数没多大区别了,使用起来非常舒服。下面是constexpr lambda的例子:

1
2
3
4
5
6
template <typename I>
constexpr auto func(I i)
{
//use a lambda in constexpr context
return [i](auto j){ return i + j; };
}

       constexpr if和constexpr lambda是C++17提供的非常棒的特性,enjoy it。

string_view

       C++17中的string_view是一个char数据的视图或者说引用,它并不拥有该数据,是为了避免拷贝,因此使用string_view可以用来做性能优化。你应该用string_view来代替const char和const string了。string_view的方法和string类似,用法很简单:

1
2
3
4
5
6
7
8
9
10
const char* data = "test";
std::string_view str1(data, 4);
std::cout<<str1.length()<<'\n'; //4
if(data==str1)
{
std::cout<<"ok"<<'\n';
}
const std::string str2 = "test";
std::string_view str3(str2, str2.size());

       构造string_view的时候用char*和长度来构造,这个长度可以自由确定,它表示string_view希望引用的字符串的长度。因为它只是引用其他字符串,所以它不会分配内存,不会像string那样容易产生临时变量。我们通过一个测试程序来看看string_view如何来帮我们优化性能的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
using namespace std::literals;
constexpr auto s = "it is a test"sv;
auto str = "it is a test"s;
constexpr int LEN = 1000000;
boost::timer t;
for(int i = 0; i < LEN; ++i)
{
constexpr auto s1 = s.substr(3);
}
std::cout<<t.elapsed()<<'\n';
t.restart();
for(int i = 0; i < LEN; ++i)
{
auto s2 = str.substr(3);
}
std::cout<<t.elapsed()<<'\n';
//output
0.004197
0.231505

       我们可以通过字面量””sv来初始化string_view。string_view的substr和string的substr相比,快了50多倍,根本原因是它不会分配内存。

string_view的生命周期

       由于string_vew并不拥有锁引用的字符串,所以它也不会去关注被引用字符串的生命周期,用户在使用的时候需要注意,不要将一个临时变量给一个string_view,那样会导致string_view引用的内容也失效。

1
2
3
4
5
std::string_view str_v;
{
std::string temp = "test";
str_v = {temp};
}

       这样的代码是有问题的,因为出了作用域之后,string_view引用的内容已经失效了。

总结

       本文介绍了C++17的fold expression、constexpr if、constexpr lambda和string_view。fold expression为了简化可变模板参数的展开,让可以模板参数的使用变得更简单直观;constexpr if让模板具备if-else功能,非常强大。它也避免了写冗长的enable_if代码,让代码变得简洁易懂了;string_view则是用来做性能优化的,应该用它来代替const char*和const string。 这些特性对之前的C++14和C++11做了改进和增强,非常酷。

文章目录
  1. 1. 引用
  2. 2. fold expression
  3. 3. fold expression的语义
  4. 4. unary right fold的含义
  5. 5. unary left fold的含义
  6. 6. binary fold的含义
  7. 7. comma fold
  8. 8. constexpr if
  9. 9. constexpr lambda
  10. 10. string_view
  11. 11. string_view的生命周期
  12. 12. 总结