标签:Elixir

2743 ℉

15.10.09

Elixir入门教程<中>

7-键值列表-图-字典

到目前还没有讲到任何关联性数据结构,即那种可以将一个或几个值关联到一个key上。 不同语言有不同的叫法,如字典,哈希,关联数组,图,等等。

Elixir中有两种主要的关联性结构:键值列表(keyword list)和图(map)。

7.1-键值列表

在很多函数式语言中,常用二元元组的列表来表示关联性数据结构。 在Elixir中也是这样。当我们有了一个元组(不一定仅有两个元素的元组)的列表,并且每个元组的第一个元素是个原子, 那就称之为键值列表:

iex> list = [{:a, 1}, {:b, 2}]
[a: 1, b: 2]
iex> list == [a: 1, b: 2]
true
iex> list[:a]
1

当原子key和关联的值之间没有逗号分隔时,可以把原子的冒号拿到字母的后面。这时,原子与后面的数值之间要有一个空格。

如你所见,Elixir使用比较特殊的语法来定义这样的列表,但实际上它们会映射到一个元组列表。 因为它们是简单的列表而已,所有针对列表的操作,键值列表也可以用。

比如,可以用++运算符为列表添加元素:

iex> list ++ [c: 3]
[a: 1, b: 2, c: 3]
iex> [a: 0] ++ list
[a: 0, a: 1, b: 2]

上面例子中重复出现了:a这个key,这是允许的。 以这个key取值时,取回来的是第一个找到的(因为有序):

iex> new_list = [a: 0] ++ list
[a: 0, a: 1, b: 2]
iex> new_list[:a]
0

键值列表十分重要,它有两大特点: - 有序 - key可以重复(!仔细看上面两个示例)

例如,Ecto库使用这两个特点写出了精巧的DSL(用来写数据库query):

query = from w in Weather,
          where: w.prcp > 0,
          where: w.temp < 20,
        select: w

这些特性使得键值列表成了Elixir中为函数提供额外选项的默认手段。 在第5章我们讨论了if/2宏,提到了下方的语法:

iex> if false, do: :this, else: :that
:that

do: 和else: 就是键值列表!事实上代码等同于:

iex> if(false, [do: :this, else: :that])
:that

当键值列表是函数最后一个参数时,方括号就成了可选的。

为了操作关键字列表,Elixir提供了键值(keyword)模块。 记住,键值列表就是简单的列表,和列表一样提供了线性的性能。列表越长,获取长度或找到一个键值的速度越慢。 因此,关键字列表在Elixir中一般就作为函数调用的可选项。 如果你要存储大量数据,并且保证一个键只对应最多一个值,那就使用图。

对键值列表做模式匹配:

iex> [a: a] = [a: 1]
[a: 1]
iex> a
1
iex> [a: a] = [a: 1, b: 2]
** (MatchError) no match of right hand side value: [a: 1, b: 2]
iex> [b: b, a: a] = [a: 1, b: 2]
** (MatchError) no match of right hand side value: [a: 1, b: 2]

尽管如此,对列表使用模式匹配很少用到。因为不但要元素个数相等,顺序还要匹配。

7.2-图(maps)

无论何时想用键-值结构,图都应该是你的第一选择。Elixir中,用%{}定义图:

iex> map = %{:a => 1, 2 => :b}
%{2 => :b, :a => 1}
iex> map[:a]
1
iex> map[2]
:b

和键值列表对比,图有两主要区别: - 图允许任何类型值作为键 - 图的键没有顺序

如果你向图添加一个已有的键,将会覆盖之前的键-值对:

iex> %{1 => 1, 1 => 2}
%{1 => 2}

如果图中的键都是原子,那么你也可以用键值列表中的一些语法:

iex> map = %{a: 1, b: 2}
%{a: 1, b: 2}

对比键值列表,图的模式匹配很是有用:

iex> %{} = %{:a => 1, 2 => :b}
%{:a => 1, 2 => :b}
iex> %{:a => a} = %{:a => 1, 2 => :b}
%{:a => 1, 2 => :b}
iex> a
1
iex> %{:c => c} = %{:a => 1, 2 => :b}
** (MatchError) no match of right hand side value: %{2 => :b, :a => 1}

如上所示,图A与另一个图B做匹配。图B中只要包含有图A的键,那么两个图就能匹配上。若图A是个空的,那么任意图B都能匹配上。 但是如果图B里不包含图A的键,那就匹配失败了。

图还有个有趣的功能:它提供了特殊的语法来修改和访问原子键:

iex> map = %{:a => 1, 2 => :b}
%{:a => 1, 2 => :b}
iex> map.a
1
iex> %{map | :a => 2}
%{:a => 2, 2 => :b}
iex> %{map | :c => 3}
** (ArgumentError) argument error

使用上面两种语法要求的前提是所给的键是切实存在的。最后一条语句错误的原因就是键:c不存在。

未来几章中我们还将讨论结构体(structs)。结构体提供了编译时的保证,它是Elixir多态的基础。 结构体是基于图的,上面例子提到的修改键值的前提就变得十分重要。

图模块提供了许多关于图的操作。它提供了与键值列表许多相似的API,因为这两个数据结构都实现了字典的行为。

图是最近连同EEP 43被引入Erlang虚拟机的。 Erlang 17提供了EEP的部分实现,只支持_一小部分_图功能。 这意味着图仅在存储不多的键时,图的性能还行。为了解决这个问题,Elixir还提供了HashDict模块该模块提供了一个字典来支持大量的键,并且性能不错。

7.3-字典(Dicts)

Elixir中,键值列表和图都被称作字典。换句话说,一个字典就像一个接口(在Elixir中称之为行为behaviour)。 键值列表和图模块实现了该接口。

这个接口定义于Dict模块,该模块还提供了底层实现的一个API:

iex> keyword = []
[]
iex> map = %{}
%{}
iex> Dict.put(keyword, :a, 1)
[a: 1]
iex> Dict.put(map, :a, 1)
%{a: 1}

字典模块允许开发者实现他们自己的字典形式,提供一些特殊的功能,然后联系到现存的的Elixir代码中去。 字典模块还提供了所有字典类型都可以使用的函数。如,Dicr.equal?/2可以比较两个字典类型(可以是不同的实现)。

你会疑惑些程序时用keyword,Map还是Dict模块呢?答案是:看情况。

如果你的代码期望接受一个关键字作为参数,那么使用简直列表模块。如果你想操作一个图,那就使用图模块。 如果你想你的API对所有字典类型的实现都有用,那就使用字典模块(确保以不同的实现作为参数测试一下)。

8-模块

Elixir中我们把许多函数组织成一个模块。我们在前几章已经提到了许多模块,如String模块

iex> String.length "hello"
5

创建自己的模块,用defmodule宏。用def宏在其中定义函数:

iex> defmodule Math do
...>   def sum(a, b) do
...>     a + b
...>   end
...> end

iex> Math.sum(1, 2)
3

像ruby一样,模块名大写起头

8.1-编译

通常把模块写进文件,这样可以编译和重用。假如文件math.ex有如下内容:

defmodule Math do
  def sum(a, b) do
    a + b
  end
end

这个文件可以用elixirc进行编译:

$ elixirc math.ex

这将生成名为Elixir.Math.beam的bytecode文件。 如果这时再启动iex,那么这个模块就已经可以用了(假如在含有该编译文件的目录启动iex):

iex> Math.sum(1, 2)
3

Elixir工程通常组织在三个文件夹里: - ebin,包括编译后的字节码 - lib,包括Elixir代码(.ex文件) - test,测试代码(.exs文件)

实际项目中,构建工具Mix会负责编译,并且设置好正确的路径。 而为了学习方便,Elixir也提供了脚本模式,可以更灵活而不用编译。

8.2-脚本模式

除了.ex文件,Elixir还支持.exs脚本文件。Elixir对两种文件一视同仁,唯一区别是.ex文件有待编译, 而.exs文件用来作脚本执行,不需要编译。例如,如下创建名为math.exs的文件:

defmodule Math do
  def sum(a, b) do
    a + b
  end
end

IO.puts Math.sum(1, 2)

执行之:

$ elixir math.exs

这中情况,文件将在内存中编译和执行,打印出“3”作为结果。没有比特码文件生成。 后文中(为了学习和练习方便),推荐使用脚本模式执行学到的代码。

8.3-命名函数

在某模块中,我们可以用def/2宏定义函数,用defp/2定义私有函数。 用def/2定义的函数可以被其它模块中的代码使用,而私有函数仅在定义它的模块内使用。

defmodule Math do
  def sum(a, b) do
    do_sum(a, b)
  end

  defp do_sum(a, b) do
    a + b
  end
end

Math.sum(1, 2)    #=> 3
Math.do_sum(1, 2) #=> ** (UndefinedFunctionError)

函数声明也支持使用卫兵或多个子句。 如果一个函数有好多子句,Elixir会匹配每一个子句直到找到一个匹配的。 下面例子检查参数是否是数字:

defmodule Math do
  def zero?(0) do
    true
  end

  def zero?(x) when is_number(x) do
    false
  end
end

Math.zero?(0)  #=> true
Math.zero?(1)  #=> false

Math.zero?([1,2,3])
#=> ** (FunctionClauseError)

如果没有一个子句能匹配参数,会报错。

8.4-函数捕捉

本教程中提到函数,都是用name/arity的形式描述。这种表示方法可以被用来获取一个命名函数(赋给一个函数型变量)。 下面用iex执行一下上文定义的math.exs文件:

$ iex math.exs
iex> Math.zero?(0)
true
iex> fun = &Math.zero?/1
&Math.zero?/1
iex> is_function fun
true
iex> fun.(0)
true

&<function notation>从函数名捕捉一个函数,它本身代表该函数值(函数类型的值)。 它可以不必赋给一个变量,直接用括号来使用该函数。 本地定义的,或者已导入的函数,比如is_function/1,可以不前缀模块名:

iex> &is_function/1
&:erlang.is_function/1
iex> (&is_function/1).(fun)
true

这种语法还可以作为快捷方式使用与创建函数:

iex> fun = &(&1 + 1)
#Function<6.71889879/1 in :erl_eval.expr/5>
iex> fun.(1)
2

&1 表示传给该函数的第一个参数。上面例子中,&(&1+1)其实等同于n x->x+1 end。 在创建短小函数时,这个很方便。想要了解更多关于&捕捉操作符,参考Kernel.SpecialForms文档

8.5-默认参数

Elixir中,命名函数也支持默认参数:

defmodule Concat do
  def join(a, b, sep \\ " ") do
    a <> sep <> b
  end
end

IO.puts Concat.join("Hello", "world")      #=> Hello world
IO.puts Concat.join("Hello", "world", "_") #=> Hello_world

任何表达式都可以作为默认参数,但是只在函数调用时用到了才被执行(函数定义时,那些表达式只是存在那儿,不执行;函数调用时,没有用到默认值,也不执行)。

defmodule DefaultTest do
  def dowork(x \\ IO.puts "hello") do
    x
  end
end
iex> DefaultTest.dowork 123
123
iex> DefaultTest.dowork
hello
:ok

如果有默认参数值的函数有了多条子句,推荐先定义一个函数头(无具体函数体)仅为了声明这些默认值:

defmodule Concat do
  def join(a, b \\ nil, sep \\ " ")

  def join(a, b, _sep) when nil?(b) do
    a
  end

  def join(a, b, sep) do
    a <> sep <> b
  end
end

IO.puts Concat.join("Hello", "world")      #=> Hello world
IO.puts Concat.join("Hello", "world", "_") #=> Hello_world
IO.puts Concat.join("Hello")               #=> Hello

使用默认值时,注意对函数重载会有一定影响。考虑下面例子:

defmodule Concat do
  def join(a, b) do
    IO.puts "***First join"
    a <> b
  end

  def join(a, b, sep \\ " ") do
    IO.puts "***Second join"
    a <> sep <> b
  end
end

如果将以上代码保存在文件“concat.ex”中并编译,Elixir会报出以下警告:

concat.ex:7: this clause cannot match because a previous clause at line 2 always matches

编译器是在警告我们,在使用两个参数调用join函数时,总使用第一个函数定义。 只有使用三个参数调用时,才会使用第二个定义:

$ iex concat.exs
iex> Concat.join "Hello", "world"
***First join
"Helloworld"
iex> Concat.join "Hello", "world", "_"
***Second join
"Hello_world"

后面几章将介绍使用命名函数来做循环,如何从别的模块中导入函数,以及模块的属性等。

9-递归

因为在Elixir中(或所有函数式语言中),数据有不变性(immutability),因此在写循环时与传统的命令式(imperative)语言有所不同。 例如某命令式语言的循环可以这么写:

for(i = 0; i < array.length; i++) {
  array[i] = array[i] * 2
}

上面例子中,我们改变了array,以及辅助变量i的值。这在Elixir中是不可能的。 尽管如此,函数式语言却依赖于某种形式的循环—递归:一个函数可以不断地被递归调用,直到某条件满足才停止。 考虑下面的例子:打印一个字符串若干次:

defmodule Recursion do
  def print_multiple_times(msg, n) when n <= 1 do
    IO.puts msg
  end

  def print_multiple_times(msg, n) do
    IO.puts msg
    print_multiple_times(msg, n - 1)
  end
end

Recursion.print_multiple_times("Hello!", 3)
# Hello!
# Hello!
# Hello!

一个函数可以有许多子句(上面看起来定义了两个函数,但卫兵条件不同,可以看作同一个函数的两个子句)。 当参数匹配该子句的模式,且该子句的卫兵表达式返回true,才会执行该子句内容。 上面例子中,当print_multiple_times/2第一次调用时,n的值是3。

第一个子句有卫兵表达式要求n必须小于等于1。因为不满足此条件,代码找该函数的下一条子句。

参数匹配第二个子句,且该子句也没有卫兵表达式,因此得以执行。 首先打印msg,然后调用自身并传递第二个参数n-1(=2)。 这样msg又被打印一次,之后调用自身并传递参数n-1(=1)。

这个时候,n满足第一个函数子句条件。遂执行该子句,打印msg,然后就此结束。

我们称例子中第一个函数子句这种子句为“基本情形”。 基本情形总是最后被执行,因为起初通常都不匹配执行条件,程序而转去执行其它子句。 但是,每执行一次其它子句,条件都向这基本情形靠拢一点,直到最终回到基本情形处执行代码。

下面我们使用递归的威力来计算列表元素求和:

defmodule Math do
  def sum_list([head|tail], accumulator) do
    sum_list(tail, head + accumulator)
  end

  def sum_list([], accumulator) do
    accumulator
  end
end

Math.sum_list([1, 2, 3], 0) #=> 6

我们首先用列表[1,2,3]和初值0作为参数调用函数,程序将逐个匹配各子句的条件,执行第一个符合要求的子句。 于是,参数首先满足例子中定义的第一个子句。参数匹配使得head = 1,tail = [2,3],accumulator = 0。

然后执行该字据内容,把head + accumulator作为第二个参数,连带去掉了head的列表做第一个参数,再次调用函数本身。 如此循环,每次都把新传入的列表的head加到accumulator上,传递去掉了head的列表。 最终传递的列表为空,符合第二个子句的条件,执行该子句,返回accumulator的值6。

几次函数调用情况如下:

sum_list [1, 2, 3], 0
sum_list [2, 3], 1
sum_list [3], 3
sum_list [], 6

这种使用列表做参数,每次削减一点列表的递归方式,称为“递减”算法,是函数式编程的核心。
如果我们想给每个列表元素加倍呢?:

defmodule Math do
  def double_each([head|tail]) do
    [head * 2| double_each(tail)]
  end

  def double_each([]) do
    []
  end
end

Math.double_each([1, 2, 3]) #=> [2, 4, 6]

此处使用了递归来遍历列表元素,使它们加倍,然后返回新的列表。 这样以列表为参数,递归处理其每个元素的方式,称为“映射(map)”算法。

递归和列尾调用优化(tail call optimization)是Elixir中重要的部分,通常用来创建循环。 尽管如此,在Elixir中你很少会使用以上方式来递归地处理列表。

下一章要介绍的Enum模块为操作列表提供了诸多方便。 比如,下面例子:

iex> Enum.reduce([1, 2, 3], 0, fn(x, acc) -> x + acc end)
6
iex> Enum.map([1, 2, 3], fn(x) -> x * 2 end)
[2, 4, 6]

或者,使用捕捉的语法:

iex> Enum.reduce([1, 2, 3], 0, &+/2)
6
iex> Enum.map([1, 2, 3], &(&1 * 2))
[2, 4, 6]

10-枚举类型和流

10.1-枚举类型

Elixir提供了枚举类型(enumerables)的概念,使用[Enum模块]()操作它们。我们已经介绍过两种枚举类型:列表和图。

iex> Enum.map([1, 2, 3], fn x -> x * 2 end)
[2, 4, 6]
iex> Enum.map(%{1 => 2, 3 => 4}, fn {k, v} -> k * v end)
[2, 12]

Enum模块为枚举类型提供了大量函数来变化,排序,分组,过滤和读取元素。 Enum模块是开发者最常用的模块之一。

Elixir还提供了范围(range):

iex> Enum.map(1..3, fn x -> x * 2 end)
[2, 4, 6]
iex> Enum.reduce(1..3, 0, &+/2)
6

因为Enum模块在设计时为了适用于不同的数据类型,所以它的API被限制为多数据类型适用的函数。 为了实现某些操作,你可能需要针对某类型使用某特定的模块。 比如,如果你要在列表中某特定位置插入一个元素,要用List模块中的List.insert_at/3函数。而向某些类型内插入数据是没意义的,比如范围。

Enum中的函数是多态的,因为它们能处理不同的数据类型。 尤其是,模块中可以适用于不同数据类型的函数,它们是遵循了Enumerable协议。 我们在后面章节中将讨论这个协议。下面将介绍一种特殊的枚举类型:流。

10.2-积极vs懒惰

Enum模块中的所有函数都是积极的。多数函数接受一个枚举类型,并返回一个列表:

iex> odd? = &(rem(&1, 2) != 0)
#Function<6.80484245/1 in :erl_eval.expr/5>
iex> Enum.filter(1..3, odd?)
[1, 3]

这意味着当使用Enum进行多种操作时,每次操作都生成一个中间列表,直到得出最终结果:

iex> 1..100_000 |> Enum.map(&(&1 * 3)) |> Enum.filter(odd?) |> Enum.sum
7500000000

上面例子是一个含有多个操作的管道。从一个范围开始,然后给每个元素乘以3。 该操作将会生成的中间结果是含有100000个元素的列表。 然后我们过滤掉所有偶数,产生又一个新中间结果:一个50000元素的列表。 最后求和,返回结果。 >这个符号的用法似乎和F#中的不一样啊…

作为一个替代,流模块提供了懒惰的实现:

iex> 1..100_000 |> Stream.map(&(&1 * 3)) |> Stream.filter(odd?) |> Enum.sum
7500000000

与之前Enum的处理不同,流先创建了一系列的计算操作。然后仅当我们把它传递给Enum模块,它才会被调用。流这种方式适用于处理大量的(甚至是无限的)数据集合。

10.3-流

流是懒惰的,比起Enum来说。 分步分析一下上面的例子,你会发现流与Enum的区别:

iex> 1..100_000 |> Stream.map(&(&1 * 3))
#Stream<[enum: 1..100000, funs: [#Function<34.16982430/1 in Stream.map/2>]]>

流操作返回的不是结果列表,而是一个数据类型—流,一个表示要对范围1..100000使用map操作的动作。

另外,当我们用管道连接多个流操作时:

iex> 1..100_000 |> Stream.map(&(&1 * 3)) |> Stream.filter(odd?)
#Stream<[enum: 1..100000, funs: [...]]>

流模块中的函数接受任何枚举类型为参数,返回一个流。 流模块还提供了创建流(甚至是无限操作的流)的函数。 例如,Stream.cycle/1可以用来创建一个流,它能无限周期性枚举所提供的参数(小心使用):

iex> stream = Stream.cycle([1, 2, 3])
#Function<15.16982430/2 in Stream.cycle/1>
iex> Enum.take(stream, 10)
[1, 2, 3, 1, 2, 3, 1, 2, 3, 1]

另一方面,Stream.unfold/2函数可以生成给定的有限值:

iex> stream = Stream.unfold("hełło", &String.next_codepoint/1)
#Function<39.75994740/2 in Stream.unfold/2>
iex> Enum.take(stream, 3)
["h", "e", "ł"]

另一个有趣的函数是Stream.resource/3,它可以用来包裹某资源,确保该资源在使用前打开,在用完后关闭(即使中途出现错误)。–类似C#中的use{}关键字。 比如,我们可以stream一个文件:

iex> stream = File.stream!("path/to/file")
#Function<18.16982430/2 in Stream.resource/3>
iex> Enum.take(stream, 10)

这个例子读取了文件的前10行内容。流在处理大文件,或者慢速资源(如网络)时非常有用。

一开始Enum和流模块中函数的数量多到让人气馁。但你会慢慢地熟悉它们。 建议先熟悉Enum模块,然后因为应用而转去流模块中那些相应的,懒惰版的函数。

11-进程

Elixir中,所有代码都在进程中执行。进程彼此独立,一个接一个并发执行,彼此通过消息传递来沟通。 进程不仅仅是Elixir中最基本的并发单位,它们还是Elixir创建分布式和高容错程序的基础。

Elixir的进程和操作系统中的进程不可混为一谈。 Elixir的进程非常非常地轻量级(在使用CPU和内存角度上说。但是它又不同于其它语言中的线程)。 因此,同时运行着数万个进程也并不是罕见的事。

本章将讲解派生新进程的基本知识,以进程间如何发送和接受消息。

11.1-进程派生

派生(spawning)一个新进程的方法是使用自动导入(kernel函数)的spawn/1函数:

iex> spawn fn -> 1 + 2 end
#PID<0.43.0>

函数spawn/1接收一个_函数_作为参数,在其派生出的进程中执行这个函数。

注意spawn/1返回一个PID(进程标识)。在这个时候,这个派生的进程很可能已经结束。 派生的进程执行完函数后便会结束:

iex> pid = spawn fn -> 1 + 2 end
#PID<0.44.0>
iex> Process.alive?(pid)
false

你可能会得到与例子中不一样的PID

self/0函数获取当前进程的PID:

iex> self()
#PID<0.41.0>
iex> Process.alive?(self())
true

而可以发送和接收消息,让进程变得越来越有趣。

11.2-发送和接收

使用send/2函数发送消息,用receive/1接收消息:

iex> send self(), {:hello, "world"}
{:hello, "world"}
iex> receive do
...>   {:hello, msg}  -> msg
...>   {:world, msg} -> "won't match"
...> end
"world"

当有消息被发给某进程,该消息就被存储在该进程的邮箱里。receive/1语句块检查当前进程的邮箱,寻找匹配给定模式的消息。 函数receive/1支持分支子句,如case/2。 当然也可以给子句加上卫兵表达式。

如果找不到匹配的消息,当前进程将一直等待,知道下一条信息到达。但是可以设置一个超时时间:

iex> receive do
...>   {:hello, msg}  -> msg
...> after
...>   1_000 -> "nothing after 1s"
...> end
"nothing after 1s"

超时时间设为0表示你知道当前邮箱内肯定有邮件存在,很自信,因此设了这个极短的超时时间。

把以上概念综合起来,演示进程间发送消息:

iex> parent = self()
#PID<0.41.0>
iex> spawn fn -> send(parent, {:hello, self()}) end
#PID<0.48.0>
iex> receive do
...>   {:hello, pid} -> "Got hello from #{inspect pid}"
...> end
"Got hello from #PID<0.48.0>"

在shell中执行程序时,辅助函数flush/0很有用。它清空缓冲区,打印进程邮箱中的所有消息:

iex> send self(), :hello
:hello
iex> flush()
:hello
:ok

11.3-链接

Elixir中最常用的进程派生方式是通过函数spawn_link/1。 在举例子讲解spawn_link/1之前,来看看如果一个进程失败了会发生什么:

iex> spawn fn -> raise "oops" end
#PID<0.58.0>

。。。啥也没发生。这时因为进程都是互不干扰的。如果我们希望一个进程中发生失败可以被另一个进程知道,我们需要链接它们。 使用spawn_link/1函数,例子:

iex> spawn_link fn -> raise "oops" end
#PID<0.60.0>
** (EXIT from #PID<0.41.0>) an exception was raised:
    ** (RuntimeError) oops
        :erlang.apply/2

当失败发生在shell中,shell会自动终止执行,并显示失败信息。这导致我们没法看清背后过程。 要弄明白链接的进程在失败时发生了什么,我们在一个脚本文件使用spawn_link/1并且执行和观察它:

# spawn.exs
spawn_link fn -> raise "oops" end

receive do
  :hello -> "let's wait until the process fails"
end

这次,该进程在失败时把它的父进程也弄停止了,因为它们是链接的。
手动链接进程:Process.link/1。 建议可以多看看Process模块,里面包含很多常用的进程操作函数。

进程和链接在创建能高容错系统时扮演重要角色。在Elixir程序中,我们经常把进程链接到某“管理者”上。 由这个角色负责检测失败进程,并且创建新进程取代之。因为进程间独立,默认情况下不共享任何东西。 而且当一个进程失败了,也不会影响其它进程。 因此这种形式(进程链接到“管理者”角色)是唯一的实现方法。

其它语言通常需要我们来try-catch异常,而在Elixir中我们对此无所谓,放手任进程挂掉。 因为我们希望“管理者”会以更合适的方式重启系统。 “要死你就快一点”是Elixir软件开发的通用哲学。

在讲下一章之前,让我们来看一个Elixir中常见的创建进程的情形。

11.4-状态

目前为止我们还没有怎么谈到状态。但是,只要你创建程序,就需要状态。 例如,保存程序的配置信息,或者分析一个文件先把它保存在内存里。 你怎么存储状态?

进程就是(最常见的)答案。我们可以写无限循环的进程,保存一个状态,然后通过收发信息来告知或改变该状态。 例如,写一个模块文件,用来创建一个提供k-v仓储服务的进程:

defmodule KV do
  def start do
    {:ok, spawn_link(fn -> loop(%{}) end)}
  end

  defp loop(map) do
    receive do
      {:get, key, caller} ->
        send caller, Map.get(map, key)
        loop(map)
      {:put, key, value} ->
        loop(Map.put(map, key, value))
    end
  end
end

注意start函数简单地派生一个新进程,这个进程以一个空的图为参数,执行loop/1函数。 这个loop/1函数等待消息,并且针对每个消息执行合适的操作。 加入受到一个:get消息,它把消息发回给调用者,然后再次调用自身loop/1,等待新消息。 当受到:put消息,它便用一个新版本的图变量(里面的k-v更新了)再次调用自身。

执行一下试试:

iex> {:ok, pid} = KV.start
#PID<0.62.0>
iex> send pid, {:get, :hello, self()}
{:get, :hello, #PID<0.41.0>}
iex> flush
nil

一开始进程内的图变量是没有键值的,所以发送一个:get消息并且刷新当前进程的收件箱,返回nil。 下面再试试发送一个:put消息:

iex> send pid, {:put, :hello, :world}
#PID<0.62.0>
iex> send pid, {:get, :hello, self()}
{:get, :hello, #PID<0.41.0>}
iex> flush
:world

注意进程是怎么保持一个状态的:我们通过同该进程收发消息来获取和更新这个状态。 事实上,任何进程只要知道该进程的PID,都能读取和修改状态。

还可以注册这个PID,给它一个名称。这使得人人都知道它的名字,并通过名字来向它发送消息:

iex> Process.register(pid, :kv)
true
iex> send :kv, {:get, :hello, self()}
{:get, :hello, #PID<0.41.0>}
iex> flush
:world

使用进程维护状态,以及注册进程都是Elixir程序非常常用的方式。 但是大多数时间我们不会自己实现,而是使用Elixir提供的抽象实现。 例如,Elixir提供的agent就是一种维护状态的简单的抽象实现:

iex> {:ok, pid} = Agent.start_link(fn -> %{} end)
{:ok, #PID<0.72.0>}
iex> Agent.update(pid, fn map -> Map.put(map, :hello, :world) end)
:ok
iex> Agent.get(pid, fn map -> Map.get(map, :hello) end)
:world

Agent.start/2方法加一个一个:name选项可以自动为其注册一个名字。 除了agents,Elixir还提供了创建通用服务器(generic servers,称作GenServer)、 通用时间管理器以及事件处理器(又称GenEvent)的API。 这些,连同“管理者”树,都可以在Mix和OTP手册里找到详细说明。

12-I/O

本章简单介绍Elixir的输入、输出机制,以及相关的模块,如IO文件路径

现在介绍I/O似乎有点早,但是I/O系统可以让我们一窥Elixir哲学,满足我们对该语言以及VM的好奇心。

12.1-IO模块

IO模块是Elixir语言中读写标准输入、输出、标准错误、文件、设备的主要机制。 使用该模块的方法颇为直接:

iex> IO.puts "hello world"
"hello world"
:ok
iex> IO.gets "yes or no? "
yes or no? yes
"yes\n"

IO模块中的函数默认使用标准输入输出。 我们也可以传递:stderr来指示将错误信息写到标准错误设备上:

iex> IO.puts :stderr, "hello world"
"hello world"
:ok

12.2-文件模块

文件模块包含了可以让我们读写文件的函数。 默认情况下文件是以二进制模式打开,它要求程序员使用特殊的IO.binread/2IO.binwrite/2函数来读写文件:

iex> {:ok, file} = File.open "hello", [:write]
{:ok, #PID<0.47.0>}
iex> IO.binwrite file, "world"
:ok
iex> File.close file
:ok
iex> File.read "hello"
{:ok, "world"}

文件可以使用:utf8编码打开,然后就可以被IO模块中其他函数使用了:

iex> {:ok, file} = File.open "another", [:write, :utf8]
{:ok, #PID<0.48.0>}

除了打开、读写文件的函数,文件模块还有许多函数来操作文件系统。这些函数根据Unix功能相对应的命令命名。 如File.rm/1用来删除文件;File.mkdir/1用来创建目录;File.mkdir_p/1创建目录并保证其父目录一并创建; 还有File.cp_r/2File.rm_rf/2用来递归地复制和删除整个目录。

你还会注意到文件模块中,函数一般有一个名称类似的版本。区别是名称上一个有!(bang)一个没有。 例如,上面的例子我们在读取“hello”文件时,用的是不带!号的版本。 下面用例子演示下它们的区别:

iex> File.read "hello"
{:ok, "world"}
iex> File.read! "hello"
"world"
iex> File.read "unknown"
{:error, :enoent}
iex> File.read! "unknown"
** (File.Error) could not read file unknown: no such file or directory

注意看,当文件不存在时,带!号的版本会报错。就是说不带!号的版本能照顾到模式匹配出来的不同的情况。 但有的时候,你就是希望文件在那儿,!使得它能报出有意义的错误。 因此,不要写:

{:ok, body} = File.read(file)

相反地,应该这么写:

case File.read(file) do
  {:ok, body} -> # handle ok
  {:error, r} -> # handle error
end

或者

File.read!(file)

12.3-路径模块

文件模块中绝大多数函数都以路径作为参数。通常这些路径都是二进制,可以被路径模块提供的函数操作:

iex> Path.join("foo", "bar")
"foo/bar"
iex> Path.expand("~/hello")
"/Users/jose/hello"


有了以上介绍的几个模块和函数,我们已经能对文件系统进行基本的IO操作。 下面将讨论IO令人好奇的高级话题。这部分不是写Elixir程序必须掌握的,可以跳过不看。 但是如果你大概地看看,可以了解一下IO是如何在VM上实现以及其它一些有趣的内容。

12.4-进程和组长

你可能已经发现,File.open/2函数返回了一个包含PID的元祖:

iex> {:ok, file} = File.open "hello", [:write]
{:ok, #PID<0.47.0>}

这是因为IO模块实际上是同进程协同工作的。当你调用IO.write(pid, binary)时,IO模块将发送一条消息给执行操作的进程。 让我们用自己的代码表述下这个过程:

iex> pid = spawn fn ->
...>  receive do: (msg -> IO.inspect msg)
...> end
#PID<0.57.0>
iex> IO.write(pid, "hello")
{:io_request, #PID<0.41.0>, #PID<0.57.0>, {:put_chars, :unicode, "hello"}}
** (ErlangError) erlang error: :terminated

调用IO.write/2之后,可以看见打印出了发给IO模块的请求。然而因为我们没有提供某些东西,这个请求失败了。

StringIO模块提供了一个基于字符串的IO实现:

iex> {:ok, pid} = StringIO.open("hello")
{:ok, #PID<0.43.0>}
iex> IO.read(pid, 2)
"he"

Erlang虚拟机用进程给IO设备建模,允许同一个网络中的不同节点通过交换文件进程,实现节点间的文件读写。 在所有IO设备之中,有一个特殊的进程,称作组长(group leader)。

当你写东西到标准输出,实际上是发送了一条消息给组长,它把内容写给*STDIO文件表述者*:

iex> IO.puts :stdio, "hello"
hello
:ok
iex> IO.puts Process.group_leader, "hello"
hello
:ok

组长可为每个进程做相应配置,用于处理不同的情况。 例如,当在远程终端执行代码时,它能保证远程机器的消息可以被重定向到发起操作的终端上。

12.5-*iodata*和chardata

在以上所有例子中,我们都用的是二进制/字符串方式读写文件。 在“二进制、字符串和字符列表”那章里,我们注意到字符串就是普通的bytes,而字符列表是code points的列表。

IO模块和文件模块中的函数接受列表作为参数。这也就算了,其实还可以接受混合类型的列表,里面是整形、二进制都行:

iex> IO.puts 'hello world'
hello world
:ok
iex> IO.puts ['hello', ?\s, "world"]
hello world
:ok

尽管如此,有些地方还是要注意。一个列表可能表示一串byte,或者一串字符。用哪一种看IO设备是怎么编码的。 如果不指明编码,文件就以raw模式打开,这时候只能用文件模块里bin*开头(二进制版)的函数对其进行操作。 这些函数接受*iodata*作为参数,即,它们期待一个整数值的列表,用来表示byte或二进制。

尽管只是细微的差别,但你只需要考虑那些细节,如果你打算传递列表给那些函数。 底层的bytes已经可以表示二进制,这种表示就是raw的。

13-别名和代码引用

为了实现软件重用,Elixir提供了三种指令(directives)。之所以称之为“指令”是因为它们的作用域是词法作用域(lexicla scope)。

13.1-别名

alias可以为任何模块名设置别名。想象一下Math模块,它针对特殊的数学运算使用了特殊的列表实现:

defmodule Math do
  alias Math.List, as: List
end

现在,任何对List的引用将被自动变成对Math.List的引用。 如果还想访问原来的List,需要前缀’Elixir’:

List.flatten             #=> uses Math.List.flatten
Elixir.List.flatten      #=> uses List.flatten
Elixir.Math.List.flatten #=> uses Math.List.flatten

Elixir中定义的所有模块都在一个主Elixir命名空间。但是为方便起见,我们平时都不再前面加‘Elixir’。

别名常被使用与定义快捷方式中。实际上不带as选项去调用alias会自动将这个别名设置为模块名的最后一部分:

alias Math.List

就相当于:

alias Math.List, as: List

注意,别名是词法作用域。即,允许你在某个函数中设置别名:

defmodule Math do
  def plus(a, b) do
    alias Math.List
    # ...
  end

  def minus(a, b) do
    # ...
  end
end

上面例子中,alias指令只在函数plus/2中有用,minus/2不受影响。

13.2-require

Elixir提供了许多宏,用于元编程(写能生成代码的代码)。

宏就是一堆代码,只是它是在编译时被展开和执行。就是说,为了使用一个宏,你需要确保它的模块和实现在编译时可用。 这使用require指令:

iex> Integer.odd?(3)
** (CompileError) iex:1: you must require Integer before invoking the macro Integer.odd?/1
iex> require Integer
nil
iex> Integer.odd?(3)
true

Elixir中,Integer.odd?/1函数被定义为一个宏,因此他可以被当作卫兵表达式(guards)使用。 为了调用这个宏,你首先得确保用require引用了_Integer_模块。

总的来说,一个模块在被用到之前不需要被require引用,除非我们想让这个宏在整个模块中可用。 尝试调用一个没有引入的宏会导致报错。注意,像alias指令一样,require也是词法作用域的。 下文中我们会进一步讨论宏。

13.3-import

当我们想轻松地访问别的模块中的函数和宏时,我们使用import指令加上那个模块完整的名字。 例如,如果我们想多次使用List模块中的duplicate函数,我们可以简单地import它:

iex> import List, only: [duplicate: 2]
nil
iex> duplicate :ok, 3
[:ok, :ok, :ok]

这个例子中,我们只从List模块导入了函数duplicate/2。尽管only:选项是可选的,但是推荐使用。 除了only:选项,还有except:选项。 使用选项only:还可以传递给它:macros:functions。如下面例子,程序仅导入Integer模块中所有的宏:

import Integer, only: :macros

或者,仅导入所有的函数:

import Integer, only: :functions

注意,import也是词法作用域,意味着我们可以在某特定函数中导入宏或方法:

defmodule Math do
  def some_function do
    import List, only: [duplicate: 2]
    # call duplicate
  end
end

在此例子中,导入的函数List.duplicate/2只在那个函数中可见。这个模块中其它函数都不可用(自然,别的模块也不受影响)。

注意,若import一个模块,将自动require它。

13.4-别名机制

讲到这里你会问,Elixir的别名到底是什么,它是怎么实现的?

Elixir中的别名是以大写字母开头的标识符(像String, Keyword等等),在编译时会被转换为原子。 例如,别名‘String’会被转换为:"Elixir.String"

iex> is_atom(String)
true
iex> to_string(String)
"Elixir.String"
iex> :"Elixir.String"
String

使用alias/2指令,其实只是简单地改变了这个别名将要转换的结果。

别名如此工作,是因为在Erlang虚拟机中(以及后来的Elixir),模块是被表述成原子的。例如,我们调用一个Erlang模块的机制是:

iex> :lists.flatten([1,[2],3])
[1, 2, 3]

这也是允许我们动态调用某模块内给定函数的机制:

iex> mod = :lists
:lists
iex> mod.flatten([1,[2],3])
[1,2,3]

一句话,我们只是简单地在原子:lists上调用了函数flatten

13.5-嵌套

介绍了别名,现在可以讲讲嵌套(nesting)以及它在Elixir中是如何工作的。

考虑下面的例子:

defmodule Foo do
  defmodule Bar do
  end
end

该例子定义了两个模块_Foo_和_Foo.Bar_。后者在_Foo_中可以用_Bar_为名来访问,因为它们在同一个词法作用域中。 如果之后开发者决定把Bar模块挪到另一个文件中,那它就需要以全名(Foo.Bar)或者别名来指代。

换句话说,上面的代码等同于:

defmodule Elixir.Foo do
  defmodule Elixir.Foo.Bar do
  end
  alias Elixir.Foo.Bar, as: Bar
end

在以后章节我们可以看到,别名在宏机制中扮演了很重要的角色,来保证宏是干净的(hygienic)。

讨论到这里,模块基本上讲得差不多了。之后会讲解模块的属性。

14-模块属性

在Elixir中,模块属性(attributes)主要服务于三个目的: 1. 作为一个模块的注释,通常附加上用户或虚拟机用到的信息 2. 作为常量 3. 在编译时作为一个临时的模块存储

让我们一个一个讲解。

14.1-作为注释

Elixir从Erlang带来了模块属性的概念。例子:

defmodule MyServer do
  @vsn 2
end

这个例子中,我们显式地为该模块设置了版本这个属性。 属性标识@vsn会被Erlang虚拟机的代码装载机制用到,读取并检查该模块是否在某处被更新了。 如果不注明版本号,会设置为这个模块函数的md5 checksum。

Elixir有个好多系统保留属性。这里只列举一些最常用的: - @moduledoc 为当前模块提供文档 - @doc 为该属性后面的函数或宏提供文档 - @behaviour (注意这个单词是英式拼法)用来注明一个OTP或用户自定义行为 - @before_compile 提供一个每当模块被编译之前执行的钩子。这使得我们可以在模块被编译之前往里面注入函数。

@moduledoc和@doc是很常用的属性,推荐经常使用(写文档)。

Elixir视文档为一等公民,提供了很多方法来访问文档。

让我们回到上几章定义的Math模块,为它添加文档,然后依然保存在math.ex文件中:

defmodule Math do
  @moduledoc """
  Provides math-related functions.

  ### Examples

      iex> Math.sum(1, 2)
      3

  """

  @doc """
  Calculates the sum of two numbers.
  """
  def sum(a, b), do: a + b
end

上面例子使用了heredocs注释。heredocs是多行的文本,用三个引号包裹,保持里面内容的格式。 下面例子演示在iex中,用h命令读取模块的注释:

$ elixirc math.ex
$ iex
iex> h Math # Access the docs for the module Math
...
iex> h Math.sum # Access the docs for the sum function
...

Elixir还提供了ExDoc工具,利用注释文档生成HTNL页。

你可以看看模块里面列出的模块属性列表, 看看Elixir还支持那些模块属性。

Elixir还是用这些属性来定义typespecs: - @spec 为一个函数提供specification - @callback 为行为回调函数提供spec - @type 定义一个@spec中用到的类型 - @typep 定义一个私有类型,用于@spec - @opaque 定义一个opaque类型用于@spec

本节讲了一些内置的属性。当然,属性可以被开发者、或者被类库扩展来支持自定义的行为。

14.2-作为常量

Elixir开发者经常会模块属性当作常量使用:

defmodule MyServer do
  @initial_state %{host: "147.0.0.1", port: 3456}
  IO.inspect @initial_state
end

不同于Erlang,默认情况下用户定义的属性不被存储在模块里。属性值仅在编译时存在。 开发者可以通过调用Module.register_attribute/3来使属性的行为更接近Erlang。

访问一个未定义的属性会报警告:

defmodule MyServer do
  @unknown
end
warning: undefined module attribute @unknown, please remove access to @unknown or explicitly set it to nil before access

最后,属性也可以在函数中被读取: ``` defmodule MyServer do @my_data 14 def first_data, do: @my_data @my_data 13 def second_data, do: @my_data end

MyServer.first_data #=> 14 MyServer.second_data #=> 13


注意在函数内读取某属性读取的是该属性当前值的快照。换句话说,读取的是编译时的值,而非运行时。
我们即将看到,这点使得属性可以作为模块在编译时的临时存储。

### 14.3-作为临时存储
Elixir组织中有一个项目,叫做[Plug](https://github.com/elixir-lang/plug)。这个项目的目标是创建一个Web的库和框架。

>类似于rack?

Plug库允许开发者定义它们自己的plug,可以在一个web服务器上运行:

defmodule MyPlug do use Plug.Builder

plug :set_header plug :send_ok

def set_header(conn, _opts) do put_resp_header(conn, “x-header”, “set”) end

def send_ok(conn, _opts) do send(conn, 200, “ok”) end end

IO.puts “Running MyPlug with Cowboy on http://localhost:4000" Plug.Adapters.Cowboy.http MyPlug, []


上面例子我们用了```plug/1```宏来连接各个在处理请求时会被调用的函数。
在内部,每当你调用```plug/1```时,Plug库把参数存储在@plug属性里。
在模块被编译之前,Plug执行一个回调函数,这个函数定义了处理http请求的方法。
这个方法将顺序执行所有保存在@plug属性里的plugs。

为了理解底层的代码,我们需要宏。因此我们将回顾一下元编程手册里这种模式。
但是这里的焦点是怎样使用属性来存储数据,让开发者得以创建DSL。

另一个例子来自ExUnit框架,它使用模块属性作为注释和存储:

defmodule MyTest do use ExUnit.Case

@tag :external test “contacts external service” do # … end end


ExUnit中,@tag标签被用来注释该测试用例。之后,这些标签可以作为过滤测试用例之用。
例如,你可以避免执行那些被标记成```:external```的测试,因为它们执行起来很慢。


本章带你一窥Elixir元编程的冰山一角,讲解了模块属性在开发中是如何扮演关键角色的。

下一章将讲解结构体和协议。

## 15-结构体

在之前的几章中,我们谈到过图:

iex> map = %{a: 1, b: 2} %{a: 1, b: 2} iex> map[:a] 1 iex> %{map | a: 3} %{a: 3, b: 2}


结构体是基于图的一个扩展。它将默认值,编译时保证和多态性带入Elixir。

定义一个结构体,你只需在某模块中调用```defstruct/1```:

iex> defmodule User do …> defstruct name: “john”, age: 27 …> end {:module, User, <<70, 79, 82, ...>>, {:struct, 0}}

 
 现在可以用```%User()```语法创建这个结构体的“实例”了:

iex> %User{} %User{name: “john”, age: 27} iex> %User{name: “meg”} %User{name: “meg”, age: 27} iex> is_map(%User{}) true


结构体的编译时保证指的是编译时会检查结构体的字段存不存在:

iex> %User{oops: :field} ** (CompileError) iex:3: unknown key :oops for struct User


当讨论图的时候,我们演示了如何访问和修改图现有的字段。结构体也是一样的:

iex> john = %User{} %User{name: “john”, age: 27} iex> john.name “john” iex> meg = %{john | name: “meg”} %User{name: “meg”, age: 27} iex> %{meg | oops: :field} ** (ArgumentError) argument error


通过使用这种修改的语法,虚拟机知道没有新的键增加到图/结构体中,使得图可以在内存中共享它们的结构。
在上面例子中,john和meg共享了相同的键结构。

结构体也能用在模式匹配中,它们保证结构体有相同的类型:

iex> %User{name: name} = john %User{name: “john”, age: 27} iex> name “john” iex> %User{} = %{} ** (MatchError) no match of right hand side value: %{}


匹配能工作,是由于结构体再底层图中有个字段叫```__struct__```:

iex> john.struct User


总之,结构体就是个光秃秃的图外加一个默认字段。我们这里说的光秃秃的图指的是,为图实现的协议都不能用于结构体。
例如,你不能枚举也不能访问一个结构体:

iex> user = %User{} %User{name: “john”, age: 27} iex> user[:name] ** (Protocol.UndefinedError) protocol Access not implemented for %User{age: 27, name: “john”}


结构体也不是字典,因为也不适用字典模块的函数:

iex> Dict.get(%User{}, :name) ** (ArgumentError) unsupported dict: %User{name: “john”, age: 27}


下一章我们将介绍结构体是如何同协议进行交互的。

## 16-协议


协议是实现Elixir多态性的重要机制。任何数据类型只要实现了某协议,那么该协议的分发就是可用的。
让我们看个例子。

在Elixir中,只有false和nil被认为是false。其它的值都被认为是true。
根据程序需要,有时需要一个```blank?```协议,返回一个布尔值,以说明该参数是否为空。
举例来说,一个空列表或者空二进制可以被认为是空的。

我们可以如下定义协议:

defprotocol Blank do @doc “Returns true if data is considered blank/empty” def blank?(data) end


这个协议期待一个函数```blank?```,它接受一个待实现的参数。
我们为不同的数据类型实现这个协议:

Integers are never blank

defimpl Blank, for: Integer do def blank?(_), do: false end

Just empty list is blank

defimpl Blank, for: List do def blank?([]), do: true def blank?(_), do: false end

Just empty map is blank

defimpl Blank, for: Map do # Keep in mind we could not pattern match on %{} because # it matches on all maps. We can however check if the size # is zero (and size is a fast operation). def blank?(map), do: map_size(map) == 0 end

Just the atoms false and nil are blank

defimpl Blank, for: Atom do def blank?(false), do: true def blank?(nil), do: true def blank?(_), do: false end


我们可以为所有内建数据类型实现协议:
  - 原子
  - BitString
  - 浮点型
  - 函数
  - 整型
  - 列表
  - 图
  - PID
  - Port
  - 引用
  - 元祖

现在手边有了一个定义并被实现的协议,如此使用之:

iex> Blank.blank?(0) false iex> Blank.blank?([]) true iex> Blank.blank?([1, 2, 3]) false


给它传递一个并没有实现该协议的数据类型,会导致报错:

iex> Blank.blank?(“hello”) ** (Protocol.UndefinedError) protocol Blank not implemented for “hello”


### 16.1-协议和结构体
协议和结构体一起使用能够大大加强Elixir的可扩展性。<br/>

在前面几章中我们知道,尽管结构体就是图,但是它们和图并不共享各自协议的实现。
像前几章一样,我们先定义一个名为```User```的结构体:

iex> defmodule User do …> defstruct name: “john”, age: 27 …> end {:module, User, <<70, 79, 82, ...>>, {:struct, 0}}

 然后看看:

iex> Blank.blank?(%{}) true iex> Blank.blank?(%User{}) ** (Protocol.UndefinedError) protocol Blank not implemented for %User{age: 27, name: “john”}


结构体没有使用协议针对图的实现,而是使用它自己的协议实现:

defimpl Blank, for: User do def blank?(_), do: false end


如果愿意,你可以定义你自己的语法来检查一个user为不为空。
不光如此,你还可以使用结构体创建更强健的数据类型,比如队列,然后实现所有相关的协议,比如枚举(Enumerable)或检查是否为空。

有些时候,程序员们希望给结构体提供某些默认的协议实现。因为显式给所有结构体都实现某些协议实在是太枯燥了。
这引出了下一节“回归大众”(falling back to any)的说法。

### 16.2-回归大众
能够给所有类型提供默认的协议实现肯定是很方便的。在定义协议时,把```@fallback_to_any```设置为```true```即可:

defprotocol Blank do @fallback_to_any true def blank?(data) end

现在这个协议可以被这么实现:

defimpl Blank, for: Any do def blank?(_), do: false end


现在,那些我们还没有实现```Blank```协议的数据类型(包括结构体)也可以来判断是否为空了。

### 16.3-内建协议
Elixir自带了一些内建协议。在前面几章中我们讨论过枚举模块,它提供了许多方法。
只要任何一种数据结构它实现了Enumerable协议,就能使用这些方法:

iex> Enum.map [1, 2, 3], fn(x) -> x * 2 end [2,4,6] iex> Enum.reduce 1..3, 0, fn(x, acc) -> x + acc end 6


另一个例子是```String.Chars```协议,它规定了如何将包含字符的数据结构转换为字符串类型。
它暴露为函数```to_string```:

iex> to_string :hello “hello”


注意,在Elixir中,字符串插值操作调用的是```to_string```函数:

iex> “age: #{25}” “age: 25”

上面代码能工作是因为数字类型实现了```String.Chars```协议。如果传进去的是元组就会报错:

iex> tuple = {1, 2, 3} {1, 2, 3} iex> “tuple: #{tuple}” ** (Protocol.UndefinedError) protocol String.Chars not implemented for {1, 2, 3}


当想要打印一个比较复杂的数据结构时,可以使用```inspect```函数。该函数基于协议```Inspect```:

iex> “tuple: #{inspect tuple}” “tuple: {1, 2, 3}”


_Inspect_协议用来将任意数据类型转换为可读的文字表述。IEx用来打印表达式结果用的就是它:

iex> {1, 2, 3} {1,2,3} iex> %User{} %User{name: “john”, age: 27}


记住,习惯上来说,无论何时,头顶#号被插的值,会被表现成一个不合语法的字符串。
在转换为可读的字符串时丢失了信息,因此别指望还能从该字符串取回原来的那个对象:

iex> inspect &(&1+2) “#Function<6.71889879/1 in :erl_eval.expr/5>“ ```

Elixir中还有些其它协议,但本章就讲这几个比较常用的。下一章将讲讲Elixir中的错误捕捉以及异常。

jack.zh 标签:Elixir 继续阅读

3435 ℉

15.10.07

Elixir入门教程<上>

1. 简介

1.1 安装

  • Windows平台,Elixir提供了exe安装程序,自己去下载
  • Windows Mac OS 和Linux 请先安装Erlang17+,再下载Elixir make

1.2 交互模式

运行iex

1.3 执行脚本

elixir simple.exs

2. 基本数据类型

本章介绍Elixir一些基本的类型, 如:整型(integer),浮点型(float),布尔(boolean),原子(atom,又称symbol),字符串(string),列表(list)和元组(tuple)等。 它们在iex中显示如下:

iex> 1          # integer
iex> 0x1F       # integer
iex> 1.0        # float
iex> true       # boolean
iex> :atom      # atom / symbol
iex> "elixir"   # string
iex> [1, 2, 3]  # list
iex> {1, 2, 3}  # tuple

2.1 基本算数运算

打开iex,输入以下表达式:

iex> 1 + 2
3
iex> 5 * 5
25
iex> 10 / 2
5.0

10 / 2返回了一个浮点型的5.0而非整型的5,这是预期的。在Elixir中,/运算符总是返回浮点型数值。

如果你想进行整型除法,或者求余数,可以使用函数divrem。(rem的意思是division remainder):

iex> div(10, 2)
5
iex> div 10, 2
5
iex> rem 10, 3
1

在写函数参数时,括号是可选的。(ruby程序员会心一笑)

Elixir支持用捷径(shortcut)书写二进制、八进制、十六进制整数,如:

iex> 0b1010
10
iex> 0o777
511
iex> 0x1F
31

揉揉眼,八进制是0o,数字0+小写o。

输入浮点型数字需要一个小数点,且在其后至少有一位数字。Elixir支持使用e来表示指数:

iex> 1.0
1.0
iex> 1.0e-10
1.0e-10

Elixir中浮点型都是64位双精度。

2.2 布尔

Elixir使用truefalse两个布尔值。

iex> true
true
iex> true == false
false

Elixir提供了许多用以判断类型的函数,如is_boolean/1函数可以用来检查参数是不是布尔型。

在Elixir中,函数通过名称和参数个数(又称元数,arity)来识别。 如is_boolean/1表示名为is_boolean,接受一个参数的函数; 而is_boolean/2表示与其同名、但接受2个参数的不同函数。(只是打个比方,这样的is_boolean实际上不存在)

另外,<函数名>/<元数>这样的表述为了在讲述函数时方便。在实际程序中如果调用函数, 是不用注明/1或是/2的。

iex> is_boolean(true)
true
iex> is_boolean(1)
false

类似的函数还有is_integer/1is_float/1is_number/1,分别测试参数是否是整型、浮点型或者两者其一。

可以在交互式命令行中使用h命令来打印函数或运算符的帮助信息。如h is_boolean/1h ==/2。注意此处提及某个函数时,不但要给出名称,还要加上元数/<arity>

2.3 原子

原子(atom)是一种常量,变量名就是它的值。有些语言(如ruby)中称其为符号(symbol):

iex> :hello
:hello
iex> :hello == :world
false

布尔值truefalse实际上就是原子:

iex> true == :true
true
iex> is_atom(false)
true  

此外原子也支持:"原子名"的方式 大写字母开头的”变量”实际是:"Elixir.变量名"的别名:

iex> Hello == :"Elixir.Hello"
true  

但如果这个别名已经有Elixir.前缀了 那么就不会再有Elixir前缀了:

iex> Elixir.Hello == :"Elixir.Hello"
true

模块调用其实也是使用到了这种原子别名:

iex> IO == :"Elixir.IO"
true
iex> :"Elixir.IO".puts "an atom"
an atom

2.4 字符串

在Elixir中,字符串以双括号包裹,采用UTF-8编码:

iex> "hellö"
"hellö"

Elixir支持字符串插值(语法类似ruby):

iex> "hellö #{:world}"
"hellö world"

字符串可以直接包含换行符,或者其转义字符:

iex> "hello
...> world"
"hello\nworld"
iex> "hello\nworld"
"hello\nworld"

你可以使用IO模块(module)里的IO.puts/1方法打印字符串:

iex> IO.puts "hello\nworld"
hello
world
:ok

函数IO.puts/1打印完字符串后,返回原子值:ok

字符串在Elixir内部被表示为二进制数值(binaries),也就是一连串的字节(bytes):

iex> is_binary("hellö")
true

注意,二进制数值(binary)是Elixir内部的存储结构之一。字符串、列表等类型在语言内部就存储为二进制数值,因此它们也可以被专门操作二进制数值的函数修改。

你可以查看字符串包含的字节数量:

iex> byte_size("hellö")
6

为啥是6?不是5个字符么?注意里面有一个非ASCII字符ö,在UTF-8下被编码为2个字节。

我们可以使用专门的函数来返回字符串中的字符数量:

iex> String.length("hellö")
5

String模块中提供了很多符合Unicode标准的函数来操作字符串。 如:

iex> String.upcase("hellö")
"HELLÖ"

记住,单引号和双引号包裹的字符串在Elixir中是两种不同的数据类型:

iex> 'hellö' == "hellö"
false

我们将在之后关于“二进制、字符串与字符列表”章节中详细讲述它们的区别。

2.5 匿名函数

在Elixir中,使用关键字fnend来界定函数。如:

iex> add = fn a, b -> a + b end
#Function<12.71889879/2 in :erl_eval.expr/5>
iex> is_function(add)
true
iex> is_function(add, 2)
true
iex> is_function(add, 1)
false
iex> add.(1, 2)
3

在Elixir中,函数是头等公民。你可以将函数作为参数传递给其他函数,就像整型和浮点型一样。 在上面的例子中,我们向函数is_function/1传递了由变量add表示的匿名函数,结果返回true。 我们还可以调用函数is_function/2来判断该参数函数的元数(参数个数)。

注意,在调用一个匿名函数时,在变量名和写参数的括号之间要有个点号(.)。

匿名函数是闭包,意味着它们可以访问当前作用域(scope)内的其它变量:

iex> add_two = fn a -> add.(a, 2) end
#Function<6.71889879/1 in :erl_eval.expr/5>
iex> add_two.(2)
4

这个例子定义的匿名函数add_two它内部使用了之前在同一个iex内定义好的add变量。 但要注意,在匿名函数内修改了所引用的外部变量的值,并不实际反映到该变量上:

iex> x = 42
42
iex> (fn -> x = 0 end).()
0
iex> x
42

这个例子中匿名函数把引用了外部变量x,并修改它的值为0.这时函数执行后,外部的x没有被影响。

2.6(链式)列表

Elixir使用方括号标识列表。列表可以包含任意类型的值:

iex> [1, 2, true, 3]
[1, 2, true, 3]
iex> length [1, 2, 3]
3

两个列表可以使用++/2拼接,使用--/2做“减法”:

iex> [1, 2, 3] ++ [4, 5, 6]
[1, 2, 3, 4, 5, 6]
iex> [1, true, 2, false, 3, true] -- [true, false]
[1, 2, 3, true]

本教程将多次涉及列表的头(head)和尾(tail)的概念。 列表的头指的是第一个元素,而尾指的是除了第一个元素以外,其它元素组成的列表。 它们分别可以用函数hd/1tl/1从原列表中取出:

iex> list = [1,2,3]
iex> hd(list)
1
iex> tl(list)
[2, 3]

尝试从一个空列表中取出头或尾将会报错:

iex> hd []
** (ArgumentError) argument error

2.7 元组

Elixir使用大括号(花括号)定义元组(tuples)。 类似列表,元组也可以承载任意类型的数据:

iex> {:ok, "hello"}
{:ok, "hello"}
iex> tuple_size {:ok, "hello"}
2

元组使用连续的内存空间存储数据。这意味着可以很方便地使用索引访问元组数据,以及获取元组大小。(索引从0开始):

iex> tuple = {:ok, "hello"}
{:ok, "hello"}
iex> elem(tuple, 1)
"hello"
iex> tuple_size(tuple)
2

也可以很方便地使用函数put_elem/3设置某个位置的元素值:

iex> tuple = {:ok, "hello"}
{:ok, "hello"}
iex> put_elem(tuple, 1, "world")
{:ok, "world"}
iex> tuple
{:ok, "hello"}

注意函数put_elem/3返回一个新元组。原来那个由变量tuple标识的元组没有被改变。 这是因为Elixir的数据类型是不可变的。 这种不可变性使你永远不用担心你的数据会在某处被某些代码改变。 在处理并发程序时,该不可变性有利于减少多个不同程序实体在同时修改一个数据结构时引起的竞争以及其他麻烦。

2.8 列表还是元组?

列表与元组的区别:列表在内存中是以链表的形式存储的,一个元素指向下一个元素,然后再下一个…直到到达列表末尾。我们称这样的一对(元素值+指向下一个元素的指针)为列表的一个单元(cons cell)。 用Elixir语法表示这种模式:

iex> list = [1|[2|[3|[]]]]
[1, 2, 3]

列表方括号中的竖线(|)表示列表头与尾的分界。

这个原理意味着获取列表的长度是一个线性操作:我们必须遍历完整个列表才能知道它的长度。 但是列表的前置拼接操作很快捷:

iex> [0] ++ list
[0, 1, 2, 3]
iex> list ++ [4]
[1, 2, 3, 4]

上面例子中第一条语句是前置拼接操作,执行起来很快。因为它只是简单地添加了一个新列表单元,它指向原先列表头部。而原先的列表没有任何变化。

第二条语句是后缀拼接操作,执行速度较慢。这是因为它重建了原先的列表,让原先列表的末尾元素指向那个新元素。

而另一方面,元组在内存中是连续存储的。这意味着获取元组大小,或者使用索引访问元组元素的操作十分快速。 但是元组在修改或添加元素时开销很大,因为这些操作会在内存中对元组的进行整体复制。

这些讨论告诉我们当如何在不同的情况下选择使用不同的数据结构。

函数常用元组来返回多个信息。如File.read/1,它读取文件内容,返回一个元组:

iex> File.read("path/to/existing/file")
{:ok, "... contents ..."}
iex> File.read("path/to/unknown/file")
{:error, :enoent}

如果传递给函数File.read/1的文件路径有效,那么函数返回一个元组,其首元素是原子:ok,第二个元素是文件内容。 如果路径无效,函数也将返回一个元组,其首元素是原子:error,第二个元素是错误信息。

大多数情况下,Elixir会引导你做正确的事。 如有个叫elem/2的函数,它使用索引来访问一个元组元素。 这个函数没有相应的列表版本,因为根据存储机制,列表不适用通过索引来访问:

iex> tuple = {:ok, "hello"}
{:ok, "hello"}
iex> elem(tuple, 1)
"hello"

当需要计算某数据结构包含的元素个数时,Elixir遵循一个简单的规则:** 如果操作在常数时间内完成(答案是提前算好的),这样的函数通常被命名为**size***。 而如果操作需要显式计数,那么该函数通常命名为**length*

例如,目前讲到过的4个计数函数:byte_size/1(用来计算字符串有多少字节) ,tuple_size/1(用来计算元组大小) ,length/1(计算列表长度)以及String.length/1(计算字符串中的字符数)。

按照命名规则,当我们用byte_size获取字符串所占字节数时,开销较小。 但是当我们用String.length获取字符串unicode字符个数时,需要遍历整个字符串,开销较大。

除了本章介绍的数据类型,Elixir还提供了PortReferencePID三个数据类型(它们常用于进程交互)。 这些数据类型将在讲解进程时详细介绍。

3. 基本运算符

通过前几章我们知道Elixir提供了 +-*/4个算术运算符,外加整数除法函数div/2和取余函数rem/2。Elixir还提供了++--运算符来操作列表:

iex> [1,2,3] ++ [4,5,6]
[1,2,3,4,5,6]
iex> [1,2,3] -- [2]
[1,3]

使用<>进行字符串拼接:

iex> "foo" <> "bar"
"foobar"

Elixir还提供了三个布尔运算符:orandnot。这三个运算符只接受布尔值作为第一个参数:

iex> true and true
true
iex> false or is_atom(:example)
true

如果提供了非布尔值作为第一个参数,会报异常:

iex> 1 and true
** (ArgumentError) argument error

orand可短路,即它们仅在第一个参数无法决定整体结果的情况下才执行第二个参数:

iex> false and error("This error will never be raised")
false
iex> true or error("This error will never be raised")
true

如果你是Erlang程序员,Elixir中的andor其实就是andalsoorelse运算符。

除了这几个布尔运算符,Elixir还提供||&&!运算符。它们可以接受任意类型的参数值。 在使用这些运算符时,除了falsenil的值都被视作true

# or
iex> 1 || true
1
iex> false || 11
11

# and
iex> nil && 13
nil
iex> true && 17
17

# !
iex> !true
false
iex> !1
false
iex> !nil
true

根据经验,当参数返回的是布尔时,使用andornot;如果非布尔值,用&&||!

Elixir还提供了==!====!==<=>=<>这些比较运算符:

iex> 1 == 1
true
iex> 1 != 2
true
iex> 1 < 2
true

=====的不同之处是后者在判断数字时更严格:

iex> 1 == 1.0
true
iex> 1 === 1.0
false

在Elixir中,可以判断不同类型数据的大小:

iex> 1 < :atom
true

这很实用。排序算法不必担心如何处理不同类型的数据。总体上,不同类型的排序顺序是:

number < atom < reference < functions < port < pid < tuple < maps < list < bitstring

不用背,只要知道有这么回事儿就可以。

4.模式匹配

  • 匹配运算符
  • 模式匹配
  • pin运算符

本章起教程进入不那么基础的阶段,开始涉及函数式编程概念。 在Elixir中,=运算符实际上是一个匹配运算符。 本章将讲解如何使用=运算符来对数据结构进行模式匹配。最后本章还会讲解pin运算符^,用来访问某变量之前绑定的值。

4.1 匹配运算符

我们已经多次使用=符号进行变量的赋值操作:

iex> x = 1
1
iex> x
1

在Elixir中,=其实称为匹配运算符。下面来学习这样的概念:

iex> 1 = x
1
iex> 2 = x
** (MatchError) no match of right hand side value: 1

注意1 = x是一个合法的表达式。由于前面给x赋值为1,左右相同,所以它匹配成功了。而两侧不匹配的时候,MatchError将被抛出。

变量只有在匹配操作符=的左侧时才被赋值:

iex> 1 = unknown
** (RuntimeError) undefined function: unknown/0

错误原因是unknown变量没有被赋过值,Elixir猜你想调用一个名叫unknown/0的函数,但是找不到这样的函数。

4.2 模式匹配

匹配运算符不光可以匹配简单数值,还能用来解构复杂的数据类型。 例如,我们在元组上使用模式匹配:

iex> {a, b, c} = {:hello, "world", 42}
{:hello, "world", 42}
iex> a
:hello
iex> b
"world"

在两端不匹配的情况下,模式匹配会失败。比方说,匹配两端的元组不一样长:

iex> {a, b, c} = {:hello, "world"}
** (MatchError) no match of right hand side value: {:hello, "world"}

或者两端不是一个类型:

iex> {a, b, c} = [:hello, "world", "!"]
** (MatchError) no match of right hand side value: [:hello, "world", "!"]

有趣的是,我们可以匹配特定值。下面例子中匹配的左端当且仅当右端是个元组,且第一个元素是原子:ok

iex> {:ok, result} = {:ok, 13}
{:ok, 13}
iex> result
13

iex> {:ok, result} = {:error, :oops}
** (MatchError) no match of right hand side value: {:error, :oops}

用在列表上:

iex> [a, b, c] = [1, 2, 3]
[1, 2, 3]
iex> a
1

列表支持匹配自己的headtail(这相当于同时调用hd/1tl/1,给headtail赋值):

iex> [head | tail] = [1, 2, 3]
[1, 2, 3]
iex> head
1
iex> tail
[2, 3]

hd/1tl/1函数一样,以上代码不能对空列表使用:

iex> [h|t] = []
** (MatchError) no match of right hand side value: []

[head|tail]这种形式不光在模式匹配时可以用,还可以用作前置数值:

iex> list = [1, 2, 3]
[1, 2, 3]
iex> [0|list]
[0, 1, 2, 3]

模式匹配使得程序员可以容易地解构数据结构(如元组和列表)。在后面我们还会看到,它是Elixir的一个基础,对其它数据结构同样适用,比如图和二进制。

4.3 pin运算符

在Elixir中,变量可以被重新绑定:

iex> x = 1
1
iex> x = 2
2

如果你不想这样,可以使用pin运算符(^)。加上了pin运算符的变量,在匹配时使用的值是匹配前就赋予的值:

iex> x = 1
1
iex> ^x = 2
** (MatchError) no match of right hand side value: 2
iex> {x, ^x} = {2, 1}
{2, 1}
iex> x
2

注意如果一个变量在匹配中被引用超过一次,所有的引用都应该绑定同一个模式:

iex> {x, x} = {1, 1}
1
iex> {x, x} = {1, 2}
** (MatchError) no match of right hand side value: {1, 2}

有些时候,你并不在意模式里的一些值。通常你就可以把它们绑定到特殊的变量“_”上。例如,如果你只想要某列表的head,而不要tail值。你可以这么做:

iex> [h | _] = [1, 2, 3]
[1, 2, 3]
iex> h
1

变量“_”特殊之处在于它不能被读。尝试读取它会报“未绑定的变量”错误:

iex> _
** (CompileError) iex:1: unbound variable _

尽管模式匹配可以让我们创建功能强大的结构,但是它的作用被限制了。 比如,你不能让函数调用作为匹配的左端。下面例子就是非法的:

iex> length([1,[2],3]) = 3
** (CompileError) iex:1: illegal pattern

5. 控制语句

  • case
  • 子句与卫兵表达式
  • cond
  • if和unless
  • do语句块

本章讲解Elixir的流程控制结构。

5.1 case

case将一个值与许多模式进行比较,直到找到一个匹配的:

iex> case {1, 2, 3} do
...>   {4, 5, 6} ->
...>     "This clause won't match"
...>   {1, x, 3} ->
...>     "This clause will match and bind x to 2 in this clause"
...>   _ ->
...>     "This clause would match any value"
...> end

如果与一个已赋值的变量做比较,要用pin运算符(^)标记该变量:

iex> x = 1
1
iex> case 10 do
...>   ^x -> "Won't match"
...>   _  -> "Will match"
...> end

可以加上卫兵表达式提供额外的条件:

iex> case {1, 2, 3} do
...>   {1, x, 3} when x > 0 ->
...>     "Will match"
...>   _ ->
...>     "Won't match"
...> end

上面例子中,第一个待比较的模式多了一个条件:x必须是正数。

5.2 子句中的卫兵表达式

Erlang中只允许以下卫兵表达式出现在子句中:

  • 比较运算符(==,!=,===,!==,>,<,<=,>=)
  • 布尔运算符(and,or)以及否定运算符(not,!)
  • 算数运算符(+,-,*,/)
  • <>和++如果左端是字面值
  • in运算符
  • 以下类型判断函数:
    • is_atom/1
    • is_binary/1
    • is_bitstring/1
    • is_boolean/1
    • is_float/1
    • is_function/1
    • is_function/2
    • is_integer/1
    • is_list/1
    • is_map/1
    • is_number/1
    • is_pid/1
    • is_reference/1
    • is_tuple/1
  • 外加以下函数:
    • abs(number)
    • bit_size(bitstring)
    • byte_size(bitstring)
    • div(integer, integer)
    • elem(tuple, n)
    • hd(list)
    • length(list)
    • map_size(map)
    • node()
    • node(pid | ref | port)
    • rem(integer, integer)
    • round(number)
    • self()
    • tl(list)
    • trunc(number)
    • tuple_size(tuple)

记住,卫兵表达式中出现的错误不会抛出,只会简单地让卫兵条件失败:

iex> hd(1)
** (ArgumentError) argument error
    :erlang.hd(1)
iex> case 1 do
...>   x when hd(x) -> "Won't match"
...>   x -> "Got: #{x}"
...> end
"Got 1"

如果case中没有一条模式能匹配,会报错:

iex> case :ok do
...>   :error -> "Won't match"
...> end
** (CaseClauseError) no case clause matching: :ok

匿名函数也可以像下面这样,用多个模式或卫兵条件来灵活地匹配该函数的参数:

iex> f = fn
...>   x, y when x > 0 -> x + y
...>   x, y -> x * y
...> end
#Function<12.71889879/2 in :erl_eval.expr/5>
iex> f.(1, 3)
4
iex> f.(-1, 3)
-3

需要注意的是,所有case模式中表示的参数个数必须一致,否则会报错。

上面的例子两个待匹配模式都是xy。如果再有一个模式表示的参数是xyz,那就不行:

iex(5)> f2 = fn
...(5)>   x,y -> x+y
...(5)>   x,y,z -> x+y+z
...(5)> end
** (CompileError) iex:5: cannot mix clauses with different arities in function definition
    (elixir) src/elixir_translator.erl:17: :elixir_translator.translate/2

5.3 cond

case是拿一个值去同多个值或模式进行匹配,匹配了就执行那个分支的语句。

然而,许多情况下我们要检查不同的条件,找到第一个结果为true的,执行它的分支。 这时我们用cond:

iex> cond do
...>   2 + 2 == 5 ->
...>     "This will not be true"
...>   2 * 2 == 3 ->
...>     "Nor this"
...>   1 + 1 == 2 ->
...>     "But this will"
...> end
"But this will"

这样的写法和命令式语言里的else if差不多一个意思(尽管很少这么写)。

如果没有一个条件结果为true,会报错。因此,实际应用中通常会使用true作为最后一个条件。 因为即使上面的条件没有一个是true,那么该cond表达式至少还可以执行这最后一个分支:

iex> cond do
...>   2 + 2 == 5 ->
...>     "This is never true"
...>   2 * 2 == 3 ->
...>     "Nor this"
...>   true ->
...>     "This is always true (equivalent to else)"
...> end

用法就好像许多语言中switch语句中的default一样。

最后需要注意的是,cond视所有除了falsenil的数值都为true

iex> cond do
...>   hd([1,2,3]) ->
...>     "1 is considered as true"
...> end
"1 is considered as true"

5.4 if和unless

除了casecond,Elixir还提供了两很常用的宏:if/2unless/2,用它们检查单个条件:

iex> if true do
...>   "This works!"
...> end
"This works!"
iex> unless true do
...>   "This will never be seen"
...> end
nil

如果给if/2的条件结果为false或者nil,那么它在do/end间的语句块就不会执行,该表达式返回nilunless/2相反。

它们都支持else语句块:

iex> if nil do
...>   "This won't be seen"
...> else
...>   "This will"
...> end
"This will"

有趣的是,if/2unless/2是以宏的形式提供的,而不像在很多语言中那样是语句。 可以阅读文档或if/2的源码(Kernel模块)。Kernel模块还定义了诸如+/2运算符和is_function/2函数。它们是默认是被自动导入,因而一开始就在你的代码中可用。

5.5 do语句块

以上讲解的4种流程控制结构:casecondifunless,它们都被包裹在do/end语句块中。 即使我们把if语句写成这样:

iex> if true, do: 1 + 2
3

在Elixir中,do/end语句块方便地将一组表达式传递给do:。以下是等同的:

iex> if true do
...>   a = 1 + 2
...>   a + 10
...> end
13
iex> if true, do: (
...>   a = 1 + 2
...>   a + 10
...> )
13

我们称第二种语法使用了键值列表(keyword lists)。我们可以这样传递else

iex> if false, do: :this, else: :that
:that

注意一点,do/end语句块永远是被绑定在最外层的函数调用上。例如:

iex> is_number if true do
...>  1 + 2
...> end

将被解析为:

iex> is_number(if true) do
...>  1 + 2
...> end

这使得Elixir认为你是要调用函数is_number/2(第一个参数是if true,第二个是语句块)。 这时就需要加上括号解决二义性:

iex> is_number(if true do
...>  1 + 2
...> end)
true

关键字列表在Elixir语言中占有重要地位,在许多函数和宏中都有使用。后文中还会对其进行详解。

6. 二进制数据-字符串-字符列表

  • UTF-8和Unicode
  • 二进制(和bitstring)
  • 字符列表

在“基本类型”一章中介绍了字符串,以及如何使用is_binary/1函数来检查它:

iex> string = "hello"
"hello"
iex> is_binary string
true

本章将学习理解,二进制数据(binaries)是个啥,它怎么和字符串扯上关系的,以及用单引号包裹的值,’像这样’,是啥意思。

6.1 UTF-8和Unicode

Elixir中,字符串就是UTF-8编码的二进制数据(binaries)。为了弄清这句话啥意思,我们要先理解两个概念:bytescode point的区别。

“二进制数据”(binaries)这个翻译不一定准确。它其实就是以二进制为内容的列表。下文有些地方可能会简单写成“二进制”。如有模糊的地方,一般也会用原文标出。

bytescode points就不怎么翻译了(bytes有时会翻译成字节),可根据上下文语境理解。

字母acode point97,而字母łcode point322

当把字符串"hełło"写到硬盘上的时候,需要将其code point转化为字节(bytes)。 如果一个字节对应一个code point,那是存储不了"hełło"的,因为字母ł的code point322,超过了一个字节所能存储的最大数值(255)。

但是如你所见,该字母能够显示到屏幕上,说明还是有一定的解决方法的。方法就是编码。

要用字节表示code point,我们需要在一定程度上对其进行编码。 Elixir使用UTF-8为默认编码格式。 当我们说,某个字符串是UTF-8编码的二进制数据(binaries),这句话意思是该字符串是一串字节,以一定方法组织,来表示特定的一串code points。

因此当我们存储字母ł的时候,实际上是用两个字节来表示它。 这就是为什么有时候对同一字符串调用函数byte_size/1String.length/1结果不一样:

iex> string = "hełło"
"hełło"
iex> byte_size string
7
iex> String.length string
5

UTF-8需要1个字节来表示code points:‘h’‘e’‘o’各用一个,而‘ł’用2个字节。 在Elixir中,可以使用?运算符获取某字符的code point值:

iex> ?a
97
iex> ?ł
322

你还可以使用String模块里的函数,将字符串切成单独的code points:

iex> String.codepoints("hełło")
["h", "e", "ł", "ł", "o"]

Elixir为字符串操作提供了强大的支持。实际上,Elixir通过了文章“字符串类型破了”记录的所有测试。

不仅如此,因为字符串是二进制数据,Elixir还提供了更强大的底层类型的操作。下面就来介绍该底层类型—二进制数据。

6.2 二进制数据(和bitstring)

在Elixir中可以用<<>>定义一个二进制数据:

iex> <<0, 1, 2, 3>>
<<0, 1, 2, 3>>
iex> byte_size <<0, 1, 2, 3>>
4

一个二进制只是一连串字节。这些字节可以以任何方法组织,即使凑不成一个合法的字符串:

iex> String.valid?(<<239, 191, 191>>)
false

字符串的拼接操作实际上是二进制的拼接操作:

iex> <<0, 1>> <> <<2, 3>>
<<0, 1, 2, 3>>

一个常见技巧是,通过给某字符串尾部拼接一个空字节<<0>>,来看看该字符串内部二进制的样子:

iex> "hełło" <> <<0>>
<<104, 101, 197, 130, 197, 130, 111, 0>>

二进制中的每个数值都表示一个byte,因此其最大是255。 如果超出了255,二进制允许你再提供一个修饰符,标识一下那个位置的存储空间大小,使其可以满足存储要求。或者使用修饰符将其转换为utf8编码后的形式(变成多个字节的二进制),再存储:

iex> <<255>>
<<255>>
iex> <<256>> # truncated
<<0>>
iex> <<256 :: size(16)>> # use 16 bits (2 bytes) to store the number
<<1, 0>>
iex> <<256 :: utf8>> # the number is a code point
"Ā"
iex> <<256 :: utf8, 0>>
<<196, 128, 0>>

如果一个byte8 bits,那如果我们给一个size1 bit的修饰符会怎样?:

iex> <<1 :: size(1)>>
<<1::size(1)>>
iex> <<2 :: size(1)>> # truncated
<<0::size(1)>>
iex> is_binary(<< 1 :: size(1)>>)
false
iex> is_bitstring(<< 1 :: size(1)>>)
true
iex> bit_size(<< 1 :: size(1)>>)
1

这样(每个元素是1 bit)就不再是二进制数据(人家每个元素是byte,至少8 bits)了,而是bitstring,就是一串比特! 所以实际上二进制数据(binary)就是一串比特(bitstring),只是它容纳的比特总数必须是8的倍数。

我们也可以对二进制数据或bitstring做模式匹配:

iex> <<0, 1, x>> = <<0, 1, 2>>
<<0, 1, 2>>
iex> x
2
iex> <<0, 1, x>> = <<0, 1, 2, 3>>
** (MatchError) no match of right hand side value: <<0, 1, 2, 3>>

注意(没有修改器标识的情况下)二进制数据中的每个元素都应该匹配8 bits。 因此上面最后的例子,匹配的左右两端不具有相同容量,因此出现错误。

下面是使用了修饰符标识的匹配例子:

iex> <<0, 1, x :: binary>> = <<0, 1, 2, 3>>
<<0, 1, 2, 3>>
iex> x
<<2, 3>>

上面的模式仅在二进制尾部元素被修改器标识为又一个二进制时才正确。 字符串的连接操作也是一个意思:

iex> "he" <> rest = "hello"
"hello"
iex> rest
"llo"

总之,记住字符串是UTF-8编码的二进制数据,而二进制数据是特殊的、bit数量是8的倍数的bitstring。 这种机制增加了Elixir在处理bitsbytes时的灵活性。 现实中99%的时候你会用到is_binary/1byte_size/1函数,来跟二进制数据打交道。

6.3 字符列表

字符列表就是字符的列表。 双引号包裹字符串,单引号包裹字符列表。

iex> 'hełło'
[104, 101, 322, 322, 111]
iex> is_list 'hełło'
true
iex> 'hello'
'hello'

字符列表存储的不是字节,而是字符的code points(实际上就是这些code points的普通列表)。 如果某字符不属于ASCII范围,iex就打印它的code point。

实际应用中,字符列表常被用来做一些老的库,或者同Erlang平台交互时使用的参数。因为这些老库不接受二进制数据作为参数。

将字符列表和字符串之间转换,使用函数to_string/1to_char_list/1

iex> to_char_list "hełło"
[104, 101, 322, 322, 111]
iex> to_string 'hełło'
"hełło"
iex> to_string :hello
"hello"
iex> to_string 1
"1"

注意这些函数是多态的。它们不但转化字符列表和字符串,还能转化字符串和整数,等等。

jack.zh 标签:Elixir 继续阅读

1
Fork me on GitHub