数组是编程里最基础也最容易“以为自己懂了”的东西。很多人能背出“数组是一段连续内存,存放相同类型元素”,可真到了写代码的时候,照样踩进越界、空洞、类型混用的坑。这篇博文不打算从零科普“什么是数组”,而是把数组的类型纬度和概念边界彻底拆开揉碎,聊清楚到底是什么、怎么用、不同语言又各自有什么脾气。适合已经写过一段时间代码、想真正把基础打扎实的人。
1. 数组的类型维度:从“一维列表”到“同质容器”的认知升级
1.1 先理清数组的本质:容器、同质性与顺序
数组的第一层含义,是容器——它像一排带编号的储物柜,每个柜子都能放东西。但和购物袋(列表)不同的是,数组里的所有柜子容量通常一样大,而且必须放同一种类的东西,这就是同质性(Homogeneity)。很多人忽略了这个“同质性”不是物理限制,而是类型系统层面的约束:编译器需要靠它来推断每个元素占多少字节、该用哪条指令去读取和写入。
以C语言为例:
int a[5] = {1, 2, 3, 4, 5};这行代码背后,编译器知道每个int占4字节,所以数组a总共占20字节,a[3]的地址就是a + 3 * 4。这是数组一切性能优势的来源:随机访问的时间复杂度是O(1),因为地址可以直接算出来,不需要像链表那样从头遍历。理解了这个,你就明白了为什么数组适合存储紧密排列的数值、像素、音频采样点——它们是位置即语义的数据。
1.2 数组的“类型”到底指什么:元素类型、维度、长度还是容器类型?
标题里“数组的类型与概念2”里的“类型”其实包含四层含义,混为一谈就会产生认知混乱:
| 类型维度 | 典型问题 | 说明 |
|---|---|---|
| 元素类型 | int[]和string[]区别 | 数据本身是什么类型,决定了存储宽度和可执行的操作 |
| 维度结构 | 一维、二维、多维 | 描述的是下标数量,不是数据本身的嵌套关系 |
| 长度属性 | 定长还是变长 | 编译期可知还是运行期动态调整 |
| 容器类型 | 原生数组还是数组对象 | 语言层面的抽象差异,比如C数组与Java数组完全不同 |
我曾经遇到过一位初学者纠结“为什么Java里数组是对象,有.length属性,而C语言里数组却只是个裸地址”。问题的根源就在这个表里——Java数组是对象容器,自带类型信息和长度;C数组则是裸内存区块,只负责占地方,所有边界和长度都要程序员自己管。这不仅是语法差异,更是设计理念差异。
1.3 为什么“数组是引用类型”这句话经常引发争论
在C#和Java里,数组是引用类型。但在C++里,数组既可以是栈上的对象,也可以是指向堆内存的指针。在许多初学者脑中,“引用类型”意味着“赋给另一个变量时不会拷贝数据,只会拷贝地址”。这个说法用起来没错,但它容易让人误以为数组本身是“指针”。
实际上,在Java中:
int[] a = {1, 2, 3}; int[] b = a; b[0] = 99; // a[0]也是99这里a和b确实是两个指向同一堆数组对象的引用。但在C++中:
int a[3] = {1, 2, 3}; auto b = a; // b是指针,不是数组!b的类型是int*,不是int[3]。这种“数组退化为指针”(array decay)的规则让很多人写出隐藏bug。理解这个,你就知道为什么C++里传数组给函数时要同时传长度,因为一旦退化为指针,长度信息就丢了。
2. 按存储结构划分:静态数组、动态数组与多维数组的底层差异
2.1 静态数组:编译期的“铁饭碗”
静态数组的长度在编译时已经确定,存储在栈上(局部变量)或数据段(全局变量)。它的特点就是分配和释放零开销,但长度写死,无法伸缩。比如在嵌入式领域,静态数组是主流,因为内存布局必须完全可控。
#define SENSOR_COUNT 8 float readings[SENSOR_COUNT];这段代码在进入函数时直接移动栈指针,8个float占32字节,函数结束自动回收。看起来简单,但有一个隐蔽问题:如果未来某个传感器升级,需要返回9个值,就得改宏定义并重新编译整个工程。静态数组的“确定性”带来了“僵化性”,这就是为什么静态数组适合数据规模天然固定的场景,比如矩阵卷积核、固定采样率下的缓冲区。
2.2 动态数组:灵活性与性能的微妙平衡
动态数组(比如C++的std::vector、Java的ArrayList、Python的list)本质上不是在语言层面做出来的魔法,而是在堆上手动维护一个“自动扩容的静态数组”。它的增长策略非常值得研究:当容量不够时,常见做法是扩大到原来的1.5倍或2倍,然后整体拷贝旧数据。
为什么要“倍增”而不是“每次加一”?假设每次扩容只多增加1个元素的空间,往数组里插入n个元素就要拷贝O(n^2)次数据;而倍增策略下,总拷贝次数是1 + 2 + 4 + ... + 2^k = 2^(k+1)-1,均摊到每次插入,时间复杂度是O(1)。这就是“均摊复杂度”一词的典型来源。
在实际项目中,我见过很多人盲目追求动态数组的便利,结果在性能敏感场景里频繁扩容导致卡顿。一个有效的规避技巧是预估容量并提前reserve(预留空间):
std::vector<int> v; v.reserve(10000); // 提前分配足够空间,避免多次扩容 for (int i = 0; i < 10000; ++i) v.push_back(i);如果你用Python的list做同样的追加操作,它会自动处理扩容,但如果你能预先估算长度,可以用[None] * 10000再赋值,而不是一次次append,性能差异在高频循环中极为明显。
2.3 多维数组:本质仍是“一维内存”
二维数组在逻辑上是一张表格,在内存里却是连续排列的。C语言的行优先(row-major)顺序意味着arr[1][0]紧跟在arr[0][3]后面;而Fortran和MATLAB默认按列优先(column-major)存储。这个差异不仅影响性能,还会影响你在两种语言间迁移代码时的心智模型。
int matrix[3][4] = {{1,2,3,4}, {5,6,7,8}, {9,10,11,12}}; // 内存布局: 1 2 3 4 5 6 7 8 9 10 11 12实际代码里,性能问题经常藏在多维数组的遍历顺序里:
// 高效:按行遍历 for (int i = 0; i < 3; i++) for (int j = 0; j < 4; j++) sum += matrix[i][j]; // 低效:按列遍历,缓存命中率大打折扣 for (int j = 0; j < 4; j++) for (int i = 0; i < 3; i++) sum += matrix[i][j];这不是玄学,而是CPU缓存的局部性原理发挥作用。按物理连续的顺序访问内存,缓存行预取能命中;反过来跳着访问,缓存不断失效,性能可能差好几倍。在做图像处理或矩阵运算时,这个顺序选择甚至比算法本身的优化空间还大。
2.4 交错数组(Jagged Array):多维数组的“省内存”替代方案
在C#里,交错数组(数组的数组)和真正的二维数组经常让人混淆。int[][]是“外层数组存内层数组引用,内层数组长度可以各不相同”;int[,]则是必须整整齐齐的矩形。
// 交错数组:每一行长度不同,内存更节省 int[][] jagged = new int[3][]; jagged[0] = new int[] {1, 2}; jagged[1] = new int[] {3, 4, 5, 6}; jagged[2] = new int[] {7}; // 矩形数组:必须规整 int[,] rect = new int[3, 4];交错数组适合存储“每行数据量不同”的结构,比如按用户分组的数据,避免预留大量用不到的空白空间。但副作用是,内存不连续,遍历时缓存性能差,而且访问语法从[i,j]变成了[i][j],写起来略繁琐。
3. 按元素类型划分:从基本类型到对象数组的陷阱
3.1 值类型数组与引用类型数组的存储差异
值类型(如C#的int、struct)数组在内存里存的就是数据本身;引用类型(如C#的string、class)数组存的是引用(地址),真正的对象散落在堆上。这意味着一个Person[100]的数组,只包含100个引用(每个8字节,总共800字节),而不是100个Person对象的总大小。
Person[] people = new Person[100]; // 这里还没有任何Person对象! people[0] = new Person("Alice"); // 手动创建对象并放入这个“只创建数组,不创建元素”的行为经常让人误以为new Person[100]就是创建了100个人。实际上,如果漏掉初始化,取到的就是null,调用方法直接抛NullReferenceException。这是我在代码评审里看到过最高频的低级错误。
3.2 对象数组里的“协变(Covariance)”陷阱
在Java和C#中,数组是支持协变的(covariant),含义是:如果Dog是Animal的子类,那么Dog[]可以赋值给Animal[]。这看起来方便,但在运行期会埋雷:
Animal[] animals = new Dog[10]; animals[0] = new Cat(); // 编译通过,运行期抛ArrayStoreException为什么语言设计者要允许这种不安全操作?因为Java早期为了支持泛型场景(比如一个方法接收Object[]来打印任意对象数组),不得不做出这种妥协。到了真正泛型引入(如ArrayList<T>)之后,数组协变反而成了历史包袱。你不需要背这个结论,但你需要知道:写库函数时,优先用List<T>而不是数组作为对外接口,避免调用方踩协变坑。
3.3 字符数组与字符串:不可变性带来的性能与习惯冲突
字符数组(char[])是刚学习编程时人人用过的“临时字符串”。但很多现代语言(如Java、C#)的String是不可变的,每次“修改”都产生新对象。于是有人为了性能转回去用char[]做拼接。
但实际上,现代编译器已经对字符串拼接做了大量优化(Java的+拼接在编译期会转化为StringBuilder,C#也有类似机制)。在大多数业务场景里,直接用StringBuilder或模板字符串,可读性高得多。只有在性能瓶颈确实定位到字符串操作时,才值得考虑char[]这种“裸金属”方案。
我在一个日志解析工具里做过一个对比实验:用普通字符串拼接处理10000行日志,耗时约120毫秒;改成StringBuilder后降到40毫秒;用预分配的char[]手动填充,勉强再降到35毫秒。为了这5毫秒,代码复杂度翻了好几倍,完全没必要。合理使用高层抽象,往往比强行优化更重要。
3.4 泛型数组:为什么不能直接创建
在Java里,new T[10]是无法通过编译的,因为泛型是通过类型擦除实现的,运行期不知道T的真实类型,无法确定数组元素类型。常见的绕路做法是:
// 通过反射创建泛型数组 T[] arr = (T[]) Array.newInstance(clazz, size);但这种代码会触发“unchecked”警告,本质上是在和类型系统博弈。更推荐的做法是用List<T>代替泛型数组,因为ArrayList<T>不需要运行期的的具体类型信息,编译器在取元素时会自动插入强制转换。
我自己写过不少泛型工具类,最终都回归到“内部用ArrayList,对外暴露T[](通过toArray转换)”的模式。这大概是最接近“既想要泛型类型安全,又想要数组操作性能”的折中方案了。
4. 数组与其他容器类型的概念边界
4.1 数组 vs 切片(Slice):谁的“视图”更灵活
Go语言里,数组和切片是两个完全不同的类型。数组是值类型,长度固定,作为函数参数传递时会整体拷贝,开销很大;切片是引用类型,内部是一个{pointer, len, cap}三元组,共享底层数组。
arr := [4]int{1, 2, 3, 4} s := arr[1:3] // 切片,指向arr的底层数组 s[0] = 99 fmt.Println(arr[1]) // 99,切片修改影响了原数组这个共享底层数组的机制可以让切片操作极快,但也带来了著名的“切片截断陷阱”:对切片继续append时,如果容量够用,会直接覆盖原数组的后续位置;如果容量不够,则可能分配一块新数组,原数组不受影响。这种“有时改原数组,有时不改”的行为,让不少人在调试时抓狂。
我的建议是:如果切片只用于读取,共享底层没问题;如果要修改或扩展,最好先copy一份,避免“写坏别人的数据”。
4.2 数组 vs 列表(List):什么时候不该用数组
数组的随机访问性能极佳,但插入和删除却不是它的强项——因为需要搬移后边的所有元素。比如在长度为n的数组头部插入一个元素,最坏要移动n个元素,时间复杂度O(n);而链表在头部插入只需O(1)。
所以在以下场景不要硬用数组:
- 数据频繁在头部或中间增删,且数据量不可预测
- 数据量极大但只做顺序读取
- 需要根据某个字段频繁查找(这时该考虑哈希表或平衡树,而非数组或链表)
选用容器不是“越底层越好”,而是根据读写模式选择最合适的数据结构。数组适合“固定长度、频繁随机访问、极少插入删除”的场景。如果用数组硬扛频繁插入,你会写出一个性能像蜗牛的“伪链表”。
4.3 数组 vs 集合(Set):去重场景下的误区
很多人一拿到去重需求,就习惯性用数组硬查:
// 错误示范:O(n^2)的去重 std::vector<int> result; for (int x : input) { bool found = false; for (int y : result) if (x == y) { found = true; break; } if (!found) result.push_back(x); }数据量小时没问题,一旦到几万级别,双循环就跑不动了。正确的思路是用哈希集合,碰撞概率控制好后,单次查找是O(1),整个去重降到O(n)。这本质上是容器类型选择的认知问题:数组不提供“快速查找”的语义,非要让它做查找的活,就是把螺丝刀当锤子用。
5. 数组类型在编程语言中的演进与实战选型
5.1 C/C++ 的裸数组、std::array与std::vector
C语言数组是“裸的”,C++为了保持兼容性继承了这种裸性,但标准库提供了容器替代品:
std::array<T,N>:固定长度、栈上存储、有.size()方法,不会退化为指针,无额外开销std::vector<T>:动态长度、堆上存储、自动扩容,绝大多数动态数组场景的首选
我自己写代码的默认准则是:能用std::array就不用C风格数组,能用std::vector就不用std::array。原因是“裸数组”在函数传参时发生指针退化,极容易丢失长度信息,而容器自己带着长度,越界也能用.at()检查,调试友好度提升几个等级。当然,C++里std::vector也有它的坑——扩容时迭代器失效,导致“黄了半边天”的野指针问题。所以涉及迭代器保持的场景,优先写好reserve(),把“扩容导致失效”的概率压到最低。
5.2 Java 的数组与集合框架:搭档而非对手
Java数组语法直接、性能好,但功能薄弱。集合框架(ArrayList、LinkedList等)为其补充了动态增长、装箱、修饰等方法。两者的关系从“竞争”变成“协作”:
List<String> list = new ArrayList<>(Arrays.asList("a", "b", "c")); String[] arr = list.toArray(new String[0]);这段代码里,new String[0]看起来是“长度为0的数组”,实际起到的是“类型标记”作用:toArray会根据它去分配一个正确类型的数组来接收结果。这在Java里是一个约定俗成的巧技,不少人第一次看到会疑惑“为什么要传一个空数组”,其实就是要个类型信息。
5.3 Python 的列表、数组模块与NumPy
Python的list是一个“存储任意对象的动态数组”,本质上和Java的ArrayList是一类东西。但如果我们用纯Python的list做数值运算,性能慢得让人怀疑人生。于是有了array模块和NumPy:
# array模块:更像C数组,元素类型统一 from array import array a = array('i', [1, 2, 3]) # 'i'代表有符号int # NumPy:真正的高性能数值数组 import numpy as np b = np.array([1, 2, 3], dtype=np.int32)NumPy的多维数组(ndarray)不是简单数据结构,它采用数据缓冲区+形状元组+步幅元组描述,这种设计意味着b[0]和b.T[0]的地址计算方式都不同,但也因此能实现向量化运算和广播。对于处理图像、音频、科学计算的人来说,NumPy array才是日常的“数组”,Python内置的list更像是“装了引用的大袋子”。
5.4 数组类型选型速查表
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 函数内固定长度局部数据 | C++std::array/ Java 原生数组 | 栈分配、零扩容、访问快 |
| 未知长度的数据收集 | std::vector/ArrayList/ Pythonlist | 自动扩容,API丰富 |
| 数值密集运算 | NumPyndarray | 向量化、缓存友好、科学计算标准 |
| 多维矩阵、图像像素 | 多维数组(行优先遍历) | 连续内存、索引可预测 |
| 需要传递子区域 | Go切片 / C#数组切片 / Python切片 | 共享底层、避免数据拷贝 |
| 频繁头尾插入删除 | 链表 / 双端队列 | 避免O(n)搬移 |
| 去重、成员判断 | 哈希集合 | O(1)查找 |
6. 数组操作中的常见问题与排查技巧实录
6.1 数组越界:为什么有时候不报错反而更可怕
C/C++里访问数组越界属于“未定义行为”,有时程序正常跑,有时崩溃,有时返回奇怪的值。我见过最典型的是一个图像处理库,外圈像素偶尔出现雪花噪点,查了三天才定位到是一个内层循环从i=0到i<=width,多写了一次越界赋值,把相邻数组元素冲掉了。
避免这种问题的最有效手段是开启编译器保护或者直接使用容器。在C++中,如果你有强烈的裸数组情节,记得尽量用std::array::at()(带检查),而不是operator[](无检查)。代价是轻微性能损失,但Debug阶段换来的是准确定位。
注意:在生产级别的热点代码里,
at()的边界检查可能拖慢运行速度,所以线上去掉检查是可以接受的。但在开发阶段,宁可慢一点,也要让错误尽早爆出来。
6.2 数组拷贝:浅拷贝与深拷贝的隐雷
Java的Arrays.copyOf拷贝数组时,如果数组里放的是可变对象,拷贝的只是引用,也就是说新数组和老数组“共享对象”。如果对象本身是List或可变Map,改一个,另一个也跟着变。
// 浅拷贝 int[] a = {1,2,3}; int[] b = Arrays.copyOf(a, a.length); b[0] = 100; // a不受影响,因为int是值类型 // 对象数组浅拷贝 StringBuilder[] builders = {...}; StringBuilder[] copy = Arrays.copyOf(builders, builders.length); copy[0].append("x"); // 原数组的builders[0]也被改了如果确实需要深拷贝(数组里的每个对象都复制一份),那就得遍历数组,逐个创建新对象。这个操作的成本很高,只有在确有必要时才这么做。
6.3 多维数组的“行列互换”性能陷阱
处理二维数组时,最容易踩的坑是“行列遍历顺序不对”。这是因为内存的连续性和CPU缓存的局部性。只要有遍历二维数组的需求,先问自己:内存布局是什么,我应当按哪个方向优先?
比如在C/C++中,matrix[i][j]的i变化对应行偏移,j变化对应列偏移;而行优先存储下,j变化时地址是连续跳动的,所以内层循环应该循环j,而不是循环i。在实际业务中,我曾把一个矩阵转置算法的运行时间由3.2秒降到0.8秒,只改了这一件事:把内循环从行变成了列。这个优化没有改变任何算法复杂度,纯粹是数据访问模式的优化。
6.4 稀疏矩阵/稀疏数组:别用普通数组硬扛
如果矩阵里绝大多数元素是0,为它分配完整二维数组就是一种浪费。稀疏矩阵的常规做法是只存储非零元素,并用行索引、列索引来记录它们的坐标。常见的存储格式有COO(坐标列表)、CSR(压缩稀疏行)、CSC(压缩稀疏列)。
# 用dict模拟稀疏数组 sparse = {(0, 0): 1.0, (2, 1): 3.5, (10, 10): 2.0} def get(i, j): return sparse.get((i, j), 0.0)当矩阵规模达到上万乘上万,而有效非零元素只有几百时,这种“字典+坐标”的方式比二维数组节省几个量级的内存。理解了这个方向,你就不会盲目地为“大数组”寻求扩容方案,而是考虑结构调整。
6.5 数组在并发环境下的“伪共享”问题
在并发编程里,数组元素如果被不同线程频繁读写,可能会触发“伪共享(false sharing)”,即多个线程修改不同但恰好位于同一缓存行的数组元素,导致缓存行不断在核心间同步,性能断崖式下降。
之前在某后台服务的统计模块里,我用一个long[] counters保存各线程的计数,每个线程更新自己的counters[tid]。测试时发现8线程跑起来不如单线程,查了很久才发现这就是伪共享。解决办法是在每个计数之间填充足够字节,让它们分布在不同的缓存行(通常64字节),性能马上恢复。
// 伪代码示意 struct PaddedCounter { volatile long value; long padding[7]; // 填补到64字节缓存行边界 };这类问题不属于“数组入门”,但它说明了一个道理:数组的物理布局在并发场景下比逻辑结构更重要。
7. 从原理到实践:我如何理解“数组的类型与概念2”
整个项目内容围绕“数组”展开,但你发现没有,它并不是教科书式的罗列。它希望我们不是背诵“数组是什么”,而是要点破数组的多面性——既是最简单的容器,又是复杂数据结构的地基;既是单块连续内存地址的抽象,又是类型系统高度介入的设计产物。
我在实际学习和编码中,最大的迭代是:不再把数组当成“一堆数据”,而是当成一个有“形状”“类型约束”“访问方式”的综合体。这种抽象意识上的升维,会直接改变代码设计:
- 在接口设计中,我倾向于用“切片/视图”而不是原始数组来传递数据,减少复制;
- 在性能敏感场景中,我会刻意选择行遍历或列遍历;
- 在需要动态变化时,我会主动选择
vector/ArrayList而不是裸数组; - 在做数值计算时,我会绕开语言内置的数组,直接上NumPy或Eigen这类专用库。
数组本身的API知识其实很少,真正值钱的是:你清楚每个数组的物理属性和逻辑属性,并在不同场景下做出合理取舍。这个能力比记住“什么时候用数组什么时候用链表”这种规则重要得多。
8. 进阶感知:数组在算法与系统设计中的隐藏价值
8.1 数组是很多数据结构的“地基”
栈、队列、堆、哈希表、图邻接表,这些数据结构,实现到最后往往就是一个数组(或若干个数组)。比如堆(优先队列)用数组实现时,父节点和子节点的下标关系是parent(i) = (i-1)/2、left(i) = 2*i+1,这种完全二叉树的隐式表示就是靠数组的索引映射完成的。
写算法题的时候,很多人刷完“数组题”就觉得数组只是“把元素存起来”的工具,这是比较大的误区。数组本身也可以承载逻辑关系:并查集里的parent[]数组、KMP算法里的next[]数组、树状数组里的tree[],本质都是把拓扑结构编码成扁平数组。这种用索引关系来代替显式指针的思维方式,是算法设计里非常重要的能力。
8.2 内存布局就是性能布局
了解数组的连续内存布局不仅让你看懂缓存命中率,还会让你在设计系统时意识到:“感到慢”未必是算法复杂度高,有时候只是访存模式不符预期。
比如你有一个很大的对象数组,vector<Person>,其中Person字段很多(姓名、年龄、地址、手机号)。遍历这个数组求所有人年龄总和时,虽然只用到年龄,但内存还是会按对象大小把无关字段一起加载到缓存行里。这样缓存利用率不高。如果拆成vector<int>存年龄,遍历时就只加载年龄字段,性能能明显上升。这种“结构化数据按字段拆分存储”的做法,叫结构体数组转数组结构体(AoS to SoA),在图形学、物理引擎、游戏编程中非常常见。
// AoS: 数组存对象,每个对象含多个字段 struct Point3 { float x, y, z; }; std::vector<Point3> points; // SoA: 每个字段一个数组 struct PointsSoA { std::vector<float> xs, ys, zs; };当数据量大、性能要求高时,SoA方式通常比AoS更友好,因为它允许CPU只加载需要的字段列。对象数组则是更符合直觉的方式。这个设计天平,值得你在真实项目中反复权衡。
8.3 数组类型的“边界感”:什么时候该打破“数组思维”
数组虽然强大,但不要被它的“连续地址+固定类型”的框架锁死。日常代码大概率要处理动态增长、异构数据、复杂查询,这时候更该选其他结构。比如:
- 需要保证数据唯一性:
HashSet或TreeSet - 需要键值关联:
HashMap/Dictionary - 需要频繁修改中间元素且长度变化大:
LinkedList不一定更好,但ArrayList如果每次都触发扩容,也要重新评估
我见过很多项目为了“微小的性能优势”,强行使用数组并手写一堆复杂逻辑,结果维护成本剧增。基础工具本身没有高下,合适的场景用合适的容器才是王道。
9. 实操总结:数组类型选型的清单与避坑指南
9.1 选型实操清单
- 明确元素数量是否固定:固定用静态数组,不固定用动态容器
- 明确元素类型是否统一:统一用数组/强类型列表,不统一用元组或对象列表
- 明确访问模式:频繁随机访问优先用数组,频繁插入删除考虑链表/双端队列
- 明确内存约束:内存受限时考虑数组是否比对象数组更省内存,能否用位数组/压缩结构
- 明确并发访问:多线程读写同一数组时,考虑拆分数组或加Padding防止伪共享
9.2 避坑指南
- 不要用数组做“查找”:复杂度O(n),应选用哈希表
- 不要在循环内频繁创建动态数组:每次都重新分配内存,后果是GC压力剧增
- 不要忽略数组的垃圾回收成本:大对象数组的回收也是时间大户,尽量避免“朝生夕死”的超大数组
- 不要以为数组是线程安全的:在并发环境下,对同一数组读写需要加锁或使用原子操作
- 不要混淆“数组长度”与“有效元素个数”:动态容器中存储的数据量与容量是两个概念,遍历时用
size()而不是capacity()
9.3 最后再分享一个切身体会
有一次我接手某些遗留代码,里面全是C风格的裸数组和复数下标计算。明明功能很简单,但读起来像在解谜。后来我做了一次“温和重构”,把所有裸数组改成std::array或std::vector,在不改变运行逻辑的情况下,代码行数减少了将近三分之一,崩溃率也明显降低。后来我慢慢明白了一个道理:数组本身没有罪,但它的“裸性”很容易让错误藏得深。真正的高手不是“炫技般地用裸数组秀性能”,而是知道什么时候用容器、什么时候用数组、什么时候必须拆分存储。
数组的类型与概念,远不是一张表、一个定义就能说尽的。它牵扯到语言的设计哲学、硬件的缓存特性、算法的逻辑架构,以及编写代码时的取舍思维。如果你把今天这些内容消化掉,再回头看写过的代码,大概率会发现一些“用错了容器”的地方。这就是本篇博文想带给你的价值。