第1章 接受现实
UNIX用户推崇的设计理念,是将命令行工具设计得小巧、功能单一。这些工具可以任意地组合起来使用。每个工具都是获取输入,转换输入内容,并以下一个工具(或人类)能够使用的格式输出结果。
这种设计理念非常灵活而且带来了极好的重用性。UNIX工具能够以其设计者预想不到的方式进行组合。而且每个工具都给其他工具添加了更多的可能性。
这种设计理念还是高可靠的–每个小程序只做好一件事,这就使测试变得更加容易。
还一个好处,就是命令管道可以并行工作。
$ grep Elixir *.pml | wc -1
grep命令运行的时候,字数统计程序wc也在运行。因为wc同步地处理grep的输出,所以一旦grep执行完毕,计算结果也就出来了。
Elixir 让我们以类似于UNIX shell的方式来解决问题,但不是用命令行工具,而是用函数。而且如果我们愿意,可以将它们串联起来。那些函数越小巧,功能越单一,组合以后的灵活性就越大。
我们甚至可以让这些函数以并行的方式运行-Elixir拥有简单而强大的机制来让它们能够彼此传递消息。而这些机制并非老掉牙的进程或线程–我们讨论的是允许单台机器上运行几百万个实例而且有几百台这样的机器进行交互的能力。BruceTate对此的想法是:“许多程序员将线程和进程看作是罪恶之源;Elixir开发者则认为它们是重要的简化机制。”
数据转换的理念是函数式编程的核心:函数将输入转换成输出。三角函数sin就是一个例子-给它输入$ pi/4$,你就会得到0.7071……。HTML模板系统也是函数;它接受包含占位符的模板和一组命名值,并生成完整的HTML文档。
换一种方式思考:
- 面向对象不是设计代码的唯一方法
- 函数式编程不一定是复杂的和纯数学的
- 编程的基础不是赋值、if语句和循环
- 并发不一定需要锁、信号量、监视器等类似的东西
- 进程不必消耗大量的资源
- 元编程不只是语言的附属品
- 即使编程是你的工作,也应该是充满乐趣的
第2章 模式匹配
Elixir 核心哲学中最重要、最颠覆传统语言的概念:模式匹配(Pattern Matching)。
在 Elixir 中,= 不是赋值符号,而是“匹配操作符”(Match Operator)。它更像数学里的等号(方程两边平衡),它的工作是:让左边的模式(Pattern)和右边的值(Value)进行匹配。
我们来逐行复盘你的实验,看看底层发生了什么:
a=1 $\rightarrow$ 成功(绑定)
- 动作: 左边是一个没有被绑定的变量 a,右边是数字 1。
- 底层逻辑: 当左边是一个干净的变量时,模式匹配必定成功。作为成功的副作用,值 1 被绑定到了变量 a 上。所以 IEx 返回了结果 1。
1=a $\rightarrow$ 成功(断言)
- 动作: 左边是数字字面量 1,右边是变量 a。
- 底层逻辑: 此时 a 里面已经是 1 了,所以等式变成了 1 = 1。左边的字面量 1 成功匹配了右边的 1。这里并没有发生赋值,而是进行了一次断言(Assertion)。因为两边相等,所以顺利通过。
1=2 $\rightarrow$ 报错 (MatchError)
- 动作: 左边是 1,右边是 2。
- 底层逻辑: 匹配器试图让 1 等于 2。因为字面量 1 无法匹配字面量 2,两边无法平衡,所以无情抛出 MatchError(模式匹配错误),告诉你右边的值 2 没办法塞进左边的模式 1 里。
2=a $\rightarrow$ 报错 (MatchError)
- 动作: 左边是 2,右边是当前值为 1 的变量 a。
- 底层逻辑: 此时等式相当于 2 = 1。由于左边是固定的字面量 2,它要求右边必须也是 2 才能匹配成功。但右边是 1,所以再次引发 MatchError。这充分证明了 = 左边如果是具体的值,它是没有赋值能力的。
a=2 $\rightarrow$ 成功(重绑定 / Rebinding)
- 动作: 左边是变量 a,右边是数字 2。
- 底层逻辑: 很多函数式编程语言(如 Erlang)是不允许变量改变的(只能绑定一次)。但 Elixir 允许变量重绑定。当左边又是孤零零的变量 a 时,匹配再次无条件成功,a 抛弃了旧值 1,重新绑定了新值 2。
在实际开发中,我们很少拿它来单纯比对数字,而是用来解构复杂的数据结构(比如元组 Tuple 或列表 List):
比如一个获取用户信息的函数返回了一个元组:{:ok, "张三"},我们可以这样匹配并直接拿到名字:
{:ok, name} = {:ok, "张三"},此时变量 name 就会被自动绑定为 "张三", IO.puts(name) 输出 "张三"
在Elixir里,等号不是赋值,而更像一种断言(assertion)。如果Elixir可以找到一种方式让等号的左边等于右边,则执行成功。Elixir将等号“=”称为匹配运算符(match operator)
iex> list=[1,2,[3,4,5]]
[1, 2, [3, 4, 5]]
iex> [a,b,c]=list
[1, 2, [3, 4, 5]]
iex> a
1
iex> b
2
iex> c
[3, 4, 5]
Elixir会试图让等号的左边等于右边。左边列表包含3个变量,而右边列表有3个值,所以将值设置到相应变量,等号左右两边才相等。Elixir 将这个处理过程称为模式匹配(pattermatching)。模式匹配只有在其值与模式的结构相同,而且模式中的项目与值中对应的项目都匹配时才算成功。
用(下画线 _)忽略匹配值:如果我们不需要获取匹配值,可以使用特殊变量_(下画线)。它类似于一般变量,只不过它会立即丢弃提供给它的任何值。在匹配过程中,它就像一个通配符,声称“我可以接受任何值”。下面的例子匹配所有包含3个元素并且第1个元素的值为1的列表:
iex> [a,_,_]=[1,2,3]
[1, 2, 3]
iex> a
1
每次匹配变量仅绑定一次:在匹配过程中,变量一旦被绑定为某个值,那么该值在匹配其余部分的时候就会保持不变。
iex> [a,a]=[1,1]
[1, 1]
iex> [a,a]=[1,2]
** (MatchError) no match of right hand side value:
[1, 2]
(stdlib 8.0.3) erl_eval.erl:796: :erl_eval.expr/6
iex:15: (file)
如果让变量已有值强制参与匹配(不修改变量的值),使用^前缀
iex> a=2
2
iex> ^a=1
** (MatchError) no match of right hand side value:
1
(stdlib 8.0.3) erl_eval.erl:796: :erl_eval.expr/6
iex:16: (file)
iex> [^a,2,3]=[1,2,3]
** (MatchError) no match of right hand side value:
[1, 2, 3]
(stdlib 8.0.3) erl_eval.erl:796: :erl_eval.expr/6
iex:16: (file)
iex> [a,2,3]=[1,2,3]
[1, 2, 3]
iex> a
1
iex>
第3章 不可变性
不可变的数据才是已知的:在Elixir中,所有值都是不可变的。最复杂的嵌套列表、数据库记录–这些东西的行为就跟最简单的整型值一样。它们的值都是不可变的。在Elixir里,变量一旦引用了一个列表,如[1,2,3],你就知道它总是引用相同的值(直到你重新绑定变量为止)。而这就使得并发变得没那么恐怖了。
假若你需要给[1,2,3]里的每个元素加上100呢?Elixir会基于原始数据生成包含新数据的副本,原始数据则保持不变,而你的操作也不会影响到其他持有原始数据引用的代码。
实验一:复杂的嵌套列表,就跟数字 1 一样稳固
在传统语言(如 Python 或 JavaScript)中,如果你把一个列表赋值给另一个变量,修改其中一个,另一个也会跟着变(因为它们共享同一个内存指针,这在并发时是灾难)。但在 Elixir 中,所有值都是绝对不可变的:
# 1. 创建一个复杂的嵌套列表,绑定到变量 list_a
iex> list_a = [1, [2, 3], "nested"]
[1, [2, 3], "nested"]
# 2. 把 list_a “赋值”(引用)给 list_b
iex> list_b = list_a
[1, [2, 3], "nested"]
# 3. 此时我们尝试调用 List 模块的函数,往 list_a 里面添加一个新元素
iex> List.insert_at(list_a, 0, "new_value")
["new_value", 1, [2, 3], "nested"]
# 4. 【见证奇迹的时刻】重新查看 list_a 和 list_b 的值:
iex> list_a
[1, [2, 3], "nested"]
iex> list_b
[1, [2, 3], "nested"]
没有任何函数能够真正“修改” list_a 的内部数据。List.insert_at 并没有在原地修改列表,而是在内存里高效复制并生成了一个全新的列表返回给你。list_a 和 list_b 引用的内容,从它们诞生的那一刻起,就永远被钉死在内存里了,绝对不会改变。这行为就和数字 1 + 1 返回 2,但数字 1 本身没有变成 2 是一模一样的。
实验二:变量重绑定,改变的是“标签”,而不是“数据”
# 1. 我们先让 list_b 记住当前的 list_a
iex> list_b = list_a
[1, [2, 3], "nested"]
# 2. 对 list_a 进行“重绑定”
iex> list_a = [9, 9, 9]
[9, 9, 9]
# 3. 看看刚才共享同一份数据的 list_b 变了没有?
iex> list_b
[1, [2, 3], "nested"]
在 Elixir 中,变量仅仅是一个“指向内存中某块数据的标签(指针)”。当你执行 list_a = [9, 9, 9] 时,你只是撕下了 list_a 这个标签,把它贴到了新数据 [9, 9, 9] 上。而原本那块 [1, [2, 3], "nested"] 的内存数据依然完好无损地躺在那里,被 list_b 牢牢引用着。数据本身,从未被修改过。
用不可变数据编写程序:一旦接受了这个概念,用不可变数据写程序就非常容易了。你只要记住,任何转换数据的函数都会返回新的数据副本。这样,我们永远都不是将字符串的首字母转换成大写,而是返回一份首字母转换成大写的字符串副本。
iex> name="elixir"
"elixir"
iex> cap_name=String.capitalize(name)
"Elixir"
iex> cap_name
"Elixir"
iex> name
"elixir"
如果你以前用面向对象语言编程,可能不喜欢我们将代码写成String.capitalizename而不是name.capitalize()。但在面向对象的语言里,对象大多拥有可变状态。当做 name.capitalize()这样的调用的时候,你无法得知是否已经修改了 name的内部状态,返回的是否是字符串首字母大写的副本,或两者都无法得知。情况变得非常模棱两可。
在函数式语言里,我们总是转换数据。我们从不就地修改它。每次使用这个语法的时候,都要记住这一点。
第4章 Elixir基础
Elixir的内置类型有:
- 值类型
- 任意大小的整数
- 浮点数:双精度 64 位浮点数,遵守 IEEE 754 标准,用于处理小数
- 原子:Elixir 灵魂级的值类型。其名字就是它的值(如 :ok, :error)。它们在内存中会被注册进一个“原子表”,相同名字的原子在内存里只有一份,因此比对速度极快(本质是比对内存整数地址),是模式匹配的绝对主力。
- 区间(技术上讲不是值类型,底层实现是结构体)
- 正则表达式(技术上讲不是值类型,底层实现是结构体)
- 系统类型
- PID和端口
- 引用
- 集合类型
- 元组
- 列表
- 散列表
- 二进制型
原子是常量,用于表示某些东西的名字。它以冒号(:)开头,其后跟随一个原子单词或者Elixir运算符。原子单词由字母、数字、下画线和符号“@”组成,以感叹号或者问号结尾。可以创建一个由任意字符序列组成的原子,只要其包含在双引号里并以冒号开头。下面都是原子类型的数据:
:fred
:is binary?
:var@2
:<>
:===
:"func/3"
:"long john silver"
原子的名字就是它的值。两个同名原子总是相等的,即使它们是由相隔万里的两台不同计算机的不同应用程序创建的。我们会大量使用原子来标记值。
区间被表示为开始..结束,开始和结束可以是任意类型值。但是,如果你想遍历区间中的值,其两端必须是整数。
# range_demo.exs
IO.puts("=== 1. 整数区间演示 ===")
int_range = 1..5
IO.puts("成功创建整数区间: #{inspect(int_range)}")
# 整数区间可以完美进行遍历
doubled_list = Enum.map(int_range, fn x -> x * 2 end)
IO.puts("遍历翻倍后的结果: #{inspect(doubled_list)}")
# 输出
#=== 1. 整数区间演示 ===
#成功创建整数区间: 1..5
#遍历翻倍后的结果: [2, 4, 6, 8, 10]
系统类型:这些类型对应着Erlang虚拟机的底层资源。
- PID和端口: PID是本地或者远端进程的引用,而端口是读/写资源(通常是对应用程序外部)的引用。当前进程的PID值可以通过调用self获得。当一个新进程被创建时会得到一个新的PID。
- 引用:make_ref 函数用于创建全局唯一的引用;任何其他引用都不会与之相等。本书不涉及如何使用引用。
元组
元组表示一组有序元素的集合。如同所有的Elixir数据结构,一旦元组被创建就无法修改。元组写在一对花括号内,其元素用逗号隔开。
{ 1, 2 } { :ok, 42,"next" } { :error, :enoent }
通常函数调用在不出错的情况下会返回一个元组,其第一个元素为原子:ok。例如
{:ok,#PID<0.39.0>}
列表
列表的字面量语法: [1,2,3]。这也许会让你认为列表和其他语言的数组类似,但是它不是(事实上,Elixir 里的元组类型更接近于通常意义的数组)。相反,列表是一个链式数据结构。列表要么为空,要么由首部和尾部组成。首部包含一个值,尾部本身是一个列表。
如第7章“列表和递归”所述,递归定义的列表是Elixi编程的核心部分之一。
考虑其实现特征,列表适合线性遍历,随机访问列表会很费时(为了获得第n个元素,不得不扫描前n-1个元素)。但取得列表的首部并提取出尾部总是很方便。
列表还有另外一个性能优势。一旦列表被构造出来,就永远不会发生变化。所以,假如要剔除列表的首部,仅留下尾部,就不再需要拷贝列表,仅返回尾部的指针即可。这是所有列表遍历技巧的基础,我们将在第7章详细说明。
Elixir有些运算符只对列表有效:
iex> [1,2,3]++[4,5,6] #连接
[1, 2, 3, 4, 5, 6]
iex> [1,2,3,4]--[2,4] #差集
[1, 3]
iex> 1 in [1,2,3,4] #属于
true
iex> 5 in [1,2,3,4]
false
关键字列表
因为我们常常需要一组键值对,Elixir提供了一个简写方式,如果这样写:
[ name: "Dave", city: "Dallas", likes: "Programming" ]
Elixir将其转换为一个由双值元组组成的列表:
[ {:name, "Dave"}, {:city, "Dallas"}, {:likes, "Programming"} ]
- 这里
{:name, "Dave"}语法糖写法name: "Dave" - 当关键字列表作为函数调用的最后一个参数时,Elixir允许去掉方括号。
DB.save record, [ {:use_transaction, true}, {:logging, "HIGH"} ]
可以更简洁地写成:
DB.save record, use_transaction: true, logging: "HIGH"