[关闭]
@levinzhang 2023-06-24T10:06:33.000000Z 字数 6027 阅读 304

重新学习C++11之后的C++

by

摘要:

C++是一门古老但不断演进的语言。它已经存在很久了,但是其变化也是非常大的,尤其是2011年之后。在本文中,我们将回顾它最引人注目的一些新特性,包括range、lambda、基于range的循环和移动语义,所有的这些特性都会通过一个vector进行练习。


Key Takeaways

C++是一门古老但不断演进的语言。你几乎可以使用它来做任何事情,而且可以在很多地方找到它的身影。实际上,C++的发明者Bjarne Stroustrup将其描述为一切事物的隐形基础。有时,它可以深入到另外一门语言的库中,因为C++可以用于性能关键的路径中。它可以在小型的嵌入式系统中运行,也可以为视频游戏提供动力。你的浏览器可能正在使用它。C++几乎无处不在!

C++为何如此重要

迄今为止,C++已经存在了很长的时间,但是其变化也是非常大的,尤其是2011年之后。当时,推出了一个名为C++11的新标准,标志着一个频繁更新的时代正式开启。如果你从C++11就没有使用过C++,那么你有很多东西需要补习,这要从哪里开始呢?

该语言是需要编译的,面向特定的架构,如PC、大型机、嵌入式设备、定制硬件,或者你想到的其他东西。如果你需要代码在不同类型的机器上运行,那需要重新编译它。这有缺点也有优点。不同的配置会带来更多的维护工作,但编译到特定架构能够让你“因地制宜(down to the metal)”,从而获得速度方面的优势。

不管你的目标是哪种平台,均需要一个编译器。你还需要一个编辑器或集成开发环境(IDE)来编写C++代码。ISOCpp给出了一个资源清单,包括C++编译器。Gnu编译器集(Gnu compiler collection,gcc)、Clang和Visual Studio均有免费版本。你甚至可以使用Matt Godbolt的编译器探索器,在浏览器上尝试基于各种编译器的代码。编译器可能支持不同版本的C++,所以必须在编译器标记中说明你所需要的版本,例如g++的-std=c++23或Visual Studio的/std:c++latest。ISOCpp网站上有一个FAQ区域,概述了最近的一些变化,包括C++11和C++14,以及整体的概览。另外,还有多本关于C++最近版本的图书。

使用Vector快速了解C++11

如果你已经被落下了,那么大量的资源可能会让你不知所措。但是,我们可以通过一个小例子来理解一些基础知识。停下来,亲自动手试一试往往是最好的学习方法。因此,我们从简单基础的东西开始吧!

一个很有用(且简单)的起点是不太起眼的vector,它位于std命名空间的vector头文件中。CppReference提供了一个概述,告诉我们vector是一个序列容器,封装了动态大小的数组。因此,vector包含了一个连续的元素序列,我们可以根据需要调整vector的大小。vector本身是一个类模板,因此它需要一个类型,例如std::vector<int>。我们可以使用push_back将一个条目添加到vector的尾部。C++11引入了一个名为emplace_back的新方法,该方法取值来构造一个新的条目。对于int,代码看上去是一样的:

  1. std::vector<int> numbers;
  2. numbers.push_back(1);
  3. numbers.emplace_back(1);

如果我们有比int更复杂的东西,那么就可能在emplace版本中获得性能方面的收益,因为emplace版本可以就地构造条目,从而避免对其进行复制。

C++11引入了r-value引用移动语义(move semantics)来避免不必要的复制。潜在的性能改善是C++11的驱动力之一,后续的版本都是在此基础上进行的。为了解释什么是r-value引用,我们可以考虑前面样例中的push_back方法。它有两个重载形式,其中一个会接受一个常量引用,即const T&值,另外一个接受一个r-value引用,即T&&值。第二个版本会将元素移动到vector中,这可以避免复制临时对象。与之类似,emplace_back的签名通过r-value引用来获取参数,Args&&…,同样允许移动参数而无需复制。移动语义是一个很大的话题,我们只是接触到了它的皮毛。如果你想了解更多详情的话,Thomas Becker在2013年撰写了一篇很好的文章,介绍了它的细节。

我们创建一个vector并在其中放置几个条目,然后使用来自iostream头文件的std::cout展示其内容。我们使用流插入操作符<<来显示这些元素。我们基于vectorsize编写一个for循环,并使用操作符[]来访问每个元素:

  1. #include <iostream>
  2. #include <vector>
  3. void warm_up()
  4. {
  5. std::vector<int> numbers;
  6. numbers.push_back(1);
  7. numbers.emplace_back(1);
  8. for(int i=0; i<numbers.size(); ++i)
  9. {
  10. std::cout << numbers[i] << ' ';
  11. }
  12. std::cout << '\n';
  13. }
  14. int main()
  15. {
  16. warm_up();
  17. }

该代码会显示两个1。这段代码可以在编译器探索器上找到。

类模板参数推断

让我们做一些更有意思的事情,并学习一下现代的C++。我们构建几个数字三角,会发现它们之间存在一个模式。数字三角的值是1,3,6,10……它们分别由1,1+2,1+2+3,1+2+3+4,……相加而成。如果我们这些斯诺克球架起来,就可以组成一个三角形,它也因此得名:

如果再增加一排,我们就会再增加六个斯诺克球。再加一排就会增加七个,以此类推。

为了得到数字1,2,3等,我们可以构建一个充满1的vector,然后将这些数字相加。我们可以直接创建一个vector,比如18个1,而不必再增加另一个循环。我们说明想要多少个元素,然后再指明它的值:

  1. std::vector numbers(18, 1);

注意我们不需要再声明<int>了。因为从C++17开始,类模板参数推断(CTAD)就已经实现了。编译器可以推断出我们指的是int,因为我们要求的值是1,这是一个int。如果我们需要显示vector,那么可以使用基于range的for循环。此时,我们不必使用基于vector索引的传统for循环,而是声明一个类型,甚至可以使用新的关键字auto,告诉编译器判断类型,然后是冒号和容器:

  1. for (auto i : numbers)
  2. {
  3. std::cout << i << ' ';
  4. }
  5. std::cout << '\n';

CTAD和基于range的for循环是C++11以来引入的一些便利特性。

Range

有了由“1”组成的vector,我们就可以包含numeric头文件,并使用部分的和来填充一个新的vector,如1,1+1,1+1+1……,这样就有了1,2,3……我们需要声明新vector的类型,因为这里要从一个空的vector开始,如果没有任何值可供使用,那么编译器将无法推断其类型。partial_sum需要开头和结尾的数字,最后我们需要使用back_inserter,这样目标vector会根据需要增长:

  1. #include <algorithm>
  2. std::vector numbers(18, 1);
  3. std::vector<int> sums;
  4. std::partial_sum(numbers.begin(), numbers.end(),
  5. std::back_inserter(sums));

这样我们就得到了1到18的数字,均包含边界值。我们已经完成了数字三角的部分工作,但是C++现在可以让我们的代码更加简洁。C++11引入了iota函数,也位于numeric头文件中,它能够用不断增加的值填充一个容器:

  1. std::vector<int> sums(18);
  2. std::iota(sums.begin(), sums.end(), 1);

实际上,C++23引入了一个range版本,它会为我们找到对应的beginend

  1. std::ranges::iota(sums, 1);

C++23还没有得到广泛的支持,所以可能需要等到你的编译器提供range版本。numericalgorithm头文件中的很多算法都有两个版本,其中一个需要一对输入迭代器(即first and last),另一个则是range版本,只需要接受容器即可。ranges重载正在逐渐添加到标准C++中。ranges提供的功能远远超过我们这里避免声明两个迭代器的场景。我们可以过滤和转换输出,将这些东西连接在一起,并使用视图来避免复制数据。ranges支持惰性计算,所以视图的内容会在需要的时候才评估计算出来。Ivan Čukić的Functional Programming in C++一书在这方面提供了更多的细节(书中还包含更多的内容)。

我们需要做的最后一件事就是形成数字三角。查看vector的部分和:

  1. std::partial_sum(sums.begin(), sums.end(), sums.begin());

我们已经得到了想要的数字三角,即1,3,6,10,15……171。

我们注意到,有些算法有ranges版本,那我们可以尝试一个。前两个三角数字是1和3是奇数,然后是两个偶数6和10。这个模式是不是可持续的呢?如果我们对vector进行转换,用点号“.”来标记奇数,用星号“*”来标记偶数,就能看出最终结果。我们可以声明一个新的vector来存放转换结果。对于每个数字,仅需要一个字符,所以我们需要一个char类型的vector

  1. std::vector<char> odd_or_even.

我们可以编写一个简短的函数,它会获取一个int并返回对应的字符:

  1. char flag_odd_or_even(int i)
  2. {
  3. return i % 2 ? '.' : '*';
  4. }

如果i % 2的值不为零,这就是一个奇数,所以我们返回.,否则,返回*。我们可以在来自algorithm头文件的transform函数中使用这个自己的函数。最初的版本需要一对输入迭代器(first和last)、一个输出迭代器和一个一元函数(unary function),该函数会接受一个输入,就像我们的flag_odd_or_even函数这样。C++20引入了一个ranges版本,它能够接受一个输入源,而不是一对迭代器,另外还需要一个输出迭代器和一元函数。这意味着我们可以通过如下方式来转换先前生成的和:

  1. std::vector<char> odd_or_even;
  2. std::ranges::transform(sums,
  3. std::back_inserter(odd_or_even),
  4. flag_odd_or_even);

输出将会如下所示:

  1. . . * * . . * * . . * * . . * * . .

看上去,我们确实是不断地得到两个奇数,然后是两个偶数。Stack Exchange的数学网站阐述了出现这种现象的原因

Lambdas

我们使用另一个新的C++特性对我们的代码做最后的改进。如果我们想要看一下实际的转换代码的话,那需要要转移到另外一个地方才能看到这个一元函数都做了些什么。

C++11引入了匿名函数或lambda表达式的特性。它们看起来与有名称的函数类似,将参数放在括号中,将函数主体放到花括号中,但是它们没有名字,不需要返回类型,并且有一个用[]表示的捕获组:

  1. [](int i) { return i%2? '.':'*'; }

如果与有名称的函数进行对比,会看到两者的相似性:

  1. char flag_odd_or_even(int i){ return i % 2 ? '.' : '*'; }

我们可以在捕获组中声明变量,这会给我们一个闭包。这些内容超出了本文的范围,但是在函数式编程中它们是非常强大和常见的。

如果我们将一个lambda分配给一个变量,

  1. auto lambda = [](int i) { return i % 2 ? '.' : '*'; };

那么,我们就可以像调用有名称的函数那样调用它:

  1. lambda(7);

这个特性允许我们使用lambda重写转换调用:

  1. std::ranges::transform(sums,
  2. std::back_inserter(odd_or_even),
  3. [](int i) { return i%2? '.':'*'; });

这样的话,我们就可以在一个地方看到转换函数,而不必再去查看其他的地方了。

总结

将所有的内容组合在一起,就形成了如下的代码:

  1. #include <algorithm>
  2. #include <iostream>
  3. #include <numeric>
  4. #include <vector>
  5. int main()
  6. {
  7. std::vector<int> sums(18);
  8. std::iota(sums.begin(), sums.end(), 1);
  9. std::partial_sum(sums.begin(), sums.end(), sums.begin());
  10. std::vector<char> odd_or_even;
  11. std::ranges::transform(sums,
  12. std::back_inserter(odd_or_even),
  13. [](int i) { return i%2? '.':'*'; });
  14. for (auto c : odd_or_even)
  15. {
  16. std::cout << c << ' ';
  17. }
  18. std::cout << '\n';
  19. }

我们使用了ranges、lambda和基于range的for循环,浏览了移动语义,并练习了对vector的使用。对于首次重回C++的人来说,这是一个不错的起点!

你可以在编译器探索器中尝试上述的代码

关于作者

Frances Buontempo

Frances Buontempo有多年的C++经验,还有过使用Python和其他各种语言的经验。她曾发表过关于C++的演讲,并且是ACCU的Overload杂志的编辑。她有数学背景,为PragProg写了一本关于遗传算法和机器学习的书,并且正在为Manning写一本名为C++ Bookcamp的C++书,以帮助那些被现代C++落下的人迎头赶上。

查看英文原文:Relearning C++ After C++11

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注