C++中的五种高级初始化技术:从reserve到piecewise_construct等

一、简介

从动态容器操作到编译时常量,C++提供了多种技术。在这篇文章中,将深入研究高级初始化方法,如reserve()emplace_back,以及使用 piecewise_constructforward_as_tuple 构建元组。由于这些技术,我们可以减少临时对象的数量,并更有效地创建变量。

在讲解高级初始化技术之前,我们先看一个前置背景,使用下面的类来方便地说明何时调用它的特殊成员函数。这样,我们就能看到额外的临时对象。

struct MyType {
    MyType() { std::cout << "MyType default\n"; }
    explicit MyType(std::string str) : str_(std::move(str)) { 
        std::cout << std::format("MyType {}\n", str_); 
    }
    ~MyType() { 
        std::cout << std::format("~MyType {}\n", str_);  
    }
    MyType(const MyType& other) : str_(other.str_) { 
        std::cout << std::format("MyType copy {}\n", str_); 
    }
    MyType(MyType&& other) noexcept : str_(std::move(other.str_)) { 
        std::cout << std::format("MyType move {}\n", str_);  
    }
    MyType& operator=(const MyType& other) { 
        if (this != &other)
            str_ = other.str_;
        std::cout << std::format("MyType = {}\n", str_);  
        return *this;
    }
    MyType& operator=(MyType&& other) noexcept { 
        if (this != &other)
            str_ = std::move(other.str_);
        std::cout << std::format("MyType = move {}\n", str_);  
        return *this; 
    }
    std::string str_;
};

现在我们可以从相对简单但基本的元素开始。

二、reserve 结合 emplace_back

C++中的vector是一种可以根据需要增长的动态数组。但是,每次向量增长超过其当前容量时,它可能需要重新分配内存,这代价是昂贵的。为了优化这一点,可以使用reserve()方法结合emplace_back()

reserve方法不改变vector的大小,但确保vector有足够的已分配内存来存储指定数量的元素。通过提前预留空间,可以防止在向向量添加元素时进行多次重新分配。

示例:

#include <iostream>
#include <string>
#include <vector>
#include <format>

struct MyType {
    MyType() { std::cout << "MyType default\n"; }
    explicit MyType(std::string str) : str_(std::move(str)) { 
        std::cout << std::format("MyType {}\n", str_); 
    }
    ~MyType() { 
        std::cout << std::format("~MyType {}\n", str_);  
    }
    MyType(const MyType& other) : str_(other.str_) { 
        std::cout << std::format("MyType copy {}\n", str_); 
    }
    MyType(MyType&& other) noexcept : str_(std::move(other.str_)) { 
        std::cout << std::format("MyType move {}\n", str_);  
    }
    MyType& operator=(const MyType& other) { 
        if (this != &other)
            str_ = other.str_;
        std::cout << std::format("MyType = {}\n", str_);  
        return *this;
    }
    MyType& operator=(MyType&& other) noexcept { 
        if (this != &other)
            str_ = std::move(other.str_);
        std::cout << std::format("MyType = move {}\n", str_);  
        return *this; 
    }
    std::string str_;
};

int main() {    
    {
        std::cout << "--- push_back\n";
        std::vector<MyType> vec;
        vec.push_back(MyType("First"));
        std::cout << std::format("capacity: {}\n", vec.capacity());
        vec.push_back(MyType("Second"));
    }
    {
        std::cout << "--- emplace_back\n";
        std::vector<MyType> vec;
        vec.emplace_back("First");
        std::cout << std::format("capacity: {}\n", vec.capacity());
        vec.emplace_back("Second");
    }
    {
        std::cout << "--- reserve() + emplace_\n";
        std::vector<MyType> vec;
        vec.reserve(2);  // Reserve space for 2 elements
        vec.emplace_back("First");
        vec.emplace_back("Second");
    }
}

输出:

--- push_back
MyType First
MyType move First
~MyType 
capacity: 1
MyType Second
MyType move Second
MyType move First
~MyType 
~MyType 
~MyType First
~MyType Second
--- emplace_back
MyType First
capacity: 1
MyType Second
MyType move First
~MyType 
~MyType First
~MyType Second
--- reserve() + emplace_
MyType First
MyType Second
~MyType First
~MyType Second

在这个例子中,可以看到三种插入技术之间的比较:

  • push_back()
  • emplace_back()
  • reserve()结合emplace_back

第一种情况下,必须将临时对象传递给push_back,并移动它们来初始化vector的元素。但是还有一个重新分配,因为当添加第二个元素时,vector必须增长。

相对而言,emplace_back()技术更好,更容易编写,因为没有创建临时对象。

但是,第三种选择是最有效的,因为可以预先预留空间,然后在适当的地方创建元素。

通过使用reserveemplace_back,可以确保vector在添加元素到预留容量时不需要重新分配内存。这种组合是优化性能的一种强大方式,特别是在向vector`中添加多个元素时。

三、C++ 20的constinit

constinit是一个强大的工具,用于强制常量初始化,特别是对于静态或线程局部变量。这个关键字在C++ 20中引入,它解决了C++中一个长期存在的难题:静态初始化顺序的问题。通过确保变量在编译时初始化,constinit提供了一个更可预测和更安全的初始化过程。

在其核心,constinit保证它所限定的变量在编译时被初始化。这对全局变量或静态变量尤其有益,可以确保它们不受动态初始化顺序问题的影响。

示例:

#include <array>

// 编译期初始化
constexpr int compute(int v) { return v*v*v; }
constinit int global = compute(10);

// 这个将不再起作用:
// constinit int another = global;

int main() {
    // 但允许以后更改……
    global = 100;

    // global is not constant!
    // std::array<int, global> arr;
}

在上面的代码中,全局变量是在编译时使用compute函数初始化的。然而,与constconstexpr不同,constinit不会使变量不可变。也就是说,虽然它的初始值是在编译时设置的,但可以在运行时对其进行修改,如main函数所示。此外,由于constinit变量不是constexpr,因此不能使用它初始化另一个constinit对象(如其他的int)。

四、Lambda表达式和初始化

C++ 14对Lambda捕获进行了重大更新,引入了在Lambda捕获子句中直接初始化新数据成员的能力。这个特性称为带有初始化器的捕获或广义Lambda捕获,它在使用Lambda时为我们提供了更大的灵活性和精度。

传统上,Lambda表达式可以从其封闭范围捕获变量。在C++ 14中,现在可以直接在捕获子句中创建和初始化新的数据成员,使Lambdas更加通用。

示例:

#include <iostream>

int main() {
    int x = 30;
    int y = 12;
    const auto foo = [z = x + y]() { 
    	std::cout << z; 
    };
    x = 0;
    y = 0;
    foo();
}

输出:

42

在这里,创建了一个新的数据成员z,并用xy的和进行初始化。这个初始化发生在Lambda定义点,而不是调用点。因此,即使在Lambda定义之后修改了xy, z仍然保持其初始值。

为了更好地理解这个特性,来看看Lambda是如何转换为可调用类型的:

struct _unnamedLambda {
    void operator()() const {
        std::cout << z;
    }
    int z;
} someInstance;

Lambda本质上变成了一个带有operator()()方法和数据成员z的未命名结构的实例。

使用初始化器捕获不仅限于简单类型,还可以捕获引用。

这种技术在什么情况会很方便呢?至少有两种情况:

  • 捕获只能移动的类型,特别是通过值。
  • 优化。

考虑第一种情况,下面是捕获std::unique_ptr的方法:

#include <iostream>
#include <memory>

int main(){
    std::unique_ptr<int> p(new int{10});
    const auto bar = [ptr=std::move(p)] {
        std::cout << "pointer in lambda: " << ptr.get() << '\n';
    };
    std::cout << "pointer in main(): " << p.get() << '\n';
    bar();
}

在以前的C++ 11中,不能按值捕获惟一的指针,只可能通过引用捕获。现在,从C++ 14开始,可以将对象移动到闭包类型的成员中。

另一个情况是优化。比如,如果捕获一个变量,然后计算一些临时对象:

auto result = std::find_if(vs.begin(), vs.end(),
        [&prefix](const std::string& s) {
            return s == prefix + "bars"; 
        }
    );

为什么不计算一次并将其存储在lambda对象中呢?

result = std::find_if(vs.begin(), vs.end(), 
        [savedString = prefix + "bars"](const std::string& s) { 
            return s == savedString; 
        }
    );

这样,savedString只计算一次,而不是每次调用函数时都计算一次。

五、make_unique_for_overwrite

通过使用智能指针,获得了能够显著降低与动态内存分配相关的风险的工具。但是,和所有任何工具一样,总是有改进和优化的空间。

当使用make_unique(或make_shared)分配数组时,默认行为是对每个元素进行值初始化。这意味着对于内置类型,每个元素都设置为零,对于自定义类型,调用其默认构造函数。虽然这确保了内存被初始化为已知状态,但它带来了性能开销,特别是当想要立即覆盖已分配的内存时。

示例:

auto ptr = std::make_unique<int[]>(1000); 

这一行不仅为1000个整数分配内存,而且还将每个整数初始化为零。如果下一步是用来自文件或网络操作的数据填充该内存,那么初始归零是不必要的,也是浪费的。

为了解决这种低效率问题,C++ 20引入了make_unique_for_overwritemake_shared_for_overwrite。这些函数分配内存时不需要对其进行值初始化,这使得它们在直接打算覆盖内存时更快。

auto ptr = std::make_unique_for_overwrite<int[]>(1000);

当分配的内存立即被其他数据覆盖时,*_for_overwrite函数是最有用的。但是要注意,如果内存没有被覆盖,它包含不确定的值,这时如果访问的话可能导致未定义的行为。

这些新功能可以显著提高执行大量内存操作的应用程序的性能,例如数据处理工具或游戏引擎。

六、piecewise_construct 和 forward_as_tuple

最后,让我们看看第五种技术:使用多参数构造函数直接初始化对或元组。这就是std::piecewise_constructstd::forward_as_tuple发挥作用的地方。

示例:

std::pair<MyType, MyType> p { "one", "two" };

上面的代码创建了没有额外临时MyType对象的pair

但是,如果有一个额外的构造函数接受两个参数,那该怎么办呢?

MyType(std::string str, int a)

在这种情况下,如果这样:

std::pair<MyType, MyType> p { "one", 1, "two", 2 };

毫无疑问,它失败了,因为该调用对编译器是二义性的。

在这些情况下,std::piecewise_construct可以提供帮助。它是一个指示std::pair执行分段构造的标记。当与std::forward_as_tuple(创建左值或右值引用的元组)结合使用时,可以将多个参数转发给pair元素的构造函数。

#include <iostream>
#include <string>
#include <format>

struct MyType {
    MyType() { std::cout << "MyType default\n"; }
    explicit MyType(std::string str) : str_(std::move(str)) { 
        std::cout << std::format("MyType {}\n", str_); 
    }
    MyType(std::string str, int a) : str_(std::move(str)) { 
        std::cout << std::format("MyType {}, {}\n", str_, a); 
    }
    ~MyType() { 
        std::cout << std::format("~MyType {}\n", str_);  
    }
    MyType(const MyType& other) : str_(other.str_) { 
        std::cout << std::format("MyType copy {}\n", str_); 
    }
    MyType(MyType&& other) noexcept : str_(std::move(other.str_)) { 
        std::cout << std::format("MyType move {}\n", str_);  
    }
    MyType& operator=(const MyType& other) { 
        if (this != &other)
            str_ = other.str_;
        std::cout << std::format("MyType = {}\n", str_);  
        return *this;
    }
    MyType& operator=(MyType&& other) noexcept { 
        if (this != &other)
            str_ = std::move(other.str_);
        std::cout << std::format("MyType = move {}\n", str_);  
        return *this; 
    }
    std::string str_;
};

int main() {
    {
        std::cout << "regular: \n";
        std::pair<MyType, MyType> p { MyType{"one", 1}, MyType{"two", 2}};
    }
    {
        std::cout << "piecewise + forward: \n";
        std::pair<MyType, MyType>p2(std::piecewise_construct,
                   std::forward_as_tuple("one", 1),
                   std::forward_as_tuple("two", 2));
    }
}

运行这个程序会看到以下输出:

regular: 
MyType one, 1
MyType two, 2
MyType move one
MyType move two
~MyType 
~MyType 
~MyType two
~MyType one
piecewise + forward: 
MyType one, 1
MyType two, 2
~MyType two
~MyType one

可以看到,常规方法创建了两个临时对象。而 使用分段选项可以直接将参数传递给pair的元素。

std::piecewise_construct对于像std::mapstd::unordered_map这样存储键值对(std::pair)的容器特别有用。当想要向这些容器中插入元素,并且键或值(或两者)具有多参数构造函数或不可复制时,std::piecewise_construct的实用程序变得很方便。

示例:

#include <string>
#include <map>

struct Key {
    Key(int a, int b) : sum(a + b) {}
    int sum;
    bool operator<(const Key& other) const { 
        return sum < other.sum; 
    }
};

struct Value {
    Value(const std::string& s, double d) : name(s), data(d) {}
    std::string name;
    double data;
};

int main() {
    std::map<Key, Value> myMap;

    // doesn't compile: ambiguous
    // myMap.emplace(3, 4, "example", 42.0);

    // works:
    myMap.emplace(
        std::piecewise_construct,
        std::forward_as_tuple(3, 4),  
        std::forward_as_tuple("example", 42.0) 
    );
}


七、总结

本文探讨了初始化C++代码的各种技术。深入研究了现代C++特性的复杂性,包括reserveemplace_back的效率、constinit的准确性和lambda初始化的灵活性。此外,还研究了piecewise forward_as_tuple的细微差别。这些高级技术展示了c++语言的发展和强大,并为开发人员提供了编写更具表现力、更高效和更通用的代码的能力。

有些人可能会认为这是语言中不必要的复杂,这不是一定的。考虑emplace()函数,它可以改进容器插入。但是,如果不需要优化,可以使用更简单的代码来传递临时对象。

所提供的高级技术列表可能并不详尽。博主也非常好奇是否还有其他更有效但更有挑战性的初始化对象的有用技术。可以在评论区分享你的想法。
在这里插入图片描述

  • 18
    点赞
  • 31
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Lion Long

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值