C++111417 新语言特性重难点解析

C++111417 新语言特性重难点解析,第1张

C++11/14/17 新语言特性重难点解析

C++11 是 C++ 语言发展史上具有里程碑意义的一个版本,其核心主要改进了之前的 C++ 98/03 存在的两大问题:

  • 废弃了 C++ 98/03 标准一些不实用的语法和库(如 std::auto_ptr),改进了 C++ 98/03 标准一些语法元素的用法(如 auto 关键字、统一类的初始化列表语法),新增了一些其他编程语言早已经支持的关键字和语法(如 final 关键字、=default 语法 、=delete 语法);
  • 开始在语法和自带的标准库的层面上增加对 *** 作系统的功能的支持(如线程库、时间库)。之前很多功能的实现,C++ 语言本身是无法支持的,必须依赖使用原生的 *** 作系统的 API 函数。

随着标准的发展,后继的又产生了 C++14、C++17 以及现在最新的 C++20 标准,但它们都是对 C++11 作小范围的修改和扩展,主要内容是继续完善一些特性和进一步提高一些标准库的性能。由于 C++ 11 新增了大量的方便开发的功能与特性,支持该标准的编译器(如 VC++ 12(Visual Studio 2013)、g++ 4.8 )一经发布,即被广大开发者和使用 C++ 的企业广泛采用。笔者也在 C++11 发布以后将自己的个人和公司项目全部使用 C++11 语法重构了一遍,C++ 11 新增的特性确实大大提高了开发效率,让人耳目一新。

对于 MSVC 编译器,支持 C++11 新标准的最低版本是 VC++ 12(即 Visual Studio 2013,Visual Studio 以下简称 VS),Visual Studio 2015 支持部分 C++ 14 特性,VS 2017 完美支持 C++14 和部分 C++17 特性,VS 2019 完美支持 C++17 大多数语言特性。

对于 gcc/g++ 编译器,支持 C++11 新标准的最低版本是 gcc/g++ 4.8,支持 C++14 是 gcc/g++ 4.9,gcc/g++ 7.3 完美支持 C++17 大多数语言特性。

各个编译器对于 C++11/14/17 语法特性和标准库支持情况,完整列表可参考:https://en.cppreference.com/w/cpp/compiler_support。

对于某个支持 C++11/14/17 语言的标准的 Visual Studio 版本,你一般不需要做任何特殊设置即可使用新语言标准支持的语法特性和库功能,当然你也可以通过设置指定具体的语言规范版本,以 Visual Studio 2019 为例,新建一个 C++ 项目之后,选中该项目,在d出的右键菜单中选择【Properties】菜单项打开该项目的【属性设置】对话框,如下图所示:

 

 然后选择【C/C++】- 【Language】- 【C++ Language Standard】,在d出的下拉菜单中选择你需要的项即可:

这里需要说明一下,选项 Default 在 VS 2019 中即对应使用 C++ 14 规范标准,选项 Preview - Features from the latest C++ Working Draft(/std:c++ latest) 即使用当前最新的 C++ 标准(这里是 C++20),当 Microsoft 完成 C++20 标准中所有的实现时,这里会多出一个选项,即 ISO C++20 Standard(/std:c++20)。

对于 gcc/g++ 编译器(最低版本 4.8),如果要使用 C++11/14/17 语言规范,则需要编译时设置相应的选项值。例如将 test.cpp 文件编译成名为 test 的可执行文件,同时需要 C++ 11 语言规范支持,则使用:
g++ -g -o test test.cpp -std=c++11
在使用 Makefile 文件编译项目时可以这样指定:
make CXXFLAGS="-g -O0 -std=c++11"
在使用 cmake 编译项目时可以在 CMakeLists.txt 文件中添加如下行:
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++11 -g -Wall -O0 -Wno-unused-variable")
-std 选项的值可以按需换成 c++14 或 c++17

对于 gcc/g++ 7.3 及以上版本,由于编译器本身就支持包括 C++17 在内的所有语言特性,因此如果代码中用到某个特性,由于编译器本身就支持,可以不必使用 -std 选项指定具体的语言标准。

C++ 11/14/17 新增的特性非常多,本专题不会全部介绍,而是介绍一些实际开发中高频使用的语法元素和特性。


统一的类成员初始化语法与 std::initializer_list

假设类 A 有一个成员变量是一个 int 数组,在 C++ 98/03 标准中,如果我们要在构造函数中对其进行初始化,我们需要这样写:

//C++ 98/03 类成员变量是数组时的初始化语法
class A
{
    public:
        A()
    {
            arr[0] = 2;
            arr[1] = 0;
        arr[2] = 1;
        arr[3] = 9;
        }

    public:
        int arr[4];
};

对于字符数组,我们可能就要在构造函数中使用 strcpy、memcpy 这一类函数了;再者如果数组元素足够多,初始值又没什么规律,这种赋值代码会有很多行。但是,如果 arr 是一个局部变量,我们在定义 arr 时其实是可以使用如下的语法初始化的:

int arr[4] = {2, 0, 1, 9};

既然 C++98/03 标准中,局部变量数组支持这种语法,为什么在类成员变量语法中就不支持呢?这是旧语法不合理的一个地方,因此在 C++11 语法中类成员变量也可以使用这种语法进行初始化了:

//C++ 11 类成员变量是数组时的初始化语法
class A
{
    public:
        A() : arr{2, 0, 1, 9}
        {        
        }

    public:
        int arr[4];
};

新语法相比较旧语法,更加简洁。

在像 Java 这类语言中,定义一个类时,即可给其成员变量设置一个初始值,语法如下:

class A
{
    public int a = 1;
    public String string = "helloworld"; 
};

但在 C++ 89/03 标准中要使用这种语法,必须是针对类的 static const 成员,且必须是整型(包括 bool、char、int、long 等)。

//C++ 89/03 在类定义处初始化成员变量
class A
{
    public:
        //T 的类型必须整型,且必须是 static const 成员
        static const T t = 某个整型值;
};
在 C++ 11 标准中,就没有这种限制了,你可以使用花括号(即 {})对任意类型的变量进行初始化,且不用是 static 类型。
//C++ 11 在类定义处初始化成员变量
class A
{
    public:
        bool        ma{true};
        int            mb{2019};
        std::string mc{"helloworld"};     
};

当然,在实际开发的时候,建议还是将这些成员变量的初始化统一写到构造函数的初始化列表中去,方便代码阅读和维护。

综上所述,在 C++ 11 标准中,无论是局部变量还是类变量,使用花括号({})初始化的语法被统一起来,写法也变得简洁起来。

那么这种语法是如何实现的呢?如何在自定义类中也支持这种花括号呢?这就需要用到 C++11 引入的新对象 std::initializer_list,这是一个模板对象,接收一个自定义参数类型 T,T 既可以是基础数据类型(如编译器内置的 bool、char、int 等)也可以是自定义复杂数据类型。为了使用 std::initializer_list,需要包含头文件 头文件。下面是一个例子:

#include 
#include 
#include 

class A
{
public:
    A(std::initializer_list integers)
    {
        m_vecIntegers.insert(m_vecIntegers.end(), integers.begin(), integers.end());
    }

    ~A()
    {

    }

    void append(std::initializer_list integers)
    {
        m_vecIntegers.insert(m_vecIntegers.end(), integers.begin(), integers.end());
    }

    void print()
    {
        size_t size = m_vecIntegers.size();
        for (size_t i = 0; i < size; ++i)
        {
            std::cout << m_vecIntegers[i] << std::endl;
        }
    }

private:
    std::vector m_vecIntegers;
};

int main()
{
    A a{ 1, 2, 3 };
    a.print();

    std::cout << "After appending..." << std::endl;

    a.append({ 4, 5, 6 });
    a.print();

    return 0;
}

上述代码,我们自定义了一个类 A,为了让 A 的构造函数和append 方法同时支持花括号语法,给这两个方法同时设置了一个参数 integers,参数类型均为 std::initializer_list,程序执行结果如下:

[root@myaliyun testxx]# ./test_initializer_list 
1
2
3
After appending...
1
2
3
4
5
6

再来看一个例子,网上某 C++ json 库支持如下语法创建一个 json 对象:

// a way to express an _array_ of key/value pairs
// [["currency", "USD"], ["value", 42.99]]
json array_not_object = json::array({ {"currency", "USD"}, {"value", 42.99} });

那么这个 json::array() 方法是如何实现的呢?这利用 std::initializer_list 也很容易实现,首先花括号中有两个元素 {"currency", "USD"} 和 {"value", 42.99},且这两个元素的值不一样,前者是两个字符串类型,后者是一个字符串和一个浮点型,因此我们可以创建两个构造函数分别支持这两种类型的构造函数,构造的对象类型为 jsonNode,然后创建一个类型为 json 的对象,实现其 array() 方法,该方法接收一个参数,参数类型为 std::initializer_list<,完整的代码如下所示:

#include 
#include 
#include 
#include 

//简单地模拟 json 支持的几种数据类型
enum class jsonType
{
    jsonTypeNull,
    jsonTypeInt,
    jsonTypeLong,
    jsonTypeDouble,
    jsonTypeBool,
    jsonTypeString,
    jsonTypeArray,
    jsonTypeObject
};

struct jsonNode
{
    jsonNode(const char* key, const char* value) : 
        m_type(jsonType::jsonTypeString),
        m_key(key),
        m_value(value)
    { 
        std::cout << "jsonNode contructor1 called." << std::endl;
    }

    jsonNode(const char* key, double value) :
        m_type(jsonType::jsonTypeDouble),
        m_key(key),
        m_value(std::to_string(value))
    {
        std::cout << "jsonNode contructor2 called." << std::endl;
    }

    //...省略其他类型的构造函数...

    jsonType    m_type;
    std::string m_key;
    //始终使用string类型保存值是避免浮点类型因为精度问题而显示不同的结果
    std::string m_value;
};

class json
{
public:
    static json& array(std::initializer_list nodes)
    {            
        m_json.m_nodes.clear();
        m_json.m_nodes.insert(m_json.m_nodes.end(), nodes.begin(), nodes.end());

        std::cout << "json::array() called." << std::endl;

        return m_json;
    }

    json()
    {

    }

    ~json()
    {

    }

    std::string toString()
    {        
        size_t size = m_nodes.size();
        for (size_t i = 0; i < size; ++i)
        {
            switch (m_nodes[i].m_type)
            {
            //根据类型,组装成一个json字符串,代码省略...
            case jsonType::jsonTypeDouble:
                break;
            }
        }
    }

private:
    std::vector m_nodes;

    static json           m_json;
};

json json::m_json;

int main()
{
    json array_not_object = json::array({ {"currency", "USD"}, {"value", 42.99} });

    return 0;
}

程序执行结果如下:

[root@myaliyun testxx]# ./construct_complex_objects 
jsonNode contructor1 called.
jsonNode contructor2 called.
json::array() called.

通过上面两个例子希望读者可以理解 std::initializer_list< 的使用场景,std::initializer_list 除了构造函数还提供了三个成员函数,这和 stl 的其他容器的同名方法用法一样:

//返回列表中元素的个数
size_type size() const;
//返回第一个元素的指针
const T* begin() const;
//返回最后一个元素的下一个位置,代表结束
const T* end() const;
注解标签(attributes)

在其他语言中又叫注解(annotations)又叫,在 C++98/03 时代,不同的编译器使用不同的注解去为代码增加一些额外的说明,读者可能在各种 C/C++ 代码中见过像 #pragma、__declspec、__attribute 等注解。然而不同的编译器对于同一功能可能使用不同的注解,这样导致我们需要为不同的编译器编写针对那个编译器的注解代码。从 C++11 开始,新的语言标准开始统一制定了一些常用的注解标签,使用注解标签的语法是:

[[attribute]] types/functions/enums/etc

这些标签可用于修饰任意类型、函数或者 enumeration,在 C++17 之前不能用于修饰命名空间(namespace)和 enumerator,在 C++17 标准中这个限制也被取消了。

读者可能对 enumeration 和 enumerator 这两个词感到困惑,前者指的从 C 时代就存在的不限定作用域的枚举。例如下面的枚举类型就是一个 enumeration:

//一个 enumeration 的例子
enum Color
{
    black,
    white,
    red
};

//无法编译通过
bool white = true;

此时,由于枚举值 white 对外部不可见(必须通过 Color::white 来引用),可以定义一个同名的 white 变量。这种枚举变量被称之为限定作用域的枚举。

在分清楚了enumeration 和 enumerator 之后,让我们回到正题上来。

C++11 引入了的常用的注解标签有 [[noreturn]],这个注解的含义是告诉编译器某个函数没有返回值,例如:

[[noreturn]] void terminate();

C++14 引入了 [[deprecated]] 标签用于表示一个函数或者类型等已经被弃用,当你使用这些被弃用的函数或者类型,编译时编译器会给出相应的警告,有的编译器直接产生编译错误。

[[deprecated]] void funcX();

这个标签在实际开发中非常有用,尤其在设计一些库代码时,如果库的作者希望某个函数或者类型不想再被用户使用可以使用该标注标记。当然,你也可以使用以下语法给出编译时的具体警告或者出错信息:

[[deprecated("use funY instead")]] void funcX();

有如下代码:

#include 

[[deprecated("use funcY instead")]] void funcX()
{
    //实现省略...
}

int main()
{
    funcX();
    return 0;
}

我在 main 函数中调用被标记为 deprecated 的函数 funcX,在 gcc/g++ 7.3 中编译会得到如下警告信息:

[root@myaliyun testmybook]# g++ -g -o test_attributes test_attributes.cpp 
test_attributes.cpp: In function ‘int main()’:
test_attributes.cpp:10:11: warning: ‘void funcX()’ is deprecated: use funcY instead [-Wdeprecated-declarations]
     funcX();
           ^
test_attributes.cpp:3:42: note: declared here
 [[deprecated("use funcY instead")]] void funcX()
                                          ^~~~~
C++ 17 提供了如下三个实用注解:
  • [[fallthrough]]
  • [[nodiscard]]
  • [[maybe_unused]]

让我们逐一来介绍它们的用法。

[[fallthrough]] 用于 switch-case 语句中,当某个 case 分支执行完毕后如果没有 break 语句,编译器可能会给出一条警告,但有时候这可能是开发者故意为之的,为了让编译器明确的知道开发者的意图,可以在需要某个 case 分支被“贯穿”处(上一个 case 没有 break)显式设置 [[fallthrough]] 标记。代码示例如下:

switch (type)
{
case 1:
    func1();
    //这个位置缺少break语句,且没有fallthrough标注,
    //可能是一个逻辑错误,编译时编译器可能会给出警告,以提醒修改之
case 2:
    func2();
    //这里也缺少break语法,但是使用了fallthrough标注,
    //说明是开发者有意为之,编译器不会给出任何警告   
[[fallthrough]];
case 3:
    func3();

}

注意,在 gcc/g++ 中 [[fallthrough]] 后面的分号不是必须的,在 Visual Studio 中必须加上分号,否则无法编译通过。熟悉 go 语言的读者,可能对 fallthrough 这一语法特性非常熟悉,go 中在 switch-case 后加上 fallthrough 是一个常用的告诉编译器意图的语法规则。例如:

//以下是 go 语法
s := "abcd"
    switch s[3] {
    case 'a':
        fmt.Println("The integer was <= 4")
        fallthrough
    case 'b':
        fmt.Println("The integer was <= 5")
        fallthrough
    case 'c':
        fmt.Println("The integer was <= 6")
    default:
        fmt.Println("default case")
    }

[[nodiscard]] 一般用于修饰函数,告诉函数调用者必须关注该函数的返回值(即不能丢弃该函数返回值)。如果调用者未将该函数的返回值赋值给一个变量,编译器会给出一个警告。例如假设有一个网络连接函数 connect,我们通过返回值明确的说明了连接是否建立成功,为了防止调用者在使用时直接将其值丢弃,我们可以将该函数使用 [[nodiscard]] 标注标记:

[[nodiscard]] int connect(const char* address, short port)
{
    //实现省略...
}

int main()
{
    connect("127.0.0.1", 8888);

    return 0;
}

到 C++20 中对于诸如 operator new()、std::allocate() 等库函数均使用了 [[nodiscard]] 进行了标记,以强调必须使用这些函数的返回值。

[[maybe_unused]] 有些编译器会对程序代码中未被使用的函数或变量给出警告,在 C++17 之前,程序员们为了消除这些警告要么修改编译器警告选项设置,要么定义一个类似于 UNREFERENCED_PARAMETER 的宏来显式调用这些未使用的变量一次以消除编译警告。

#define UNREFERENCED_PARAMETER(x) x

int APIENTRY wWinMain(HINSTANCE hInstance,
                     HINSTANCE hPrevInstance,
                     LPWSTR    lpCmdLine,
                     int       nCmdShow)
{
    UNREFERENCED_PARAMETER(hPrevInstance);
    UNREFERENCED_PARAMETER(lpCmdLine);

    //...无关代码省略
}

上述代码节选自一个标准的 Win32 程序的结构,其中函数参数 hPrevInstance 和 lpCmdLine 一般不会被用到,编译器会给出警告,为了消除这类警告,定义了一个宏 UNREFERENCED_PARAMETER,并调用之,造成这两个参数被使用到的假象。

有了 [[maybe_unused]] 注解之后,我们再也不需要这类宏来“欺骗”编译器了。上述代码使用该注解可以修改如下:

int APIENTRY wWinMain(HINSTANCE hInstance,
                     [[maybe_unused]] HINSTANCE hPrevInstance,
                     [[maybe_unused]] LPWSTR    lpCmdLine,
                     int       nCmdShow)
{    
    //...无关代码省略
}
final/override/=default/=delete 语法

final、override、=default、=delete 是 C++11 添加的一组非常具有标记意义的新语法,我们来逐一介绍它们。

final 关键字

final 关键字修饰一个类,这个类将不允许被继承,这在其他语言(如 Java)中早就实现了。在 C++ 11 中 final 关键字要写在类名的后面,其他语言是写在 class 关键字的前面。示例如下:

class A final
{

};

class B : A
{

};

由于类 A 被声明成 final,B 继承 A 编译器会报如下错误提示 A 不能被继承:

error C3246: 'B' : cannot inherit from 'A' as it has been declared as 'final'
override 关键字

C++ 语法规定,父类中加了 virtual 关键字的方法可以被子类重写,子类重写该方法时可以加或不加 virtual 关键字,例如像下面这样:

class A 
{
protected:
    virtual void func(int a, int b)
    {
    }
};

class B : A
{
protected:
    virtual void func(int a, int b)
    {
    }
};

class C : B
{
protected:
    void func(int a, int b)
    {
    }
};

这种宽松的规定可能会带来两个问题:

  • 当我们阅读代码时,无论子类重写的方法是否添加了 virtual 关键字,我们都没法直观地确定该方法是否是重写的父类方法;
  • 如果我们在子类中不小心写错了需要重写的方法的函数签名(可能是参数类型、个数或返回值类型),这个方法就会变成一个独立的方法,这可能会违背我们最初想重写父类某个方法的初衷,而编译器在编译时并不会检查到这个错误。

为了解决以上两个问题, C++ 11 引进了 override 关键字,其实 override 关键字并不是什么新语法,在 Java 等其他语言中早就支持。被 override 修饰的类方法是改写父类的同名方法,加了该关键字后在编译阶段,编译器会作相应的检查,如果其父类不存在相同签名格式的类方法,编译器会给出相应的错误提示。

情形一 父类没有子类标记了 override 的方法

class A
{

};

class B : A
{
protected:
    void func(int k, int d) override
    {
    }
};

由于父类 A 中没有 func 方法,编译器会提示错误:

error C3668: 'B::func' : method with override specifier 'override' did not override any base class methods

情形二 父类有子类标记了 override 的方法,但函数签名不一致

class A
{
protected:
    virtual int func(int k, int d)
    {
        return 0;
    }
};

class B : A
{
protected:
    virtual void func(int k, int d) override
    {
    }
};

编译器会报同样的错误。正确代码:

class A
{
protected:
    virtual void func(int k, int d)
    {  
    }
};

class B : A
{
protected:
    virtual void func(int k, int d) override
    {
    }
};
=default 语法

如果一个 C++ 类没有显式地给出构造函数、析构函数、拷贝构造函数、operator = 这几类函数的实现,在需要它们时,编译器会自动生成;或者,在给出这些函数的声明时,如果没有给出其实现,编译器在链接时就会报错。=default 如果标记这类函数,编译器会给出默认实现。我们来看一个例子:

class A
{

};

int main()
{
    A a;
    return 0;
}

这样的代码是可以编译通过的,因为编译器会默认生成一个 A 的无参构造函数,假设我们现在给 A 提供一个有参数形式的构造函数:

class A
{
public:
    A(int i)
    {
    }

};

int main()
{
    A a;
    return 0;
}

这个时候编译器就不会自动生成默认无参数的构造函数了,这段代码会编译出错,错误提示 A 没有合适的无参构造函数:

error C2512: 'A' : no appropriate default constructor available

我们这个时候可以手动给 A 加上无参构造函数,也可以使用 =default 语法强行让编译器自己生成:

class A
{
public:
    A() = default;

    A(int i)
    {
    }

};

int main()
{
    A a;
    return 0;
}

=default 笔者觉得最大的作用就是,在开发中简化了那些构造函数中没有实际的初始化代码的写法,尤其是声明和实现分别属于一个 .h 和 .cpp 文件。例如,对于类 A,其头文件为 a.h ,其实现文件为 a.cpp,正常情况下我们需要在 a.cpp 文件中写其构造函数和析构函数的实现(可能没有实际构造和析构代码):

//a.h
class A
{
public:
    A();
    ~A();
};

//a.cpp
#include "a.h"

A::A()
{
}

A::~A()
{
}

a.cpp 中构造函数和析构函数我们不得不写上,有了 =default 关键字,我们可以在 a.h 中直接写成:

//a.h
class A
{
public:
    A() = default;
    ~A() = default ;
};

//a.cpp
#include "a.h"
//这里不用在写A的构造函数和析构函数的实现了
=delete 语法

既然,有强制让编译器生成构造函数、析构函数、拷贝构造函数、operator =的语法,那么也应该有禁止编译器生成这些函数的语法,没错,就是 =delete。

函数在 C++ 98/03 规范中,如果我们想让一个类不能被拷贝(即不能调用其拷贝构造函数),我们可以将其拷贝构造和 operator = 函数定义成 private 的。

class A
{
public:
    A() = default;
    ~A() = default;

private:
    A(const A& a)
    {
    }

    A& operator =(const A& a)
    {
    }
};

int main()
{
    A a1;
    A a2(a1);
    A a3;
    a3 = a1;

    return 0;
}

以上代码在利用 a1 构造 a2 时编译器会提示错误:

error C2248: 'A::A' : cannot access private member declared in class 'A'
error C2248: 'A::operator =' : cannot access private member declared in class 'A'

我们利用了这种方式间接实现了一个类不能被拷贝的功能,这也是继承自 boost::noncopyable 的类不能被拷贝的实现原理。现在有了 =delete 语法,我们直接使用该语法,直接禁止编译器生成这两个函数即可:

class A
{
public:
    A() = default;
    ~A() = default;

public:
    A(const A& a) = delete;

    A& operator =(const A& a) = delete;
};

int main()
{
    A a1;
    //A a2(a1);
    A a3;
    //a3 = a1;

    return 0;
}

一般在一些工具类中,我们不需要用到构造函数、析构函数、拷贝构造函数、operator= 这四个函数,为了防止编译器自己生成,同时也是为了减小生成的可执行文件的体积,笔者建议使用 =delete 语法将这四个函数“删除”,例如:

class EncodeUtil
{
public:
    static std::wstring EncodeUtil::AnsiToUnicode(const std::string& strAnsi);
    static std::string UnicodeToAnsi(const std::wstring& strUnicode);
    static std::string AnsiToUtf8(const std::string& strAnsi);
    static std::string Utf8ToAnsi(const std::string& strUtf8);
    static std::string UnicodeToUtf8(const std::wstring& strUnicode);
    static std::wstring Utf8ToUnicode(const std::string& strUtf8);

private:
    EncodeUtil() = delete;
    ~EncodeUtil() = delete;

    EncodeUtil(const EncodeUtil& rhs) = delete;
    EncodeUtil& operator=(const EncodeUtil& rhs) = delete;
};
auto 关键字

auto 关键字在C++ 98/03 标准中与 static 关键字相反,用于修饰所有局部变量,即这个变量具有“自动”生命周期,但是这个规定没有任何实际的用处。因而在 C++ 11 新标准中修改了其用法,用于让编译器自己去推导一些变量的数据类型,auto的实现原理是基于模板类型推断的。如:

int a = 1;
auto b = a;

这里 b 的类型被声明为 auto ,编译器会根据 a 的类型推导出变量 b 的类型也是 int。但是这样的写法在实际开发中实用价值不高,所以 auto 一般会用于让编译器推导一些复杂的模板数据类型,简化语法。如:

std::map seasons;
seasons["spring"] = "123";
seasons["summer"] = "456";
seasons["autumn"] = "789";
seasons["winter"] = "101112";

for (std::map::iterator iter = seasons.begin(); iter != seasons.end(); ++iter)
{
    std::cout << iter->second << std::endl;
}

上面代码中迭代器变量 iter 其类型是 std::map::iterator,这一串的类型太长了,在 C++ 11 语法中我们可以使用 auto 关键字达到同样的效果:

std::map seasons;
seasons["spring"] = "123";
seasons["summer"] = "456";
seasons["autumn"] = "789";
seasons["winter"] = "101112";

for (auto iter = seasons.begin(); iter != seasons.end(); ++iter)
{
    std::cout << iter->second << std::endl;
}

是不是方便了很多?

Range-based 循环语法

大多数语言都支持 for-each 语法遍历一个数组或集合中的元素,C++ 11 中才支持这种语法,可谓姗姗来迟。在 C++ 98/03 规范中,对于一个数组 int arr[10],如果我们想要遍历这个数组,只能使用递增的计数去引用数组中每个元素:

int arr[10] = {0};
for (int i = 0; i < 10; ++i)
{
    std::cout << arr[i] << std::endl;
}

在 C++ 11 规范中有了 for-each 语法,我们可以这么写:

int arr[10] = {0};
for (int i : arr)
{
    std::cout << i << std::endl;
}

对于上面 auto 关键字章节遍历 std::map,我们也可以使用这种语法:

std::map seasons;
seasons["spring"] = "123";
seasons["summer"] = "456";
seasons["autumn"] = "789";
seasons["winter"] = "101112";

for (auto iter : seasons)
{
    std::cout << iter.second << std::endl;
}

for-each 语法虽然很强大,但是有两个需要注意的地方:

  • for-each 中的迭代器类型与数组或集合中的元素的类型完全一致,而原来使用老式语法迭代 stl 容器(如 std::map)时,迭代器是类型的取地址类型。因此,在上面的例子中,老式语法中,iter是一个指针类型(std::pair*),使用iter->second 去引用键值;而在 for-each 语法中,iter是数据类型(std::pair),使用 iter.second 直接引用键值。

  • for-each 语法中对于复杂数据类型,迭代器是原始数据的拷贝,而不是原始数据的引用。什么意思呢?我们来看一个例子:

std::vector v;
v.push_back("zhangsan");
v.push_back("lisi");
v.push_back("maowu");
v.push_back("maliu");
for (auto iter : v)
{
    iter = "hello";
}

我们遍历容器 v,意图将 v 中的元素的值都修改成“hello”,但是实际执行时我们却达不到我们想要的效果。这就是上文说的 for-each 中的迭代器是元素的拷贝,所以这里只是将每次拷贝修改成“hello”,原始数据并不会被修改。我们可以将迭代器修改成原始数据的引用:

std::vector v;
v.push_back("zhangsan");
v.push_back("lisi");
v.push_back("maowu");
v.push_back("maliu");
for (auto& iter : v)
{
    iter = "hello";
}

这样我们就达到修改原始数据的目的了。这一点在使用 for-each 比较容易出错,对于容器中是复杂数据类型,我们尽量使用这种引用原始数据的方式,以避免复杂数据类型不必要的调用构造函数的开销。

class A
{
public:
    A()
    {
    }
    ~A() = default;

    A(const A& rhs)
    {
    }

public:
    int m;
};

int main()
{ 
    A a1;
    A a2;
    std::vector v;
    v.push_back(a1);
    v.push_back(a2);
    for (auto iter : v)
    {
        //由于iter是v中的元素的拷贝,所以每一次循环,iter都会调用A的拷贝构造函数生成一份
        //实际使用for-each循环时应该尽量使用v中元素的引用,减少不必要的拷贝函数的调用开销
        iter.m = 9;
    }

    return 0;
}
自定义对象如何支持 Range-based 循环

介绍了这么多,如何让我们自定义的对象支持 Range-based 循环语法呢?为了让一个对象支持这种语法,这个对象至少需要实现如下两个方法:

//需要返回第一个迭代子的位置
Iterator begin();
//需要返回最后一个迭代子的下一个位置
Iterator end();

上面的 Iterator 是自定义数据类型的迭代子类型,这里的 Iterator 类型必须支持如下三种 *** 作(原因下文会解释):

  • operator++ (即自增) *** 作,即可以自增之后返回下一个迭代子的位置;

  • operator != (即判不等 *** 作) *** 作;

  • operator* 即解引用(dereference) *** 作。

下面是一个自定义对象支持 for-each 循环的例子:

#include 
#include 

template
class A
{
public:
    A()
    {
        for (size_t i = 0; i < N; ++i)
        {
            m_elements[i] = i;
        }
    }

    ~A()
    {

    }

    T* begin()
    {
        return m_elements + 0;
    }

    T* end()
    {
        return m_elements + N;
    }

private:
    T       m_elements[N];
};

int main()
{
    A a;
    for (auto iter : a)
    {
        std::cout << iter << std::endl;
    }

    return 0;
}

注意:上述代码中,迭代子 Iterator 是 T*,这是指针类型,本身就支持 operator ++ 和 operator != *** 作,所以这里并没有提供这两个方法的实现。那么为什么迭代子要支持 operator ++ 和 operator != *** 作呢?我们来看一下编译器是如何实现这种 for-each 循环的。

for-each 循环的实现原理

上述 for-each 循环可抽象成如下公式:

for (for-range-declaration : for-range-initializer)
    statement;

C++ 14 标准是这样解释上面的公式的:

auto && __range = for-range-initializer;
for ( auto __begin = begin-expr, __end = end-expr; __begin != __end; ++__begin )
{
    for-range-declaration = *__begin;
    statement;
}

在这个循环中,begin-expr 返回的迭代子 _begin 需要支持自增 *** 作,且每次循环时会与 end-expr 返回的迭代子 _end 做判不等比较,在循环内部,通过调用迭代子的解引用(*) *** 作取得实际的元素。这就是上文说的迭代子对象需要支持 operator++、operator != 和 operator* 的原因了。

但是上面的公式中,在一个逗号表达式中 auto __begin = begin-expr, __end = end-expr; 由于只使用了一个类型符号 auto 导致其实迭代子 _begin 和结束迭代子 _end 是同一个类型,这样不太灵活,在某些设计中,可能希望循环结束时的迭代子是另外一种类型。

因此到了 C++17 标准时,要求编译器解释 for-each 循环成如下形式:

auto && __range = for-range-initializer;
auto __begin = begin-expr;
auto __end = end-expr;
for ( ; __begin != __end; ++__begin ) {
    for-range-declaration = *__begin;
    statement;
}

看到了吧,代码行 2 和 3 将获取起始迭代子 _begin 和结束迭代子 _end 分开来写,这样这两个迭代子就可以是不同的类型了。虽然类型可以不一样,但这两种类型之间仍然要支持 operator!= *** 作。C++17 就 C++14 的这种改变,对旧的代码不会产生任何影响,但可以让后来的开发更加灵活。

关于 Range-based for loop 更详细的规范,可以参考这里:https://en.cppreference.com/w/cpp/language/range-for。

结构化绑定

stl 的 map 容器很多读者应该都很熟悉,map 容器提供了一个 insert 方法,我们用该方法向 map 中插入元素,但是应该很少有人记得 insert 方法的返回值是什么类型,让我们来看一下 C++98/03 提供的 insert 方法的签名:

std::pair insert( const value_type& value );

这里我们仅关心其返回值,这个返回值是一个 std::pair 类型,由于 map 中的元素的 key 不允许重复,所以如果 insert 方法调用成功,T1 是被成功插入到 map 中的元素的迭代器,T2 的类型为 bool,此时其值为 true(表示插入成功);如果 insert 由于 key 重复,T1 是造成 insert 插入失败、已经存在于 map 中的元素的迭代器,此时 T2 的值为 false(表示插入失败)。

在 C++98/03 标准中我们可以使用 std::pair 的 first 和 second 属性来分别引用 T1 和 T2 的值。如下面的我们熟悉的代码所示:

#include 
#include 
#include 

int main()
{
    std::map cities;
    cities["beijing"]   = 0;
    cities["shanghai"]  = 1;
    cities["shenzhen"]  = 2;
    cities["guangzhou"] = 3;

    //for (const auto& [key, value] : m)
    //{
    //    std::cout << key << ": " << value << std::endl;
    //}

    //这一行在 C++11 之前写法实在太麻烦了,
    //std::pair::iterator, int> insertResult = cities.insert(std::pair("shanghai", 2));
    //C++11中我们写成:
    auto insertResult = cities.insert(std::pair("shanghai", 2));

    std::cout << "Is insertion successful ? " << (insertResult.second ? "true" : "false") 
              << ", element key: " << insertResult.first->first << ", value: " << insertResult.first->second << std::endl;

    return 0;
}

代码 19 行实在太麻烦了,我们使用 auto 关键字让编译器自动推导类型。

std::pair 一般只能表示两个元素,C++11 标准中引入了 std::tuple 类型,有了这个类型,我们就可以放任意个元素了,原来需要定义成结构体的 POD 对象我们可以直接使用 std::tuple 表示,例如下面表示用户信息的结构体:

struct UserInfo
{
    std::string username;
    std::string password;
    int         gender;
    int         age;
    std::string address;
};

int main()
{
    UserInfo userInfo = { "Tom", "123456", 0, 25, "Pudong Street" };
    std::string username = userInfo.username;
    std::string password = userInfo.password;
    int gender = userInfo.gender;
    int age = userInfo.age;
    std::string address = userInfo.address;

    return 0;
}

我们不再需要定义 struct UserInfo 这样的对象,可以直接使用 std::tuple 表示:

int main()
{    
    std::tuple userInfo("Tom", "123456", 0, 25, "Pudong Street");

    std::string username = std::get<0>(userInfo);
    std::string password = std::get<1>(userInfo);
    int gender = std::get<2>(userInfo);
    int age = std::get<3>(userInfo);
    std::string address = std::get<4>(userInfo);

    return 0;
}

从 std::tuple 中获取对应位置的元素,我们使用 std::get ,其中 N 是元素的序号(从 0 开始)。

与定义结构体相比,通过 std::pair 的 first 和 second 还是 std::tuple 的 std::get 方法来获取元素子属性,这些代码都是非常难以维护的,其根本原因是 first 和 second 这样的命名不能做到见名知意。

C++17 引入的结构化绑定(Structured Binding )将我们从这类代码中解放出来。结构化绑定使用语法如下:

auto [a, b, c, ...] = expression;
auto [a, b, c, ...] { expression };
auto [a, b, c, ...] ( expression );

右边的 expression 可以是一个函数调用、花括号表达式或者支持结构化绑定的某个类型的变量。例如:

//形式1
auto [iterator, inserted] = someMap.insert(...);
//形式2
double myArray[3] = { 1.0, 2.0, 3.0 };
auto [a, b, c] = myArray;
//形式3
struct Point
{
    double x;
    double y;
};
Point myPoint(10.0, 20.0);
auto [myX, myY] = myPoint;

这样,我们可以给用于绑定到目标的变量名(语法中的 a、b、c)起一个有意义的名字。

需要注意的是,绑定名称 a、b、c 是绑定目标的一份拷贝,当绑定类型不是基础数据类型时,如果你的本意不是想要得到绑定目标的副本,为了避免拷贝带来的不必要开销,建议使用引用,如果不需要修改绑定目标建议使用 const 引用。示例如下:

double myArray[3] = { 1.0, 2.0, 3.0 };
auto& [a, b, c] = myArray;
//形式3
struct Point
{
    double x;
    double y;
};
Point myPoint(10.0, 20.0);
const auto& [myX, myY] = myPoint;

结构化绑定(Structured Binding )是 C++17 引入的一个非常好用的语法特性。有了这种语法,在遍历像 map 这样的容器时,我们可以使用更简洁和清晰的代码去遍历这些容器了:

std::map cities;
cities["beijing"] = 0;
cities["shanghai"] = 1;
cities["shenzhen"] = 2;
cities["guangzhou"] = 3;

for (const auto& [cityName, cityNumber] : cities)
{
    std::cout << cityName << ": " << cityNumber << std::endl;
}

上述代码中 cityName 和 cityNumber 可以更好地反映出这个 map 容器的元素内容。

我们再来看一个例子,某 WebSocket 网络库(https://github.com/uNetworking/uWebSockets)中有如下代码:

std::pair uncork(const char *src = nullptr, int length = 0, bool optionally = false) {
        LoopData *loopData = getLoopData();

        if (loopData->corkedSocket == this) {
            loopData->corkedSocket = nullptr;

            if (loopData->corkOffset) {
                
                auto [written, failed] = write(loopData->corkBuffer, loopData->corkOffset, false, length);
                loopData->corkOffset = 0;

                if (failed) {
                    
                    return {0, true};
                }
            }

            
            return write(src, length, optionally, 0);
        } else {
            
            return {0, false};
        }
    }

代码的第 9 行 write 函数返回类型是 std::pair,被绑定到 [written, failed] 这两个变量中去。前者在写入成功的情况下表示实际写入的字节数,后者表示是否写入成功。

std::pair write(const char *src, int length, bool optionally = false, int nextLength = 0) {
    //具体实现省略...
}

结构化绑定的限制

结构化绑定不能使用 constexpr 修饰或被申明为 static,例如:

//正常编译
auto [first, second] = std::pair(1, 2);
//无法编译通过
//constexpr auto [first, second] = std::pair(1, 2);
//无法编译通过
//static auto [first, second] = std::pair(1, 2);

有些编译器也不支持在 lamda 表达式捕获列表中使用结构化绑定语法。

stl 容器新增的实用方法介绍 原位构造与容器的 emplace 系列函数

在介绍 emplace 和 emplace_back 方法之前,我们先看一段代码:

#include 
#include 

class Test
{
public:
    Test(int a, int b, int c)
    {
        ma = a;
        mb = b;
        mc = c;
        std::cout << "Test constructed." << std::endl;
    }

    ~Test()
    {
        std::cout << "Test destructed." << std::endl;
    }

    Test(const Test& rhs)
    {
        if (this == &rhs)
            return;

        this->ma = rhs.ma;
        this->mb = rhs.mb;
        this->mc = rhs.mc;

        std::cout << "Test copy-constructed." << std::endl;
    }

private:
    int ma;
    int mb;
    int mc;
};


int main()
{
    std::list collections;
    for (int i = 0; i < 10; ++i)
    {
        Test t(1 * i, 2 * i, 3 * i);
        collections.push_back(t);
    }

    return 0;
}

上述代码在一个循环里面产生一个对象,然后将这个对象放入集合当中,这样的代码在实际开发中太常见了。但是这样的代码存在严重的效率问题。循环中的 t 对象在每次循环时,都分别调用一次构造函数、拷贝构造函数和析构函数。这个过程示意如下:

 循环 10 次,总共调用三十次。但实际上我们的初衷是创建一个对象 t,将其直接放入集合中,而不是将 t 作为一个中间临时产生的对象,这样的话,总共只需要调用 t 的构造函数 10 次就可以了。有,C++ 11 提供了一个在这种情形下替代 push_back 的方法 —— emplace_back,使用 emplace_back,我们将 main 函数中的代码改写一下:

std::list collections;
for (int i = 0; i < 10; ++i)
{       
    collections.emplace_back(1 * i, 2 * i, 3 * i);
}

实际执行的时候,我们发现现在,只需要调用 Test 类的构造函数 10 次,大大地提高了执行效率。

同理,在这种情形下,对于像 std::list、std::vector 这样的容器,其 push/push_front 方法在 C++11 中也有对应的改进方法即 emplace/emplace_front 方法。C++ Reference 上将这里的 emplace  *** 作称之为“原位构造元素”(EmplaceConstructible)是非常贴切的。

原方法C++ 11 改进方法方法含义push/insertemplace在容器指定位置原位构造元素push_frontemplace_front在容器首部原位构造元素push_backemplace_back在容器尾部原位构造元素

 除了使用 emplace 系列函数原位构造元素,我们也可以为 Test 类添加移动构造函数(Move Constructor)来复用产生的临时对象 t 以提高效率,这将在后面介绍 std::move() 方法时介绍。

std::map 的 try_emplace 与 insert_or_assign 方法

由于 std::map 中的元素的 key 是唯一的,所以在实际开发中我们经常会遇到这样一类需求:即往某个 map 中插入元素时需要先检测 map 中指定的 key 是否存在,如果不存在才做插入 *** 作,如果存在,则直接取来使用;或者在指定 key 不存在时做插入 *** 作,存在时做更新 *** 作。

以 PC 版 QQ 为例,好友列表中每个好友都对应一个 userid,当我们双击某个 QQ 好友头像时,如果与该好友的聊天对话框(这里使用 ChatDialog 表示)已经创建,则直接激活显示,如果不存在,则创建并显示之。假设我们使用 std::map 来管理这些聊天对话框,在 C++17 之前,我们必须编写额外的逻辑去判断元素是否存在,上述逻辑可以编写成如下代码:

class ChatDialog
{
//其他实现省略...
public:
    void activate()
    {
        //实现省略
    }
};

//用于管理所有聊天对话框的map,key是好友id,ChatDialog是聊天对话框指针
std::map m_ChatDialogs;

//双击好友头像后
void onDoubleClickFriendItem(int64_t userid)
{
    auto targetChatDialog = m_ChatDialogs.find(userid);
    //好友对话框不存在,则创建之,并激活
    if (targetChatDialog == m_ChatDialogs.end())
    {
        ChatDialog* pChatDialog = new ChatDialog();
        m_ChatDialogs.insert(std::pair(userid, pChatDialog));
        pChatDialog->activate();
    }
    //好友对话框存在,直接激活
    else
    {
        targetChatDialog->second->activate();
    }
}

在 C++ 17 中 map 提供了一个 try_emplace 这样的方法,该方法会检测指定的 key 是否存在,如果存在,则什么也不做。函数签名如下:

template 
pair try_emplace(const key_type& k, Args&&... args);

template 
pair try_emplace(key_type&& k, Args&&... args);

template 
iterator try_emplace(const_iterator hint, const key_type& k, Args&&... args);

template 
iterator try_emplace(const_iterator hint, key_type&& k, Args&&... args);

上述函数签名中, 参数 k 表示需要插入的 key,args 参数是一个不定参数,表示构造 value 对象需要传给构造函数的参数,hint 参数可以指定插入位置。

在前两种签名形式中, try_emplace 的返回值是一个 std::pair 类型,其中 T2 是一个 bool 类型表示元素是否成功插入 map 中,T1 是一个 map 的迭代器,如果插入成功,则返回指向插入位置的元素的迭代器,如果插入失败,则返回 map 中已存在的相同 key 元素的迭代器。 我们用 try_emplace 改写上面的代码(这里我们不关心插入位置,因此使用前两个签名):

#include 
#include 

class ChatDialog
{
//其他实现省略...
public:
    void activate()
    {
        //实现省略
    }
};

//用于管理所有聊天对话框的map,key是好友id,ChatDialog是聊天对话框指针
std::map m_ChatDialogs;

//普通版本
void onDoubleClickFriendItem(int64_t userid)
{
    auto targetChatDialog = m_ChatDialogs.find(userid);
    //好友对话框不存在,则创建之,并激活
    if (targetChatDialog == m_ChatDialogs.end())
    {
        ChatDialog* pChatDialog = new ChatDialog();
        m_ChatDialogs.insert(std::pair(userid, pChatDialog));
        pChatDialog->activate();
    }
    //好友对话框存在,直接激活
    else
    {
        targetChatDialog->second->activate();
    }
}

//C++ 17版本1
void onDoubleClickFriendItem2(int64_t userid)
{   
    //结构化绑定和try_emplace都是 C++17语法
    auto [iter, inserted] = m_ChatDialogs.try_emplace(userid);
    if (inserted)
        iter->second = new ChatDialog();   

    iter->second->activate();
}

int main()
{
    //测试用例
    //906106643 是userid
    onDoubleClickFriendItem2(906106643L);
    //906106644 是userid
    onDoubleClickFriendItem2(906106644L);
    //906106643 是userid
    onDoubleClickFriendItem2(906106643L);

    return 0;
}

使用了 try_emplace 改写后的代码简洁了许多。但是上述代码存在一个注意事项,由于 std::map m_ChatDialogs 的 value 是指针类型(ChatDialog*),而 try_emplace 第二个不参数支持的是构造一个 ChatDialog 对象,而不是指针类型,因此,当某个 userid 不存在时,成功插入 map 后会导致相应的 value 为空指针。因此,我们利用 inserted 的值按需 new 出一个 ChatDialog。当然,新的 C++ 语言规范(C++11 及后续版本)提供了灵活而强大的智能指针以后,我们就不应该再有任何理由去使用裸指针了,因此上述代码可以使用 std::unique_ptr 智能指针类型来重构:


#include 
#include 
#include 

class ChatDialog
{
//其他实现省略...
public:
    ChatDialog()
    {
        std::cout << "ChatDialog constructor" << std::endl;
    }

    ~ChatDialog()
    {
        std::cout << "ChatDialog destructor" << std::endl;
    }

    void activate()
    {
        //实现省略
    }
};

//用于管理所有聊天对话框的map,key是好友id,value是ChatDialog是聊天对话框智能指针
std::map> m_ChatDialogs;

//C++ 17 版本2
void onDoubleClickFriendItem3(int64_t userid)
{   
    //结构化绑定和try_emplace都是 C++17语法
    auto spChatDialog = std::make_unique();
    auto [iter, inserted] = m_ChatDialogs.try_emplace(userid, std::move(spChatDialog));
    iter->second->activate();
}

int main()
{
    //测试用例
    //906106643 是userid
    onDoubleClickFriendItem3(906106643L);
    //906106644 是userid
    onDoubleClickFriendItem3(906106644L);
    //906106643 是userid
    onDoubleClickFriendItem3(906106643L);

    return 0;
}

上述代码将 map 的类型从 std::map 改为 std::map> ,让程序自动管理聊天对话框对象。程序在 gcc/g++ 7.3 下编译并运行输出如下:

[root@mydev test]# g++ -g -o test_map_try_emplace_with_smartpointer test_map_try_emplace_with_smartpointer.cpp -std=c++17
[root@mydev test]# ./test_map_try_emplace_with_smartpointer
ChatDialog constructor
ChatDialog constructor
ChatDialog constructor
ChatDialog destructor
ChatDialog destructor
ChatDialog destructor

上述代码中构造函数和析构函数均被调用了 3 次,实际上,按最原始的逻辑(上文中普通版本)ChatDialog 应该只被构造和析构 2 次,多出来的一次是因为在 try_emplace 时,无论某个 userid 是否存在于 map 中均创建一个 ChatDialog 对象(这个是额外的、用不上的对象),由于这个对象并没有被用上,当出了函数 onDoubleClickFriendItem3 作用域后,智能指针对象 spChatDialog 被析构,进而导致这个额外的、用不上的 ChatDialog 对象被析构。这相当于做了一次无用功。为此,我们可以继续优化我们的代码如下:

#include 
#include 
#include 

class ChatDialog
{
//其他实现省略...
public:
    ChatDialog()
    {
        std::cout << "ChatDialog constructor" << std::endl;
    }

    ~ChatDialog()
    {
        std::cout << "ChatDialog destructor" << std::endl;
    }

    void activate()
    {
        //实现省略
    }
};

//用于管理所有聊天对话框的map,key是好友id,value是ChatDialog是聊天对话框智能指针
std::map> m_ChatDialogs;

//C++ 17版本3
void onDoubleClickFriendItem3(int64_t userid)
{   
    //结构化绑定和try_emplace都是 C++17语法    
    auto [iter, inserted] = m_ChatDialogs.try_emplace(userid, nullptr);
    if (inserted)
    {
        //这样就按需创建了
        auto spChatDialog = std::make_unique();
        iter->second = std::move(spChatDialog);
    }

    iter->second->activate();
}

int main()
{
    //测试用例
    //906106643 是userid
    onDoubleClickFriendItem3(906106643L);
    //906106644 是userid
    onDoubleClickFriendItem3(906106644L);
    //906106643 是userid
    onDoubleClickFriendItem3(906106643L);

    return 0;
}

上述代码我们按照之前的裸指针版本的思路,按需创建一个智能指针对象。这样就避免了一次 ChatDialog 对象无用的构造和析构。再次编译程序,执行结果如下:

[root@mydev test]# g++ -g -o test_map_try_emplace_with_smartpointer2 test_map_try_emplace_with_smartpointer2.cpp -std=c++17
[root@mydev test]# ./test_map_try_emplace_with_smartpointer2
ChatDialog constructor
ChatDialog constructor
ChatDialog destructor
ChatDialog destructor

为了演示 try_emplace 函数支持原位构造(上文已经介绍),我们将 map 的 value 类型改成 ChatDialog 类型,当然,这里只是为了演示方便,实际开发中对于非 POD 类型的复杂数据类型,在 stl 容器中应该存储其指针或者智能指针类型,而不是对象本身。修改后的代码如下:

#include 
#include 

class ChatDialog
{
//其他实现省略...
public:
    ChatDialog(int64_t userid) : m_userid(userid)
    {
        std::cout << "ChatDialog constructor" << std::endl;
    }

    ~ChatDialog()
    {
        std::cout << "ChatDialog destructor" << std::endl;
    }

    void activate()
    {
        //实现省略
    }

private:
    int64_t     m_userid;
};

//用于管理所有聊天对话框的map,key是好友id,value是ChatDialog是聊天对话框对象
std::map   m_ChatDialogs;

//C++ 17版本4
void onDoubleClickFriendItem3(int64_t userid)
{   
    //第二个userid是传给ChatDialog构造函数的参数
    auto [iter, inserted] = m_ChatDialogs.try_emplace(userid, userid);   
    iter->second.activate();
}

int main()
{
    //测试用例
    //906106643 是userid
    onDoubleClickFriendItem3(906106643L);
    //906106644 是userid
    onDoubleClickFriendItem3(906106644L);
    //906106643 是userid
    onDoubleClickFriendItem3(906106643L);

    return 0;
}

上述代码中,我们为 ChatDialog 类的构造函数增加了一个 userid 参数,因此当我们调用 try_emplace 方法时,需要传递一个参数,这样 try_emplace 就会根据 map 中是否已存在同样的 userid 按需构造 ChatDialog 对象。程序执行结果和上一个代码示例应该是一样的:

[root@mydev test]# g++ -g -o test_map_try_emplace_with_directobject test_map_try_emplace_with_directobject.cpp -std=c++17
[root@mydev test]# ./test_map_try_emplace_with_directobject
ChatDialog constructor
ChatDialog constructor
ChatDialog destructor
ChatDialog destructor

关于 std::move 和 智能指针对象 std::unique_ptr 我们将在后面小节详细介绍。

上面我们介绍了如果 map 中指定的 key 不存在则插入,存在则使用的情形。我们再来介绍一下如果 map 中指定的 key 不存在则插入,存在则更新其 value 值的情形。C++17 为此也为 map 容易新增了一个这样的方法 insert_or_assign,让我们不再像 C++17 标准之前,需要额外编写先判断是否存在,不存在则插入,存在则更新的代码了,这次我们可以直接一步到位。insert_or_assign 的函数签名如下:

template 
pair insert_or_assign(const key_type& k, M&& obj);

template 
pair insert_or_assign(key_type&& k, M&& obj);

template 
iterator insert_or_assign(const_iterator hint, const key_type& k, M&& obj);

template 
iterator insert_or_assign(const_iterator hint, key_type&& k, M&& obj);

其各个函数参数的含义与 try_emplace 一样,这里就不再赘述。

我们来看一个例子:

int main()
{
    std::map mapUsersAge{ { "Alex", 45 }, { "John", 25 } };
    mapUsersAge.insert_or_assign("Tom", 26);
    mapUsersAge.insert_or_assign("Alex", 27);

    for (const auto& [userName, userAge] : mapUsersAge)
    {
        std::cout << "userName: " << userName << ", userAge: " << userAge << std::endl;
    }
}

上述代码中,尝试插入名为 Tom 的用户,由于该人名在 map 中不存在,因此插入成功;当插入人名为 Alex 的用户时,由于 map 中已经存在该人名了,因此只对其年龄进行更新,Alex 的年龄从 45 更新为 27。程序执行结果如下:

[root@mydev test]# g++ -g -o test_map_insert_or_assign test_map_insert_or_assign.cpp -std=c++17
[root@mydev test]# ./test_map_insert_or_assign
userName: Alex, userAge: 27
userName: John, userAge: 25
userName: Tom, userAge: 26
std::thread

C++ 11 提供的 std::thread 类

无论是 Linux 还是 Windows 上创建线程的 API,都有一个非常不方便的地方,就是线程函数的签名必须是固定的格式(参数个数和类型、返回值类型都有要求)。C++11 新标准引入了一个新的类 std::thread(需要包含头文件),使用这个类的可以将任何签名形式的函数作为线程函数。以下代码分别创建两个线程,线程函数签名不一样:

#include 
#include 

void threadproc1()
{
    while (true)
    {
        printf("I am New Thread 1!n");
    }
}

void threadproc2(int a, int b)
{
    while (true)
    {
        printf("I am New Thread 2!n");
    }
}

int main()
{
    //创建线程t1
    std::thread t1(threadproc1);
    //创建线程t2
    std::thread t2(threadproc2, 1, 2);

    while (true)
    {
        //Sleep(1000);
        //权宜之计,让主线程不要提前退出
    }

    return 0;
}

当然, std::thread 在使用上容易犯一个错误,即在 std::thread 对象在线程函数运行期间必须是有效的。什么意思呢?我们来看一个例子:

#include 
#include 

void threadproc()
{
    while (true)
    {
        printf("I am New Thread!n");
    }
}

void func()
{
    std::thread t(threadproc);
}

int main()
{
    func();

    while (true)
    {
        //Sleep(1000);
        //权宜之计,让主线程不要提前退出
    }

    return 0;
}

上述代码在 func 中创建了一个线程,然后又在 main 函数中调用 func 方法,乍一看好像代码没什么问题,但是在实际运行时程序会崩溃。崩溃的原因是,当 func 函数调用结束后,func 中局部变量 t (线程对象)被销毁了,而此时线程函数仍然在运行。这就是我所说的,使用 std::thread 类时,必须保证线程函数运行期间,其线程对象有效。这是一个很容易犯的错误,解决这个问题的方法是,std::thread 对象提供了一个 detach 方法,这个方法让线程对象与线程函数脱离关系,这样即使线程对象被销毁,仍然不影响线程函数的运行。我们只需要在在 func 函数中调用 detach 方法即可,代码如下:

//其他代码保持不变,这里就不重复贴出来了
void func()
{
    std::thread t(threadproc);
    t.detach();
}

然而,在实际编码中,这也是一个不推荐的做法,原因是我们需要使用线程对象去控制和管理线程的运行和生命周期。所以,我们的代码应该尽量保证线程对象在线程运行期间有效,而不是单纯地调用 detach 方法使线程对象与线程函数的运行分离。

thread_local

C++11 标准提供了一个新的关键字 thread_local 来定义一个线程变量。使用方法如下:

thread_local int g_mydata = 1;

有了这个关键字,使用线程局部存储的代码同时在 Windows 和 Linux 运行了。示例如下:

#include 
#include 
#include 

thread_local int g_mydata = 1;

void thread_func1()
{
    while (true)
    {
        ++g_mydata;
    }
}

void thread_func2()
{
    while (true)
    {
        std::cout << "g_mydata = " << g_mydata << ", ThreadID = " << std::this_thread::get_id() << std::endl;
        std::this_thread::sleep_for(std::chrono::seconds(1));
    }
}

int main()
{
    std::thread t1(thread_func1);
    std::thread t2(thread_func2);

    t1.join();
    t2.join();

    return 0;
}

需要注意的是,如果读者是在 Windows 平台下,虽然 thread_local 关键字在 C++ 11 标准中引入,但是 Visual Studio 2013 (支持 C++ 11 语法的最低的一个版本)编译器却并不支持这个关键字,建议在 Visual Studio 2015 及以上版本中测试上述代码。

最后关于线程局部存储变量,我还再强调两点:

  • 对于线程变量,每个线程都会有该变量的一个拷贝,并行不悖,互不干扰。该局部变量一直都在,直到线程退出为止。
  • 系统的线程局部存储区域内存空间并不大,所以尽量不要利用这个空间存储大的数据块,如果不得不使用大的数据块,可以将大的数据块存储在堆内存中,再将该堆内存的地址指针存储在线程局部存储区域。
std::mutex C++ 11/14/17 线程资源同步对象

在 C/C++ 语言中直接使用 *** 作系统提供的多线程资源同步 API 虽然功能强大,但毕竟存在诸多限制,且同样的代码却不能同时兼容 Windows 和 Linux 两个平台;再者 C/C++ 这种传统语言的使用份额正在被 Java、python、go 等语言慢慢蚕食,很大一部分原因是 C/C++ 这门编程语言在一些功能上缺少“完备性”,如对线程同步技术的支持,而这些功能在像 Java、python、go 中是标配。因此 C++ 11 标准新加入了很多现代语言标配的东西,其中线程资源同步对象就是其中很重要的一部分。本小节将讨论 C++ 11 标准中新增的用于线程同步的 std::mutex 和 std::condition_variable 对象的用法,有了它们我们就可以写出跨平台的多线程程序了。

std::mutex 系列

关于 mutex 的基本概念上文已经介绍过了,这里不再赘述。

C++ 11/14/17 中提供了如下 mutex 系列类型:

互斥量版本作用mutexC++11最基本的互斥量timed_mutexC++11有超时机制的互斥量recursive_mutexC++11可重入的互斥量recursive_timed_mutexC++11结合 timed_mutex 和 recursive_mutex 特点的互斥量shared_timed_mutexC++14具有超时机制的可共享互斥量shared_mutexC++17共享的互斥量

这个系列的对象均提供了加锁(lock)、尝试加锁(trylock)和解锁(unlock)的方法,我们以 std::mutex 为例来看一段示例代码:

#include 
#include 
#include 
#include 

// protected by g_num_mutex
int g_num = 0;  
std::mutex g_num_mutex;

void slow_increment(int id) 
{
    for (int i = 0; i < 3; ++i) {
        g_num_mutex.lock();
        ++g_num;
        std::cout << id << " => " << g_num << std::endl;
        g_num_mutex.unlock();

        //sleep for 1 second
        std::this_thread::sleep_for(std::chrono::seconds(1));
    }
}

int main()
{
    std::thread t1(slow_increment, 0);
    std::thread t2(slow_increment, 1);
    t1.join();
    t2.join();

    return 0;
}

上述代码中,创建了两个线程 t1 和 t2,在线程函数的 for 循环中调用 std::mutex.lock() 和 std::mutex.unlock() 对全局变量 g_num 进行保护。编译程序并输出结果如下:

[root@localhost testmultithread]# g++ -g -o mutex c11mutex.cpp -std=c++0x -lpthread
[root@localhost testmultithread]# ./mutex 
0 => 1
1 => 2
0 => 3
1 => 4
1 => 5
0 => 6

为了避免死锁, std::mutex.lock() 和 std::mutex::unlock() 方法需要成对使用,但是如上文介绍的如果一个函数中有很多出口,而互斥体对象又是需要在整个函数作用域保护的资源,那么在编码时因为忘记在某个出口处调用 std::mutex.unlock 而造成死锁,上文中推荐使用利用 RAII 技术封装这两个接口,其实 C++ 11 标准也想到了整个问题,因为已经为我们提供了如下封装:

互斥量管理版本作用lock_guardC++11基于作用域的互斥量管理unique_lockC++11更加灵活的互斥量管理shared_lockC++14共享互斥量的管理scope_lockC++17多互斥量避免死锁的管理

我们这里以 std::lock_guard 为例:

void func()
{
    std::lock_guard guard(mymutex);
    //在这里放被保护的资源 *** 作
}

mymutex 的类型是 std::mutex,在 guard 对象的构造函数中,会自动调用 mymutex.lock() 方法加锁,当该函数出了作用域后,调用 guard 对象时析构函数时会自动调用 mymutex.unlock() 方法解锁。

注意: mymutex 生命周期必须长于函数 func 的作用域,很多人在初学这个利用 RAII 技术封装的 std::lock_guard 对象时,可能会写出这样的代码:

//错误的写法,这样是没法在多线程调用该函数时保护指定的数据的。
void func()
{
    std::mutex m;
    std::lock_guard guard(m);
    //在这里放被保护的资源 *** 作
}

另外,如果一个 std::mutex 对象已经调用了 lock() 方法,再次调用时,其行为是未定义的,这是一个错误的做法。所谓“行为未定义”即在不同平台上可能会有不同的行为。

#include 

int main()
{
    std::mutex m;
    m.lock();
    m.lock();
    m.unlock();

    return 0;
}

实际测试时,上述代码重复调用 std::mutex.lock() 方法在 Windows 平台上会引起程序崩溃。

上述代码在 Linux 系统上运行时会阻塞在第二次调用 std::mutex.lock() 处,验证结果如下:

[root@localhost testmultithread]# g++ -g -o mutexlock mutexlock.cpp -std=c++0x -lpthread
[root@localhost testmultithread]# gdb mutexlock
Reading symbols from /root/testmultithread/mutexlock...done.
(gdb) r
Starting program: /root/testmultithread/mutexlock 
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib64/libthread_db.so.1".
^C
Program received signal SIGINT, Interrupt.
0x00007ffff7bcd4ed in __lll_lock_wait () from /lib64/libpthread.so.0
Missing separate debuginfos, use: debuginfo-install glibc-2.17-260.el7.x86_64 libgcc-4.8.5-36.el7.x86_64 libstdc++-4.8.5-36.el7.x86_64
(gdb) bt
#0  0x00007ffff7bcd4ed in __lll_lock_wait () from /lib64/libpthread.so.0
#1  0x00007ffff7bc8dcb in _L_lock_883 () from /lib64/libpthread.so.0
#2  0x00007ffff7bc8c98 in pthread_mutex_lock () from /lib64/libpthread.so.0
#3  0x00000000004006f7 in __gthread_mutex_lock (__mutex=0x7fffffffe3e0)
    at /usr/include/c++/4.8.2/x86_64-redhat-linux/bits/gthr-default.h:748
#4  0x00000000004007a2 in std::mutex::lock (this=0x7fffffffe3e0) at /usr/include/c++/4.8.2/mutex:134
#5  0x0000000000400777 in main () at mutexlock.cpp:7
(gdb) f 5
#5  0x0000000000400777 in main () at mutexlock.cpp:7
7        m.lock();
(gdb) l
2    
3    int main()
4    {
5        std::mutex m;
6        m.lock();
7        m.lock();
8        m.unlock();
9    
10        return 0;
11    }
(gdb)

我们使用 gdb 运行程序,然后使用 bt 命令看到程序确实阻塞在第二个 m.lock() 的地方(代码第 7 行)。

不管怎样,对一个已经调用 lock() 方法再次调用 lock() 方法的做法是错误的,我们实际开发中要避免这么做。

std::shared_mutex

C++ 11 标准让很多开发者诟病的原因之一是,C++ 新标准借鉴 boost 库的 boost::mutex、boost::shared_mutex 而引入 std::mutex 和 std::shared_mutex,但是在 C++11 中只引入了 std::mutex,直到 C++ 17 才有 std::shared_mutex,这让只能使用仅支持 C++11 标准的编译器(例如 Visual Studio 2013,gcc/g++ 4.8)的开发者非常不方便。

商业项目中一般不会轻易升级编译器,因为商业项目一般牵涉的代码范围较大,升级编译器后可能导致大量旧的文件需要修改,例如对于被广泛使用的 CentOS 7.0,其自带的 gcc 编译器是 4.8,升级 gcc 的同时会导致系统自带的 glibc 库发生变化,导致系统中大量其他程序无法运行。因此,实际的商业项目中,升级旧的开发环境是非常慎重的。

std::shared_mutex 底层实现主要原理是 *** 作系统提供的读写锁,也就是说,在存在多个线程对共享资源读、少许线程对共享资源写的情况下,std::shared_mutex 比 std::mutex 效率更高。

std::shared_mutex 提供了 lock() 和 unlock() 方法获取写锁和解除写锁,提供了 lock_shared() 和 unlock_shared() 方法获取读锁和解除读锁,写锁模式我们称为排他锁(Exclusive Locking),读锁模式我们称为共享锁(Shared Locking)。

另外,C++ 新标准中引入与 std::shared_mutex 配合使用的 std::unique_lock、std::shared_lock 两个对象用于进入作用域自动加锁、出了作用域自动解除锁,前者用于加解 std::shared_mutex 的写锁,后者用于加解 std::shared_mutex 的读锁。

std::unique_lock 在 C++11 引入,std::shared_lock 在 C++14 引入。

下面是对共享资源存在多个读线程和一个写线程,分别使用 std::mutex 和 std::shared_mutex 做的一个性能测试,测试代码如下:


//读线程数量
#define READER_THREAD_COUNT  8
//最大循环次数
#define LOOP_COUNT           5000000

#include 
#include   
#include 
#include 

class shared_mutex_counter {
public:
    shared_mutex_counter() = default;
    ~shared_mutex_counter() = default;

    //使用std::shared_mutex,同一时刻多个读线程可以同时访问value_值
    unsigned int get() const {
        //注意:这里使用std::shared_lock
        std::shared_lock lock(mutex_);
        return value_;
    }

    //使用std::shared_mutex,同一个时刻仅有一个写线程可以修改value_值
    void increment() {
        //注意:这里使用std::unique_lock
        std::unique_lock lock(mutex_);
        value_++;
    }

    //使用std::shared_mutex,同一个时刻仅有一个写线程可以重置value_值
    void reset() {
        //注意:这里使用std::unique_lock
        std::unique_lock lock(mutex_);
        value_ = 0;
    }

private:
    mutable std::shared_mutex   mutex_;
    //value_是多个线程的共享资源
    unsigned int                value_ = 0;
};

class mutex_counter {
public:
    mutex_counter() = default;
    ~mutex_counter() = default;

    //使用std::mutex,同一时刻仅有一个线程可以访问value_的值
    unsigned int get() const {
        std::unique_lock lk(mutex_);
        return value_;
    }

   //使用std::mutex,同一时刻仅有一个线程可以修改value_的值
    void increment() {
        std::unique_lock lk(mutex_);
        value_++;
    }

private:
    mutable std::mutex      mutex_;
    //value_是多个线程的共享资源
    unsigned int            value_ = 0;
};

//测试std::shared_mutex
void test_shared_mutex()
{
    shared_mutex_counter counter;
    int temp;

    //写线程函数
    auto writer = [&counter]() {
        for (int i = 0; i < LOOP_COUNT; i++) {
            counter.increment();
        }
    };

    //读线程函数
    auto reader = [&counter, &temp]() {
        for (int i = 0; i < LOOP_COUNT; i++) {
            temp = counter.get();
        }
    };

    //存放读线程对象指针的数组
    std::thread** tarray = new std::thread*[READER_THREAD_COUNT];

    //记录起始时间
    clock_t start = clock();

    //创建READER_THREAD_COUNT个读线程
    for (int i = 0; i < READER_THREAD_COUNT; i++)
    {
        tarray[i] = new std::thread(reader);
    }

    //创建一个写线程
    std::thread tw(writer);

    for (int i = 0; i < READER_THREAD_COUNT; i++)
    {
        tarray[i]->join();
    }
    tw.join();

    //记录起始时间
    clock_t end = clock();
    printf("[test_shared_mutex]n");
    printf("thread count: %dn", READER_THREAD_COUNT);
    printf("result: %d cost: %dms temp: %d n", counter.get(), end - start, temp);
}

//测试std::mutex
void test_mutex()
{
    mutex_counter counter;

    int temp;

    //写线程函数
    auto writer = [&counter]() {
        for (int i = 0; i < LOOP_COUNT; i++) {
            counter.increment();
        }
    };

    //读线程函数
    auto reader = [&counter, &temp]() {
        for (int i = 0; i < LOOP_COUNT; i++) {
            temp = counter.get();
        }
    };

    //存放读线程对象指针的数组
    std::thread** tarray = new std::thread*[READER_THREAD_COUNT];

    //记录起始时间
    clock_t start = clock();

    //创建READER_THREAD_COUNT个读线程
    for (int i = 0; i < READER_THREAD_COUNT; i++)
    {
        tarray[i] = new std::thread(reader);
    }

    //创建一个写线程
    std::thread tw(writer);

    for (int i = 0; i < READER_THREAD_COUNT; i++)
    {
        tarray[i]->join();
    }
    tw.join();

    //记录结束时间
    clock_t end = clock();
    printf("[test_mutex]n");
    printf("thread count:%dn", READER_THREAD_COUNT);
    printf("result:%d cost:%dms temp:%d n", counter.get(), end - start, temp);
}

int main() {
    //为了排除测试程序的无关因素,测试时只开启一个  
    test_mutex();
    //test_shared_mutex();
    return 0;
}

以下是我在 Windows 上 Visual Studio 2019 的测试结果:

 在 Linux 机器上,由于 std::shared_mutex 是 C++17 才引入的(gcc 7.0 及以上,我使用的是 gcc 7.3),因此编译时需要加上编译参数 --std=c++17,测试结果如下:

[root@myaliyun testmutexbenchmark]# g++ -g -o test_shared_mutex TestSharedMutexBenchmark.cpp -std=c++17 -lpthread
[root@myaliyun testmutexbenchmark]# vi TestSharedMutexBenchmark.cpp 
[root@myaliyun testmutexbenchmark]# g++ -g -o test_mutex TestSharedMutexBenchmark.cpp -std=c++17 -lpthread
[root@myaliyun testmutexbenchmark]# ll
total 416
-rwxr-xr-x 1 root root 205688 Nov 10 22:35 test_mutex
-rwxr-xr-x 1 root root 205688 Nov 10 22:35 test_shared_mutex
-rw-r--r-- 1 root root   4112 Nov 10 22:35 TestSharedMutexBenchmark.cpp
[root@myaliyun testmutexbenchmark]# ./test_mutex
[test_mutex]
thread count:8
result:5000000 cost:2460000ms temp:4341759 
[root@myaliyun testmutexbenchmark]# ./test_shared_mutex 
[test_shared_mutex]
thread count: 8
result: 5000000 cost: 2620000ms temp: 735375

由于我的 Linux 机器配置不高,所以在 Linux 机器上的测试结果 std::shared_mutex 比 std::mutex 差别并不明显,读者可以尝试修改 READER_THREAD_COUNT 的值来测试不同数量的读线程的输出结果。

std::mutex 和 std::shared_mutex 分别对应 java jdk 中的 ReentrantLock 和 ReentrantReadWriteLock。

std::condition_variable 

C++ 11 提供了 std::condition_variable 这个类代表条件变量,与 Linux 系统原生的条件变量一样,同时提供了等待条件变量满足的 wait 系列方法(wait、wait_for、wait_until 方法),发送条件信号使用 notify 方法(notify_one 和 notify_all 方法),当然使用 std::condition_variable 对象时需要绑定一个 std::unique_lock 或 std::lock_guard 对象。

C++ 11 中 std::condition_variable 不再需要显式调用方法初始化和销毁。

 我们将上文中介绍 Linux 条件变量的例子改写成 C++ 11 版本:

#include 
#include 
#include 
#include 
#include 

class Task
{
public:
    Task(int taskID)
    {
        this->taskID = taskID;
    }

    void doTask()
    {
        std::cout << "handle a task, taskID: " << taskID << ", threadID: " << std::this_thread::get_id() << std::endl; 
    }

private:
    int taskID;
};

std::mutex                mymutex;
std::list          tasks;
std::condition_variable   mycv;

void* consumer_thread()
{    
    Task* pTask = NULL;
    while (true)
    {
        std::unique_lock guard(mymutex);
        while (tasks.empty())
        {                
            //如果获得了互斥锁,但是条件不合适的话,pthread_cond_wait会释放锁,不往下执行。
            //当发生变化后,条件合适,pthread_cond_wait将直接获得锁。
            mycv.wait(guard);
        }

        pTask = tasks.front();
        tasks.pop_front();

        if (pTask == NULL)
            continue;

        pTask->doTask();
        delete pTask;
        pTask = NULL;        
    }

    return NULL;
}

void* producer_thread()
{
    int taskID = 0;
    Task* pTask = NULL;

    while (true)
    {
        pTask = new Task(taskID);

        //使用括号减小guard锁的作用范围
        {
            std::lock_guard guard(mymutex);
            tasks.push_back(pTask);
            std::cout << "produce a task, taskID: " << taskID << ", threadID: " << std::this_thread::get_id() << std::endl; 
        }

        //释放信号量,通知消费者线程
        mycv.notify_one();

        taskID ++;

        //休眠1秒
        std::this_thread::sleep_for(std::chrono::seconds(1));
    }

    return NULL;
}

int main()
{
    //创建5个消费者线程
    std::thread consumer1(consumer_thread);
    std::thread consumer2(consumer_thread);
    std::thread consumer3(consumer_thread);
    std::thread consumer4(consumer_thread);
    std::thread consumer5(consumer_thread);

    //创建一个生产者线程
    std::thread producer(producer_thread);

    producer.join();
    consumer1.join();
    consumer2.join();
    consumer3.join();
    consumer4.join();
    consumer5.join();

    return 0;
}

编译并执行程序输出结果如下所示:

[root@localhost testmultithread]# g++ -g -o cpp11cv cpp11cv.cpp -std=c++0x -lpthread
[root@localhost testmultithread]# ./cpp11cv 
produce a task, taskID: 0, threadID: 140427590100736
handle a task, taskID: 0, threadID: 140427623671552
produce a task, taskID: 1, threadID: 140427590100736
handle a task, taskID: 1, threadID: 140427632064256
produce a task, taskID: 2, threadID: 140427590100736
handle a task, taskID: 2, threadID: 140427615278848
produce a task, taskID: 3, threadID: 140427590100736
handle a task, taskID: 3, threadID: 140427606886144
produce a task, taskID: 4, threadID: 140427590100736
handle a task, taskID: 4, threadID: 140427598493440
produce a task, taskID: 5, threadID: 140427590100736
handle a task, taskID: 5, threadID: 140427623671552
produce a task, taskID: 6, threadID: 140427590100736
handle a task, taskID: 6, threadID: 140427632064256
produce a task, taskID: 7, threadID: 140427590100736
handle a task, taskID: 7, threadID: 140427615278848
produce a task, taskID: 8, threadID: 140427590100736
handle a task, taskID: 8, threadID: 140427606886144
produce a task, taskID: 9, threadID: 140427590100736
handle a task, taskID: 9, threadID: 140427598493440

需要注意的是,如果在 Linux 平台上,std::condition_variable 也存在虚假唤醒这一现象,如何避免与上文中介绍 Linux 原生的条件变量方法一样。

 原子 *** 作类 C++11 对整型变量原子 *** 作的支持

在 C++ 98/03 标准中,如果想对整型变量进行原子 *** 作,要么利用 *** 作系统提供的相关原子 *** 作 API,要么利用对应 *** 作系统提供的锁对象来对变量进行保护,无论是哪种方式,编写的代码都无法实现跨平台 *** 作,例如上一小介绍的 Interlocked 系列 API 代码仅能运行于 Windows 系统,无法移植到 Linux 系统。C++ 11 新标准发布以后,改变了这种困境,新标准提供了对整型变量原子 *** 作的相关库,即 std::atomic ,这是一个模板类型:

template
struct atomic;

你可以传入具体的整型类型(如bool、char、short、int、uint等)对模板进行实例化,实际上 stl 库也提供了这些实例化的模板类型:

类型别名定义std::atomic_boolstd::atomicstd::atomic_charstd::atomicstd::atomic_scharstd::atomicstd::atomic_ucharstd::atomicstd::atomic_shortstd::atomicstd::atomic_ushortstd::atomicstd::atomic_intstd::atomicstd::atomic_uintstd::atomicstd::atomic_longstd::atomicstd::atomic_ulongstd::atomicstd::atomic_llongstd::atomicstd::atomic_ullongstd::atomicstd::atomic_char16_tstd::atomicstd::atomic_char32_tstd::atomicstd::atomic_wchar_tstd::atomicstd::atomic_int8_tstd::atomicstd::atomic_uint8_tstd::atomicstd::atomic_int16_tstd::atomicstd::atomic_uint16_tstd::atomicstd::atomic_int32_tstd::atomicstd::atomic_uint32_tstd::atomicstd::atomic_int64_tstd::atomicstd::atomic_uint64_tstd::atomic

上表中仅列出了 C++ 11 支持的常用的整型原子变量,完整的列表读者可以参考这里:https://zh.cppreference.com/w/cpp/atomic/atomic。

有了 C++ 语言本身对原子变量的支持以后,我们就可以“愉快地”写出跨平台的代码了,我们来看一段代码:

#include 
#include 

int main()
{
    std::atomic value;
    value = 99;
    printf("%dn", (int)value);

    //自增1,原子 *** 作
    value++;
    printf("%dn", (int)value);

    return 0;
}

以上代码可以同时在 Windows 和 Linux 平台上运行,但是有读者可能会根据个人习惯将上述代码写成如下形式:

#include 
#include 

int main()
{
    std::atomic value = 99;
    printf("%dn", (int)value);

    //自增1,原子 *** 作
    value++;
    printf("%dn", (int)value);

    return 0;
}

代码仅仅做了一点简单的改动,这段代码在 Windows 平台上运行良好,但是在 Linux 平台上会无法编译通过(这里指的是在支持 C++ 11语法的 g++ 编译中编译),提示错误是:

error: use of deleted function ‘std::atomic::atomic(const std::atomic&)’

产生这个错误的原因是 “std::atomic value = 99;” 这一行代码调用的是 std::atomic 的拷贝构造函数,对于 int 型,其形式一般如下:

std::atomic::atomic(const std::atomic& rhs);

而根据 C++ 11 的规范,这个拷贝构造函数是默认使用 =delete 语法禁止编译器生成的,g++ 遵循了这个标准,参见这里 https://zh.cppreference.com/w/cpp/atomic/atomic/operator%3D:

atomic& operator=( const atomic& ) = delete;

所以 Linux 平台上编译器会提示错误,而 Windows 的 VC++ 编译器没有遵循这个规范。而对于代码:

value = 99;

g++ 和 VC++ 同时实现规范中的:

T operator=( T desired )

因此,如果读者想利用 C++ 11 提供的 std::atomic 库编写跨平台的代码,在使用 std::atomic 提供的方法时建议参考官方 std::atomic 提供的接口说明来使用,而不是想当然地认为一个方法在此平台上可以运行,在另外一个平台也能有相同的行为,避免出现上面说的这种情形。

 上述代码中之所以可以对 value 进行自增(++) *** 作是因为 std::atomic 类内部重载了 operator = 运算符,除此以外, std::atomic 提供了大量有用的方法,这些方法您一定会觉得似曾相似:

方法名方法说明operator=存储值于原子对象store原子地以非原子对象替换原子对象的值load原子地获得原子对象的值exchange原子地替换原子对象的值并获得它先前持有的值compare_exchange_weak
compare_exchange_strong原子地比较原子对象与非原子参数的值,若相等则进行交换,若不相等则进行加载fetch_add原子地将参数加到存储于原子对象的值,并返回先前保有的值fetch_sub原子地从存储于原子对象的值减去参数,并获得先前保有的值fetch_and原子地进行参数和原子对象的值的逐位与,并获得先前保有的值fetch_or原子地进行参数和原子对象的值的逐位或,并获得先前保有的值fetch_xor原子地进行参数和原子对象的值的逐位异或,并获得先前保有的值operator++ operator++(int) operator-- operator--(int)令原子值增加或减少一operator+= operator-= operator&= operator竖杠= operator^==加、减,或与原子值进行逐位与、或、异或  智能指针类

C/C++ 语言最为人所诟病的特性之一就是存在内存泄露问题,因此后来的大多数语言都提供了内置内存分配与释放功能,有的甚至干脆对语言的使用者屏蔽了内存指针这一概念。这里不置贬褒,手动分配内存与手动释放内存有利也有弊,自动分配内存和自动释放内存亦如此,这是两种不同的设计哲学。有人认为,内存如此重要的东西怎么能放心交给用户去管理呢?而另外一些人则认为,内存如此重要的东西怎么能放心交给系统去管理呢?在 C/C++ 语言中,内存泄露的问题一直困扰着广大的开发者,因此各类库和工具的一直在努力尝试各种方法去检测和避免内存泄露,如 boost,智能指针技术应运而生。

C++ 98/03 的尝试——std::auto_ptr

在 2019 年讨论 std::auto_ptr 不免有点让人怀疑是不是有点过时了,确实如此,随着 C++11 标准的出现(最新标准是 C++20),std::auto_ptr 已经被彻底废弃了,取而代之是 std::unique_ptr。然而,我之所以还向你介绍一下 std::auto_ptr 的用法以及它的设计不足之处是想让你了解 C++ 语言中智能指针的发展过程,一项技术如果我们了解它过去的样子和发展的轨迹,我们就能更好地掌握它,不是吗?

std::auto_ptr 的基本用法如下代码所示:

#include 

int main()
{
    //初始化方式1
    std::auto_ptr sp1(new int(8));
    //初始化方式2
    std::auto_ptr sp2;
    sp2.reset(new int(8));

    return 0;
}

智能指针对象 sp1 和 sp2 均持有一个在堆上分配 int 对象,其值均是 8,这两块堆内存均可以在 sp1 和 sp2 释放时得到释放。这是 std::auto_ptr 的基本用法。

sp 是 smart pointer(智能指针)的简写。

 std::auto_ptr 真正让人容易误用的地方是其不常用的复制语义,即当复制一个 std::auto_ptr 对象时(拷贝复制或 operator = 复制),原对象所持有的堆内存对象也会转移给复制出来的对象。示例代码如下:

#include 
#include 

int main()
{
    //测试拷贝构造
    std::auto_ptr sp1(new int(8));
    std::auto_ptr sp2(sp1);
    if (sp1.get() != NULL)
    {
        std::cout << "sp1 is not empty." << std::endl;
    }
    else
    {
        std::cout << "sp1 is empty." << std::endl;
    }

    if (sp2.get() != NULL)
    {
        std::cout << "sp2 is not empty." << std::endl;
    }
    else
    {
        std::cout << "sp2 is empty." << std::endl;
    }

    //测试赋值构造
    std::auto_ptr sp3(new int(8));
    std::auto_ptr sp4;
    sp4 = sp3;
    if (sp3.get() != NULL)
    {
        std::cout << "sp3 is not empty." << std::endl;
    }
    else
    {
        std::cout << "sp3 is empty." << std::endl;
    }

    if (sp4.get() != NULL)
    {
        std::cout << "sp4 is not empty." << std::endl;
    }
    else
    {
        std::cout << "sp4 is empty." << std::endl;
    }

    return 0;
}

上述代码中分别利用拷贝构造(sp1 => sp2)和 赋值构造(sp3 => sp4)来创建新的 std::auto_ptr 对象,因此 sp1 持有的堆对象被转移给 sp2,sp3 持有的堆对象被转移给 sp4。我们得到程序执行结果如下:

[root@iZ238vnojlyZ testx]# g++ -g -o test_auto_ptr test_auto_ptr.cpp
[root@iZ238vnojlyZ testx]# ./test_auto_ptr 
sp1 is empty.
sp2 is not empty.
sp3 is empty.
sp4 is not empty.

由于 std::auto_ptr 这种不常用的复制语义,我们应该避免在 stl 容器中使用 std::auto_ptr,例如我们绝不应该写出如下代码:

std::vector> myvectors;

当用算法对容器 *** 作的时候(如最常见的容器元素遍历),很难避免不对容器中的元素实现赋值传递,这样便会使容器中多个元素被置为空指针,这不是我们想看到的,会造成很多意想不到的错误。

以史为鉴,作为 std::auto_ptr 的替代者 std::unique_ptr 吸取了这个经验教训。下文会来详细介绍。

正因为 std::auto_ptr 的设计存在如此重大缺陷,C++11 标准在充分借鉴和吸收了 boost 库中智能指针的设计思想,引入了三种类型的智能指针,即 std::unique_ptr、std::shared_ptr 和 std::weak_ptr。

boost 还有 scoped_ptr,C++11 并没有全部照搬,而是选择了三个最实用的指针类型。在 C++11 中可以通过 std::unique_ptr 达到与 boost::scoped_ptr 一样的效果。

 所有的智能指针类(包括 std::unique_ptr)均包含于头文件  中。

正因为存在上述设计上的缺陷,在 C++11及后续语言规范中 std::auto_ptr 已经被废弃,你的代码不应该再使用它。

 std::unique_ptr

std::unique_ptr 对其持有的堆内存具有唯一拥有权,也就是说引用计数永远是 1,std::unique_ptr 对象销毁时会释放其持有的堆内存。可以使用以下方式初始化一个 std::unique_ptr 对象:

//初始化方式1
std::unique_ptr sp1(new int(123));

//初始化方式2
std::unique_ptr sp2;
sp2.reset(new int(123));

//初始化方式3
std::unique_ptr sp3 = std::make_unique(123);

你应该尽量使用初始化方式 3 的方式去创建一个 std::unique_ptr 而不是方式 1 和 2,因为形式 3 更安全,原因 Scott Meyers 在其《Effective Modern C++》中已经解释过了,有兴趣的读者可以阅读此书相关章节。

令很多人对 C++11 规范不满的地方是,C++11 新增了 std::make_shared() 方法创建一个 std::shared_ptr 对象,却没有提供相应的 std::make_unique() 方法创建一个 std::unique_ptr 对象,这个方法直到 C++14 才被添加进来。当然,在 C++11 中你很容易实现出这样一个方法来:

template
std::unique_ptr make_unique(Ts&& ...params)
{
    return std::unique_ptr(new T(std::forward(params)...));
}

鉴于 std::auto_ptr 的前车之鉴,std::unique_ptr 禁止复制语义,为了达到这个效果,std::unique_ptr 类的拷贝构造函数和赋值运算符(operator =)被标记为 delete。

template 
class unique_ptr
{
    //省略其他代码...

    //拷贝构造函数和赋值运算符被标记为delete
    unique_ptr(const unique_ptr&) = delete;
    unique_ptr& operator=(const unique_ptr&) = delete;
};

因此,下列代码是无法通过编译的:

std::unique_ptr sp1(std::make_unique(123));;

//以下代码无法通过编译
//std::unique_ptr sp2(sp1);
std::unique_ptr sp3;
//以下代码无法通过编译
//sp3 = sp1;

禁止复制语义也存在特例,即可以通过一个函数返回一个 std::unique_ptr:

#include 

std::unique_ptr func(int val)
{
    std::unique_ptr up(new int(val));
    return up;
}

int main()
{
    std::unique_ptr sp1 = func(123);

    return 0;
}

上述代码从 func 函数中得到一个 std::unique_ptr 对象,然后返回给 sp1。

既然 std::unique_ptr 不能复制,那么如何将一个 std::unique_ptr 对象持有的堆内存转移给另外一个呢?答案是使用移动构造,示例代码如下:

#include 

int main()
{
    std::unique_ptr sp1(std::make_unique(123));

    std::unique_ptr sp2(std::move(sp1));

    std::unique_ptr sp3;
    sp3 = std::move(sp2);

    return 0;
}

以上代码利用 std::move 将 sp1 持有的堆内存(值为 123)转移给 sp2,再把 sp2 转移给 sp3。最后,sp1 和 sp2 不再持有堆内存的引用,变成一个空的智能指针对象。并不是所有的对象的 std::move *** 作都有意义,只有实现了移动构造函数(Move Constructor)或移动赋值运算符(operator =)的类才行,而 std::unique_ptr 正好实现了这二者,以下是实现伪码:

template
class unique_ptr
{
    //其他函数省略...
public:
    unique_ptr(unique_ptr&& rhs)
    {
        this->m_pT = rhs.m_pT;
        //源对象释放
        rhs.m_pT = nullptr;
    }

    unique_ptr& operator=(unique_ptr&& rhs)
    {
        this->m_pT = rhs.m_pT;
        //源对象释放
        rhs.m_pT = nullptr;
        return *this;
    }

private:
    T*    m_pT;
};

这是 std::unique_ptr 具有移动语义的原因,希望读者可以理解之。

std::unique_ptr 不仅可以持有一个堆对象,也可以持有一组堆对象,示例如下:

#include 
#include 

int main()
{
    //创建10个int类型的堆对象
    //形式1
    std::unique_ptr sp1(new int[10]);

    //形式2
    std::unique_ptr sp2;
    sp2.reset(new int[10]);
    //形式3
    std::unique_ptr sp3(std::make_unique(10));

    for (int i = 0; i < 10; ++i)
    {
        sp1[i] = i;
        sp2[i] = i;
        sp3[i] = i;
    }

    for (int i = 0; i < 10; ++i)
    {
        std::cout << sp1[i] << ", " << sp2[i] << ", " << sp3[i] << std::endl;
    }

    return 0;
}

程序执行结果如下:

[root@myaliyun testmybook]# g++ -g -o test_unique_ptr_with_array test_unique_ptr_with_array.cpp -std=c++17
[root@myaliyun testmybook]# ./test_unique_ptr_with_array 
0, 0, 0
1, 1, 1
2, 2, 2
3, 3, 3
4, 4, 4
5, 5, 5
6, 6, 6
7, 7, 7
8, 8, 8
9, 9, 9

std::shared_ptr 和 std::weak_ptr 也可以持有一组堆对象,用法与 std::unique_ptr 相同,下文不再赘述。

自定义智能指针对象持有的资源的释放函数

默认情况下,智能指针对象在析构时只会释放其持有的堆内存(调用 delete 或者 delete[]),但是假设这块堆内存代表的对象还对应一种需要回收的资源(如 *** 作系统的套接字句柄、文件句柄等),我们可以通过自定义智能指针的资源释放函数。假设现在有一个 Socket 类,对应着 *** 作系统的套接字句柄,在回收时需要关闭该对象,我们可以如下自定义智能指针对象的资源析构函数,这里以 std::unique_ptr 为例:

#include 
#include 

class Socket
{
public:
    Socket()
    {

    }

    ~Socket()
    {

    }

    //关闭资源句柄
    void close()
    {

    }
};

int main()
{
    auto deletor = [](Socket* pSocket) {
        //关闭句柄
        pSocket->close();
        //TODO: 你甚至可以在这里打印一行日志...
        delete pSocket;
    };

    std::unique_ptr spSocket(new Socket(), deletor);

    return 0;
}

自定义 std::unique_ptr 的资源释放函数其规则是:

std::unique_ptr
std::shared_ptr

std::unique_ptr 对其持有的资源具有独占性,而 std::shared_ptr 持有的资源可以在多个 std::shared_ptr 之间共享,每多一个 std::shared_ptr 对资源的引用,资源引用计数将增加 1,每一个指向该资源的 std::shared_ptr 对象析构时,资源引用计数减 1,最后一个 std::shared_ptr 对象析构时,发现资源计数为 0,将释放其持有的资源。多个线程之间,递增和减少资源的引用计数是安全的。(注意:这不意味着多个线程同时 *** 作 std::shared_ptr 引用的对象是安全的)。std::shared_ptr 提供了一个 use_count() 方法来获取当前持有资源的引用计数。除了上面描述的,std::shared_ptr 用法和 std::unique_ptr 基本相同。

下面是一个初始化 std::shared_ptr 的示例:

//初始化方式1
std::shared_ptr sp1(new int(123));

//初始化方式2
std::shared_ptr sp2;
sp2.reset(new int(123));

//初始化方式3
std::shared_ptr sp3;
sp3 = std::make_shared(123);

和 std::unique_ptr 一样,你应该优先使用 std::make_shared 去初始化一个 std::shared_ptr 对象。

再来看另外一段代码:

#include 
#include 

class A
{
public:
    A()
    {
        std::cout << "A constructor" << std::endl;
    }

    ~A()
    {
        std::cout << "A destructor" << std::endl;
    }
};

int main()
{
    {
        //初始化方式1
        std::shared_ptr sp1(new A());

        std::cout << "use count: " << sp1.use_count() << std::endl;

        //初始化方式2
        std::shared_ptr sp2(sp1);
        std::cout << "use count: " << sp1.use_count() << std::endl;

        sp2.reset();
        std::cout << "use count: " << sp1.use_count() << std::endl;

        {
            std::shared_ptr sp3 = sp1;
            std::cout << "use count: " << sp1.use_count() << std::endl;
        }

        std::cout << "use count: " << sp1.use_count() << std::endl;
    }

    return 0;
}
  • 上述代码 22 行 sp1 构造时,同时触发对象 A 的构造,因此 A 的构造函数会执行;
  • 此时只有一个 sp1 对象引用 22 行 new 出来的 A 对象(为了叙述方便,下文统一称之为资源对象 A),因此代码 24 行打印出来的引用计数值为 1;
  • 代码 27 行,利用 sp1 拷贝一份 sp2,导致代码 28 行打印出来的引用计数为 2;
  • 代码 30 行调用 sp2 的 reset() 方法,sp2 释放对资源对象 A 的引用,因此代码 31 行打印的引用计数值再次变为 1;
  • 代码 34 行 利用 sp1 再次 创建 sp3,因此代码 35 行打印的引用计数变为 2;
  • 程序执行到 36 行以后,sp3 出了其作用域被析构,资源 A 的引用计数递减 1,因此 代码 38 行打印的引用计数为 1;
  • 程序执行到 39 行以后,sp1 出了其作用域被析构,在其析构时递减资源 A 的引用计数至 0,并析构资源 A 对象,因此类 A 的析构函数被调用。

所以整个程序的执行结果如下:

[root@myaliyun testmybook]# ./test_shared_ptr_use_count 
A constructor
use count: 1
use count: 2
use count: 1
use count: 2
use count: 1
A destructor
std::enable_shared_from_this

实际开发中,有时候需要在类中返回包裹当前对象(this)的一个 std::shared_ptr 对象给外部使用,C++ 新标准也为我们考虑到了这一点,有如此需求的类只要继承自 std::enable_shared_from_this 模板对象即可。用法如下:

#include 
#include 

class A : public std::enable_shared_from_this
{
public:
    A()
    {
        std::cout << "A constructor" << std::endl;
    }

    ~A()
    {
        std::cout << "A destructor" << std::endl;
    }

    std::shared_ptr getSelf()
    {
        return shared_from_this();
    }
};

int main()
{
    std::shared_ptr sp1(new A());

    std::shared_ptr sp2 = sp1->getSelf();

    std::cout << "use count: " << sp1.use_count() << std::endl;

    return 0;
}

上述代码中,类 A 的继承 std::enable_shared_from_this 并提供一个 getSelf() 方法返回自身的 std::shared_ptr 对象,在 getSelf() 中调用 shared_from_this() 即可。

std::enable_shared_from_this 用起来比较方便,但是也存在很多不易察觉的陷阱。

陷阱一:不应该共享栈对象的 this 给智能指针对象

假设我们将上面代码 main 函数 25 行生成 A 对象的方式改成一个栈变量,即:

//其他相同代码省略...

int main()
{
    A a;

    std::shared_ptr sp2 = a.getSelf();

    std::cout << "use count: " << sp2.use_count() << std::endl;

    return 0;
}

运行修改后的代码会发现程序在 std::shared_ptr sp2 = a.getSelf(); 产生崩溃。这是因为,智能指针管理的是堆对象,栈对象会在函数调用结束后自行销毁,因此不能通过 shared_from_this() 将该对象交由智能指针对象管理。切记:智能指针最初设计的目的就是为了管理堆对象的(即那些不会自动释放的资源)。

陷阱二:避免 std::enable_shared_from_this 的循环引用问题

再来看另外一段代码:

// test_std_enable_shared_from_this.cpp : This file contains the 'main' function. Program execution begins and ends there.
//
#include 
#include 

class A : public std::enable_shared_from_this
{
public:
    A()
    {
        m_i = 9;
        //注意:
        //比较好的做法是在构造函数里面调用shared_from_this()给m_SelfPtr赋值
        //但是很遗憾不能这么做,如果写在构造函数里面程序会直接崩溃

        std::cout << "A constructor" << std::endl;
    }

    ~A()
    {
        m_i = 0;

        std::cout << "A destructor" << std::endl;
    }

    void func()
    {
        m_SelfPtr = shared_from_this();
    }

public:
    int                 m_i;
    std::shared_ptr  m_SelfPtr;

};

int main()
{
    {
        std::shared_ptr spa(new A());
        spa->func();
    }

    return 0;
}

乍一看上面的代码好像看不出什么问题,让我们来实际运行一下看看输出结果:

[root@myaliyun testmybook]# g++ -g -o test_std_enable_shared_from_this_problem test_std_enable_shared_from_this_problem.cpp
[root@myaliyun testmybook]# ./test_std_enable_shared_from_this_problem
A constructor

我们发现在程序的整个生命周期内,只有 A 类构造函数的调用输出,没有 A 类析构函数的调用输出,这意味着 new 出来的 A 对象产生了内存泄漏了!

我们来分析一下为什么 new 出来的 A 对象得不到释放。当程序执行到 42 行后,spa 出了其作用域准备析构,在析构时其发现仍然有另外的一个 std::shared_ptr 对象即 A::m_SelfPtr 引用了 A,因此 spa 只会将 A 的引用计数递减为 1,然后就销毁自身了。现在留下一个矛盾的处境:必须销毁 A 才能销毁其成员变量 m_SelfPtr,而销毁 m_SelfPtr 必须先销毁 A。这就是所谓的 std::enable_shared_from_this 的循环引用问题。我们在实际开发中应该避免做出这样的逻辑设计,这种情形下即使使用了智能指针也会造成内存泄漏。也就是说一个资源的生命周期可以交给一个智能指针对象,但是该智能指针的生命周期不可以再交给整个资源来管理。

std::weak_ptr

std::weak_ptr 是一个不控制资源生命周期的智能指针,是对对象的一种弱引用,只是提供了对其管理的资源的一个访问手段,引入它的目的为协助 std::shared_ptr 工作。

std::weak_ptr 可以从一个 std::shared_ptr 或另一个 std::weak_ptr 对象构造,std::shared_ptr 可以直接赋值给 std::weak_ptr ,也可以通过 std::weak_ptr 的 lock() 函数来获得 std::shared_ptr。它的构造和析构不会引起引用计数的增加或减少。std::weak_ptr 可用来解决 std::shared_ptr 相互引用时的死锁问题(即两个std::shared_ptr 相互引用,那么这两个指针的引用计数永远不可能下降为 0, 资源永远不会释放)。

示例代码如下:

#include 
#include 

int main()
{
    //创建一个std::shared_ptr对象
    std::shared_ptr sp1(new int(123));
    std::cout << "use count: " << sp1.use_count() << std::endl;

    //通过构造函数得到一个std::weak_ptr对象
    std::weak_ptr sp2(sp1);
    std::cout << "use count: " << sp1.use_count() << std::endl;

    //通过赋值运算符得到一个std::weak_ptr对象
    std::weak_ptr sp3 = sp1;
    std::cout << "use count: " << sp1.use_count() << std::endl;

    //通过一个std::weak_ptr对象得到另外一个std::weak_ptr对象
    std::weak_ptr sp4 = sp2;
    std::cout << "use count: " << sp1.use_count() << std::endl;

    return 0;
}

程序执行结果如下:

[root@myaliyun testmybook]# g++ -g -o test_weak_ptr test_weak_ptr.cpp 
[root@myaliyun testmybook]# ./test_weak_ptr
use count: 1
use count: 1
use count: 1
use count: 1

无论通过何种方式创建 std::weak_ptr 都不会增加资源的引用计数,因此每次输出引用计数的值都是 1。

既然,std::weak_ptr 不管理对象的生命周期,那么其引用的对象可能在某个时刻被销毁了,如何得知呢?std::weak_ptr 提供了一个 expired() 方法来做这一项检测,返回 true,说明其引用的资源已经不存在了;返回 false,说明该资源仍然存在,这个时候可以使用 std::weak_ptr 的 lock() 方法得到一个 std::shared_ptr 对象然后继续 *** 作资源,以下代码演示了该用法:

//tmpConn_ 是一个 std::weak_ptr 对象
//tmpConn_引用的TcpConnection已经销毁,直接返回
if (tmpConn_.expired())
    return;

std::shared_ptr conn = tmpConn_.lock();
if (conn)
{
    //对conn进行 *** 作,省略...
}

有读者可能对上述代码产生疑问,既然使用了 std::weak_ptr 的 expired() 方法判断了对象是否存在,为什么不直接使用 std::weak_ptr 对象对引用资源进行 *** 作呢?实际上这是行不通的,std::weak_ptr 类没有重写 operator-> 和 operator* 方法,因此不能像 std::shared_ptr 或 std::unique_ptr 一样直接 *** 作对象,同时 std::weak_ptr 类也没有重写 operator!  *** 作,因此也不能通过 std::weak_ptr 对象直接判断其引用的资源是否存在:

#include 

class A
{
public:
    void doSomething()
    {

    }
};

int main()
{    
    std::shared_ptr sp1(new A());

    std::weak_ptr sp2(sp1);

    //正确代码
    if (sp1)
    {
        //正确代码
        sp1->doSomething();
        (*sp1).doSomething();
    }

    //正确代码
    if (!sp1)
    {

    }

    //错误代码,无法编译通过
    //if (sp2)
    //{
    //    //错误代码,无法编译通过
    //    sp2->doSomething();
    //    (*sp2).doSomething();
    //}

    //错误代码,无法编译通过
    //if (!sp2)
    //{

    //}

    return 0;
}

之所以 std::weak_ptr 不增加引用资源的引用计数来管理资源的生命周期,是因为,即使它实现了以上说的几个方法,调用它们也是不安全的,因为在调用期间,引用的资源可能恰好被销毁了,这会造成棘手的错误和麻烦。

因此,std::weak_ptr 的正确使用场景是那些资源如果可能就使用,如果不可使用则不用的场景,它不参与资源的生命周期管理。例如,网络分层结构中,Session 对象(会话对象)利用 Connection 对象(连接对象)提供的服务工作,但是 Session 对象不管理 Connection 对象的生命周期,Session 管理 Connection 的生命周期是不合理的,因为网络底层出错会导致 Connection 对象被销毁,此时 Session 对象如果强行持有 Connection 对象与事实矛盾。

std::weak_ptr 的应用场景,经典的例子是订阅者模式或者观察者模式中。这里以订阅者为例来说明,消息发布器只有在某个订阅者存在的情况下才会向其发布消息,而不能管理订阅者的生命周期。

class Subscriber
{

};

class SubscribeManager
{
public:
    void publish()
    {
        for (const auto& iter : m_subscribers)
        {
            if (!iter.expired())
            {
                //TODO:给订阅者发送消息
            }
        }
    }

private:
    std::vector>   m_subscribers;
};
智能指针对象的大小

一个 std::unique_ptr 对象大小与裸指针大小相同(即 sizeof(std::unique_ptr) == sizeof(void*)),而 std::shared_ptr 的大小是 std::unique_ptr 的 2 倍。以下是我分别在 Visual Studio 2019 和 gcc/g++ 4.8 上(二者都编译成 x64 程序)的测试结果:

测试代码

#include 
#include 
#include 

int main()
{
    std::shared_ptr sp0;
    std::shared_ptr sp1;
    sp1.reset(new std::string());
    std::unique_ptr sp2;
    std::weak_ptr sp3;

    std::cout << "sp0 size: " << sizeof(sp0) << std::endl;
    std::cout << "sp1 size: " << sizeof(sp1) << std::endl;
    std::cout << "sp2 size: " << sizeof(sp2) << std::endl;
    std::cout << "sp3 size: " << sizeof(sp3) << std::endl;

    return 0;
}

Visual Studio 2019 运行结果:

 gcc/g++ 运行结果:

 在 32 位机器上,std_unique_ptr 占 4 字节,std::shared_ptr 和 std::weak_ptr 占 8 字节;在 64 位机器上,std_unique_ptr 占 8 字节,std::shared_ptr 和 std::weak_ptr 占 16 字节。也就是说,std_unique_ptr 的大小总是和原始指针大小一样,std::shared_ptr 和 std::weak_ptr 大小是原始指针的 2 倍。

智能指针使用注意事项

C++ 新标准提倡的理念之一是不应该再手动调用 delete 或者 free 函数去释放内存了,而应该把它们交给新标准提供的各种智能指针对象。C++ 新标准中的各种智能指针是如此的实用与强大,在现代 C++ 项目开发中,读者应该尽量去使用它们。智能指针虽然好用,但稍不注意,也可能存在许多难以发现的 bug,这里我根据经验总结了几条:

  • 一旦一个对象使用智能指针管理后,就不该再使用原始裸指针去 *** 作;

    看一段代码:

#include 

class Subscriber
{

};

int main()
{    
    Subscriber* pSubscriber = new Subscriber();

    std::unique_ptr spSubscriber(pSubscriber);

    delete pSubscriber;

    return 0;
}

这段代码利用创建了一个堆对象 Subscriber,然后利用智能指针 spSubscriber 去管理之,可是却私下利用原始指针销毁了该对象,这让智能指针对象 spSubscriber 情何以堪啊?

记住,一旦智能指针对象接管了你的资源,所有对资源的 *** 作都应该通过智能指针对象进行,不建议再通过原始指针进行 *** 作了。当然,除了 std::weak_ptr,std::unique_ptr 和 std::shared_ptr 都提供了获取原始指针的方法——get() 函数。

int main()
{    
    Subscriber* pSubscriber = new Subscriber();

    std::unique_ptr spSubscriber(pSubscriber);

    //pTheSameSubscriber和pSubscriber指向同一个对象
    Subscriber* pTheSameSubscriber= spSubscriber.get();

    return 0;
}
  • 分清楚场合应该使用哪种类型的智能指针;

    通常情况下,如果你的资源不需要在其他地方共享,那么应该优先使用 std::unique_ptr,反之使用 std::shared_ptr,当然这是在该智能指针需要管理资源的生命周期的情况下;如果不需要管理对象的生命周期,请使用 std::weak_ptr。

  • 认真考虑,避免 *** 作某个引用资源已经释放的智能指针;

    前面的例子,一定让你觉得非常容易知道一个智能指针的持有的资源是否还有效,但是还是建议在不同场景谨慎一点,有些场景是很容易造成误判。例如下面的代码:

#include 
#include 

class T
{
public:
    void doSomething()
    {
        std::cout << "T do something..." << m_i << std::endl;
    }

private:
    int     m_i;
};

int main()
{    
    std::shared_ptr sp1(new T());
    const auto& sp2 = sp1;

    sp1.reset();

    //由于sp2已经不再持有对象的引用,程序会在这里出现意外的行为
    sp2->doSomething();

    return 0;
}

上述代码中,sp2 是 sp1 的引用,sp1 被置空后,sp2 也一同为空。这时候调用 sp2->doSomething(),sp2->(即 operator->)在内部会调用 get() 方法获取原始指针对象,这时会得到一个空指针(地址为 0),继续调用 doSomething() 导致程序崩溃。

你一定仍然觉得这个例子也能很明显地看出问题,ok,让我们把这个例子放到实际开发中再来看一下:

//连接断开
void MonitorServer::onClose(const std::shared_ptr& conn)
{    
    std::lock_guard guard(m_sessionMutex);
    for (auto iter = m_sessions.begin(); iter != m_sessions.end(); ++iter)
    {
        //通过比对connection对象找到对应的session
        if ((*iter)->GetConnectionPtr() == conn)
        {
            m_sessions.erase(iter);
            //注意这里:程序在此处崩溃
            LOGI("monitor client disconnected: %s", conn->peerAddress().toIpPort().c_str());
            break;
        }
    }
}
  • 这段代码不是我杜撰的,而是来自于我实际的一个商业项目中。注意代码中我提醒注意的地方,该段程序会在代码 12 行处崩溃,崩溃原因是调用了 conn->peerAddress() 方法。为什么这个方法的调用可能会引起崩溃?现在可以一目了然地看出了吗?

    崩溃原因是传入的 conn 对象和上一个例子中的 sp2 一样都是另外一个 std::shared_ptr 的引用,当连接断开时,对应的 TcpConnection 对象可能早已被销毁,而 conn 引用就会变成空指针(严格来说是不再拥有一个 TcpConnection 对象),此时调用 TcpConnection 的 peerAddress() 方法就会产生和上一个示例一样的错误。

  • 作为类成员变量时,应该优先使用前置声明(forward declarations)

    我们知道,为了减小编译依赖加快编译速度和生成二进制文件的大小,C/C++ 项目中一般在 *.h 文件对于指针类型尽量使用前置声明,而不是直接包含对应类的头文件。例如:

//Test.h
//在这里使用A的前置声明,而不是直接包含A.h文件
class A;

class Test
{
public:
    Test();
    ~Test();

private:
    A*      m_pA;
};

同样的道理,在头文件中当使用智能指针对象作为类成员变量时,也应该优先使用前置声明去引用智能指针对象的包裹类,而不是直接包含包裹类的头文件。

//Test.h
#include 

//智能指针包裹类A,这里优先使用A的前置声明,而不是直接包含A.h
class A;

class Test
{
public:
    Test();
    ~Test();

private:  
    std::unique_ptr  m_spA;
};

C++ 新标准中的智能指针我想介绍的就这么多了,Modern C/C++ 已经变为 C/C++ 开发的趋势。

推荐学习 C++11 的材料

C++11/14/17 的语法虽然很实用,但是需要一定的练习才能掌握,推荐两个学习 C++11/14/17 的开源项目:

1. filezilla

filezilla 是一款开源的 FTP 软件,其源码下载地址如下:

svn - Revision 10476: /FileZilla3/trunk

需要使用 svn 工具来下载,安装好 svn 工具后,在 svn 界面中 checkout 上述地址或者使用如下命令下载:

svn co https://svn.filezilla-project.org/svn/FileZilla3/trunk filezilla

如果使用 svn 图形化工具,直接使用以下 svn 地址将源码 checkout 到指定目录即可:

https://svn.filezilla-project.org/svn/FileZilla3/trunk

2. uWebSocket 网络库

uWebSocket 是一款开源的 WebSocket 库,最新版使用了大量 C++17 的语法,美中不足的是这个库代码存在不少 bug,我在项目中使用了它,但修改了其大量的 bug,有兴趣的朋友也可以下载下来看一下:

下载地址:

GitHub - uNetworking/uWebSockets: Simple, secure & standards compliant web server for the most demanding of applications

3. TeamTalk 的 PC 端

TeamTalk 是蘑菇街开源的一款用于企业内部的即时通信工具,其下载地址是:

TeamTalk/win-client at master · balloonwj/TeamTalk · GitHub

欢迎分享,转载请注明来源:内存溢出

原文地址: http://outofmemory.cn/zaji/4752225.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-11-08
下一篇 2022-11-08

发表评论

登录后才能评论

评论列表(0条)

保存