主要聊一下 C++17,顺便记录一下 RAII。说句真心话,只要 C++ 遵照最佳实践来编码,还是很省心的,就算它有很多丑陋的地方,你不用它,不看它不就完了。
#include
int main(void)
{
if (auto i = 42; i > 0) {
std::cout << "Hello World\n";
}
}
可以保证变量是有效的。
#include
int main(void)
{
switch(auto i = 42) {
case 42:
std::cout << "Hello World\n";
break;
default:
break;
}
}
下面代码运行时会优化(移除)if
分支。
#include
constexpr const auto val = true;
int main(void)
{
if (val) {
std::cout << "Hello World\n";
}
}
constexpr
在 C++11 时就引入了,但是很多程序员都假定会优化分支,其实并没有。
C++17 还引入了 constexpr if
,如果编译时不能优化 if
将会报错。
#include
int main(void)
{
if constexpr (constexpr const auto i = 42; i > 0) {
std::cout << "Hello World\n";
}
}
C++11 中就有这样的断言,
#include
int main(void)
{
static_assert(42 == 42, "the answer");
}
C++17 引入了一个新的形式,可以直接这样使用:
#include
int main(void)
{
static_assert(42 == 42);
}
C++17 添加了嵌套命名空间,不再需要换行。在C++17之前,嵌套命名空间需要这样写:
#include
namespace X
{
namespace Y
{
namespace Z
{
auto msg = "Hello World\n";
}
}
}
int main(void)
{
std::cout << X::Y::Z::msg;
}
C++17可以把它写到同一行中:
#include
namespace X::Y::Z
{
auto msg = "Hello World\n";
}
int main(void)
{
std::cout << X::Y::Z::msg;
}
这是很多人最喜欢的 C++17 的新特性。在 C++17 以前,复杂的结构,比如结构体或者 std::pair
,可以用于返回值不止一个的函数。但它的语法非常笨重。就像这样:
#include
#include
std::pair<const char*, int>
give_me_a_pair()
{
return {
"The answer is:", 42};
}
int main(void)
{
auto p = give_me_a_pair();
std::cout << std::get<0>(p) << std::get<1>(p) << '\n';
}
在 C++17,结构化绑定(structed bindings)提供了一种将 struct 或者 std::pair
解析独立字段的方法。
#include
#include
std::pair<const char*, int>
give_me_a_pair()
{
return {
"The answer is:", 42};
}
int main(void)
{
auto [msg, answer] = give_me_a_pair();
std::cout << msg << answer << '\n';
}
同样,可以用于 struct。
#include
struct mystruct
{
const char *msg;
int answer;
};
mystruct
give_me_a_struct()
{
return {
"The answer is:", 42};
}
int main(void)
{
auto [msg, answer] = give_me_a_struct();
std::cout << msg << answer << '\n';
}
C++17 一个比较有争议的添加就是内联变量。因为现在有很多库只有头文件,也就是把实现也写在了头文件中。可这也意味着会引入全局变量,那么就可能与正在编写的代码冲突。
内联变量移除了这个问题,就像这样:
#include
inline auto msg = "Hello World\n";
int main(void)
{
std::cout << msg;
}
C++17 添加了一个 std::string_view{}
,它封装了字符序列,类似 std::array
,可以更加简单和安全地使用 C 字符串。
#include
#include
int main(void)
{
std::string_view str("Hello World\n");
std::cout << str;
}
像 std::array
,std::string_view{}
提供了基于 array 的获取器,就像这样:
#include
#include
int main(void)
{
std::string_view str("Hello World");
std::cout << str.front() << '\n';
std::cout << str.back() << '\n';
std::cout << str.at(1) << '\n';
std::cout << str.data() << '\n';
}
front
和 last
用来返回字符串的第一个和最后一个字符。at()
可以返回在字符串中任意的字符。如果下标超出字符串的长度,则会抛出 std:out_of_range()
异常。data()
直接返回基于 array 的字符串。用这个函数要小心一点,毕竟它是不安全的。
std::string_view{}
也提供了关于字符串大小的信息:
#include
#include
int main(void)
{
std::string_view str("Hello World\n");
std::cout << str.size() << '\n';
std::cout << str.max_size() << '\n';
std::cout << str.empty() << '\n';
}
string_view
还可以通过移除前后的字符来减少字符串视图(view)的大小:
#include
#include
int main(void)
{
std::string_view str("Hello World");
str.remove_prefix(1);
str.remove_suffix(1);
std::cout << str << '\n';
}
// ello Worl
注意,它并没有重新分配内存,只是改动了指针。如果需要重新分配内存的(当然,要付出一定的性能代价),则应该使用 std::string{}
。
也可以用来返回子字符串,就像这样:
#include
#include
int main(void)
{
std::string_view str("Hello World");
std::cout << str.substr(0, 5) << '\n';
}
还可以比较字符串,类似 strcmp
函数,返回 0 则代表两个字符串相同。
#include
#include
int main(void)
{
std::string_view str("Hello World");
if (str.compare("Hello World") == 0) {
std::cout << "Hello World\n";
}
std::cout << str.compare("Hello") << '\n';
std::cout << str.compare("World") << '\n';
}
最后,查找 find 系列函数是这样的:
#include
#include
int main(void)
{
std::string_view str("Hello this is a test of Hello World");
std::cout << str.find("Hello") << '\n';
std::cout << str.rfind("Hello") << '\n';
std::cout << str.find_first_of("Hello") << '\n';
std::cout << str.find_last_of("Hello") << '\n';
std::cout << str.find_first_not_of("Hello") << '\n';
std::cout << str.find_last_not_of("Hello") << '\n';
}
// 0
// 24
// 0
// 33
// 5
// 34
find
函数返回第一次出现 Hello
这个字符串的位置,这里是0。rfind
返回最后一次出现给定的字符串的位置。find_first_of()
和 find_last_of()
查找字符的首次出现和查找字符的最后一次出现的位置。在这个例子中,H
在字符串中,同时它是 msg
的开头,所以返回 0。o
作为查找字符串的最后一个字符,最后一次是在 msg
的最后一个单词 World
中。find_first_not_of()
和 find_last_not_of()
和前面的功能相反。std:any{}
能够存储任意值,获取的时候要将 std:any{}
恢复成对应的数据类型,但它能保存类型安全。any{}
内部定义有一个指针,类型变化时就会分配内存。
#include
#include
struct mystruct {
int data;
};
int main(void)
{
auto myany = std::make_any<int>(42);
std::cout << std::any_cast<int>(myany) << '\n';
myany = 4.2;
std::cout << std::any_cast<double>(myany) << '\n';
myany = mystruct{
42};
std::cout << std::any_cast<mystruct>(myany).data << '\n';
}
// 42
// 4.2
// 42
在上面的例子中,创建了 std:any{}
并且用 int、double和一个结构体去设置它的值。
std::variant
更像一个类型安全的union。使用标准 C 语言的 union,它是无法在运行时知道存储的类型,也就是同时存储 int 和 double 是有问题的。std::variant
它可以避免这个问题,尝试用不同类型来获取数据是允许的,因此它是类型安全的。
#include
#include
int main(void)
{
std::variant<int, double> v = 42;
std::cout << std::get<int>(v) << '\n';
v = 4.2;
std::cout << std::get<double>(v) << '\n';
}
在上面的例子中,std::variant
被用来存储一个 integer
和 double
,而通过 std::variant
可以安全地获取数据。
std::optional
是一个可空的值类型,它要么含值,要么不含值。一个指针是可空引用类型,代表着这个指针要么是无效的要么是有效的,并且存储了一个值。为了创建一个指针值,就必须分配内存或者至少指向一个值。std::optional
是值类型,意味着它不需要分配内存。
#include
#include
class myclass
{
public:
int val;
myclass(int v) : val{
v}
{
std::cout << "constructed\n";
}
};
int main(void)
{
std::optional<myclass> o;
std::cout << "created, but no constructed\n";
if (o) {
std::cout << "Attempt #1: " << o->val << '\n';
}
o = myclass{
42};
if (o) {
std::cout << "Attempt #2: " << o->val << '\n';
}
}
// created, but no constructed
// constructed
// Attempt #2: 42
可以看到,类没有被构造,直到我们设置了一个有效值。
RAII 可以说是 C++ 最重要的惯用法,一个不懂 RAII 的 C++ 程序员不是一个合格的 C++ 程序员。RAII 为整个 C++ 库奠定了基础和设计模式。RAII 背后的想法很简单。如果分配了资源,则在对象的构造过程中分配该资源,并且在销毁对象时,将释放该资源。换句话说,用对象来管理资源。为此, RAII 利用 C++ 的构造和销毁功能, 例如:
#include
class myclass
{
public:
myclass()
{
std::cout << "Hello from constructor\n";
}
~myclass()
{
std::cout << "Hello from destructor\n";
}
};
int main(void)
{
myclass c;
}
// Hello from constructor
// Hello from destructor
可以看到,当类被初始化时,类就被构造。不再使用它的时候,它就被销毁。用这种简单的概念就可以用来保护一个资源,确保离开时释放了资源。只要用 RAII ,基本上不会出现内存的错误。
#include
class myclass
{
int *ptr;
public:
myclass():
ptr{
new int(42)}
{
}
~myclass()
{
delete ptr;
}
int get()
{
return *ptr;
}
};
int main(void)
{
myclass c;
std::cout << "The answer is: " << c.get() << '\n';
}
可以看到,类创建的时候分配了内存,类销毁的时候释放了内存。因此,只要 myclass{}
还在,资源就是可用的,可以安全地访问资源,因为只有在 myclass{}
不可见的时候才释放资源(假设不使用指向类的引用或指针)。内存不会泄漏。如果类可见,则分配的类的内存将有效。一旦类不再可见(即脱离作用域),内存将被释放,并且不会发生泄漏。
每一个明确的资源配置动作(例如
new
)都应该在单一语句中执行,并在该语句中立刻将配置获得的资源交给 handle 对象(比如shared_ptr
),程序中一般不出现delete
。
C++ 的智能指针 std::unique_ptr{}
和 std::shared_ptr{}
就利用了这种设计模式。RAII 适用于任何必须获取然后释放的资源,例如:打开文件,就需要在最后关闭文件:
#include
class myclass
{
FILE *m_file;
public:
myclass(const char *filename) :
m_file{
fopen(filename, "rb")}
{
if (m_file == 0) {
throw std::runtime_error("unable to open file");
}
}
~myclass()
{
fclose(m_file);
std::clog << "Hello from destructor\n";
}
};
int main(void)
{
myclass c("test.txt");
try {
myclass c2("does_not_exist.txt");
} catch (const std::exception &e) {
std::cout << "exception: " << e.what() << '\n';
}
}
// exception: unable to open file
// Hello from destructor
需要注意的是,第二个类的析构函数并没有被调用,因为在初始化类的时候已经抛出了异常。也就是说,资源的获取与类本身的初始化直接相关。如果无法安全地构造类,则可以防止未分配的资源被破坏。
RAII 可以说是 C++ 简单又最强大的功能。
C++ 的语法特性实在是太多了,因此实践过程中许多人只选择了 C++ 的一部分语言特性进行开发,从而约定了最佳实践(用什么、怎么用、要不要用)。其中一个著名的规范就是CCG (C++ Core Guidelines)。为了在开发过程中更好地遵守 CCG 的最佳实践,可以使用 GSL(The Guideline Support Library) 库。
unique_ptr{}
和 sharped_ptr{}
。但有时候,某些情况下是用不了的,这些边缘情况就可以考虑用 GSL 来处理。C++ 并不区分谁拥有指针(分配和释放指针)和谁仅能访问值。例如:
#include
void init(int *p)
{
*p = 0;
}
int main(void)
{
auto p = new int;
init(p);
delete p;
}
上面的代码虽然很短,但是还是可以看到会有很多潜在的风险。比如,init
如果在方法内释放了指针,那么 main 中 delete
便会出现内存重复释放的问题。换句话说,怎么确保 delete
的时间是正确的,如果 init
把资源交给了另外的方法,delete
却提前删除资源,那么之后其它方法访问资源时,又会出现错误。
为了克服这个问题,GSL 提供了一个 gsl::owner<>{}
用于记录给定的变量是否是指针的所有者。
#include
void init(int *p)
{
*p = 0;
}
int main(void)
{
gsl::owner<int *> p = new int;
init(p);
delete p;
}
注意:这里的 gsl 不是 C 语言的科学计算库。它只是一个纯头文件的支持库。最简单的用法就是把头文件
include
拷到某个目录下,然后编译的时候使用-I
选项就可以了,例如g++ -std=c++17 xxx.cpp -Ixxx/xxx/include
。
在上面的代码中,p
被指定为指针的所有者,如果 p
不再需要了,那么它应该释放内存。上面代码还有一个问题,init
期待的是一个非 null
的指针。
通常有两种方法来克服出现 null
指针的问题。第一种,是检查 nullptr
并且抛出异常。这种解决方法的问题是,你在每个函数都要检查 nullptr
。这些检查无疑需要成本,而且代码也显得比较杂乱。另外一种做法是使用 gsl::not_null<>{}
,它可以明确地声明是否可以安全地处理空指针。
#include
gsl::not_null<int *>
test(gsl::not_null<int *> p)
{
return p;
}
int main(void)
{
auto p1 = std::make_unique<int>();
auto p2 = test(gsl::not_null(p1.get()));
}
在上面的代码中,使用 std::unique_str{}
创建了一个指针,并将它传递给 test()
函数。test()
函数不支持空指针,所以它使用 gsl:not_null<>{}
作为它的参数。反过来,test()
函数返回 gsl:not_null<>{}
,代表函数结果是非 null 的(这也是它为什么要求参数是非 null 原因)。
指针算法同样是导致不稳定和漏洞的常见错误来源。C++ Core Guidelines 不鼓励使用指针算术。
int array[10];
auto r1 = array + 1;
auto r2 = *(array + 1);
auto r3 = array[1];
使用指针算术很容易越界。为了解决这个问题,GSL 提供了一个 gsl::span{}
类,可以给我们一个安全使用指针的接口,同样适用于数组。例如:
#define GSL_THROW_ON_CONTRACT_VIOLATION
#include
#include
int main(void)
{
int array[5] = {
1, 2, 3, 4, 5};
auto span = gsl::span(array);
for (const auto &elem : span) {
std::clog << elem << '\n';
}
for (auto i = 0; i < 5; i++) {
std::clog << span[i] << '\n';
}
try {
std::clog << span[5] << '\n';
} catch(const gsl::fail_fast &e) {
std::cout << "exception: " << e.what() << '\n';
}
}
// 1
// 2
// 3
// 4
// 5
// 1
// 2
// 3
// 4
// 5
// exception: GSL: Precondition failure at ...
可以看到 gsl::span{}
在 C++ 原生的数组功能上增加了对范围的检查,防止越界。在上面的代码中,试图访问越界的下标 5,gsl::span{}
抛出了 gsl::fail_fast{}
异常。需要注意的是 GSL_THROW_ON_CONTRACT_VIOLATION
,它告诉 GSL 抛出异常而不是终止 std::terminate
或者不检查越界。
在 gsl::span{}
之上,gsl::span{}
也有一些特定的实现,比如 gsl::cstring_span{}
:
#include
#include
int main(void)
{
gsl::cstring_span<> str = gsl::ensure_z("Hello World\n");
std::cout << str.data();
for (const auto &elem : str) {
std::clog << elem;
}
}
// Hello World
// Hello World
gsl::cstring_span{}
是一个包含标准 C 风格字符串的 gsl::span{}
。使用 gsl::ensure_z()
函数来确保字符串由一个 null 字符结尾。
C++ 契约为用户提供了一种方法来说明函数期望输入的内容,以及该函数确保输出的内容。具体来说,C ++ 契约记录了API的作者和API的用户之间的契约,它还提供了该契约的编译时和运行时验证。
未来的 C++ 将会有内置的契约,但现在,可以用 GSL 提供一个基于库的实现。主要是 Expects()
和 Ensures()
这两个宏。
#define GSL_THROW_ON_CONTRACT_VIOLATION
#include
#include
int main(void)
{
try {
Expects(false);
} catch (const gsl::fail_fast &e) {
std::cout << "exception: " << e.what() << '\n';
}
}
// exception: GSL: Precondition failure at cpp17_01.cpp: 8
在上面的代码中,使用 Expects()
宏并传入 false
。看起来很像 C 语言的 assert()
函数。但不像 assert()
,Expects()
为 false
时如果不是调试的时候就会执行 std::terminate()
来终止程序。
Ensures()
宏和 Expects()
是一样的,只不过它约束的是输出而不是输入。
#define GSL_THROW_ON_CONTRACT_VIOLATION
#include
#include
int test(int i)
{
Expects(i >= 0 && i < 41);
i++;
Ensures(i < 42);
return i;
}
int main(void)
{
test(0);
try {
test(42);
} catch (const gsl::fail_fast &e) {
std::cout << "exception: " << e.what() << '\n';
}
}
// exception: GSL: Precondition failure at cpp17_01.cpp: 7
GSL 也提供给了一些有用的工具来帮助创建具有可靠性和可读性的代码。例如 gsl::finally{}
API:
#define concat1(a,b) a ## b
#define concat2(a,b) concat1(a,b)
#define ___ concat2(dont_care, __COUNTER__)
#include
#include
int main(void)
{
auto ___ = gsl::finally([]{
std::cout << "Hello World\n";
});
}
// Hello World
gsl::finally{}
通过 C++ 析构函数的机制,提供了一种简单的方式在函数退出之前执行代码。对于需要在函数退出前执行清理过程非常有用。更有用的地方是对于存在异常的时候,一旦代码中有异常,一些清理的代码可能就忘记了,但是只要 gsl::finally{}
是在异常前面的定义的,发生异常后,仍然会执行相应的代码。
在上面的代码中,还包含了一个宏,允许使用 __
定义 gsl::finally{}
的名字。使用 gsl::finally{}
必须存储 gsl::finally{}
对象才能在退出函数的时候执行析构函数。那就必须给 gsl::finally{}
起一个名字,但这非常笨重,也没有意义。显然,不会有代码去调用 gsl::finally{}
对象。这个宏提供了一个简单方式去表达了“我不关心变量的名字”。
GSL 还提供了 gsl::narrow<>()
和 gsl::narrow_cast<>()
,例如:
#include
#include
int main(void)
{
uint64_t val = 42;
auto val1 = gsl::narrow<uint32_t>(val);
auto val2 = gsl::narrow_cast<uint32_t>(val);
}
这两个函数和 static_cast<>()
一样,只不过 gsl::narrow<>()
会检查溢出。gsl::narrow_cast<>()
则只是 static_cast<>
的同义词。它们都表明一个整数的缩小正在发生,简单地说就是把分配给数字的内存空间缩小了。
#define GSL_THROW_ON_CONTRACT_VIOLATION
#include
#include
int main(void)
{
uint64_t val = 0xFFFFFFFFFFFFFFFF;
try {
gsl::narrow<uint32_t>(val);
} catch(...) {
std::cout << "narrow failed\n";
}
}
// narrow failed
在上面的代码中,尝试将 64 位整数转化到 32 位,在 gsl::narrow{}
的溢出检查中抛出了异常。
把 C++ 17、RAII、GSL放到一起,无非是希望能写出更加优雅、健壮的 C++ 代码。