「笔记」Erlang 学习笔记
开始
函数式编程
- 不可变的变量:定义了就不能改变
- 引用透明
- 没有副作用
- 多线程不共享状态,不会出现死锁,适合并发
- 使用递归,没有循环语句(for/while 都需要可变状态进行循环)
- 尾调用和尾递归
- 高阶函数:参数或返回值为函数的函数,复用粒度更低
- 模式匹配
1 | # 不可变类似学过的数学变量 |
其他特点
actor
模型,独立进程,无共享,邮箱通信- 进程之间不会影响,每个进程做自己的事情就好了
- 编译字节码,可以在任何环境运行
- 可以在不中断的情况下升级代码,热更新
- 分布式
- let it crash !
- 强大的伸缩能力,是因为及其轻量的进程
- 并非不处理错误,监控再用一定的策略处理
- 监督机制 link/monitor,crash 会起新进程
- 外界的输入,可以预知的错误,不可恢复的上下文(tcp连接)
Erlang 并不是万能的,请根据需求选择合适的工具
Let it crash: 因为误解,所以瞎说
Erlang Shell
安装配置好后,在 cmd/shell 键入 erl 进入 Erlang Shell
windows 还有 werl.exe 有一个单独的窗口和一下快捷键
内置 Emacs 功能子集 Ctrl-A/E (行首/尾) 上下切换命令, Tab 补全
1 | help(). % 帮助 .表示语句的结束 |
可以直接
Ctrl-C
两次快速退出Shell
键入
io:format("hello world~n").
,回车会打印一个hello world
数值类型
1 | %% 百分号后面是注释 |
不变的变量
Erlang
变量名由字母,下划线,数字组成,以大写字母或下划线开头
只能赋值一次(绑定/定义更合适),下划线开头仅用于不关心值的情况
除了第一次绑定变量,其他情况都视为模式匹配
1 | One = 1. |
原子
原子是以小写字母开头,或者用单引号包裹的的字面量
所见即所得,一个原子的值就是他本身,一个不可变的常量
使用原子的速度非常快,但是原子表不会被回收,所以不要动态创建原子
同时有一些保留字after
、and
、andalso
、band
、begin
、bnot
、bor
、bsl
、bsr
、 bxor
、case
、catch
、cond
、div
、end
、fun
、if
、let
、not
、of
、or
、orelse
、query
、receive
、rem
、try
、when
和 xor
操作符
1 | %% 布尔操作符 |
数据类型直接大小顺序:
number
<atom
<reference
<fun
<port
<pid
<tuple
<list
<bit string
元组和列表
列表是以链表的形式存储,所以当连接两个列表时候,需要遍历左边的列表找到尾节点连接
元组是使用连续的内存,可以很方便的访问元素,但是更新或者添加元素会导致重新创建并生成新的元组
但是新元组中的元素不会每次重新创建,会共享未更改的内容以节省内存,元组和列表中的元素也会共享。也只有不可变的特性才可以实现
在计算结构中元素个数时候,如果是常量时间内就能得到结果的则命名为size
,如果是线性时间内得到结果的命名为length
。例如:tuple_size
, byte_size
; length
, String.length
参考自Elixir 笔记
1 | %% 其中的值可以是任意类型 |
1 | A = [1]. |
Tips:
hd/1
这种称为内建函数(BIF
函数),通常一些常用操作,为了使操作性能更高使用其他语音实现
|
操作符称为cons
操作符(构造器),左边可以是任意个元素,右边必须是一个列表,构造后形成一个新的列表
二进制数据
Erlang
中使用<<>>
来表示二进制数据, 区段之间用,
隔开
当然需要一点基础前置知识:1Byte(字节) = 8bit(位)
,以及一些进制转换的知识
1 | Bin = <<97, 98, 99>>. % <<"abc">> 这里 abc 的 ASCII 码分别是 97, 98, 99,所以是"abc" |
格式
<<Value:Size/Type, Rest/binary>>
. % 二进制数据可以通过该格式来描述
其中Size 默认是
8位,
Type默认是
integer`
Type
可以由多个类型组成,使用-
分割,可能包含下面多个
- 类型包括
integer
(默认)、float
、binary
(bytes
)、bitstring
(bits
)、utf8
、utf16
、utf32
- 符号类型包括
signed
、unsigned
(只有类型是integer
才有意义) - 字节序包括
big
(默认)、little
、native
(只有当类型是数字时才有意义) - 单位:
unit:Integer
的形式,表示区段大小(1 ~ 256)- 对于
integer
,floag
,bitstring
来说默认都是 1 - 对于
binary
来说默认是 8,utfx
无需定义 Size * 单位
= 区段所占的位数1
2
3%% 用不同字节序表示数字 72, native 是根据 CPU 使用的字节序来自动选择
<<72, 0, 0, 0>>. % 小字节序
<<0, 0, 0, 72>>. % 大字节序尽管
Erlang
可以通过匹配来很方便的处理二进制数据,但是Erlang
历来不擅长处理数值密集型计算操作,但是对于不需要数值计算的应用处理非常快,构建软实时应用会是非常好的选择
- 对于
二进制字符串
<<"this is abinary string">>
这就是二进制字符串
二进制字符串对比前面的列表字符串在空间是上更加高效
列表通过链表实现的,而二进制字符串则类似元组是一整块内存
当然缺点是模式匹配会操作方法会比列表麻烦一些
通常当所存储的文本无需频繁操作或者空间效率是个实际问题时,才会使用二进制字符串
尽管二进制字符串非常轻量,但是不要用来标记数据。{<<”jack”>>, 1}。在涉及比较操作时候就会在线性时间内比较整个字符串,{jack, 1} 使用原子则能在常量时间完成。同样,尽管原子更轻量,但是除了比较,原子无法进行分割、正则其他任何操作
二进制推导式
1 | %% 与列表推导式几乎一模一样,看到这里也知道为什么 =< 才是小于等于了吧 |
模块
模块是具有名字的文件,同时包含一组函数。
通常使用Module:Function(Args)
的方式调用函数
前面使用的
hd/1
、element/2
都是erlang
模块下的函数,会自动引入所以无需Module
1 | %%% 一般文件头的概况性注释使用三个百分号 |
编译代码
- 在
cmd
中可以直接使用erlc test.erl
编译模块 - 在
Shell
中可以使用compile:file(test).
/c(test)
编译模块 - 如果不是同级目录,可以在
Shell
中使用cd("xxxk")
进入模块文件目录1
2
3%% 编译完成后可以来调用一下我们写的函数,add/3因为没用导出无法调用
test:add(1, 1). % 2
test:test(). % {3, 3}
不使用 Shell 运行程序
1 | %% test.erl |
escript
1 | #!/usr/bin/env escript |
可以使用
./test.erl
或escript test.erl
像脚本一样运行
编译选项
debug_info
: 一些工具需要使用这个选项来工作,建议一直开启{outdir, Dir}
: 指定.beam
文件存放路径,默认当前目录export_all
: 会将模块所有函数导出,可以在开发中使用,产品严禁使用{d, Macro}/{d, Macro, Value}
: 定义一些宏,Value
不设置默认是true
格式参考:
c(test, [debug_info, export_all]).
模块上也可以指定编译选项,比如:
-compile([debug_info]).
另外使用
hipe:c/2
或c(Mod, [native]).
会让程序运行更快
因为是直接编译成本地码,无法跨平台,通常不建议使用
元数据
可以通过Mod:module_info/0
或Mod:module_info/1
来查看一个模块的元数据
1 | %% 比如前面举例的文件 test:module_info(). |
vsn
通常用在代码热加载的区分不同版本
环形依赖
一定要避免环形依赖,如果模块A
调用了模块B
,那么模块B
就不应该调用模块A
函数
在Erlang
中可以通过模式匹配来实现函数的重载
每一个函数声明称为一个函数子句,必须用;
隔开,最后一个以.
结尾
1 | %% io:format/2是打印函数,这里就不过多介绍 |
卫语句
卫语句 是附加在函数头的语句,让模式匹配更具表达力
1 | %% 假设功能是判断一个整数否处于 0 ~ 3 之间,通过前面学习的可以得到 |
逻辑运算符和算术顺算符包括
BIF
函数都可以使用,除了用户自定义函数
在卫表达式中,
,
和and
类似;;
和or
类似
一些区别
- 前者卫语句中的报错会忽略,例如:
A; B
中A
报错B
为真任然可以匹配成功 - 前者无法嵌套使用,后者可以嵌套:
(A and B) or C
if
表达式
Erlang
中的if
语句又称卫模式,从名字不难看出和卫语句其实很类似
1 | if |
Erlang 中的表达式一定有返回值,所以需要一个
true
保证匹配成功
case ... of
表达式
case ... of
更像一个完整的函数,可以进行模式匹配和使用卫语句
1 | %% 假设 {Name, Num} 表示一个人的信息 |
函数、
if
、case ... of
是使用选择可以基于个人习惯,只要代码保持整洁即可
类型
Erlang
是动态类型语言,只在运行时候检查类型- 得益于基础理念,组件不应该影响整个系统,意料之外的错误也不会导致系统停止
- 动态类型也是实现代码热加载最简便的方法
- 同时
Eralng
也是强类型语言, 不会进行隐式的类型转换
可以使用
TypeA_to_TypeB
类似的函数来进行函数转换
可以使用
is_Type
类似函数称为类型检测BIF
更多
BIF
函数,详情可在文档或代码中查看
递归
函数式编程中通常没用for
/while
之类的循环。相反都是使用递归实现
递归算法是通过重复将问题分解为同类的子问题而解决问题的方法,在编程中通过函数的自调用实现
1
2
3
4
5
6
7%% 比如一个斐波那契数列
fac(0) -> 1; % 递归需要一个结束的出口
fac(N) -> N * fac(N - 1). % 其他都需要通过调用自己实现
%% 又比如计算列表长度
len([]) -> 0;
len([_ | Rest]) -> 1 + len(Rest).
尾递归
首先来分析一下前面正常递归的样子
1
2
3
4
5
6
7len([1,2,3]) = 1 + len([2, 3])
= 1 + 1 + len([3])
= 1 + 1 + 1 + len([])
= 1 + 1 + 1 + 0
= 1 + 1 + 1
= 1 + 2
= 3
因为每一步都依赖下一步的返回,所以即使
1+1+1+0
,仍然需要一个一个返回相加
如果加一个参数记录当前的结果,传入后面的递归中,其实就不需要再返回处理了
不仅是返回的问题,依赖下一步导致每一步操作都要记录,会占用更多的内存
1 | %% 使用尾递归实现计算列表长度 |
尾递归不依赖下一步的返回,也就不需要留着当前占用,实际就只会占一次调用的空间
其他函数
1 | %% 高阶函数 |
作用域和闭包
作用域顾名思义就是变量可以使用的范围。与继承有点类似,函数可以使用函数外定义的,匿名函数可以使用函数内定义的,反过来则不行
闭包是将函数内部和函数外部连接起来的桥梁,当匿名函数,作用域和可携带变量的能力组合在一起就是闭包
1 | %% 举个例子 |
异常处理
异常错误
- 编译期错误,通常是一些语法错误
- 逻辑错误,测试是最好的防御手段
- 运行时错误
- 函数/
case
/if
子句错误 - 匹配错误(一般为函数已经绑定)
- 参数错误
- 未定义错误
- 计算错误(除0,非数值计算)
- 系统限制错误(进程太多,原子太多等)
- 函数/
异常类型
包括error
、exit
、throw
,前两个会导致进程之间退出
当期望处理某个可能发生的异常,可以使用throw
抛出,同时并不会导致进程退出
在大量函数调用,深度递归中,可以将throw
当成返回使用,直接在顶部函数进行捕获,根据抛出信息再进行处理
error
和exit
的区别是,在捕获的了情况下,error
会有更多的调用栈信息
处理异常
1 | try F1() of % 捕获 F1() 调用的错误 |
被保护部分因为需要捕获异常,是无法进行尾调用优化的;
有after
的情况下,即使在of
和catch
的非保护部分仍然无法尾调用优化
1 | %% 甚至可以直接使用 catch 捕获异常 catch/1 |
catch/
的写法虽然简单,但是也会有一些问题,无法判断是正常的返回还是异常
数据结构
记录
其实记录就是一个元组,只是第一个元素是标记
1 | -record(node, { % 定义一个记录,这里是一个节点 |
Shell
中可以使用rr/1
导入记录,rd/2
定义记录,rf/0
删除记录,rl/0
展示记录
头文件
如果一个记录很多模块使用,可以使用头文件共享记录定义,头文件以.hrl
结尾
同时注意使用到的模块需要引入头文件,比如:-include("reocrd.hrl").
当然尽量避免不同功能的模块直接直接访问记录,可以提高代码的可读性和可维护性
键值存储
- 属性列表:处理小数据量,使用很少,比较局限
- 有序字典(orddict): 处理小数据量,为避免顺序错误,应该只使用其提供的接口
- 字典(dict): 处理大数据量,接口和有序字典完全一致
- 通用平衡树(gb_trees): 处理大数据量,有智能模式和简单模式
- 简单模式的操作函数:
enter/2
、lookup/2
、delete/2
、delete_any/2
- 智能模式的操作函数:
insert/3
、get/2
、update/2
、delete/2
- 智能模式是区分了已知键值是否存在的情况,从而不执行无用的检查达到更快的执行速度
dict
和gb_trees
使用起来基本差不多,通常字典读取性能更好,其他操作 GB 树更快一些
字典有fold
函数,而 GB 树只有next/1
来得到后面的值,意味着只能自己写递归函数遍历
GB 树保留了元素的顺序,有smallest/1
和largesst/1
快速获取最大/小元素
集合
ordsets
: 有序集合,适用小集合,最慢但是最容易理解new/0
、is_element/2
、add_element/2
、del_element/2
、union/1
、intersection/1
sets
: 与dict
类似,可以处理更大规模的数据,擅长读密集处理gb_sets
: 底层就是一颗gb_trees
,同样拥有智能模式和简单模式**,特性也类似sofs
: 集合的集合,使用有序列表实现,实现数学意义上的集合,二不仅仅是唯一元素时候恨有用
sets
和gb_sets
的选择类似dict
和gb_trees
,但是注意如果需要使用=:=
,则sets
是唯一选择
有向图
digraph
和digraph utils
,前者实现了构造和修改,后者实现了遍历、检测等功能
有需要的时候再深入了解也不是为一个好选择,文档中也非常容易找到相关内容
队列
queue
: new/0
、in/2
、out/1
、len/1
等
使用两个列表实现的双端队列
并发
并发指在VM
中存在多个独立进程,但是并不要求其同时运行
并行则是多个进程存在的情况下同时运行
伸缩性
让进程保持轻量是实现伸缩性必需的能力,也不必像进程池固定进程,可以需要多少使用多少Erlang
通过进制共享内存,采用消息传递机制的方式来实现并发的可靠性,效率低一点但是更安全
容错
得益于轻量进程和消息传递机制的设计,当出现某个错误可以重启进程,
保证进程继续工作的同时防止错误和坏数据传播。
分布式可以实现硬件故障导致的问题,独立的消息传递机制可以让进程在不同计算机的工作方式完全一样。
进程之间是相互独立的,每个进程只会监听自己的邮箱,处理自己的工作,以及向其他进程发消息(尽管他可能并不存在)
并发实现
Erlang
在自己的VM
中实现Erlang
进程,一个进程进占用几百字节的空间,创建时间仅几微秒
VM
为每个核启动一个线程调度器,调度器中有一个运行队列,为其中的每个Erlang
进程分一小段时间片。
同时虚拟机会自动进行负载均衡,当某个队列任务过多时会迁移到其他队列
创建进程
1 | Pid = spawn(fun() -> 1 + 1 end). % <x.xx.x> 返回一个进程标识符(pid) |
发送消息
1 | self() ! hello. % hello 给自己发一个消息 |
接受消息
1 | receive % 使用 receive 可以接受消息,与 case ... of 类似,也可以使用模式匹配和卫语句 |
如何回信?
显然,只需要我们将自己的进程标识符也发过去,对方也就可以回信了
1 | tom() -> |
简简单单几行代码就轻松实现了并发通信,更多的是需要理解并发逻辑在实际应用中如何处理
一个简单的例子
1 | %%% 一个可以存取的盒子进程 |
在Shell
中可以使用box:start().
启动盒子进程,使用box:add/2
添加物品,box:del/2
删除物品