原文链接欢迎回到 C++ - 现代 C++ | Microsoft Learn
这里先是讲了现代c++的优势,其相对于其他编程语言有快速、高效。 相对于其他语言,该语言更加灵活,跨平台(硬件平台)性也很强,可以直接访问硬件,虽然现在编程千千万但是访问硬件的语言这点可以干掉几乎90%的编程语言,其应用广泛。但是现在很多硬件的编程还是使用c语言,最近也有慢慢被c++替代的趋势。现代 C++ 代码更加简单、安全、美观,而且速度仍像以往一样快速。
接下来从几个方面来大体概括了一下现代C++的优势。
资源和智能指针
原始的c语言容易出现的内存泄露问题这里可以通过RAII(Resource Acquisition Is Initialization)的原则进行规避,这个规则要求资源(堆内存、文件句柄、套接字等)应由对象“拥有”。 该对象在其构造函数中创建或接收新分配的资源,并在其析构函数中将此资源删除。 RAII 原则可确保当所属对象超出范围时,所有资源都能正确返回到操作系统。
为了让我们更方便的遵循这个原则编程,c++标准库提供了三种智能指针类型:std::unique_ptr、std::shared_ptr 和 std::weak_ptr。
智能指针可以自己管理对象的资源,当对象被释放的时候对应的资源也会被释放,这些智能指针都是通过模板<Template>来实现的。我们只需要把我们需要的对象通过std::unique_ptr<int[]> data;
unique_ptr来指向对象,实例化的时候通过make_unique来实现,data = std::make_unique<int[]>(size)
这样实例化的对象我们就直接遵循了RAII原则。对象的管理交给智能指针,这个智能指针也是一个模板类,其内部的实现就是管理对象的生存周期以及资管的管理。比如unique_ptr
存储指向拥有的对象或数组的指针。 此对象/数组仅由 unique_ptr
拥有。 unique_ptr
被销毁后,此对象/数组也将被销毁。shared_ptr
类描述使用引用计数来管理资源的对象。 shared_ptr
对象有效保留一个指向其拥有的资源的指针或保留一个 null 指针。 资源可由多个 shared_ptr
对象拥有;当拥有特定资源的最后一个 shared_ptr
对象被销毁后,资源将释放。
std::string
和 std::string_view
这两个类为了消除字符串编程的过程中遇到的一些问题,在编程的过程中难免会引用字符串。
C语言中对于字符串的使用容易出现bug,尤其各种字符格式转换的过程,c++直接实现了自己的库
std::string 和 std::wstring,几乎可以消除与 C 样式字符串关联的所有错误。并且同时提供搜索、追加和在前面追加等操作。在 C++17 中,可以使用 std::string_view,以便提高性能。
这里可以理解到c++通过自己实现的标准库,帮我们造了一个轮子。c++把之前c语言编程中遇到的一些问题做成了标准库,避免那些问题的实现,我们通过新类直接引用即可。
std::vector
和其他标准库容器
从里面来看这是一个向量,向量在编程中也属于一种容器,其他还有map等,用来管理我们的一些数据类型,比如字符串或者整形浮点型等。容器就是装东西的,在编程语言中是用来装数据的,不同的数据类型都可以装到容器中,包括自己定义的类,我们自己定义的类也可以理解为一种数据类型,在程序的世界中一起资源皆是数据类型,都是数字最终都对应010101,针对这些容器c++都实现了自己的标准库,java等其他语言也对这些容器做了标准库,这些库在使用的过程中很多优势,尤其是其丰富的功能以及久经考验的算法。比如查找排序等,避免自己再次造轮子,除非你的算法由于当前的api,如果那样的话c++肯定会收录你的。
标准库算法
这里讲到了C++里面的一些标准库算法,包含我们常见的如搜索、排序、筛选和随机化等,这些分类在不断增长。 数学库的内容很广泛。 在 C++17 及更高版本中,提供了许多算法的并行版本。
以下是一些重要示例:
-
for_each
,默认遍历算法(以及基于范围的for
循环)。 -
transform
,用于对容器元素进行非就地修改 -
find_if
,默认搜索算法。 -
sort
、lower_bound
和其他默认的排序和搜索算法。
auto comp = [](const widget& w1, const widget& w2)
{ return w1.weight() < w2.weight(); }
sort( v.begin(), v.end(), comp );
auto i = lower_bound( v.begin(), v.end(), widget{0}, comp );
这个代码段里面用到了lambda表达式,一个简单的引用示例。
用 auto
替代显式类型名称
auto是一个非常智能的类型指定关键字,它可以自己推导出数据类型,避免我们定义的时候出现错误。可以代指任意类型,现在很多语言在定义的数据的时候都支持了任意类型,比如kotlin中使用var代指定义数据类型,不用指定具体的类型,可以在运行的时候进行决定。python直接连var这种指定都省略了。
基于范围的 for
循环
在java早就使用了这种编程方式,很多现代语言也采用了这种方式,传统的方式写起来真的很麻烦,限制很大如下:
std::vector<int> v {1,2,3};
// C-style
for(int i = 0; i < v.size(); ++i)
{
std::cout << v[i];
}
但是现代语言的写法直接如下:
// Modern C++:
for(auto& num : v)
{
std::cout << num;
}
直接给出需要遍历的对象,甚至类型都不必指定直接auto,然后我们可以轻易遍历引用其中的数据,省去很多无用的代码。
用 constexpr
表达式替代宏
constexpr也是现代c++的产物,原始定义编译时的常量采用#define宏定义的方式,但是这种方式容易出错而且无法调试,所以出现了constexpr,在预编译的时候就进行处理。
在现代 C++ 中,应优先使用 constexpr 变量定义编译时常量
其平替效果如下:
#define SIZE 10 // C-style
constexpr int size = 10; // modern C++
与 const
一样,它可以应用于变量:如果任何代码试图 modify(修改)该值,将引发编译器错误。 与 const
不同,constexpr
也可以应用于函数和类 constructor(构造函数)。 constexpr
指示值或返回值是 constant(常数),如果可能,将在编译时进行计算。
统一初始化
现代C++支持任意类型的括号初始化,当我们需要初始化数组矢量等容器时其优势明显,编译器可以自己推断每个元素的类型,比如下面的示例:
#include <vector>
struct S
{
std::string name;
float num;
S(std::string s, float f) : name(s), num(f) {}
};
int main()
{
// C-style initialization
std::vector<S> v;
S s1("Norah", 2.7);
S s2("Frank", 3.5);
S s3("Jeri", 85.9);
v.push_back(s1);
v.push_back(s2);
v.push_back(s3);
// Modern C++:
std::vector<S> v2 {s1, s2, s3};
// or...
std::vector<S> v3{ {"Norah", 2.7}, {"Frank", 3.5}, {"Jeri", 85.9} };
}
这个示例中,我们的类(C++中结构体和类class等效)S有一个自己的构造,
在v3实例中我们的vector通过<>指定了S类型,所以编译器可以自己推导类型实例,所以我们可以根据S的构造运用{}直接传参构成实例,这时候编译器自己帮我们推导出S类型的实例push到vector实例v3中。省去了v2去挨个实例化的过程,这样省略了代码的编写。
移动语义
现代 C++ 提供了移动语义,此功能可以避免进行不必要的内存复制。移动操作会将资源的所有权从一个对象转移到下一个对象,而不必再进行复制。 一些类拥有堆内存、文件句柄等资源。 实现资源所属的类时,可以定义此类的移动构造函数和移动赋值运算符。 在解析重载期间,如果不需要进行复制,编译器会选择这些特殊成员。 如果定义了移动构造函数,则标准库容器类型会在对象中调用此函数。
移动构造函数使右值对象拥有的资源无需复制即可移动到左值中。就是这样一个简单的功能,有时候能节省很多的内存以及运算时间,极大的提升程序的性能。
这里不得不提到C++中的&符号,在c中我们通过&来去操作数的地址(即指针),但是现代c++中可以在指定类型时后缀&符号来表示引用,一个&代表左值引用,比如int& 或者char &,定义为左值引用的对象可以直接指向右值(所谓的左值右值可以简单理解为“=的左边还是右边”),通过左值引用我们可以直接访问对象里面的具体数据,可以将左值引用视为对象的另一名称。起了一个别名,但是根本还是通过(指针)地址直接指向了对象。
下面是一个示例
// reference_declarator.cpp
// compile with: /EHsc
// Demonstrates the reference declarator.
#include <iostream>
using namespace std;
struct Person
{
char* Name;
short Age;
};
int main()
{
// Declare a Person object.
Person myFriend;
// Declare a reference to the Person object.
Person& rFriend = myFriend;
// Set the fields of the Person object.
// Updating either variable changes the same object.
myFriend.Name = "Bill";
rFriend.Age = 40;
// Print the fields of the Person object to the console.
cout << rFriend.Name << " is " << myFriend.Age << endl;
}
这里的rFriend直接作为myFriend的左值引用,可直接代替myFriend取值和赋值,引用在定义函数参数作为传参时可以增大传参的灵活性,我们可以通过函数修改传入参数本身的值。传统的方式需要通过指针来实现。
有了左值引用那么就有右值引用,右值引用采用&&两个&符号表示右值引用,可以直接指向右值。
关于左值右值引用的一些规则详细介绍可能需要大幅篇章,在此点到为止。
以下示例显示了 MemoryBlock
类的完整移动构造函数和移动赋值运算符:
// Move constructor.
MemoryBlock(MemoryBlock&& other) noexcept
: _data(nullptr)
, _length(0)
{
std::cout << "In MemoryBlock(MemoryBlock&&). length = "
<< other._length << ". Moving resource." << std::endl;
// Copy the data pointer and its length from the
// source object.
_data = other._data;
_length = other._length;
// Release the data pointer from the source object so that
// the destructor does not free the memory multiple times.
other._data = nullptr;
other._length = 0;
}
// Move assignment operator.
MemoryBlock& operator=(MemoryBlock&& other) noexcept
{
std::cout << "In operator=(MemoryBlock&&). length = "
<< other._length << "." << std::endl;
if (this != &other)
{
// Free the existing resource.
delete[] _data;
// Copy the data pointer and its length from the
// source object.
_data = other._data;
_length = other._length;
// Release the data pointer from the source object so that
// the destructor does not free the memory multiple times.
other._data = nullptr;
other._length = 0;
}
return *this;
}
通过上面的示例可以看到通过右值引用创建了移动构造函数。MemoryBlock(MemoryBlock&& other)直接获取other的数据到当前类,避免了复制的动作。
通过右值引用&&和一个空赋值运算符operator创建了移动赋值运算符。
Lambda 表达式
在 C 样式编程中,可以通过使用函数指针将函数传递到另一个函数。 函数指针不便于维护和理解。 它们引用的函数可能是在源代码的其他位置中定义的,而不是从调用它的位置定义的。 此外,它们不是类型安全的。 可怕的是在大型的c项目中函数指针被广泛使用,对于代码的定位查找产生很多烦恼。而且初学者对函数指针以及指针函数搞得晕头转向,函数指针是指向函数的指针,二指针函数是返回指针的函数。顺序不同意义差距很大。
现代c++直接提供了函数对象和重写 operator() 运算符的类,可以像调用函数一样调用它们。 创建函数对象的最简便方法是使用内联 lambda 表达式。
下面的示例演示如何使用 lambda 表达式传递函数对象,然后由 find_if
函数在矢量的每个元素中调用此函数对象:
find_if() 函数的语法格式如下:
InputIterator find_if (InputIterator first, InputIterator last, UnaryPredicate pred);
std::vector<int> v {1,2,3,4,5};
int x = 2;
int y = 4;
auto result = find_if(begin(v), end(v), [=](int i) { return i > x && i < y; });
这里就是找出向量数组中大于2小于4的数值,通过lambda表达式可以定制自己的筛选规则。当作函数主体传入进去。
可以将 lambda 表达式 [=](int i) { return i > x && i < y; }
理解为“采用类型 int
的单个参数并返回一个布尔值来表示此参数是否大于 x
并且小于 y
的函数”。请注意,可在 lambda 中使用来自周围上下文的 x
和 y
变量。 [=]
会指定通过值捕获这些变量;换言之,对于这些值,lambda 表达式具有自己的值副本。
当然lambda的理解也是一个令人头疼的问题,因为它还支持很多其他复杂的表达方式,它还有一个优势就是减少代码的编写,无需重新定义一个函数,编译器根据需求自己推导这个匿名函数。
异常
与错误代码相比,新式 C++ 更注重异常,将其作为报告和处理错误条件的最佳方法。 有关详细信息,请参阅现代 C++ 处理异常和错误的最佳做法。
这里非常类似java中的try catch使用。防止异常退出,直接捕获异常。
std::atomic
对线程间通信机制使用 C++ 标准库 std::atomic 结构和相关类型。这里创建原子对象,可以避免多线程之间的一些错误问题,不能复制或移动原子对象。防止数据被误修改导致未知的错误出现。
std::variant
(C++17)
C 样式编程通常通过并集(union)使不同类型的成员可以占用同一个内存位置,从而节省内存。 但是,并集不是类型安全的,并且容易导致编程错误。 C++17 引入了更加安全可靠的 std::variant 类,来作为并集的替代项。 可以使用 std::visit 函数以类型安全的方式访问 variant
类型的成员。