C++17,RAII与GSL支持库

主要聊一下 C++17,顺便记录一下 RAII。说句真心话,只要 C++ 遵照最佳实践来编码,还是很省心的,就算它有很多丑陋的地方,你不用它,不看它不就完了。

C++17的一些特性

在if/switch中初始化变量

#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;
}

库的变化

string view

C++17 添加了一个 std::string_view{} ,它封装了字符序列,类似 std::array,可以更加简单和安全地使用 C 字符串。

#include 
#include 

int main(void)
{
     
    std::string_view str("Hello World\n");
    std::cout << str;
}

std::arraystd::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';
}

frontlast 用来返回字符串的第一个和最后一个字符。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::variant, std::optional

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 被用来存储一个 integerdouble,而通过 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:资源获取即初始化

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++ 简单又最强大的功能。

开发准则支持库(GSL)

C++ 的语法特性实在是太多了,因此实践过程中许多人只选择了 C++ 的一部分语言特性进行开发,从而约定了最佳实践(用什么、怎么用、要不要用)。其中一个著名的规范就是CCG (C++ Core Guidelines)。为了在开发过程中更好地遵守 CCG 的最佳实践,可以使用 GSL(The Guideline Support Library) 库。

  • 指针所有权:明确定义谁拥有指针是防止内存泄漏和指针损坏的简单方式。一般来说,定义所有权的最佳方式使用智能指针 unique_ptr{}sharped_ptr{} 。但有时候,某些情况下是用不了的,这些边缘情况就可以考虑用 GSL 来处理。
  • 管理期望:GSL 还可以用来定义函数期待的输入和保证的输出。
  • 指针算术:指针的算术运算是导致很多内存问题和漏洞的重要原因。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 字符结尾。

契约(contracts)

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++ 代码。

你可能感兴趣的:(C/C++,C++,C++17,RAII,GSL支持库)