关于C++14:你需要知道的新特性

关于C++14:你需要知道的新特性

使C++更加安全和更加方便的有用新特性

今年8月,经过投票, C++14标准获得一致通过。目前唯一剩下的工作是ISO进行C++标准的正式发布。在本文中,我关注的是新标准中的几个重要点,展示了即将到来的改变会如何影响你的编程方式,特别是在使用被现代C++称之为习语和范型的特性时。

C++标准委员会决心使标准制定过程比过去10年更加快速。这意味着,距上一个标准(即C++11)仅3年的C++14是一次相对较小的发布。这远非一个令人失望的消息,恰恰相反,这对程序员来说是个好消息。因为这样的话,开发人员能够实时地跟上新特性。所以,今天你就可以开始使用C++14的新特性了—而且,如果你的工具链足够灵活的话,你几乎可以使用全部新特性了。

目前你可以从这里得到标准草案的一份免费副本。遗憾的是,当最终版本的标准发布时,ISO会进行收费。

缩短标准发布的时间间隔可以帮助编译器作者更实时地跟上语言变化。仅隔三年就再次发布,需要调整以适应的变化也就更少。

本文的例子主要在clang 3.4上测试过,clang 3.4覆盖了大多数C++14的新特性。目前,g++对新特性的覆盖更少一些,而Visual C++似乎落后更多。

C++14:重大变化

接下来,本文将说明对程序员编码工作会有重大影响的C++14特性,在给出实例的同时,还讨论了何时何地因何使用这些特性。

返回类型推导

在这次发布中,关键字auto的作用扩大了。C++语言本身仍然是类型安全的,但是类型安全的机制逐渐改由编译器而不是程序员来实现。

在C++11中,程序员最初使用auto是用于声明。这对于像迭代器的创建之类尤其有用,因为完整的正确的类型名可能长得可怕。使用了auto的C++代码则易读得多:

1
for ( auto ii = collection.begin() ; ...

在C++14中,auto的使用在好几个方面得到了扩展。其中之一便是意义非凡的返回类型推导。在一个函数中编写如下一行代码:这段代码依然完全地是类型安全的,因为编译器知道begin()在上下文中应该返回什么类型。因此,ii的类型是毫无疑问的,并且在使用ii的每个地方,编译器都会进行检查。

1
return 1.4;

对于程序员和编译器来说,很显然,函数返回的是double类型。因此在C++14中,程序员可以用auto代替double来定义函数返回类型:

1
auto getvalue() {

这个新特性需要注意的一个细节也是相当容易理解的。那就是,如果一个函数有多个返回路径,那么每个返回路径返回的值需要具有相同的类型。

1
2
3
4
5
6
7
auto f( int i)
{
   if ( i < 0 )
     return -1;
   else
     return 2.0
}

上面这段代码似乎显然应该推导出返回类型是double,但是C++14禁止这种带歧义性的使用。对于上述代码,编译器会报错:

1
2
3
4
5
error_01.cpp:6:5: error: 'auto' in return type deduced as 'double' here but deduced as 'int' in
       earlier return statement
     return 2.0
     ^
1 error generated.

为C++程序增加推导返回类型这一特性有诸多很好的理由。第一个理由是,有时候需要返回相当复杂的类型,例如,在STL容器中进行搜索时可能需要返回迭代器类型。auto使函数更易编写,更具可读性。第二个(可能不那么明显的)理由是,auto的使用能够增强你的重构能力。考虑以下程序:

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
#include
#include
#include
 
struct record {
    std::string name;
    int id;
};
 
auto find_id( const std::vector &people,
              const std::string &name)
{
   auto match_name = [&name]( const record& r) -> bool {
     return r.name == name;
   };
   auto ii = find_if(people.begin(), people.end(), match_name );
   if (ii == people.end())
     return -1;
   else
     return ii->id;
}
 
int main()
{
   std::vector roster = { { "mark" ,1},
                                  { "bill" ,2},
                                  { "ted" ,3}};
   std::cout << find_id(roster, "bill" ) << "\n" ;
   std::cout << find_id(roster, "ron" ) << "\n" ;
}

在这个例子中,使用auto代替int作为find_id()函数的返回类型并不能节省多少脑细胞J。但是,考虑一下,如果我决定重构record结构,将会发生什么。或许我想用一个新的类型GUID而不是一个整型来标识record对象中的人:

1
2
3
4
struct record {
    std::string name;
    GUID id;
};

record对象的变化将引起包括函数返回类型在内的一系列级联变化。但是,如果我在函数中使用了自动的返回类型推导,那么编译器将默默地为我进行这些修改。

任何有过大型项目工作经验的C++程序员都应该很熟悉这个问题–对单一数据结构的修改可能引起代码库看似无穷无尽的迭代:修改变量,修改参数,修改返回类型。auto的增加使用对减少这种工作贡献不小。

注意在上述例子及本文的余下部分,我创建并使用有名的lambda。我猜想,大多数用户在std::find_if()这样的函数中都是把lambda定义为匿名的内联对象的,这确实是非常方便的方式。由于浏览器的页面宽度有限,我认为把lambda的定义和使用分开能够使读者通过浏览器阅读代码比较容易。因此,这并不是各位读者一定应该仿效的方式,读者们只是应该感激这样使代码更加易读–特别是,当你是一位缺乏lambda使用经验的读者时。

说回auto,使用auto作为返回类型带来的一个直接推论是其分身decltype(auto)的实现,以及它在类型推导时将遵循的规则。像下面的代码片段展示的一样,现在你可以使用它自动地捕获类型信息:

1
2
3
4
5
template < typename Container>
struct finder {
   static decltype(Container::find) finder1 = Container::find;
   static decltype(auto) finder2 = Container::find;
};

泛型Lambdas

auto悄悄潜伏的另一个地方是lambda参数的定义。使用auto类型声明来定义lambda参数等价于放松限制地创建模板函数。基于推导出的参数类型,lambda将进行特定的实例化。

这方便了可重用于不同上下文的lambda的创建。在下文的简单例子中,我创建了一个lambda,用来作为一个标准库函数的谓词函数。在C++11中,我需要明确地实例化一个lambda用于整数的相加,再实例化另一个lambda用于字符串的相加。

有了泛型lambda后,我可以只定义一个带有泛型参数的lambda。尽管泛型lambda在语法上没有包含关键字template,但是很显然,它仍是C++泛型编程的进一步延展。

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
#include
#include
#include
#include
 
int main()
{
   std::vector< int > ivec = { 1, 2, 3, 4};
   std::vector svec = { "red" ,
                                     "green" ,
                                     "blue" };
   auto adder  = [](auto op1, auto op2){ return op1 + op2; };
   std::cout << "int result : "
             << std::accumulate(ivec.begin(),
                                ivec.end(),
                                0,
                                adder )
             << "\n" ;
   std::cout << "string result : "
             << std::accumulate(svec.begin(),
                                svec.end(),
                                std::string( "" ),
                                adder )
             << "\n" ;
   return 0;
}

上述代码产生以下输出:

1
2
int result : 10
string result : redgreenblue

即使你实例化匿名的内联lambda,采用泛型参数仍然是有用的,原因我已在前文中讨论过,这里再复述一下–当你的数据结构改变时,或者API中获取签名的函数修改时,泛型lambda将在重新编译时自行调整而不需要重写代码。使用泛型参数的匿名内联lambda例子如下所示:

1
2
3
4
5
6
std::cout << "string result : "
           << std::accumulate(svec.begin(),
                              svec.end(),
                              std::string( "" ),
                              [](auto op1,auto op2){ return op1+op2; } )
           << "\n" ;

可初始化的Lambda捕获

在C++11中,我们不得不开始适应lambda capture这一概念。其声明指导编译器进行closure的创建:closure是一个由lambda定义的函数的实例,同时,它绑定了定义于lambda作用域之外的变量。

在上文有关推导返回类型的示例中,定义了一个lambda,它捕获变量name,该变量被作为一个搜索字符串的谓词函数的源:

1
2
3
4
auto match_name = [&name]( const record& r) -> bool {
     return r.name == name;
   };
   auto ii = find_if(people.begin(), people.end(), match_name );

这种特殊的捕获使lambda能够访问到引用变量。捕获也可以通过值来完成。在这两种情形中,变量的使用符合C++一贯的方式–通过值捕获时lambda操作的是变量的本地副本,而通过引用捕获则意味着lambda作用于来自其作用域之外的变量实例本身。

这一切都OK,但同时也带来了一些限制。我认为,C++标准委员会觉得需要特别强调的一点是,不能使用move-only语法来初始化捕获的变量。

这说明什么呢?如果我们想把lambda作为一个参数的sink(接收器),我们会使用move语法来捕获其作用域之外的变量。作为一个例子,考虑一下如何得到一个lambda,它接收具有move-only特点的unique_ptr对象。首先,尝试通过值捕获将以失败告终:

1
2
3
std::unique_ptr< int > p( new int );
   *p = 11;
   auto y = [p]() { std::cout << "inside: " << *p << "\n" ;};

这段代码产生编译错误是因为unique_ptr不会生成拷贝构造函数–unique_ptr本身就是为禁止拷贝而生的。

修改代码通过引用捕获p能够编译通过,但是这并不能达到期望的效果,我们的初衷是通过移动变量的值到本地拷贝来接收变量值。最终,创建一个局部变量并在通过引用捕获时调用std::move()能够达到目的,但是其效率略低。

修改捕获子句的语法可以解决效率低的问题。现在,不仅仅可以声明一个捕获变量,还可以进行初始化。作为标准中的一个例子,简单情形下的使用看起来像这样:

1
auto y = [&r = x, x = x+1]()-> int {...}

它捕获x的副本同时实现对x的增量操作。这个例子很容易理解,但是我不确定它是否能够捕获这种新语法下的move-only变量的值。一个利用了这个新语法的用例如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include
#include
 
int main()
{
   std::unique_ptr< int > p( new int );
   int x = 5;
   *p = 11;
   auto y = [p=std::move(p)]() { std::cout << "inside: " << *p << "\n" ;};
   y();
   std::cout << "outside: " << *p << "\n" ;
   return 0;
}

在这个例子中,捕获的变量值p通过move语法进行初始化,在不需要声明一个局部变量的情况下有效地接收了指针。

1
2
inside: 11
Segmentation fault (core dumped)

这个恼人的结果正是你所期望的–代码在变量p已经被捕获并移动到lambda后试图解引用它(这当然会导致错误)。

[[deprecated]]属性

当我第一次在Java中见到deprecated属性的使用时,我承认我有点嫉妒这门语言。对大多数程序员来说,代码陈旧是个大问题。(有因删除代码而被称赞过吗?反正我从来没有。)这个新属性提供了解决这个问题的系统方法。

它的用法方便又简单—只需要把[[deprecated]]标签放到声明的前面即可—可以是类,变量,函数,或者其他一些实体的声明。结果看起来像这样:

1
2
3
class
[[ deprecated ]] flaky {
};

当程序中使用了过时的实体时,编译器的反应是把它留给开发人员。显然,大多数人会希望在需要时看到某种形式的警告,同时在不需要时也能够关掉警告。clang3.4中有一个例子,当实例化一个过时的类时给出了警告:

1
2
3
4
5
6
dep.cpp:14:3: warning: 'flaky' is deprecated [-Wdeprecated-declarations]
   flaky f;
   ^
dep.cpp:3:1: note: 'flaky' declared here
flaky {
^

你可能已经注意到,C++的attribute-tokens语法看起来似乎有点不常见。包含[[deprecated]]的属性列表,被放在class,enum等关键字之后,实体名之前。

这个标签具有包括消息参数的另一种形式。同样地,如何处理该消息取决于开发人员。显然,clang3.4直接忽略了该消息。因为,如下代码片段的输出中并不包含错误消息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class
[[ deprecated ]] flaky {
};
 
[[ deprecated ( "Consider using something other than cranky" )]]
int cranky()
{
    return 0;
}
 
int main()
{
   flaky f;
   return cranky();
}
1
2
3
4
5
6
dep.cpp:14:10: warning: 'cranky' is deprecated [-Wdeprecated-declarations]
   return cranky();
          ^
dep.cpp:6:5: note: 'cranky' declared here
int cranky()
     ^

二进制常量和单引号用作数字分位符

这两个新特性并不惊天动地,但它们确实代表了好的语法改进。语言中像这样的持续小改进可以提高代码的可读性并因此而减少bug数量。

除了原有的十进制、十六进制和比较不常用的八进制表示方法之外,C++程序员现在还可以使用二进制表示常量了。二进制常量以前缀0b(或0B)开头,二进制数字紧随其后。

在英美两国,在写数字时,我们习惯于使用逗号作为数字的分隔符,如:$1,000,000。这些数字分隔符纯为方便读者,它提供的语法线索使我们的大脑在处理长串的数字时更加容易。

基于完全相同的原因,C++标准委员会为C++语言增加了数字分隔符。数字分隔符不会影响数字的值,它们的存在仅仅是为了通过分组使数字的读写更容易。

使用哪个字符来表示数字分隔符呢?在C++中,几乎每个标点字符都已经有特定的用途了,因此并没有明显的选择。最终的结果是使用单引号字符,这使得百万美元在C++中写作1’000’000.00。记住,分隔符不会对常量的值有任何影响,因此,1’0’00’0’00.00也是表示百万。

下面是一个结合了这两种新特性的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include
 
int main()
{
   int val = 0b11110000;
   std::cout << "Output mask: "
             << 0b1000 '0001' 1000'0000
             << "\n" ;
   std::cout << "Proposed salary: $"
             << 300'000.00
             << "\n" ;
   return 0;
}

这段代码的输出毫不令人吃惊:

1
2
Output mask: 33152
Proposed salary: $300000

其他

C++14规范中的其他特性并不需要如此多的阐释。

变量模板就是将模板扩展到变量。用滥了的例子是变量模板pi的实现。当T表示double类型时,变量返回3.14。表示int类型时,返回3。表示std::string类型时,则可能返回字符串”3.14”或者”pi”。当头文件写好的时候,这将是一个很好的特性。

变量模板的语法及语义与类模板几乎是相同的,所以,即使不进行任何额外的学习,使用它们也应该是没有问题的(如果你已经了解了类模板的话)。

constexpr函数的限制被放松了。现在允许在case语句,if语句,循环语句等语句中进行多处返回了。这扩展了可在编译期间完成的事情的范围,增加可在编译期间完成的事情这一趋势在模板被引入后发展得尤其迅速。

其他的小特性包括可指定大小的资源回收函数和一些语法整理。

接下来

C++标准委员会明显感受到了压力,正在通过改进来保持C++语言与时俱进。在这个十年期中,他们已经在至少一个(即C++17)以上的标准上进行努力了。

也许更有趣的是,几个衍生组织的创立,这些组织可以创建技术规范文档。这些文档不会提升为标准,但是它们会发表并获得ISO标准委员会的认可。根据推测,这些事务将以更快的速度得到推进。这些组织当前工作的八大领域包括以下方面:

  • 文件系统
  • 并发性
  • 并行性
  • 网络
  • C++的AI概念(Artificial Intelligence,人工智能)–一直处于规范中。

这些技术规范的成功与否取决于其是否被采纳和使用。如果我们发现所有开发人员都跟随它们,那么这种进行标准化的新途径就算成功了。

这些年来C/C++发展良好。现代C++(或许以C++11作为开始)在保持性能的同时,在使C++语言更加易用更加安全方面取得了引人注目的进展。对于某些类型的工作,你很难找出C/C++之外的任何合理替代品。C++14并未做出C++11版本中那样的大改进,但是它把语言保持在一条很好的路上。如果C++标准委员会在未来十年保持其目前的效率,那么C++应该能够继续作为当性能被定为目标时的首选语言。


你可能感兴趣的:(C++)