Chapter 13 Utilities – A Tour of C++

13.2 Resource Management

任何大型程序的关键任务之一都是资源的管理。所谓资源就是那些能够被“获取”的东西,并且之后将会进行释放。对于一个长时间运行的程序来时,无法及时释放资源将会导致严重的性能下降,也可能会导致crash。

标准库中的组件就是设计用来防止资源的泄露。为了实现该特性,它们将会依靠基本的语法资源管理,例如构造函数与析构函数的配对,以此保证资源不会“活得过久”。

mutex m; // 用于防止对共享数据的读取
void f()
{
    sceoped_lock<mutex> lck {m}; // 获取mutex m
}

上述代码中,只有当lck的构造函数获取mutex之后,thread才能继续进行。相对应的析构函数将会释放资源。所以,scope_lock的析构函数将会在thread离开f()时释放mutex。

13.2.1 unique_ptr and shared_ptr

上述例子中,我们处理了作用域内的对象,也就是说,当它们从作用域退出时,我们对其进行释放。但是,如果对象分配在heap中呢?标准库提供了两种智能指针来帮助我们管理heap中的对象。

[1] unique_ptr用来表示特殊的所有权

[2] shared_ptr用来表示共享的所有权

这些智能指针最基本的用途就是防止内存的泄露。

void f(int i, int j)
{    
    X* p = new X; 
    unique_ptr<X> sp {new X}; // 分配一个新的X对象并给予unique_ptr

    if(i < 99)
        throw Z{};

    if(j < 77)
        return;

    delete p; // 销毁*p
}

很明显,我们在两个if语句中“忘记”将p进行删除。另一方面,unique_ptr保证了其对象将在我们退出f()时被销毁。同时,unique_ptr是一个非常轻量级的智能指针,其并不会产生额外的空间与额外的开销。我们还能使用它将heap上分配的对象传出或者传入函数。

// 构建一个X并将其赋予unique_ptr
unique_ptr<X> make_X(int i)
{
    // ...某些检测
    return unique_ptr<X>{new X{i}};
}

unique_ptr就是一个独立对象(或者一个数组)的handle,其原理类似于vector,只不过vector是一系列对象的handle。unique_ptr通过RAII(构造函数与析构函数)来控制对象的生命周期,其通过move使得return函数更为简单更为高效。

shared_ptr类似于unique_ptr,不过shared_ptr进行了拷贝(copy)而不是移动(move)。一个对象的多个shared_ptr将会分享该对象的所有权;也就是说只有当该对象的最后一个shared_ptr被销毁时该对象才会被销毁。

void f(shared_ptr<fstream>);
void g(shared_ptr<fstream>);

void user(const string& name, ios_base::openmode mode)
{
    shared_ptr<fstream> fp {new fstream(name, mode)};

    // 保证file已经被打开
    if(!*fp)
        throw No_file();

    f(fp);
    g(fp);

    //...
}

现在由fp的构造函数所打开的文件将在最后一次函数调用销毁fp的拷贝之后被关闭。需要注意的是,函数f()与g()可能会创建一个任务并保留fp的拷贝,其生命周期可能长于函数user()。因此,shared_ptr将会提供一种垃圾回收机制,其基于析构函数的资源管理机制。这将会产生额外的开销,但这一开销并不会非常大。不过shared_ptr将会使得我们难以预计被分享的对象的生命周期。只有当你真正需要分享一个对象的所有权时,才去使用shared_ptr。

在heap上创建一个对象,之后将指向该对象的指针传向智能指针,这一操作有些多余。此外,也会造成一些错误,例如,我们可能会忘记将指针传向unique_ptr或者将指向stack中的对象的指针传入shared_ptr。为了避免这些问题,标准库中提供了用于构造一个对象并且返回一个合适的指针指针的函数,make_shared()与make_unique()。

struct S
{
    int i;
    string s;
    double d;
};

auto p1 = make_shared<S>(1, "Ankh Morpork", 4.65);
auto p2 = make_unique<S>(2, "Oz", 7.62);

现在p2将是一个unique_ptr指向heap中,类型为S的对象,其初始化的值为{2, “Oz”s, 7.62}。

使用make_shared()之后,我们便不需要使用new来创建一个对象再将其传入shared_ptr。此外,make_shared()的效率也会更高。

有了unique_ptr与shared_ptr之后,在很多程序中我们可以完全避免“裸露”的new。然而,这些“智能指针”仍然是理论上的指针。尤其是shared_ptr自身并没有提供任何读取或者写入的规则。

13.2.2 move() and forward()

对于move操作与copy操作的选择几乎是隐示的。如果一个对象将要被销毁(例如return),那么编译器更倾向选择move,因为使用move将会更简单也更高效。但是,有时我们必须选择显示的方法。例如,一个unique_ptr是一个对象的唯一“所有者”。因此,unique_ptr不能被拷贝。

void f1()
{
    auto p = make_unique<int>(2);
    auto q = p; // error!!! 我们不能拷贝一个unique_ptr!
}

如果我们想要在其他地方使用unique_ptr,那么我们必须进行move。

void f1()
{
    auto p = make_unique<int>(2);
    auto q = move(p); // 现在p将会存有nullptr
}

我们需要注意,std::move()并不会move任何对象。相反,其只是将参数转换为一个右值引用。因此可以认为参数不会被再次使用,所以其被move。这一函数本应该被称为rvalue_cast。其类似于其他的case,也比较容易造成错误。

template<typename T>
void swap(T& a, T& b)
{
    T tmp {move(a)}; // T的构造函数发现了一个右值,因此使用move操作
    a = move(b);     // T的赋值运算发现了一个右值,因此使用move操作
    b = move(temp);  // T的赋值运算发现了一个右值,因此使用move操作
}

有时候使用std::move()也会比较危险。

string s1 = "Hello";
string s2 = "World";
vector<string> v;
v.push_back(s1); // 使用“const string&”作为参数,push_back()将会进行copy操作
v.push_back(move(s2)); // 使用move构造函数

上述代码中s1被copy,然而s2被move。有时候这回使用s2的push_back()更为高效。但问题是之前的对象被move了,所以当我们再次使用s2时可能会造成crash。

forward参数可以认为是另一个重要的move操作。有时,我们想要将一些列参数传入另一个函数,且不希望改变这些参数。

template<typename T, typename ... Args>
unique_ptr<T> make_unique(Args&&... args)
{
    // forward每一个参数
    return unique_ptr<T>{new T{std::forward<Args>(args)...}};
}

标准库中的forward()不同于std::move(),其能够正确处理左值与右值。只有在需要forwarding时才使用该函数,且我们不能forward()一个对象两次;一旦你forward了一个对象,那么该对象就不能被使用了。

13.4.1 array

array,其定义在中,其是一系列指定类型的元素的集合,其元素的个数在编译时被确定。因此,array与其元素能分配在stack中。我们可以将array认为是c++中内建的尺寸固定的数组,只不过array不能被隐示地转换为指针,此外array还提供了一些便利的函数。相较于使用内建的数组,array并不会产生额外的开销。此外array将会直接存储其元素,而不像STL中的其他container(遵从handle to element)。

array可以通过initializer list进行初始化。

array<int, 3> a1 = {1, 2, 3};

我们需要保证initializer list中元素的数量等于或者小于我们所声明的array的元素数量(需要注意,我们必须声明array的元素数量)。

array<int> ax = {1, 2, 3}; // error!!! 没有声明array的元素数量

array所声明的元素个数必须是constant expression(常量表达式)。

void f(int n)
{
    array<string, n> aa = {"John's", "Queens' "}; // error!!!常量表达式
}

在必要的时候,我们可以显示地将array传入一个C-style的函数,并将其作为一个指针。

void f(int* p, int sz); // C-style接口

void g()
{
    array<int, 10> a;

    f(a, a.size());        // error!!!不能进行隐示转换
    f(&a[0], a.size());    // C-style
    f(a.data(), a.size()); // C-style

    // C-style
    auto p = find(a.begin(), a.end(), 777); 
}

我们可能会疑问,为什么vector具有那么高的便利性,我们还去使用array呢?正是因为array的灵活性低,其才能更简便。有时,通过直接从stack上直接读取元素能为我们带来更高的性能,而不是通过vector(handle)从heap上分配的空间进行读取,再进行接触分配。不过,从另一方面来说,stack是一种有限的资源(尤其对于那些嵌入式设备),stack的溢出将会是另一个问题。

那么为什么我们需要选择array而不是内建的数组呢?因为array知道其自身的尺寸,所以其更匹配标准库中的算法,而且能够通过=进行拷贝。不过,array最大的优点还是其避免了隐示地转换为指针。

void h()
{
    Circle a1[10];
    array<Circle, 10> a2;

    Shape* p1 = a1; // 运行ok,但是有潜在问题
    Shape* p2 = a2; // error!!!不能进行隐示转换
    p1[3].draw();   // 不会crash但是会发生错误!!!
}

上述代码的最后一部分,由于size(Shape)≠size(Circle),因此在我们通过索引Circle[]去获取Shape*时,我们无法拿到想要的那个元素,这就会发生灾难性的后果。标准库中所提供的容器都避免了这一问题。

13.4.2 bitset

在第一章中,我们介绍了位运算。类bitset对该操作进行了范例化,其中N表示位数,且该变量需要是一个在编译时就能确定的值。对于那些并不适用long long int的位数,使用bitset将更加方便。对于较小的位数,bitset通常会进行优化。如果你想要对位数进行命名而不是计数,你可以使用set或者枚举。

bitset<9> bs1 {"110001111"};
bitset<9> bs2 {0b1'1000'1111};

bitset<9> bs3 = ~bs1;      // bs3 = 001110000
bitset<9> bs4 = bs1 & bs3; // 000000000
bitset<9> bs5 = bs1 << 2;  // 往左移动,000111100

13.5.1 variant

一般来说,variant是一种更安全并且更方便的替代union的方式。可能最为简单的例子就是返回一个值或者一个错误码。

variant<string,int> compose_message(istream& s)
{
    string mess;

    if(no_problem)
        return mess;
    else
        return error_number;
}

当我们对variant进行初始化或者赋值时,其将会记录值的类型。之后,我们可以查询variant所存储的类型并且得到该值。

auto m = compose_message(cin);

if(holds_alternative<string>(m))
{
    cout << m.get<string>();
}
else
{
    int err = m.get<int>();
}

13.6 Allocator

默认情况下,标准库的容器会使用new来分配空间。运算符new与delete将提供一个范例化的heap,其能够存储任何尺寸且由用户控制生命周期的对象。这意味着很多情况下,我们可以对时间与空间的开销进行优化。因此,标准库的容器让我们能够以我们的需求来配置allocator。

一个长时间运行的系统会使用一个event queue(事件列表),并将其传入shared_ptr。也就是说,一个事件的最后一个使用者将会隐示地进行删除。

struct Event
{
    vector<int> data = vector<int>(512);
};

list<shared_ptr<Event>> q;

void producer()
{
    for(int n = 0; n != LOTS; ++n)
    {
        lock_guard lk{m}; // m是mutex
        q.push_back(make_shared<Event>());
        cv.notify_one();
    }
}

从逻辑的角度来看,上述代码的运行没有问题。因为代码非常简短,因此其非常乐百氏并且易于维护。但是,上述代码会造成大量的碎片化。100,000个event在16个producer与4个consumer之间进行传输将会消耗6GB的内存。

常用的解决办法是使用pool allocator。其能够管理尺寸固定的对象并且在同一时间为多个对象分配空间而不是使用独立的分配器。幸运的是C++17直接提供了对这一特性的支持。pool allocator定义在pmr中。

pmr::synchronized_pool_resource pool; // pool

struct Event
{
    // 让Events使用pool
    vector<int> data = vector<int>{512, &pool}; 
};

// 让q使用pool
list<shared_ptr<Event>> q {&pool};

void producer()
{
    for(int n = 0; n != LOTS; ++n)
    {
        scope_lock lk{m};
        q.push_back(allocate_shared<Event, pmr::polymorphic_allocate>{&pool});
        cv.notify_one();
    }
}

13.8.3 function

标准库中的function类型能够用来表示任何通过运算符()调用的对象。也就是说,function类型的对象就是function object。

int f1(double);
function<int(double)> fct1{f1}; // 初始化为f1

int f2(string);
function fct2 {f2}; // fct2的类型为function<int(string)>

function fct3 = [](Shape* p) { p->draw(); }; // fct3的类型为function<void(Shape*)>

13.10 Advice

[03] 使用资源handle来管理资源(RAII)。

[04] 使用unique_ptr来引用具有多态类型的对象。

[05] 使用shared_ptr来引用共享的对象(only!)。

[06] 带有特定语义的资源handle优于智能指针。

[08] 使用make_unique与make_shared来构造这两个智能指针。

[13] 不要读取被std::move()或者std::forward()的对象。

[15] 使用array,如果一些列对象有着constexpr的尺寸。

[16] 使用array而不是内建的数组。

[21] 使用variant来显示地表达union。

 

留下评论

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据