为何你还在坚持用数组?容器不比它香几条街?「下」

以下内容为本人的烂笔头,如需要转载,请声明原文链接 微信公众号「ENG八戒」https://mp.weixin.qq.com/s/fJF4hCyL0zit3lx0BTcA8Q

数组的接棒者–容器

数组本来属于低级编程语言的一大亮点,也是最简单的数据结构之一,但是,随着软件行业的深入发展,数组的缺点越发突出,正在阻碍 C++ 前行。

前面列举了数组的诸多应用不便,为了改进内存空间的利用效率,C++ 标准库提供了多种多样的容器,完全可以替代数组的使用需求。

标准库提供的各种容器中,std::vector 能够满足 90% 的使用场景,所以如果你不清楚需要使用那种容器,一般推荐直接使用 std::vector。但如果你非常清楚你的业务场景使用需求,为了最大限度发挥容器的运行效率,需要因地制宜,选择合适的容器。

容器无论是在 STL 或者在 Qt 中都有很好的实现,笔者对两者的使用体验是 Qt 版本接口更人性化。如果你的平台是 Qt,应该会有种感悟是相见恨晚吧?这是题外话。

容器基于模板类实现,能够内置非常多的信息,包括已分配的存储空间大小等,部分容器甚至能够自动扩展和分配空间以满足存储空间灵活的使用需求。容器作为类,必然能够提供类似比较、赋值等各种操作符重载。

容器的大小

使用容器时,存储空间长度不再需要手动计算,也就没有访问越界引发的问题了。

以前面的创建九宫格的数字面板代码为例,我们使用 std::vector 或者 std::array 替代数组,它们提供的存储空间和数组一样都是连续的,先来看看 std::array

#include <array>
void create_widgets() {
    std::array<Widget*, NUM> widgets;
    for (auto &w : widgets)
        w = new Widget;
}

再来看看 std::vector

#include <vector>
#define NUM 9
void create_widgets() {
    std::vector<Widget *> widgets(NUM);
    for (auto i = widgets.begin();
        i != widgets.end(); ++ i) {
        *i = new Widget();
    }
}

可见容器也支持迭代访问。

使用 std::vector 除了可以指定初始长度,如果使用过程中插入数据导致总长度超过初始长度,还可以自动扩展空间满足即时需求。所以,使用 std::vector 时,无须一开始就指定过大的长度,甚至无须指定任意长度,那么,原来使用数组导致会占用过多空间的问题就这样被解决了。

看到这里你可能会有疑问,这种随意扩展空间的能力会不会导致容器分配的空间不再是连续的?

不会,其实 std::vector 在初始化实例对象时,系统会预分配一块连续的空间用于接下来的插入动作使用,每个插入所需要的空间都会从预分配的空间中划分,直到新插入的内容超出了预分配空间的剩余大小,这时系统会重新分配一块更大的连续空间并把容器原有的数据拷贝过去,最后释放旧空间,新内容可以继续执行插入。

看完上面的描述,好奇的读者朋友可能还会有个疑问,如果频繁地插入过多的内容,然后可能导致系统在后台频繁地重新分配空间并且拷贝内容,这样是不是非常地低效?

std::vector 支持动态拓展,灵活性更高,但是也确实会存在资源浪费的行为。如果你需要的空间是固定的,那么应该选择 std::array,这样更为高效。针对 std::vector 的这种情况,有个优化的动作可以做,在合适的时候主动调用 std::vector::reserve() 给容器重新预分配合适的空间,避免系统频繁分配。而选择在什么时候分配多少,需要看业务场景而定。

// 提前在合适的时候
std::vector<int> myVector;
// 为100个元素预留空间
myVector.reserve(100);

// ...

// 添加元素而不触发重新分配
for (int i = 0; i < 100; ++i) {
    myVector.push_back(i);
}

关于容器优化的问题,可以查阅一下笔者之前的文章《STL 提供的容器可以有多快?》,分为上下两篇,本文末尾有跳转阅读链接。

容器提供比较、赋值等操作符

为了解决数组的指针退化问题,容器基于类实现,所以对存储空间内容的整体比较、赋值等操作都可以利用操作符重载实现,下面一起看看标准库容器提供了哪些操作符?还是以 std::vector 为例。

std::vector 提供 ==!= 用于比较容器内容的相等性,只有当两个向量的大小相同且对应位置的元素都相等时,才会返回这两个向量相等。

std::vector 提供 <><=>= 等关系比较操作符,这些比较操作符基于字典顺序比较两个向量。对于非空向量,比较从第一个元素开始,直到找到不相等的一对元素为止。如果找到一对元素使得操作符左侧向量的元素小于右侧向量对应的元素,则认为左边的向量小于右边的向量;反之则左边的向量大于右边的向量。如果一个向量为空而另一个不是,则空向量被认为较小。

std::vector 提供了两种赋值操作符,分为拷贝赋值和移动赋值。赋值操作符可能会导致操作符左侧的 vector 内容发生改变,但不会自动调用 reserve 来预先分配足够的内存以容纳赋值来源的元素数量,除非必要时才会重新分配内存。如果需要保留现有容量以避免不必要的频繁重新分配,应该提前调用 reserve 函数。

拷贝赋值操作符用于将一个 vector 的所有元素复制到另一个已存在的 vector 中。这将调用元素类型的复制构造函数或赋值运算符,如果有必要,还会分配新的内存。拷贝赋值完成后,源和目标 vector 的状态相互隔离。

移动赋值操作符在 C++11 及以后版本才引入,用于右值引用的高效转移。当一个 vector 对象被移动赋值给另一个 vector 时,它会尽可能地“偷取”源向量的资源(内存和元素),而不是复制它们。这样可以避免不必要的元素复制和内存分配,从而提高效率。

关于移动的详细讲解,可以查阅一下笔者之前的文章《现代 C++ 的巨大性能飞跃之:移动语义》,本文末尾有跳转阅读链接。

举个例子演示 std::vector 的操作符使用,先定义一个元素类型,并定义比较操作符:

struct ElementType {
    int value;
    ElementType(int val) : value(val) {}
    bool operator==(const ElementType& other) const {
        return value == other.value;
    }
    bool operator<(const ElementType& other) const {
        return value < other.value;
    }
};

std::vector 中的比较操作符和赋值操作符是对元素类型的泛型处理,不限定某个元素类型,所以元素类型需要支持相应的比较和赋值操作。

为了支持 std::vector 使用相等操作符时,元素类型需要重载实现 == 操作符。重载 == 操作符后可自动推导出来 != 操作符,但为了效率和逻辑透明,有时也会单独重载实现。

为了支持 std::vector 使用比较操作符时,元素类型最少需要重载实现 < 操作符,以便容器对元素进行字典顺序排序。若元素没有这样的操作符,则比较向量会在编译期报错。

根据 C++ 的运算符重载规则,一旦重载了 < 运算符,其他的大小关系运算符可以通过 < 推导出来,但并不总是最优实现,有时候直接自定义重载会更高效。

类型的赋值操作符具有默认实现,如果默认实现不满足实际数据使用需求,则需要自定义实现。上面的演示代码中,赋值操作符采用默认实现即可。

然后看看 std::vector 如何使用比较和赋值操作

int main() {
    // 创建两个包含 ElementType 对象的 vector
    std::vector<ElementType> vec1 = {
        ElementType(1),
        ElementType(3),
        ElementType(5)};
    std::vector<ElementType> vec2 = {
        ElementType(1),
        ElementType(2),
        ElementType(3)};

    // 使用赋值操作符
    vec1 = vec2;

    // 使用比较操作符
    if (vec1 == vec2) {
        std::cout << "Vectors are equal.\n";
    } else {
        std::cout << "Vectors are not equal.\n";
    }

    // 修改元素
    vec1[1].value = 5;
    vec2[1].value = 4;

    if (vec1 < vec2) {
        std::cout << "vec1 is lexicographically less than vec2.\n";
    } else if (vec1 > vec2) {
        std::cout << "vec1 is lexicographically greater than vec2.\n";
    } else {
        std::cout << "Vectors have the same lexicographical order "
                    "or are of different sizes.\n";
    }

    return 0;
}

运行输出

Vectors are equal.
vec1 is lexicographically greater than vec2.

容器兼容裸指针

很多朋友看完上面的内容,心里还是有疑虑:保留使用数组也是万不得已啊?不是还有很多 C 库的 API 需要兼容嘛?比如 strncpy()?

其实容器对于兼容数组的老式接口也提供了丰富的方法,比如 std::vector::data() 返回向量内部存储区第一个元素的地址,std::vector::size() 返回向量内部有效元素的个数。

#include <vector>
#include <string.h>
#include <iostream>

int main()
{
    const char str[] = "Hello container!\n";
    std::vector<char> ver(sizeof(str));
    strncpy(ver.data(), str, strlen(str));
    printf("%s", ver.data());
    return 0;
}

运行输出

Hello container!

看到这里,读者朋友是否还认为在 C++ 里使用数组是一种明智的做法?长江后浪推前浪,历史的车轮在滚滚前行,到了现代 C++ 应该勇于抛弃包袱。

如果你还有任何疑问,欢迎联系我交流讨论!全文写到这里就结束了。另外,八戒有自己的技术圈交流群,如果读者朋友有兴趣入群交流技术问题,欢迎联系我。下拉到文章底部有我的联系方式!

最后,非常感激各位朋友的点 「赞」 和点击 「在看」,谢谢!

  • 20
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值