Erlang-数据结构
它的实现兼顾了 不可变性(Erlang 数据的核心特性)和 操作效率,针对不同大小的 map 采用了两种不同的底层结构,以优化性能。原本的L1已经指向了[1,2]的地址,假如我们只是找到[2|Tail],让Tail指向L2,会导致修改了原有的L1(把[]改成L2)。L2 = [Elem|L1]的操作,实际上构造了一个新的Cons,其中Head是Elem(Eterm) ,Tail是L1(Eterm)
目录
1 变量不可变
在Erlang中,变量一旦被赋值后就不能更改,这意味着变量是不可变的。不可变性简化了程序的调试过程,因为在查找错误时我们只需要找到绑定变量的地方,无需担心变量被多次赋值修改。这与C等命令式语言形成了鲜明对比。
优势:允许并发操作且无需担心数据竞争问题。由于变量值不会改变,多个进程可以安全地同时读取同一个变量而不会引发冲突。这在构建高并发系统时尤其重要。
2 内存基础单位:机器字(Word)
Erlang 内存计算的核心单位是机器字,用于统一表示指针、整数、元数据等。在 64 位系统中,1 word = 8 字节;32 位系统中,1 word = 4 字节。以下计算默认基于 64 位系统(8 字节 /word)。Erlang中所有数据类型,在Erlang虚拟机中都是通过64位的(默认大家都是64位的操作系统)名为Eterm的数值组合(一个或多个Eterm)构成的(文章最后会解释)。也可以理解为1个Eterm是1个word。
Erlang官方文档查看每种数据类型内存
3 数值
Erlang中只有两种数值类型:整形和浮点数。算术运算时会自动进行类型转换,无需要像C等一样显示强制类型转换。
3.1 整数
Erlang中的整数大小没有限制。较小的整数存放在单个机器字长内;较大的整数(即bignum)会自动按需分配内存。对于使用者来说完全无需担心溢出的问题。
%% 普通写法
123
-123
123 * 123
%% 进制写法
16#FF2
2#101
36#ZZ
%% 数值编码
$9
$A
3.2 浮点数
要求必须以数字开头
3.14
0.123
123.0
6.123e-11
3.3 算数运算
+、-、中如果有浮点数参与运算,则返回的结果一定是浮点型,如23.1结果是6.2。除法运算中返回的结果一定是浮点型,如6/3结果是2.0。
div是整除运算,返回结果是整数,6 div 4 结果是1。rem是求余数,6 rem 4结果是2。
位运算符
bsl:左移N位
bsr:右移N位
band:按位与
bor:按位或
bxor:按位或异
bnot:按位反
4 二进制串与位串
二进制串是无符号8位字节的序列。位串是广义的二进制,长度不一定是8的整数倍。 二进制串整数取值范围是0-255。二进制分为小二进制和大二进制,存储方式不同
<<0, 1, 255>>
<<"hello", 13>>
4.1 小二进制
存储在进程私有堆中,作为普通数据结构直接管理。
4.2 大二进制
存储在全局共享二进制堆中,进程通过引用计数指向它(引用计数)。多个进程可共享同一大二进制,仅当最后一个引用消失时才释放内存,避免大数据复制的开销。进程间的大消息传递时可以考虑用二进制,避免消息传递时的数据复制带来额外开销。
5 原子
原子是仅由字符序列来标识的特殊字符串常量,所以只要两个原子是相同字符串,则完全等同。原子必须由小写字母开头,首字母后可使用大写字母、数字、下划线和@。除此以外,还可以用加上单引号明确表示该字符串是原子。原子一旦创建,即使不使用也不会销毁,除非系统重启。
内存: 1 word
内部实现: 原子全局唯一,存储在全局原子表中,进程中仅通过指针引用。在表中由下标定位,因此对比两个原子时仅需对比两个小整数即可判断原子是否相等。
大小限制: 单个原子长度上限是255个字符,原子的总上限在单节点中有上限,默认是1048576(可在启动参数中设置)。因此在使用原子时尽量避免动态循环创建。
ok
error
asd22
asd_a22
asd@asd
6 列表
列表是一种可变的有序集合,用于存储可变数量的数据项。是0个或多个Erlang项式的序列,空表[]也称为nil,只占1个word。
[1]
[{a,1}, {b,2}]
[1 | [2]] %% |作为列表连接管道符
6.1 列表的底层实现
列表是单链表,由 若干个Cons 组成,Cons是[Head|Tail]
的组合,在内存中体现为两个相邻的Eterm,Head可以是任何类型的Eterm,Tail是列表类型的Eterm。如[1, 2, 3]
实际上是[1 | [2 | [3 | []] ] ]
,空列表[]是链表的终止标志。
L2 = [Elem|L1]的操作,实际上构造了一个新的Cons,其中Head是Elem(Eterm) ,Tail是L1(Eterm),然后将L2的Eterm指向了这个新的Cons,因此L2即代表了这个新的列表。
Erlang中进程内对对象的重复引用只需占用一份对象内存(只是Eterm本身一个字的拷贝),但是在对象跨进程时,对象会被展开。
L1 = [1, 2, 3].
erts_debug:size(L1). %% 结果:3 * 2 = 6
L2 = [L1, L1, L1]. %% 重复引用,实际上只占用一份对象内存
erts_debug:size(L2). %% 结果: 3 * 2 + 6 = 12
erts_debug:flat_size(L2). %% 结果: 3 * (2+6) = 24
6.2 列表拼接
想把两个列表拼接起来时有很多种方法,以下这几种方法得出的结果都是一样的:
L1 = [1, 2]
L2 = [3, 4]
%% 1、++
L1 ++ L2
%% 2、利用lists模块实现
lists:append(L1, L2) %% 底层erlang源码实际上调用的就是 ++
%% 3、自己编写函数(尾递归)
append([], L2) -> L2;
append([H | T], L2) -> append(T, [H | L2]). %% [H | T]这种方式也可拼接,不过是单个元素拼接到列表头部
6.3 ++操作注意事项
从上述的列表实现可得知,一个列表实际上是若干个cons组合,依赖于Tail指向下一个cons。因此要实现L1与L2的拼接,那就得先通过遍历找到L1,复制L1中所有的cons,并且最后把Tail指向L2。
实现流程:
- 遍历左侧列表的所有cons单元格,复制每个单元格(因为列表不可变,不能直接修改原列表)
- 当左侧列表遍历完成(到达[]),将右侧列表作为新链表的尾部。
例如,[1,2] ++ [3,4]的执行过程:
- 左侧列表[1,2]实际是[1 | [2 | []]]
- 复制第一个cons单元格:[1 | 新的尾部]
- 复制第二个cons单元格:[2 | 新的尾部]
- 左侧遍历结束,将右侧列表[3,4]作为最终尾部,得到[1 | [2 | [3,4]]],即[1,2,3,4]
可以看出++操作的时间复杂度是O(n),因为需要遍历复制左侧所有n个cons单元格,而右侧列表直接被复用(无需复制)。
为什么L1需要遍历复制而不是只找到L1最后一个cons的Tail,让其指向L2?而且为什么L2不需要遍历复制?
这是因为Erlang数据的不可变性导致的!原本的L1已经指向了[1,2]的地址,假如我们只是找到[2|Tail],让Tail指向L2,会导致修改了原有的L1(把[]改成L2)。这明显违反了不可变性。而L2只是被引用而已,并没有被修改,所以无需复制。
从上面可以看出,++使用时尽量让小列表放在左边,减少遍历复制次数。同时应该尽量避免在遍历过程中使用++操作,这可能会导致出现无限次遍历复制的列表结果(可用头部插入[H | T]来代替)。假如你对列表中插入元素的位置顺序有要求,如希望给[1,2,3]的尾部插入4。可使用以下方法:
%% ++操作
%% 优点:简单容易理解
%% 缺点:需要遍历复制左边的列表
%% 适用场景:短列表的情况下与其他方法效率相差不大,且更容易理解
[1, 2, 3] ++ [4]
%% 列表反转及头部插入
%% 优点:时间复杂度其实与++一样,都是O(N),但是内存复用性更好,无需复制复制
%% 缺点:代码会更复杂
%% 适用场景:长列表或高频尾部添加场景,能减少内存复制开销,效率更高。
L1 = [1, 2, 3],
L2 = lists:reverse(L1), %% 先翻转列表得到[3,2,1]
L3 = [4 | L2], %% 在头部加入得到[4,3,2,1]
L4 = lists:reverse(L3). %% 再次翻转得到结果
6.4 列表推导
列表推导(List Comprehension) 是一种简洁、声明式的语法,用于从一个或多个现有列表生成新列表。它的设计灵感来源于数学中的 “集合推导”,能通过 “筛选 - 转换” 逻辑快速构建列表,替代lists:map/2、lists:filter/2等函数的组合使用,让代码更简洁易读。
[Expression || Generator1, Generator2, ..., Filter1, Filter2, ...]
- Expression:对符合条件的元素进行转换的表达式(最终会成为新列表的元素)
- Generator:形式为Pattern <- List,用于从List中提取元素(Pattern是模式匹配,List是数据源列表),可包含多个生成器(类似嵌套循环)
- Filter:筛选条件(类似guard),通常是布尔表达式,只有满足所有条件的元素才会被保留并参与Expression计算
%% 1、基础用法:从列表生成新列表
Double = [X * 2 || X <- [1,2,3,4]]. % 结果:[2,4,6,8]
%% 2、带筛选条件(Filter)
EvenNumbers = [X || X <- [1,2,3,4,5,6], X rem 2 =:= 0]. % 结果:[2,4,6]
%% 3、 多个生成器(类似嵌套循环)注意A和B会有顺序,假如有顺序要求的要注意
Combinations = [A + B || A <- [1,2], B <- [3,4]]. % 结果:[4,5,5,6]
%% 4、模式匹配与筛选结合
Adults = [Name || {name, Name, Age} <- [{name, "Alice", 25}, {name, "Bob", 30}, {age, 22}], Age > 25].
使用性能注意:
- 列表推导会一次性生成整个列表,而非惰性计算。处理超大列表(如百万级元素)时,可能导致高内存占用甚至内存溢出。若无需完整列表,改用递归函数按需处理元素;对超大列表,考虑分块处理或使用lists:mapfoldl/3等流式处理函数。
- Erlang官方文档关于列表推导使用注意事项
7 字符串
Erlang中的双引号字符串实际上就是列表,也是该字符串中各字符的数值编码对应的整数。
"abcd" %% 等价于[97,98,99,100]
"Hello!" %% 等价于[72,101,108,108,111,33]
这种设计会有缺点,如拿到一个包含若干整数的列表时很难判断它到底是否字符串。同时,Erlang Shell打印时会检测列表的元素是否可以全部表示为可打印字符,如果是则打印成字符串,否则打印成整数列表,这会导致很多时候输出结果对使用者并不友好。
当然这里有个小技巧可以使字符串显示成列表,如v(1)是字符串,则[0 | v(1)]
会显示成列表(当然也可以用io:format标准化格式输出来显示,但明显这种方式方便得多)。
如果是大量文本的情况下建议使用二进制,大二进制是全局共享堆,无需产生额外的内存复制开销(见上文)
8 元组
元组可包含多个元素,元素可以是任意类型,如C的Struct一样。元组的核心特点是结构固定(创建后元素数量不可变),且支持高效的模式匹配,是 Erlang 中表示结构化数据的重要方式。
{}
{1,2,3}
{name, "asd"}
{asd, {asd, asd}}
元组和列表是Erlang中最常用的两种符合类型,核心区别是(注意下面说的大小是否可变指的是结构灵活性,并不是指会修改原数据,数据不可变性是前提):
特效 | 元组 | 列表 |
---|---|---|
大小 | 固定(创建后不可变) | 动态(可添加 / 删除元素) |
表示形式 | {e1, e2, …, en} | [e1, e2, …, en] |
访问效率 | 高(O (1),直接通过索引访问) | 低(O (n),需遍历) |
典型用途 | 结构化数据、固定格式记录 | 动态集合、序列数据 |
9 record记录
本质上是标记元组,但避免了使用元组时增减字段带来的麻烦,以及必须记住每个字段在元组中的顺序问题。使用record前需要先提前声明记录:-record(role, {name = "ming", age = 18})
- 新建:新创建记录时可用
#role{}
、#role{age = 24}
等方式,如果没有对记录中字段数据赋值时会使用声明中的默认数据。 - 获取:通过
Role#role.age
来获取记录中某个字段的值(假如Role是一个创建好的记录数据)。 - 修改:通过
Role#role{age = 24}
来修改记录中某个字段的值 - 本质:本质上是元组,如默认的
#role{}
实际上是{role, "ming", 18}
- 位置:
#role.name
代表的是在元组中的位置顺序,这里是2(1应该是role这个标记)。
10 Pid、Port、Ref
10.1 进程Pid
每个Erlang进程都会有一个唯一标识(Pid),无法通过语法创建一个Pid类型的数据,这仅用于打印,方便对pid进行对比,格式为显示格式为<X, Y, Z>
。
- X:节点标识符(在分布式系统中用于区分不同节点),一般都是0,如果是在远程节点表示就不是0
- Y:进程号(当前节点内的进程编号)
- Z:序列号(用于避免进程终止后 Pid 重复)
本地pid在远程节点中会带有节点信息,因此即便是跨节点,也可以通过pid !msg的方式发送消息给进程。
10.2 端口Port
在 Erlang 中,Port 是用于实现 Erlang 进程与外部程序(非 Erlang 代码,如 C、Python、Shell 脚本等)之间双向通信的核心机制。它允许 Erlang 虚拟机(BEAM)与外部进程交互,是 Erlang 集成外部系统或利用现有非 Erlang 库的重要方式。
Shell打印端口的格式为#Port<0.472>
。
10.3 引用Ref
可通过make_ref()函数生成,shell输出格式为#Ref<0.0.0.39>
。引用常用作保证唯一性的一次性标签。
11 map映射
在 Erlang 中,map 是一种键值对(key-value)存储结构,用于存储和快速访问关联数据。它的实现兼顾了 不可变性(Erlang 数据的核心特性)和 操作效率,针对不同大小的 map 采用了两种不同的底层结构,以优化性能。
11.1 特点
- 键值对集合:键(key)可以是任意 Erlang 数据类型(整数、原子、字符串、元组等),值(value)也可以是任意类型。
- 不可变性:任何对 map 的 “修改”(插入、删除、更新)都会生成一个新的 map,原 map 保持不变。
- 无序性:map 中的键值对没有固定顺序(虽然最新版本的 Erlang 在某些操作中会保留插入顺序,但本质上不保证有序)。
- 唯一性:键是唯一的,重复插入相同键会覆盖旧值。
11.2 底层实现
Erlang 的 map 实现会根据元素数量 动态选择底层结构,以平衡不同规模下的操作效率:
1. 小 map:平坦数组(Flat Array)
当 map 中的键值对数量较少(通常少于 32 个,具体阈值由 Erlang 虚拟机版本决定)时,采用 平坦数组 存储,本质是一个包含键值对的有序列表([{Key1, Value1}, {Key2, Value2}, …])。
- 操作方式:
- 查找:通过线性遍历数组,比较键的相等性(=:=)找到对应值。
- 插入 / 更新:生成一个新数组,若键已存在则替换对应值,否则在数组末尾添加新键值对。
- 删除:生成一个新数组,过滤掉目标键对应的键值对。
- 优势:
- 内存占用小,无需额外的哈希表元数据(如哈希桶、指针等)。
- 对于少量元素,线性遍历的开销(O (n))远小于哈希表的哈希计算和冲突处理开销。
2.大 map:哈希表(Hash Table)
当 map 中的键值对数量超过阈值(通常≥32 个)时,会自动转换为 哈希表 结构,以支持高效的操作。
- 结构细节:
- 采用 开放地址法 处理哈希冲突(而非链式哈希),哈希表是一个数组(称为 “桶”),每个桶存储一个键值对。
- 键的哈希值通过 Erlang 内置的哈希函数计算,确保不同类型的键(如原子、整数、字符串)都能生成均匀分布的哈希值。
- 哈希表会预留一定的空闲桶(负载因子控制),当元素数量增长到一定程度时,会触发 “扩容”(创建更大的哈希表并重新分布键值对)
- 操作方式:
- 查找:通过键的哈希值定位到候选桶,比较键的相等性找到对应值(平均 O (1))。
- 插入 / 更新:计算键的哈希值,找到合适的桶位置,生成包含新键值对的新哈希表(原哈希表不变)。
- 删除:生成一个新哈希表,移除目标键对应的桶项(或标记为删除)。
- 优势:
- 对于大量元素,哈希表的操作(查找、插入、删除)平均时间复杂度为 O (1),远优于数组的 O (n)。
11.3 共享与复制
由于 Erlang 的 map 是不可变的,每次修改(插入、删除、更新)都会生成新的 map。为了避免全量复制带来的性能和内存开销,map 的实现采用了 部分共享 策略:
- 小 map(数组):修改时会复制整个数组(因为数组本身较小,复制成本低),但原数组仍被其他引用持有。
- 大 map(哈希表):仅复制被修改的哈希桶,未修改的桶会被新 map 共享。例如,更新一个键值对时,新哈希表会复用原哈希表中未涉及的桶,只重新计算和存储被修改的桶。
这种 “部分共享” 机制在保证不可变性的同时,最大限度地减少了内存占用和复制开销。
更多推荐
所有评论(0)