关于数组(Array),我得先说一个真实感受:很多人对数组的印象停留在"一串数据排排队"这个层面,但真正用过一段时间后会发现,同样是"数组"两个字,在C、JavaScript、Python里代表的东西几乎完全不一样。前阵子我在调一个C语言二维数组的越界问题,越查越觉得不对劲;转头帮人看一段JavaScript代码,又是另一套行为逻辑。今天就把这个问题彻底讲透。
这篇内容适合三类人:刚学编程、被教科书里"数组是相同类型元素的集合"弄得一头雾水的初学者;已经在工作中写C/C++但偶尔被数组和指针绕晕的开发者;以及常写JavaScript、Python这类动态语言、却不清楚底层为何"数组越界也不报错"的工程师。我会从内存真实布局出发,把一维、二维、多维、树状数组、对象数组这些形态全部串起来,过程中穿插大量实际踩坑经历。
1. 同一起点,不同命运:C、JavaScript、Python里的"数组"根本不是一回事
1.1 C语言数组的三大死板规矩:定长、连续、会退化
先看C语言里的数组,它是最接近"数组本质"的实现。在C中声明一个数组int arr[5],编译器做的事情很直接:在栈或静态区划出5 * sizeof(int)字节的连续空间,然后让arr这个名字作为这一段内存的起始地址别名。
这里有三条死规矩,理解它们等于理解C数组的七成。
第一,定长。数组长度必须是编译期能确定的常量表达式,int n = 5; int arr[n];在C99之前是不合法的,C99以后这叫变长数组(VLA),但依然不能在运行时随意改变大小。你不能像JavaScript那样写arr.push(6),一不高兴数组自己长大。C的思路是:你告诉我最大需要多少,我一次性给你开辟好;你不知道,那你自己用malloc动态申请,麻烦但灵活。
第二,连续。所有元素在内存里一个挨一个,没有空洞。这意味着&arr[0]和arr拿到的是同一个地址,第i个元素地址就是起始地址 + i * sizeof(元素类型)。这个"连续"带来的性能收益极高,CPU缓存预取时只要加载一段连续缓存行,访问数组元素几乎总是命中,这也是为什么数组在实际工程里比链表快得多。
第三,会退化。这点最坑。数组名在大多数表达式里会"退化"为指向首元素的指针,只有三个地方除外:sizeof(arr)、&arr取地址、以及字符串字面量初始化字符数组时。比如:
c复制#include <stdio.h>
int main(void) {
int arr[5] = {1, 2, 3, 4, 5};
printf("sizeof(arr) = %llu\n", (unsigned long long)sizeof(arr)); // 20,整段数组的大小
printf("sizeof(&arr[0]) = %llu\n", (unsigned long long)sizeof(&arr[0])); // 8,一个指针的大小
return 0;
}
刚开始学的时候,很多人用sizeof(arr)求数组元素个数,在声明数组的同一个作用域里没问题,一旦把数组传进函数:
c复制void print_len(int a[]) {
printf("%llu\n", (unsigned long long)sizeof(a)); // 8,不是40
}
a已经退化成指针,sizeof返回的是指针大小,数组长度信息在参数传递时丢失了。这就是为什么C里函数处理数组,几乎必须把长度作为第二个参数传进来。不是设计者偷懒,是因为数组本来就只是"一段连续内存的起始地址",长度并没有跟着地址走。
1.2 JavaScript数组与Python列表的本质:根本不像"C语言数组"
再来看JavaScript。let arr = [1, 2, 3, "hello", {name: "x"}, null]——这玩意儿在C眼里完全不是数组。C数组要求同类型元素,JS数组里可以混着装数字、字符串、对象、函数;C数组定长,JS数组随便push、pop、splice动态变化。
那为什么JavaScript还叫它"Array"?因为它披着数组的外衣,底层实际上是一个"类似数组的键值对容器加长度追踪器"。JS引擎(V8)在引擎内部会做优化:如果数组元素全是一种类型、索引连续、没有空洞,会退化成真正的连续定长存储(称为Fast Elements);一旦你往里塞各种类型,或者删掉中间元素产生空洞,引擎就不得不降级成字典模式,性能大幅下降。
所以你在前端处理数据时,最忌讳的是写这样一个循环:
javascript复制const arr = [1, 2, 3];
for (let i = 0; i < arr.length; i++) {
if (arr[i] % 2 === 0) {
arr.splice(i, 1); // 删除元素后索引会前移
}
}
splice会让V8把后续元素整体前移,如果数组大、删除频繁,复杂度会变得很难看,更关键的是索引前移会导致你的i++跳过下一个元素。我在实际项目里见过一段删除偶数却漏掉元素的代码,就是这个原因。
Python的list更实诚,它本质上是一个"指向各元素的指针数组"——每个元素都是一个PyObject指针,数组本身连续,但元素可以是任意类型的对象。这也解释了为什么Python
