Elixir 快速指南



Elixir - 概述

Elixir 是一种动态函数式语言,设计用于构建可扩展和易于维护的应用程序。它利用 Erlang VM,该虚拟机以运行低延迟、分布式和容错系统而闻名,同时也能成功应用于 Web 开发和嵌入式软件领域。

Elixir 是一种基于 Erlang 和 Erlang VM 的函数式动态语言。Erlang 是一种最初由爱立信于 1986 年编写的语言,用于解决诸如分布式、容错和并发等电话问题。由 José Valim 编写的 Elixir 扩展了 Erlang,并在 Erlang VM 中提供了更友好的语法。它在保持与 Erlang 相同性能水平的同时实现了这一点。

Elixir 的特性

现在让我们讨论 Elixir 的一些重要特性:

  • 可扩展性 - 所有 Elixir 代码都在轻量级进程中运行,这些进程是隔离的,并通过消息交换信息。

  • 容错性 - Elixir 提供了主管进程,用于描述在出现问题时如何重启系统部分,返回到已知的初始状态,从而保证系统正常运行。这确保您的应用程序/平台永不宕机。

  • 函数式编程 - 函数式编程提倡一种编程风格,帮助开发人员编写简洁、快速且易于维护的代码。

  • 构建工具 - Elixir 附带一组开发工具。Mix 就是这样一个工具,它简化了项目创建、任务管理、运行测试等操作。它还有一个自己的包管理器 - Hex。

  • Erlang 兼容性 - Elixir 运行在 Erlang VM 上,使开发人员可以完全访问 Erlang 的生态系统。

Elixir - 环境配置

要运行 Elixir,您需要在本地系统上进行设置。

要安装 Elixir,您首先需要 Erlang。在某些平台上,Elixir 包中包含 Erlang。

安装 Elixir

现在让我们了解如何在不同的操作系统中安装 Elixir。

Windows 设置

要在 Windows 上安装 Elixir,请从此处下载安装程序 https://elixir.erlang.org.cn/install.html#windows,然后只需单击下一步即可完成所有步骤。您将在本地系统上安装它。

如果您在安装过程中遇到任何问题,可以查看此页面以获取更多信息。

Mac 设置

如果您已安装 Homebrew,请确保它是最新版本。要更新,请使用以下命令:

brew update

现在,使用以下命令安装 Elixir:

brew install elixir

Ubuntu/Debian 设置

在 Ubuntu/Debian 系统中安装 Elixir 的步骤如下:

添加 Erlang Solutions 仓库:

wget https://packages.erlang-solutions.com/erlang-solutions_1.0_all.deb && sudo 
dpkg -i erlang-solutions_1.0_all.deb 
sudo apt-get update 

安装 Erlang/OTP 平台及其所有应用程序:

sudo apt-get install esl-erlang 

安装 Elixir:

sudo apt-get install elixir

其他 Linux 发行版

如果您使用的是其他 Linux 发行版,请访问此页面以在本地系统上设置 Elixir。

测试设置

要在您的系统上测试 Elixir 设置,请打开终端并在其中输入 iex。它将打开交互式 Elixir shell,如下所示:

Erlang/OTP 19 [erts-8.0] [source-6dc93c1] [64-bit] 
[smp:4:4] [async-threads:10] [hipe] [kernel-poll:false]  

Interactive Elixir (1.3.1) - press Ctrl+C to exit (type h() ENTER for help) 
iex(1)>

Elixir 现在已成功安装在您的系统上。

Elixir - 基本语法

我们将从惯例的“Hello World”程序开始。

要启动 Elixir 交互式 shell,请输入以下命令。

iex

shell 启动后,使用IO.puts函数将字符串“输出”到控制台。在您的 Elixir shell 中输入以下内容:

IO.puts "Hello world"

在本教程中,我们将使用 Elixir 脚本模式,即将 Elixir 代码保存在扩展名为.ex的文件中。现在让我们将上述代码保存在test.ex文件中。在接下来的步骤中,我们将使用elixirc执行它:

IO.puts "Hello world"

现在让我们尝试运行上述程序,如下所示:

$elixirc test.ex

上述程序生成以下结果:

Hello World

在这里,我们调用函数IO.puts以将字符串作为输出生成到我们的控制台。此函数也可以像我们在 C、C++、Java 等中那样调用,在函数名后面提供括号中的参数:

IO.puts("Hello world") 

注释

单行注释以“#”符号开头。没有多行注释,但是您可以堆叠多个注释。例如:

#This is a comment in Elixir

行尾

Elixir 中不需要像“;”这样的行尾。但是,我们可以使用“;”在同一行中包含多个语句。例如:

IO.puts("Hello"); IO.puts("World!")

上述程序生成以下结果:

Hello 
World!

标识符

标识符(例如变量、函数名)用于标识变量、函数等。在 Elixir 中,您可以使用以下方法命名标识符:以小写字母开头,后跟数字、下划线和大写字母。此命名约定通常称为 snake_case。例如,以下是 Elixir 中的一些有效标识符:

var1       variable_2      one_M0r3_variable

请注意,变量也可以用下划线开头命名。不打算使用的值必须赋值给 _ 或赋值给以下划线开头的变量:

_some_random_value = 42

此外,Elixir 依靠下划线来使函数对模块私有。如果您在模块中使用下划线开头的函数名,并导入该模块,则此函数不会被导入。

关于 Elixir 中的函数命名还有许多更复杂的细节,我们将在接下来的章节中讨论。

保留字

以下词语是保留字,不能用作变量、模块或函数名。

after     and     catch     do     inbits     inlist     nil     else     end 
not     or     false     fn     in     rescue     true     when     xor 
__MODULE__    __FILE__    __DIR__    __ENV__    __CALLER__ 

Elixir - 数据类型

要使用任何语言,您需要了解该语言支持的基本数据类型。在本章中,我们将讨论 Elixir 语言支持的 7 种基本数据类型:整数、浮点数、布尔值、原子、字符串、列表和元组。

数值类型

Elixir 与任何其他编程语言一样,都支持整数和浮点数。如果您打开 Elixir shell 并输入任何整数或浮点数作为输入,它将返回其值。例如:

42

运行上述程序时,将产生以下结果:

42

您还可以使用八进制、十六进制和二进制定义数字。

八进制

要以八进制定义数字,请在其前面加上“0o”。例如,八进制中的 0o52 等于十进制中的 42。

十六进制

要以十进制定义数字,请在其前面加上“0x”。例如,十六进制中的 0xF1 等于十进制中的 241。

二进制

要以二进制定义数字,请在其前面加上“0b”。例如,二进制中的 0b1101 等于十进制中的 13。

Elixir 支持 64 位双精度浮点数。它们也可以使用指数样式定义。例如,10145230000 可以写成 1.014523e10

原子

原子的名称就是其值。可以使用冒号 (:) 符号创建它们。例如:

:hello

布尔值

Elixir 支持truefalse作为布尔值。这两个值实际上分别附加到原子 :true 和 :false。

字符串

Elixir 中的字符串用双引号括起来,并使用 UTF-8 编码。它们可以跨多行并包含插值。要定义字符串,只需用双引号将其括起来:

"Hello world"

要定义多行字符串,我们使用类似于 python 的语法,使用三个双引号:

"""
Hello
World!
"""

我们将在字符串章节中深入学习字符串、二进制和字符列表(类似于字符串)。

二进制

二进制是用<< >>括起来的字节序列,用逗号分隔。例如:

<< 65, 68, 75>>

二进制主要用于处理位和字节相关数据。默认情况下,它们可以在每个值中存储 0 到 255。可以使用 size 函数增加此大小限制,该函数说明存储该值应占用多少位。例如:

<<65, 255, 289::size(15)>>

列表

Elixir 使用方括号指定值列表。值可以是任何类型。例如:

[1, "Hello", :an_atom, true]

列表具有名为 hd 和 tl 的内置函数,用于获取列表的头和尾,它们分别返回列表的头和尾。有时,当您创建列表时,它将返回字符列表。这是因为当 Elixir 看到可打印的 ASCII 字符列表时,它会将其打印为字符列表。请注意,字符串和字符列表并不相等。我们将在后面的章节中进一步讨论列表。

元组

Elixir 使用花括号定义元组。与列表一样,元组可以保存任何值。

{ 1, "Hello", :an_atom, true 

这里出现一个问题:为什么同时提供列表元组,而它们的工作方式相同?这是因为它们具有不同的实现。

  • 列表实际上存储为链表,因此在列表中插入和删除操作非常快。

  • 另一方面,元组存储在连续的内存块中,这使得访问它们的速度更快,但在插入和删除操作上增加了额外的成本。

Elixir - 变量

变量为我们的程序提供了可操作的命名存储空间。Elixir 中的每个变量都有一个特定的类型,该类型决定变量内存的大小和布局;可在该内存中存储的值的范围;以及可应用于变量的操作集。

变量类型

Elixir 支持以下基本类型的变量。

整数 (Integer)

这些用于表示整数。在 32 位架构上大小为 32 位,在 64 位架构上大小为 64 位。Elixir 中的整数始终是有符号的。如果整数的大小超过其限制,Elixir 会将其转换为大整数 (BigInteger),大整数的内存占用范围为 3 到 n 个字,取决于内存中可以容纳的大小。

浮点数 (Float)

Elixir 中的浮点数具有 64 位精度。在内存方面也类似于整数。定义浮点数时,可以使用指数表示法。

布尔值 (Boolean)

布尔值可以取两个值:true 或 false。

字符串

字符串在 Elixir 中使用 UTF-8 编码。它们有一个字符串模块,为程序员提供了许多操作字符串的功能。

匿名函数/Lambda 表达式

这些是可以定义并赋值给变量的函数,然后可以用来调用此函数。

集合 (Collections)

Elixir 中提供了许多集合类型。其中一些包括列表、元组、映射、二进制等。这些将在后续章节中讨论。

变量声明

变量声明告诉解释器在哪里以及如何创建变量的存储空间。Elixir 不允许我们只声明变量。变量必须同时声明和赋值。例如,要创建一个名为 life 的变量并将其赋值为 42,我们可以执行以下操作:

life = 42

这将把变量 life 绑定到值 42。如果要将此变量重新赋值为一个新值,可以使用与上述相同的语法,即:

life = "Hello world"

变量命名

Elixir 中的变量命名遵循 **snake_case** 约定,即所有变量必须以小写字母开头,后跟 0 个或多个字母(大小写均可),最后可选地跟一个 '?' 或 '!'。

变量名也可以以下划线开头,但这只能在忽略变量时使用,即该变量不会再次使用,但需要赋值。

打印变量

在交互式 shell 中,只需输入变量名即可打印变量。例如,如果创建了一个变量:

life = 42 

并在 shell 中输入 'life',则输出为:

42

但是,如果要将变量输出到控制台(从文件运行外部脚本时),需要将变量作为输入提供给 **IO.puts** 函数:

life = 42  
IO.puts life 

life = 42 
IO.puts(life) 

这将给出以下输出:

42

Elixir - 运算符

运算符是一个符号,它告诉编译器执行特定的数学或逻辑操作。Elixir 提供了大量的运算符。它们分为以下几类:

  • 算术运算符
  • 比较运算符
  • 布尔运算符
  • 其他运算符

算术运算符

下表显示了 Elixir 语言支持的所有算术运算符。假设变量 **A** 为 10,变量 **B** 为 20,则:

示例

运算符 描述 示例
+ 将两个数字相加。 A + B 将得到 30
- 从第一个数字中减去第二个数字。 A - B 将得到 -10
* 将两个数字相乘。 A * B 将得到 200
/ 将第一个数字除以第二个数字。这会将数字转换为浮点数并返回浮点数结果。 A / B 将得到 0.5。
div 此函数用于获取除法的商。 div(10, 20) 将得到 0
rem 此函数用于获取除法的余数。 rem(A, B) 将得到 10

比较运算符

Elixir 中的比较运算符与大多数其他语言中提供的运算符基本相同。下表总结了 Elixir 中的比较运算符。假设变量 **A** 为 10,变量 **B** 为 20,则:

示例

运算符 描述 示例
== 检查左侧的值是否等于右侧的值(如果类型不同则转换类型)。 A == B 将得到 false
!= 检查左侧的值是否不等于右侧的值。 A != B 将得到 true
=== 检查左侧的值的类型是否等于右侧的值的类型,如果是,则检查值是否相同。 A === B 将得到 false
!== 与上面相同,但检查的是不等式而不是等式。 A !== B 将得到 true
> 检查左操作数的值是否大于右操作数的值;如果是,则条件为 true。 A > B 将得到 false
< 检查左操作数的值是否小于右操作数的值;如果是,则条件为 true。 A < B 将得到 true
>= 检查左操作数的值是否大于或等于右操作数的值;如果是,则条件为 true。 A >= B 将得到 false
<= 检查左操作数的值是否小于或等于右操作数的值;如果是,则条件为 true。 A <= B 将得到 true

逻辑运算符

Elixir 提供 6 个逻辑运算符:and、or、not、&&、|| 和 !。前三个 **and or not** 是严格的布尔运算符,这意味着它们期望它们的第一个参数是布尔值。非布尔参数将引发错误。而接下来的三个 **&&、|| 和 !** 不是严格的,不需要第一个值严格为布尔值。它们的工作方式与其严格的对应部分相同。假设变量 **A** 为 true,变量 **B** 为 20,则:

示例

运算符 描述 示例
and 检查提供的两个值是否都为真值,如果是,则返回第二个变量的值。(逻辑与)。 A and B 将得到 20
检查提供的任何一个值是否为真值。返回任何一个真值。否则返回 false。(逻辑或)。 A or B 将得到 true
not 一元运算符,反转给定输入的值。 not A 将得到 false
&& 非严格 **and**。工作方式与 **and** 相同,但不期望第一个参数为布尔值。 B && A 将得到 20
|| 非严格 **or**。工作方式与 **or** 相同,但不期望第一个参数为布尔值。 B || A 将得到 true
! 非严格 **not**。工作方式与 **not** 相同,但不期望参数为布尔值。 !A 将得到 false

**注意 -** andor&&|| 是短路运算符。这意味着如果 **and** 的第一个参数为 false,则它不会进一步检查第二个参数。如果 **or** 的第一个参数为 true,则它不会检查第二个参数。例如:

false and raise("An error")  
#This won't raise an error as raise function wont get executed because of short
#circuiting nature of and operator

位运算符

位运算符作用于位并执行逐位运算。Elixir 将位运算模块作为 **Bitwise** 包的一部分提供,因此要使用它们,需要 `use` 位运算模块。要使用它,请在 shell 中输入以下命令:

use Bitwise

在以下示例中,假设 A 为 5,B 为 6:

示例

运算符 描述 示例
&&& 按位与运算符仅当两个操作数中都存在位时,才将该位复制到结果中。 A &&& B 将得到 4
||| 按位或运算符如果位在任何一个操作数中存在,则将其复制到结果中。 A ||| B 将得到 7
>>> 按位右移运算符将第一个操作数的位向右移动第二个操作数中指定的位数。 A >>> B 将得到 0
<<< 按位左移运算符将第一个操作数的位向左移动第二个操作数中指定的位数。 A <<< B 将得到 320
^^^ 按位异或运算符仅当位在两个操作数中不同时,才将其复制到结果中。 A ^^^ B 将得到 3
~~~ 一元按位非运算符反转给定数字的位。 ~~~A 将得到 -6

其他运算符

除了上述运算符外,Elixir 还提供了一系列其他运算符,例如 **连接运算符、匹配运算符、固定运算符、管道运算符、字符串匹配运算符、代码点运算符、捕获运算符、三元运算符**,使其成为一种非常强大的语言。

示例

Elixir - 模式匹配

模式匹配是 Elixir 从 Erlang 继承的一种技术。这是一种非常强大的技术,允许我们从复杂的數據結構(如列表、元组、映射等)中提取更简单的子结构。

匹配有两个主要部分,**左边**和**右边**。右边是任何类型的數據結構。左边尝试匹配右边的數據結構,并将左边的任何变量绑定到右边的相应子结构。如果找不到匹配项,运算符将引发错误。

最简单的匹配是左边是一个单独的变量,右边是任何數據結構。**此变量将匹配任何内容**。例如:

x = 12
x = "Hello"
IO.puts(x)

您可以将变量放在结构内,以便捕获子结构。例如:

[var_1, _unused_var, var_2] = [{"First variable"}, 25, "Second variable" ]
IO.puts(var_1)
IO.puts(var_2)

这会将值 **{"First variable"}** 存储在 var_1 中,并将值 **"Second variable"** 存储在 var_2 中。还有一个特殊的 **_** 变量(或以 '_' 为前缀的变量),它的作用与其他变量完全相同,但它告诉 Elixir,**“确保这里有一些东西,但我并不关心它究竟是什么。”**。在前面的示例中,_unused_var 就是这样一个变量。

我们可以使用这种技术匹配更复杂的模式。**例如**,如果要解包并获取列表中的元组中的数字,而该列表本身又在另一个列表中,可以使用以下命令:

[_, [_, {a}]] = ["Random string", [:an_atom, {24}]]
IO.puts(a)

上述程序生成以下结果:

24

这会将 **a** 绑定到 24。其他值被忽略,因为我们使用了 '_'。

在模式匹配中,如果我们在**右边**使用变量,则使用其值。如果要使用左边变量的值,则需要使用固定运算符。

例如,如果有一个值为 25 的变量 "a",并且要将其与另一个值为 25 的变量 "b" 匹配,则需要输入:

a = 25
b = 25
^a = b

最后一行匹配 **a** 的当前值,而不是将其赋值给 **b** 的值。如果左、右两侧的集合不匹配,匹配运算符将引发错误。例如,如果尝试将元组与列表匹配,或者将大小为 2 的列表与大小为 3 的列表匹配,则会显示错误。

Elixir - 决策语句

决策结构要求程序员指定一个或多个条件,由程序进行评估或测试,以及如果条件确定为 **true** 则要执行的语句,以及可选地,如果条件确定为 **false** 则要执行的其他语句。

以下是大多数编程语言中常见的典型决策结构的一般形式:

Decision Making

Elixir 提供了与许多其他编程语言类似的 if/else 条件结构。它还有一个 **cond** 语句,该语句调用它找到的第一个 true 值。case 是另一个控制流语句,它使用模式匹配来控制程序的流程。让我们深入了解一下它们。

Elixir 提供以下几种决策语句。点击以下链接查看它们的详细信息。

序号 语句及描述
1 if 语句

if 语句由一个布尔表达式后跟do,一个或多个可执行语句,最后是一个end关键字组成。只有当布尔条件计算结果为真时,if 语句中的代码才会执行。

2 if..else 语句

if 语句后面可以跟一个可选的 else 语句(在 do..end 块内),当布尔表达式为假时,else 语句会执行。

3 unless 语句

unless 语句与 if 语句具有相同的结构。只有当指定的条件为假时,unless 语句中的代码才会执行。

4 unless..else 语句

unless..else 语句与 if..else 语句具有相同的结构。只有当指定的条件为假时,unless 语句中的代码才会执行。

5 cond

cond 语句用于根据多个条件执行代码。它类似于其他多种编程语言中的 if...else if….else 结构。

6 case

case 语句可以被认为是命令式语言中 switch 语句的替代品。case 接收一个变量/字面量,并用不同的 case 对其应用模式匹配。如果任何 case 匹配,Elixir 将执行与该 case 关联的代码并退出 case 语句。

Elixir - 字符串

Elixir 中的字符串用双引号括起来,并使用 UTF-8 编码。与 C 和 C++ 中默认字符串使用 ASCII 编码且只有 256 个不同字符不同,UTF-8 包含 1,112,064 个码位。这意味着 UTF-8 编码包含这么多不同的字符。由于字符串使用 utf-8,我们也可以使用像 ö、ł 等符号。

创建字符串

要创建一个字符串变量,只需将一个字符串赋值给一个变量:

str = "Hello world"

要将此打印到控制台,只需调用IO.puts 函数并传入变量 str:

str = str = "Hello world" 
IO.puts(str)

上述程序生成以下结果:

Hello World

空字符串

可以使用字符串字面量""创建一个空字符串。例如:

a = ""
if String.length(a) === 0 do
   IO.puts("a is an empty string")
end

以上程序产生以下结果。

a is an empty string

字符串插值

字符串插值是一种通过将常量、变量、字面量和表达式的值包含在字符串字面量中来从它们的混合构建新字符串值的方法。Elixir 支持字符串插值,要在字符串中使用变量,编写时用大括号将其括起来,并在花括号前加上'#'符号。

例如:

x = "Apocalypse" 
y = "X-men #{x}"
IO.puts(y)

这将取 x 的值并将其替换到 y 中。以上代码将产生以下结果:

X-men Apocalypse

字符串连接

我们已经在前面的章节中看到了字符串连接的使用。'<>' 运算符用于连接 Elixir 中的字符串。要连接两个字符串,

x = "Dark"
y = "Knight"
z = x <> " " <> y
IO.puts(z)

以上代码产生以下结果:

Dark Knight

字符串长度

要获取字符串的长度,我们使用String.length函数。将字符串作为参数传递,它将显示其大小。例如:

IO.puts(String.length("Hello"))

运行以上程序时,会产生以下结果:

5

反转字符串

要反转字符串,将其传递给 String.reverse 函数。例如:

IO.puts(String.reverse("Elixir"))

上述程序生成以下结果:

rixilE

字符串比较

要比较两个字符串,可以使用 == 或 === 运算符。例如:

var_1 = "Hello world"
var_2 = "Hello Elixir"
if var_1 === var_2 do
   IO.puts("#{var_1} and #{var_2} are the same")
else
   IO.puts("#{var_1} and #{var_2} are not the same")
end

上述程序生成以下结果:

Hello world and Hello elixir are not the same.

字符串匹配

我们已经看到了 =~ 字符串匹配运算符的使用。要检查字符串是否与正则表达式匹配,我们也可以使用字符串匹配运算符或 String.match? 函数。例如:

IO.puts(String.match?("foo", ~r/foo/))
IO.puts(String.match?("bar", ~r/foo/))

上述程序生成以下结果:

true 
false

这也可以通过使用 =~ 运算符来实现。例如:

IO.puts("foo" =~ ~r/foo/)

上述程序生成以下结果:

true

字符串函数

Elixir 支持大量与字符串相关的函数,一些最常用的函数列在下表中。

序号 函数及其用途
1

at(string, position)

返回给定 utf8 字符串中 position 位置的 grapheme。如果 position 大于字符串长度,则返回 nil

2

capitalize(string)

将给定字符串中的第一个字符转换为大写,其余转换为小写

3

contains?(string, contents)

检查字符串是否包含任何给定的内容

4

downcase(string)

将给定字符串中的所有字符转换为小写

5

ends_with?(string, suffixes)

如果字符串以任何给定的后缀结尾,则返回 true

6

first(string)

返回 utf8 字符串中的第一个 grapheme,如果字符串为空,则返回 nil

7

last(string)

返回 utf8 字符串中的最后一个 grapheme,如果字符串为空,则返回 nil

8

replace(subject, pattern, replacement, options \\ [])

返回一个新字符串,该字符串通过用 replacement 替换 subject 中 pattern 的出现来创建

9

slice(string, start, len)

返回从偏移量 start 开始,长度为 len 的子字符串

10

split(string)

在每个 Unicode 空格出现的位置将字符串划分为子字符串,忽略前导和尾随空格。空格组被视为单个出现。分割不会发生在不间断空格上

11

upcase(string)

将给定字符串中的所有字符转换为大写

二进制

二进制只是一系列字节。二进制使用<< >>定义。例如

<< 0, 1, 2, 3 >>

当然,这些字节可以以任何方式组织,甚至可以以不使它们成为有效字符串的序列组织。例如:

<< 239, 191, 191 >>

字符串也是二进制的。而字符串连接运算符<>实际上是二进制连接运算符

IO.puts(<< 0, 1 >> <> << 2, 3 >>)

以上代码产生以下结果:

<< 0, 1, 2, 3 >>

注意 ł 字符。由于这是 utf-8 编码的,这个字符表示占用 2 个字节。

由于二进制中表示的每个数字都应该是一个字节,当这个值超过 255 时,它会被截断。为了防止这种情况,我们使用大小修饰符来指定我们希望该数字占用多少位。例如:

IO.puts(<< 256 >>) # truncated, it'll print << 0 >>
IO.puts(<< 256 :: size(16) >>) #Takes 16 bits/2 bytes, will print << 1, 0 >>

以上程序将产生以下结果:

<< 0 >>
<< 1, 0 >>

如果字符是码位,我们也可以使用 utf8 修饰符,它将在输出中产生;否则是字节:

IO.puts(<< 256 :: utf8 >>)

上述程序生成以下结果:

Ā

我们还有一个名为is_binary的函数,它检查给定的变量是否为二进制。请注意,只有存储为 8 位倍数的变量才是二进制的。

位串

如果我们使用大小修饰符定义二进制并传递一个不是 8 的倍数的值,则最终会得到一个位串而不是二进制。例如:

bs = << 1 :: size(1) >>
IO.puts(bs)
IO.puts(is_binary(bs))
IO.puts(is_bitstring(bs))

上述程序生成以下结果:

<< 1::size(1) >>
false
true

这意味着变量bs不是二进制的,而是一个位串。我们也可以说二进制是一个位数是 8 的倍数的位串。模式匹配同样适用于二进制和位串。

Elixir - 字符列表

字符列表只不过是字符列表。考虑以下程序以了解相同的内容。

IO.puts('Hello')
IO.puts(is_list('Hello'))

上述程序生成以下结果:

Hello
true

字符列表包含字符的代码点,而不是字节,这些字符位于单引号之间。因此,双引号表示字符串(即二进制),单引号表示字符列表(即列表)。请注意,如果任何字符超出 ASCII 范围,IEx 将仅生成代码点作为输出。

字符列表主要用于与 Erlang 交互,特别是那些不接受二进制作为参数的旧库。您可以使用 to_string(char_list) 和 to_char_list(string) 函数将字符列表转换为字符串并转换回来:

IO.puts(is_list(to_char_list("hełło")))
IO.puts(is_binary(to_string ('hełło')))

上述程序生成以下结果:

true
true

注意 - 函数to_stringto_char_list是多态的,即它们可以接受多种类型的输入,例如原子、整数,并将它们分别转换为字符串和字符列表。

Elixir - 列表和元组

(链表)列表

链表是在内存中不同位置存储的元素的异构列表,并使用引用进行跟踪。链表是在函数式编程中特别使用的的数据结构。

Elixir 使用方括号指定值列表。值可以是任何类型:

[1, 2, true, 3]

当 Elixir 看到可打印的 ASCII 数字列表时,Elixir 将将其打印为字符列表(实际上是字符列表)。每当您在 IEx 中看到一个值并且不确定它是什么时,您可以使用i函数来检索有关它的信息。

IO.puts([104, 101, 108, 108, 111])

列表中的上述字符都是可打印的。运行以上程序时,会产生以下结果:

hello

您也可以使用单引号反向定义列表:

IO.puts(is_list('Hello'))

运行上述程序时,将产生以下结果:

true

请记住,在 Elixir 中,单引号和双引号表示法并不等效,因为它们由不同的类型表示。

列表的长度

要查找列表的长度,我们使用 length 函数,如下面的程序所示:

IO.puts(length([1, 2, :true, "str"]))

上述程序生成以下结果:

4

连接和减法

可以使用++--运算符连接和减去两个列表。考虑以下示例以了解这些函数。

IO.puts([1, 2, 3] ++ [4, 5, 6])
IO.puts([1, true, 2, false, 3, true] -- [true, false])

这将分别在第一种情况下为您提供连接的字符串,在第二种情况下提供减去的字符串。以上程序产生以下结果:

[1, 2, 3, 4, 5, 6]
[1, 2, 3, true]

列表的头和尾

头是列表的第一个元素,尾是列表的其余部分。它们可以使用hdtl函数检索。让我们将列表分配给变量并检索其头和尾。

list = [1, 2, 3]
IO.puts(hd(list))
IO.puts(tl(list))

这将为我们提供列表的头和尾作为输出。以上程序产生以下结果:

1
[2, 3]

注意 - 获取空列表的头或尾是错误的。

其他列表函数

Elixir 标准库提供了许多处理列表的函数。我们将在此处查看其中一些。

序号 函数名称和描述
1

delete(list, item)

从列表中删除给定的项。返回一个不包含该项的列表。如果该项在列表中出现多次,则只删除第一次出现的项。

2

delete_at(list, index)

通过删除指定索引处的值来生成一个新列表。负索引表示从列表末尾的偏移量。如果索引超出范围,则返回原始列表。

3

first(list)

返回列表中的第一个元素,如果列表为空则返回 nil。

4

flatten(list)

展平给定的嵌套列表。

5

insert_at(list, index, value)

返回一个在指定索引处插入 value 的列表。注意,索引值的上限为列表长度。负索引表示从列表末尾的偏移量。

6

last(list)

返回列表中的最后一个元素,如果列表为空则返回 nil。

元组

元组也是一种数据结构,可以在其中存储多个其他结构。与列表不同,它们将元素存储在连续的内存块中。这意味着按索引访问元组元素或获取元组大小是一个快速操作。索引从零开始。

Elixir 使用花括号定义元组。与列表一样,元组可以容纳任何值:

{:ok, "hello"}

元组的长度

要获取元组的长度,请使用 **tuple_size** 函数,如下面的程序所示:

IO.puts(tuple_size({:ok, "hello"}))

上述程序生成以下结果:

2

追加值

要向元组追加值,请使用 Tuple.append 函数:

tuple = {:ok, "Hello"}
Tuple.append(tuple, :world)

这将创建一个新的元组并返回它:{:ok, "Hello", :world}

插入值

要在给定位置插入值,我们可以使用 **Tuple.insert_at** 函数或 **put_elem** 函数。请考虑以下示例以了解相同内容:

tuple = {:bar, :baz}
new_tuple_1 = Tuple.insert_at(tuple, 0, :foo)
new_tuple_2 = put_elem(tuple, 1, :foobar)

请注意,**put_elem** 和 **insert_at** 返回了新的元组。存储在 tuple 变量中的原始元组没有被修改,因为 Elixir 数据类型是不可变的。由于不可变性,Elixir 代码更容易推理,因为您永远不必担心特定代码是否正在就地更改您的数据结构。

元组与列表

列表和元组有什么区别?

列表在内存中以链表的形式存储,这意味着列表中的每个元素都保存其值并指向下一个元素,直到到达列表的末尾。我们将值和指针的每一对称为 cons 单元。这意味着访问列表的长度是一个线性操作:我们需要遍历整个列表才能确定其大小。只要我们预先添加元素,更新列表就很快。

另一方面,元组在内存中连续存储。这意味着通过索引获取元组大小或访问元素速度很快。但是,更新或向元组添加元素代价很高,因为它需要复制内存中的整个元组。

Elixir - 关键字列表

到目前为止,我们还没有讨论任何关联数据结构,即可以将某个值(或多个值)与键关联的数据结构。不同的语言使用不同的名称来称呼这些特性,例如字典、哈希、关联数组等。

在 Elixir 中,我们有两个主要的关联数据结构:关键字列表和映射。在本章中,我们将重点介绍关键字列表。

在许多函数式编程语言中,使用 2 个项目的元组列表作为关联数据结构的表示是很常见的。在 Elixir 中,当我们有一个元组列表并且元组的第一个项目(即键)是一个原子时,我们称之为关键字列表。请考虑以下示例以了解相同内容:

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

Elixir 支持定义此类列表的特殊语法。我们可以将冒号放在每个原子的末尾,并完全删除元组。例如,

list_1 = [{:a, 1}, {:b, 2}]
list_2 = [a: 1, b: 2]
IO.puts(list_1 == list_2)

以上程序将产生以下结果:

true

这两个都表示关键字列表。由于关键字列表也是列表,因此我们可以对它们使用我们对列表使用过的所有操作。

要检索与关键字列表中原子关联的值,请将原子作为 [] 传递到列表名称之后:

list = [a: 1, b: 2]
IO.puts(list[:a])

上述程序生成以下结果:

1

关键字列表具有三个特殊特性:

  • 键必须是原子。
  • 键是有序的,由开发者指定。
  • 键可以多次给出。

为了操作关键字列表,Elixir 提供了 Keyword 模块。但是请记住,关键字列表只是列表,因此它们提供了与列表相同的线性性能特征。列表越长,查找键、计算项目数量等所需的时间就越长。因此,关键字列表主要在 Elixir 中用作选项。如果您需要存储许多项目或保证一个键最多与一个值关联,则应改用映射。

访问键

要访问与给定键关联的值,我们使用 **Keyword.get** 函数。它返回与给定键关联的第一个值。要获取所有值,我们使用 Keyword.get_values 函数。例如:

kl = [a: 1, a: 2, b: 3] 
IO.puts(Keyword.get(kl, :a)) 
IO.puts(Keyword.get_values(kl)) 

以上程序将产生以下结果:

1
[1, 2]

插入键

要添加新值,请使用 **Keyword.put_new**。如果键已存在,则其值保持不变:

kl = [a: 1, a: 2, b: 3]
kl_new = Keyword.put_new(kl, :c, 5)
IO.puts(Keyword.get(kl_new, :c))

运行上述程序时,它将创建一个具有附加键 c 的新关键字列表,并生成以下结果:

5

删除键

如果要删除键的所有条目,请使用 **Keyword.delete**;要仅删除键的第一个条目,请使用 **Keyword.delete_first**。

kl = [a: 1, a: 2, b: 3, c: 0]
kl = Keyword.delete_first(kl, :b)
kl = Keyword.delete(kl, :a)

IO.puts(Keyword.get(kl, :a))
IO.puts(Keyword.get(kl, :b))
IO.puts(Keyword.get(kl, :c))

这将删除列表中的第一个 **b** 和列表中的所有 **a**。运行上述程序时,它将生成以下结果:

0

Elixir - 映射

关键字列表是通过键处理存储在列表中的内容的一种便捷方式,但在底层,Elixir 仍在遍历列表。如果您对该列表还有其他计划需要遍历所有内容,这可能很合适,但如果您计划仅使用键作为数据的方法,这可能会造成不必要的开销。

这就是映射可以帮上忙的地方。每当您需要键值存储时,映射都是 Elixir 中的“首选”数据结构。

创建映射

使用 %{} 语法创建映射:

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

与关键字列表相比,我们已经可以看到两个区别:

  • 映射允许任何值作为键。
  • 映射的键不遵循任何顺序。

访问键

为了访问与键关联的值,映射使用与关键字列表相同的语法:

map = %{:a => 1, 2 => :b}
IO.puts(map[:a])
IO.puts(map[2])

运行上述程序时,它将生成以下结果:

1
b

插入键

要在映射中插入键,我们使用 **Dict.put_new** 函数,该函数将映射、新键和新值作为参数:

map = %{:a => 1, 2 => :b}
new_map = Dict.put_new(map, :new_val, "value") 
IO.puts(new_map[:new_val])

这将在新映射中插入键值对 **:new_val - "value"**。运行上述程序时,它将生成以下结果:

"value"

更新值

要更新映射中已存在的值,可以使用以下语法:

map = %{:a => 1, 2 => :b}
new_map = %{ map | a: 25}
IO.puts(new_map[:a])

运行上述程序时,它将生成以下结果:

25

模式匹配

与关键字列表相比,映射在模式匹配中非常有用。当在模式中使用映射时,它将始终匹配给定值的子集:

%{:a => a} = %{:a => 1, 2 => :b}
IO.puts(a)

上述程序生成以下结果:

1

这将匹配 **a** 与 **1**。因此,它将生成输出 **1**。

如上所示,只要模式中的键存在于给定映射中,映射就会匹配。因此,空映射匹配所有映射。

在访问、匹配和添加映射键时可以使用变量:

n = 1
map = %{n => :one}
%{^n => :one} = %{1 => :one, 2 => :two, 3 => :three}

Map 模块 提供了与 Keyword 模块非常相似的 API,以及用于操作映射的便利函数。您可以使用 **Map.get、Map.delete** 等函数来操作映射。

具有原子键的映射

映射具有一些有趣的属性。当映射中的所有键都是原子时,您可以使用关键字语法以方便起见:

map = %{:a => 1, 2 => :b} 
IO.puts(map.a) 

映射的另一个有趣的属性是它们提供了自己的语法来更新和访问原子键:

map = %{:a => 1, 2 => :b}
IO.puts(map.a)

上述程序生成以下结果:

1

请注意,要以这种方式访问原子键,它必须存在,否则程序将无法工作。

Elixir - 模块

在 Elixir 中,我们将多个函数分组到模块中。我们已经在前面的章节中使用了不同的模块,例如 String 模块、Bitwise 模块、Tuple 模块等。

为了在 Elixir 中创建我们自己的模块,我们使用 **defmodule** 宏。我们使用 **def** 宏在该模块中定义函数:

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

在以下部分中,我们的示例大小将越来越大,在 shell 中键入所有这些示例可能会很棘手。我们需要学习如何编译 Elixir 代码以及如何运行 Elixir 脚本。

编译

将模块写入文件以便编译和重用总是很方便的。假设我们有一个名为 math.ex 的文件,其内容如下:

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

我们可以使用命令编译文件:**elixirc**

$ elixirc math.ex

这将生成一个名为 **Elixir.Math.beam** 的文件,其中包含已定义模块的字节码。如果我们再次启动 **iex**,我们的模块定义将可用(前提是 iex 在字节码文件所在的同一目录中启动)。例如,

IO.puts(Math.sum(1, 2))

以上程序将产生以下结果:

3

脚本模式

除了 Elixir 文件扩展名 **.ex** 之外,Elixir 还支持 **.exs** 文件进行脚本编写。Elixir 对这两个文件的处理方式完全相同,唯一的区别在于目标。**.ex** 文件旨在进行编译,而 .exs 文件用于 **脚本编写**。执行时,这两个扩展名都会编译并将它们的模块加载到内存中,尽管只有 **.ex** 文件将其字节码写入磁盘,格式为 .beam 文件。

例如,如果我们想在同一个文件中运行 **Math.sum**,我们可以使用以下方式使用 .exs:

Math.exs

defmodule Math do
   def sum(a, b) do
      a + b
   end
end
IO.puts(Math.sum(1, 2))

我们可以使用 Elixir 命令运行它:

$ elixir math.exs

以上程序将产生以下结果:

3

该文件将在内存中编译并执行,打印“3”作为结果。不会创建字节码文件。

模块嵌套

模块可以在 Elixir 中嵌套。语言的此特性有助于我们更好地组织代码。要创建嵌套模块,我们使用以下语法:

defmodule Foo do
   #Foo module code here
   defmodule Bar do
      #Bar module code here
   end
end

上面给出的示例将定义两个模块:**Foo** 和 **Foo.Bar**。只要它们在相同的词法作用域中,第二个模块可以在 **Foo** 中作为 **Bar** 访问。如果稍后将 **Bar** 模块移到 Foo 模块定义之外,则必须通过其全名 (Foo.Bar) 引用它,或者必须使用别名章节中讨论的 alias 指令设置别名。

**注意**:在 Elixir 中,无需定义 Foo 模块即可定义 Foo.Bar 模块,因为该语言将所有模块名称转换为原子。您可以定义任意嵌套的模块,而无需定义链中的任何模块。例如,您可以定义 **Foo.Bar.Baz** 而无需定义 **Foo** 或 **Foo.Bar**。

Elixir - 别名

为了促进软件重用,Elixir 提供了三个指令 – **alias、require** 和 **import**。它还提供了一个名为 use 的宏,总结如下:

# Alias the module so it can be called as Bar instead of Foo.Bar
alias Foo.Bar, as: Bar

# Ensure the module is compiled and available (usually for macros)
require Foo

# Import functions from Foo so they can be called without the `Foo.` prefix
import Foo

# Invokes the custom code defined in Foo as an extension point
use Foo

现在让我们详细了解每个指令。

alias

alias 指令允许您为任何给定的模块名称设置别名。例如,如果您想为 String 模块提供别名 **'Str'**,您可以简单地编写:

alias String, as: Str
IO.puts(Str.length("Hello"))

上述程序生成以下结果:

5

String模块命名为Str别名。现在,当我们使用Str字面量调用任何函数时,它实际上都引用了String模块。当我们使用非常长的模块名并希望在当前作用域内用较短的名称替换它们时,这非常有用。

注意 − 别名必须以大写字母开头。

别名仅在其调用的词法作用域内有效。例如,如果在一个文件中包含2个模块,并在其中一个模块内创建别名,则该别名在第二个模块中不可访问。

如果将内置模块(如String或Tuple)的名称作为其他模块的别名,则要访问内置模块,需要在前面加上“Elixir.”。例如:

alias List, as: String
#Now when we use String we are actually using List.
#To use the string module: 
IO.puts(Elixir.String.length("Hello"))

运行上述程序时,它将生成以下结果:

5

require

Elixir提供宏作为元编程(编写生成代码的代码)的机制。

宏是在编译时执行和扩展的代码块。这意味着,为了使用宏,我们需要保证其模块和实现可在编译期间使用。这是通过require指令完成的。

Integer.is_odd(3)

运行上述程序时,将生成以下结果:

** (CompileError) iex:1: you must require Integer before invoking the macro Integer.is_odd/1

在Elixir中,Integer.is_odd被定义为。此宏可用作保护条件。这意味着,为了调用Integer.is_odd,我们需要Integer模块。

使用require Integer函数并按如下所示运行程序。

require Integer
Integer.is_odd(3)

这次程序将运行并产生输出:true

通常,除非我们要使用该模块中可用的宏,否则在使用之前不需要模块。尝试调用未加载的宏将引发错误。请注意,与alias指令一样,require也具有词法作用域。我们将在后面的章节中详细讨论宏。

import

我们使用import指令轻松访问其他模块中的函数或宏,而无需使用完全限定名。例如,如果我们想多次使用List模块中的duplicate函数,我们可以简单地导入它。

import List, only: [duplicate: 2]

在这种情况下,我们只从List中导入函数duplicate(参数列表长度为2)。虽然:only是可选的,但建议使用它,以避免将给定模块的所有函数导入命名空间。也可以使用:except选项,以便导入模块中的所有内容,除了函数列表。

import指令还支持将:macros:functions提供给:only。例如,要导入所有宏,用户可以编写:

import Integer, only: :macros

请注意,import也与require和alias指令一样具有词法作用域。还要注意,“导入”模块也会“需要”它

use

虽然不是指令,但use是一个与require紧密相关的宏,允许您在当前上下文中使用模块。use宏经常被开发人员用来将外部功能引入当前词法作用域,通常是模块。让我们通过一个例子来理解use指令:

defmodule Example do 
   use Feature, option: :value 
end 

Use是一个宏,它将上述内容转换为:

defmodule Example do
   require Feature
   Feature.__using__(option: :value)
end

use Module首先需要该模块,然后在Module上调用__using__宏。Elixir具有强大的元编程功能,它具有在编译时生成代码的宏。在上述实例中调用了__using__宏,并将代码注入到我们的局部上下文中。局部上下文是在编译时调用use宏的地方。

Elixir - 函数

函数是一组组织在一起以执行特定任务的语句。编程中的函数大多类似于数学中的函数。您向函数提供一些输入,它们根据提供的输入生成输出。

Elixir中有两种类型的函数:

匿名函数

使用fn..end结构定义的函数是匿名函数。这些函数有时也称为lambda函数。它们通过将它们赋值给变量名来使用。

命名函数

使用def关键字定义的函数是命名函数。这些是Elixir中提供的原生函数。

匿名函数

顾名思义,匿名函数没有名称。这些函数经常传递给其他函数。要在Elixir中定义匿名函数,我们需要fnend关键字。在这些关键字中,我们可以定义任意数量的参数和函数体,它们由->分隔。例如:

sum = fn (a, b) -> a + b end
IO.puts(sum.(1, 5))

运行上述程序时,将生成以下结果:

6

请注意,这些函数的调用方式与命名函数不同。函数名及其参数之间有一个'.'。

使用捕获运算符

我们也可以使用捕获运算符定义这些函数。这是一种创建函数的更简单的方法。我们现在将使用捕获运算符定义上述sum函数:

sum = &(&1 + &2) 
IO.puts(sum.(1, 2))

运行上述程序时,它将生成以下结果:

3

在简写版本中,我们的参数没有命名,但我们可以将其作为&1,&2,&3等等使用。

模式匹配函数

模式匹配不仅限于变量和数据结构。我们可以使用模式匹配使我们的函数多态。例如,我们将声明一个函数,它可以接受1个或2个输入(在一个元组中),并将它们打印到控制台:

handle_result = fn
   {var1} -> IO.puts("#{var1} found in a tuple!")
   {var_2, var_3} -> IO.puts("#{var_2} and #{var_3} found!")
end
handle_result.({"Hey people"})
handle_result.({"Hello", "World"})

运行上述程序时,将产生以下结果:

Hey people found in a tuple!
Hello and World found!

命名函数

我们可以定义带有名称的函数,以便以后可以轻松引用它们。命名函数使用def关键字在模块中定义。命名函数始终在模块中定义。要调用命名函数,我们需要使用它们的模块名来引用它们。

以下是命名函数的语法:

def function_name(argument_1, argument_2) do
   #code to be executed when function is called
end

现在让我们在Math模块中定义我们的命名函数sum。

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

IO.puts(Math.sum(5, 6))

运行以上程序时,会产生以下结果:

11

对于单行函数,可以使用do:定义这些函数的简写符号。例如:

defmodule Math do
   def sum(a, b), do: a + b
end
IO.puts(Math.sum(5, 6))

运行以上程序时,会产生以下结果:

11

私有函数

Elixir使我们能够定义私有函数,这些函数可以在定义它们的模块内访问。要定义私有函数,请使用defp而不是def。例如:

defmodule Greeter do
   def hello(name), do: phrase <> name
   defp phrase, do: "Hello "
end

Greeter.hello("world")

运行上述程序时,将产生以下结果:

Hello world

但是,如果我们只是尝试使用Greeter.phrase()函数显式调用phrase函数,它将引发错误。

默认参数

如果我们想要参数的默认值,我们使用参数 \\ 值语法:

defmodule Greeter do
   def hello(name, country \\ "en") do
      phrase(country) <> name
   end

   defp phrase("en"), do: "Hello, "
   defp phrase("es"), do: "Hola, "
end

Greeter.hello("Ayush", "en")
Greeter.hello("Ayush")
Greeter.hello("Ayush", "es")

运行上述程序时,将产生以下结果:

Hello, Ayush
Hello, Ayush
Hola, Ayush

Elixir - 递归

递归是一种方法,其中问题的解决方案取决于对同一问题的较小实例的解决方案。大多数计算机编程语言都支持递归,允许函数在程序文本中调用自身。

理想情况下,递归函数具有结束条件。这个结束条件,也称为基本情况,会停止重新进入函数并将函数调用添加到堆栈中。这就是递归函数调用停止的地方。让我们考虑以下示例来进一步理解递归函数。

defmodule Math do
   def fact(res, num) do
   if num === 1 do
      res
   else
      new_res = res * num
      fact(new_res, num-1)
      end
   end
end

IO.puts(Math.fact(1,5))

运行上述程序时,它将生成以下结果:

120

因此,在上面的函数Math.fact中,我们正在计算数字的阶乘。请注意,我们正在函数自身内部调用该函数。现在让我们了解一下它是如何工作的。

我们向它提供了1和我们要计算阶乘的数字。该函数检查数字是否为1,如果不是1则返回res(结束条件)。如果不是,则它创建一个变量new_res,并为其赋值previous res * current num的值。它返回我们的函数调用fact(new_res, num-1)返回的值。这将重复进行,直到我们得到num为1。一旦发生这种情况,我们就会得到结果。

让我们考虑另一个示例,逐个打印列表中的每个元素。为此,我们将利用列表的hdtl函数以及函数中的模式匹配:

a = ["Hey", 100, 452, :true, "People"]
defmodule ListPrint do
   def print([]) do
   end
   def print([head | tail]) do 
      IO.puts(head)
      print(tail)
   end
end

ListPrint.print(a)

当我们有一个空列表时,将调用第一个print函数(结束条件)。如果不是,则将调用第二个print函数,它将列表分成两部分,并将列表的第一个元素赋值给head,并将列表的其余部分赋值给tail。然后打印head,我们再次使用列表的其余部分(即tail)调用print函数。运行上述程序时,将产生以下结果:

Hey
100
452
true
People

Elixir - 循环

由于不变性,Elixir中的循环(与任何函数式编程语言一样)与命令式语言中的写法不同。例如,在像C这样的命令式语言中,你会这样写:

for(i = 0; i < 10; i++) {
   printf("%d", array[i]);
}

在上例中,我们正在修改数组和变量i。在Elixir中不可能进行修改。相反,函数式语言依赖于递归:递归调用函数,直到达到停止递归操作继续的条件。在此过程中不会修改任何数据。

现在让我们使用递归编写一个简单的循环,打印n次hello。

defmodule Loop 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

Loop.print_multiple_times("Hello", 10)

运行上述程序时,将产生以下结果:

Hello
Hello
Hello
Hello
Hello
Hello
Hello
Hello
Hello
Hello

我们利用了函数的模式匹配技术和递归成功地实现了一个循环。递归定义很难理解,但是将循环转换为递归很容易。

Elixir为我们提供了Enum模块。此模块用于大多数迭代循环调用,因为它比尝试为相同内容找出递归定义要容易得多。我们将在下一章讨论这些内容。只有当您找不到使用该模块的解决方案时,才应使用您自己的递归定义。这些函数经过尾调用优化,速度相当快。

Elixir - 可枚举

枚举是可枚举的对象。“枚举”意味着逐个(通常按顺序,通常按名称)计算集合/集合/类别的成员。

Elixir提供了枚举的概念和Enum模块来处理它们。Enum模块中的函数仅限于(顾名思义)枚举数据结构中的值。枚举数据结构的示例是列表、元组、映射等。Enum模块为我们提供了超过100个处理枚举的函数。我们将在本章讨论一些重要的函数。

所有这些函数都将枚举作为第一个元素,并将函数作为第二个元素,并在其上工作。这些函数描述如下。

all?

当我们使用all?函数时,整个集合必须计算为true,否则将返回false。例如,要检查列表中的所有元素是否都是奇数,则:

res = Enum.all?([1, 2, 3, 4], fn(s) -> rem(s,2) == 1 end) 
IO.puts(res)

运行上述程序时,将产生以下结果:

false

这是因为此列表并非所有元素都是奇数。

any?

顾名思义,如果集合的任何元素计算为true,此函数将返回true。例如:

res = Enum.any?([1, 2, 3, 4], fn(s) -> rem(s,2) == 1 end)
IO.puts(res)

运行上述程序时,将产生以下结果:

true

chunk

此函数将我们的集合分成大小为第二个参数的小块。例如:

res = Enum.chunk([1, 2, 3, 4, 5, 6], 2)
IO.puts(res)

运行上述程序时,将产生以下结果:

[[1, 2], [3, 4], [5, 6]]

each

可能需要迭代集合而不产生新值,在这种情况下,我们使用each函数:

Enum.each(["Hello", "Every", "one"], fn(s) -> IO.puts(s) end)

运行上述程序时,将产生以下结果:

Hello
Every
one

map

要将我们的函数应用于每个项目并产生新的集合,我们使用map函数。它是函数式编程中最有用的构造之一,因为它非常简洁和简短。让我们考虑一个例子来理解这一点。我们将把列表中存储的值加倍,并将其存储在新列表res中:

res = Enum.map([2, 5, 3, 6], fn(a) -> a*2 end)
IO.puts(res)

运行上述程序时,将产生以下结果:

[4, 10, 6, 12]

reduce

reduce函数帮助我们将枚举减少到单个值。为此,我们提供一个可选的累加器(在此示例中为5)传递到我们的函数中;如果没有提供累加器,则使用第一个值:

res = Enum.reduce([1, 2, 3, 4], 5, fn(x, accum) -> x + accum end)
IO.puts(res)

运行上述程序时,将产生以下结果:

15

累加器是传递给fn的初始值。从第二次调用开始,前一次调用的返回值将作为累加器传递。我们也可以在没有累加器的情况下使用reduce:

res = Enum.reduce([1, 2, 3, 4], fn(x, accum) -> x + accum end)
IO.puts(res)

运行上述程序时,将产生以下结果:

10

uniq

uniq 函数会移除集合中的重复元素,只返回集合中唯一元素的集合。例如:

res = Enum.uniq([1, 2, 2, 3, 3, 3, 4, 4, 4, 4])
IO.puts(res)

运行上述程序,会产生以下结果:

[1, 2, 3, 4]

及早求值

Enum 模块中的所有函数都是及早求值的。许多函数都接收一个可枚举对象并返回一个列表。这意味着当使用 Enum 执行多个操作时,每个操作都会生成一个中间列表,直到得到最终结果。让我们来看下面的例子来理解这一点:

odd? = &(odd? = &(rem(&1, 2) != 0) 
res = 1..100_000 |> Enum.map(&(&1 * 3)) |> Enum.filter(odd?) |> Enum.sum 
IO.puts(res) 

运行上述程序时,将产生以下结果:

7500000000

上面的例子有一系列的操作。我们从一个范围开始,然后将范围中的每个元素乘以 3。这个第一个操作现在将创建一个包含 100,000 个元素的列表并返回。然后我们保留列表中所有奇数元素,生成一个新的列表,现在包含 50,000 个元素,然后我们对所有元素求和。

上面代码片段中使用的 |> 符号是 管道操作符:它简单地将左侧表达式的输出作为第一个参数传递给右侧的函数调用。它类似于 Unix 的 | 操作符。它的目的是突出显示一系列函数转换数据流。

如果没有 管道操作符,代码看起来会很复杂:

Enum.sum(Enum.filter(Enum.map(1..100_000, &(&1 * 3)), odd?))

我们还有许多其他函数,但是这里只描述了其中一些重要的函数。

Elixir - 流

许多函数都接收一个可枚举对象并返回一个 列表。这意味着,在使用 Enum 执行多个操作时,每个操作都会生成一个中间列表,直到得到最终结果。

与 Enum 的及早求值操作相反,Stream 支持惰性操作。简而言之,Stream 是惰性的、可组合的可枚举对象。这意味着 Stream 只有在绝对需要时才会执行操作。让我们来看一个例子来理解这一点:

odd? = &(rem(&1, 2) != 0)
res = 1..100_000 |> Stream.map(&(&1 * 3)) |> Stream.filter(odd?) |> Enum.sum
IO.puts(res)

运行上述程序时,将产生以下结果:

7500000000

在上面的例子中,1..100_000 |> Stream.map(&(&1 * 3)) 返回一个数据类型,一个实际的流,它表示对范围 1..100_000 的映射计算。它还没有计算这个表示。Stream 不会生成中间列表,而是构建一系列计算,只有当我们将底层 Stream 传递给 Enum 模块时才会调用这些计算。在处理大型的、可能是无限的集合时,Stream 非常有用。

Stream 和 Enum 具有许多共同的函数。Stream 主要提供与 Enum 模块提供的函数相同的函数,这些函数在对输入可枚举对象执行计算后将列表作为返回值。其中一些列在下面的表中:

序号 函数及其描述
1

chunk(enum, n, step, leftover \\ nil)

将可枚举对象分成块,每个块包含 n 个元素,每个新块从可枚举对象的 step 个元素开始。

2

concat(enumerables)

创建一个流,枚举可枚举对象中的每个可枚举对象。

3

each(enum, fun)

为每个元素执行给定的函数。

4

filter(enum, fun)

创建一个流,根据枚举时给定的函数过滤元素。

5

map(enum, fun)

创建一个流,将在枚举时应用给定的函数。

6

drop(enum, n)

惰性地丢弃可枚举对象的接下来的 n 个元素。

Elixir - 结构体

结构体是在 map 之上构建的扩展,提供编译时检查和默认值。

定义结构体

要定义一个结构体,使用 defstruct 结构:

defmodule User do
   defstruct name: "John", age: 27
end

与 defstruct 一起使用的关键字列表定义了结构体将具有的字段及其默认值。结构体采用定义它们的模块的名称。在上面的例子中,我们定义了一个名为 User 的结构体。我们现在可以使用类似于创建 map 的语法来创建 User 结构体:

new_john = %User{})
ayush = %User{name: "Ayush", age: 20}
megan = %User{name: "Megan"})

以上代码将生成三个具有不同值的结构体:

%User{age: 27, name: "John"}
%User{age: 20, name: "Ayush"}
%User{age: 27, name: "Megan"}

结构体提供编译时保证,只有通过 defstruct 定义的字段(以及所有字段)才能存在于结构体中。因此,一旦在模块中创建了结构体,就不能定义自己的字段。

访问和更新结构体

当我们讨论 map 时,我们展示了如何访问和更新 map 的字段。同样的技术(和相同的语法)也适用于结构体。例如,如果我们想更新我们在前面示例中创建的用户,那么:

defmodule User do
   defstruct name: "John", age: 27
end
john = %User{}
#john right now is: %User{age: 27, name: "John"}

#To access name and age of John, 
IO.puts(john.name)
IO.puts(john.age)

运行上述程序时,将产生以下结果:

John
27

要更新结构体中的值,我们将再次使用我们在 map 章节中使用的相同过程:

meg = %{john | name: "Meg"}

结构体也可以用于模式匹配,既可以匹配特定键的值,也可以确保匹配的值与匹配值具有相同的类型。

Elixir - 协议

协议是在 Elixir 中实现多态性的机制。只要数据类型实现了协议,就可以对该协议进行分派。

让我们考虑一个使用协议的例子。我们在前面的章节中使用了名为 to_string 的函数将其他类型转换为字符串类型。这实际上是一个协议。它根据给定的输入进行操作,而不会产生错误。这看起来像是我们正在讨论模式匹配函数,但随着我们的进一步讨论,事实证明情况并非如此。

考虑下面的例子来进一步理解协议机制。

让我们创建一个协议来显示给定的输入是否为空。我们将这个协议称为 blank?

定义协议

我们可以在 Elixir 中以以下方式定义协议:

defprotocol Blank do
   def blank?(data)
end

正如你所看到的,我们不需要为函数定义一个函数体。如果你熟悉其他编程语言中的接口,你可以认为协议基本上是相同的东西。

所以这个协议说的是,任何实现它的东西都必须有一个 empty? 函数,尽管实现者如何响应该函数取决于实现者。在定义了协议之后,让我们了解如何添加几个实现。

实现协议

由于我们已经定义了一个协议,我们现在需要告诉它如何处理它可能接收的不同输入。让我们基于我们之前使用的例子。我们将为列表、map 和字符串实现 blank 协议。这将显示我们传递的内容是否为空。

#Defining the protocol
defprotocol Blank do
   def blank?(data)
end

#Implementing the protocol for lists
defimpl Blank, for: List do
   def blank?([]), do: true
   def blank?(_), do: false
end

#Implementing the protocol for strings
defimpl Blank, for: BitString do
   def blank?(""), do: true
   def blank?(_), do: false
end

#Implementing the protocol for maps
defimpl Blank, for: Map do
   def blank?(map), do: map_size(map) == 0
end

IO.puts(Blank.blank? [])
IO.puts(Blank.blank? [:true, "Hello"])
IO.puts(Blank.blank? "")
IO.puts(Blank.blank? "Hi")

你可以为任意数量的类型实现你的协议,只要对你的协议的使用有意义即可。这是一个关于协议相当基本的用例。运行上述程序时,会产生以下结果:

true
false
true
false

注意 - 如果你将其用于除你为其定义协议的类型以外的任何类型,它将产生错误。

Elixir - 文件 I/O

文件 I/O 是任何编程语言不可或缺的一部分,因为它允许语言与文件系统上的文件交互。在本节中,我们将讨论两个模块:Path 和 File。

Path 模块

path 模块是一个非常小的模块,可以被认为是文件系统操作的辅助模块。File 模块中的大多数函数都期望路径作为参数。最常见的是,这些路径将是常规二进制数据。Path 模块提供处理此类路径的功能。建议使用 Path 模块中的函数,而不是仅仅操作二进制数据,因为 Path 模块透明地处理不同的操作系统。需要注意的是,Elixir 在执行文件操作时会自动将斜杠 (/) 转换为反斜杠 (\)(在 Windows 上)。

让我们考虑下面的例子来进一步理解 Path 模块:

IO.puts(Path.join("foo", "bar"))

运行上述程序时,将产生以下结果:

foo/bar

path 模块提供了很多方法。你可以查看不同的方法 这里。如果你正在执行许多文件操作,这些方法经常被使用。

File 模块

file 模块包含允许我们将文件打开为 I/O 设备的函数。默认情况下,文件以二进制模式打开,这要求开发人员使用 IO 模块中的特定 IO.binreadIO.binwrite 函数。让我们创建一个名为 newfile 的文件并向其中写入一些数据。

{:ok, file} = File.read("newfile", [:write]) 
# Pattern matching to store returned stream
IO.binwrite(file, "This will be written to the file")

如果你去打开我们刚才写入的文件,内容将以以下方式显示:

This will be written to the file 

现在让我们了解如何使用 file 模块。

打开文件

要打开文件,我们可以使用以下两个函数中的任何一个:

{:ok, file} = File.open("newfile")
file = File.open!("newfile")

现在让我们了解 File.open 函数和 File.open!() 函数之间的区别。

  • File.open 函数总是返回一个元组。如果文件成功打开,它将元组中的第一个值返回为 :ok,第二个值是 io_device 类型的字面量。如果发生错误,它将返回一个元组,第一个值为 :error,第二个值为原因。

  • 另一方面,File.open!() 函数如果文件成功打开将返回一个 io_device,否则将引发错误。注意:这是我们将要讨论的所有 file 模块函数中遵循的模式。

我们还可以指定要打开此文件的模式。要以只读模式和 utf-8 编码模式打开文件,我们使用以下代码:

file = File.open!("newfile", [:read, :utf8])

写入文件

我们有两种方法可以写入文件。让我们看看第一种方法,使用 File 模块的 write 函数。

File.write("newfile", "Hello")

但是,如果要对同一个文件进行多次写入,则不应使用此方法。每次调用此函数时,都会打开一个文件描述符并产生一个新进程来写入文件。如果在循环中进行多次写入,请通过 File.open 打开文件,并使用 IO 模块中的方法写入文件。让我们考虑一个例子来理解这一点:

#Open the file in read, write and utf8 modes. 
file = File.open!("newfile_2", [:read, :utf8, :write])

#Write to this "io_device" using standard IO functions
IO.puts(file, "Random text")

你可以使用其他 IO 模块方法,如 IO.writeIO.binwrite,写入以 io_device 打开的文件。

从文件读取

我们有两种方法可以从文件读取。让我们看看第一种方法,使用 File 模块的 read 函数。

IO.puts(File.read("newfile"))

运行此代码时,你应该得到一个元组,其第一个元素为 :ok,第二个元素为 newfile 的内容。

我们还可以使用 File.read! 函数来获取返回给我们的文件内容。

关闭打开的文件

每当你使用 File.open 函数打开文件后,在完成使用后,都应使用 File.close 函数关闭它:

File.close(file)

Elixir - 进程

在Elixir中,所有代码都在进程内运行。进程彼此隔离,并发运行,并通过消息传递进行通信。Elixir的进程不应与操作系统进程混淆。Elixir中的进程在内存和CPU方面都非常轻量级(不像许多其他编程语言中的线程)。因此,同时运行成千上万甚至数十万个进程并不少见。

在本章中,我们将学习生成新进程以及在不同进程之间发送和接收消息的基本结构。

spawn函数

创建新进程最简单的方法是使用spawn函数。spawn接受一个将在新进程中运行的函数。例如:

pid = spawn(fn -> 2 * 2 end)
Process.alive?(pid)

运行上述程序时,将产生以下结果:

false

spawn函数的返回值是PID。这是进程的唯一标识符,因此如果您运行上面的代码,您的PID将不同。正如您在这个例子中看到的,当我们检查进程是否存活时,进程已经死亡。这是因为进程一旦完成给定函数的运行就会退出。

如前所述,所有Elixir代码都在进程内运行。如果您运行self函数,您将看到当前会话的PID:

pid = self
 
Process.alive?(pid)

运行上述程序时,会产生以下结果:

true

消息传递

我们可以使用send向进程发送消息,并使用receive接收消息。让我们向当前进程传递一条消息并在同一进程中接收它。

send(self(), {:hello, "Hi people"})

receive do
   {:hello, msg} -> IO.puts(msg)
   {:another_case, msg} -> IO.puts("This one won't match!")
end

运行上述程序时,将产生以下结果:

Hi people

我们使用send函数向当前进程发送了一条消息,并将其传递给了self的PID。然后我们使用receive函数处理传入的消息。

当向进程发送消息时,消息将存储在进程邮箱中。receive块遍历当前进程邮箱,搜索与任何给定模式匹配的消息。receive块支持守卫和许多子句,例如case。

如果邮箱中没有与任何模式匹配的消息,则当前进程将等待直到有匹配的消息到达。也可以指定超时时间。例如:

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

运行上述程序时,将产生以下结果:

nothing after 1s

注意 - 当您已经预期邮箱中存在消息时,可以指定0超时。

链接

Elixir中最常见的spawn方式实际上是通过spawn_link函数。在查看spawn_link的示例之前,让我们了解一下进程失败时会发生什么。

spawn fn -> raise "oops" end

运行上述程序时,会产生以下错误:

[error] Process #PID<0.58.00> raised an exception
** (RuntimeError) oops
   :erlang.apply/2

它记录了一个错误,但生成进程仍在运行。这是因为进程是隔离的。如果我们希望一个进程中的失败传播到另一个进程,我们需要将它们链接起来。这可以通过spawn_link函数完成。让我们考虑一个例子来理解这一点:

spawn_link fn -> raise "oops" end

运行上述程序时,会产生以下错误:

** (EXIT from #PID<0.41.0>) an exception was raised:
   ** (RuntimeError) oops
      :erlang.apply/2

如果您在iex shell中运行此代码,则shell会处理此错误而不会退出。但是,如果您首先创建一个脚本文件,然后使用elixir <file-name>.exs运行,则父进程也会因这个失败而被终止。

进程和链接在构建容错系统中起着重要作用。在Elixir应用程序中,我们经常将进程链接到监督器,监督器将检测进程何时死亡并在其位置启动一个新进程。这只有在进程被隔离并且默认情况下不共享任何内容时才有可能。由于进程是隔离的,因此一个进程中的故障不可能崩溃或破坏另一个进程的状态。虽然其他语言需要我们捕获/处理异常;在Elixir中,我们实际上可以放任进程失败,因为我们期望监督器能够正确地重新启动我们的系统。

状态

如果您正在构建一个需要状态的应用程序,例如,为了保留您的应用程序配置,或者您需要解析一个文件并将其保存在内存中,您将把它存储在哪里?Elixir的进程功能在执行此类操作时非常有用。

我们可以编写无限循环、维护状态以及发送和接收消息的进程。例如,让我们编写一个模块,该模块启动充当键值存储的新进程,存储在一个名为kv.exs的文件中。

defmodule KV do
   def start_link do
      Task.start_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_link函数启动一个运行loop函数的新进程,从空映射开始。然后,loop函数等待消息并对每条消息执行相应的操作。对于:get消息,它将消息发送回调用方并再次调用loop,以等待新消息。而:put消息实际上使用新的映射版本调用loop,其中存储了给定的键和值。

现在让我们运行以下代码:

iex kv.exs

现在您应该在iex shell中。要测试我们的模块,请尝试以下操作:

{:ok, pid} = KV.start_link

# pid now has the pid of our new process that is being 
# used to get and store key value pairs 

# Send a KV pair :hello, "Hello" to the process
send pid, {:put, :hello, "Hello"}

# Ask for the key :hello
send pid, {:get, :hello, self()}

# Print all the received messages on the current process.
flush()

运行上述程序时,将产生以下结果:

"Hello"

Elixir - 符号

在本章中,我们将探索sigil,这是语言提供的用于处理文本表示的机制。Sigil以波浪号 (~) 字符开头,后跟一个字母(标识sigil),然后是一个分隔符;可选地,可以在最终分隔符之后添加修饰符。

正则表达式

Elixir中的正则表达式是sigil。我们在String章节中已经看到它们的用法。让我们再举一个例子来看看如何在Elixir中使用正则表达式。

# A regular expression that matches strings which contain "foo" or
# "bar":
regex = ~r/foo|bar/
IO.puts("foo" =~ regex)
IO.puts("baz" =~ regex)

运行上述程序时,将产生以下结果:

true
false

Sigil支持8个不同的分隔符:

~r/hello/
~r|hello|
~r"hello"
~r'hello'
~r(hello)
~r[hello]
~r{hello}
~r<hello>

支持不同分隔符的原因是,不同的分隔符可能更适合不同的sigil。例如,对正则表达式使用括号可能是一个令人困惑的选择,因为它们可能会与正则表达式中的括号混淆。但是,括号对于其他sigil可能非常有用,正如我们将在下一节中看到的。

Elixir支持与Perl兼容的正则表达式,也支持修饰符。您可以在这里了解更多关于正则表达式用法的知识here

字符串、字符列表和单词列表

除了正则表达式外,Elixir还有3个内置sigil。让我们来看看这些sigil。

字符串

~s sigil用于生成字符串,就像双引号一样。例如,当字符串同时包含双引号和单引号时,~s sigil非常有用:

new_string = ~s(this is a string with "double" quotes, not 'single' ones)
IO.puts(new_string)

此sigil生成字符串。运行上述程序时,会产生以下结果:

"this is a string with \"double\" quotes, not 'single' ones"

字符列表

~c sigil用于生成字符列表:

new_char_list = ~c(this is a char list containing 'single quotes')
IO.puts(new_char_list)

运行上述程序时,将产生以下结果:

this is a char list containing 'single quotes'

单词列表

~w sigil用于生成单词列表(单词只是普通的字符串)。在~w sigil中,单词由空格分隔。

new_word_list = ~w(foo bar bat)
IO.puts(new_word_list)

运行上述程序时,将产生以下结果:

foobarbat

~w sigil还接受c、sa修饰符(分别用于字符列表、字符串和原子),这些修饰符指定结果列表元素的数据类型:

new_atom_list = ~w(foo bar bat)a
IO.puts(new_atom_list)

运行上述程序时,将产生以下结果:

[:foo, :bar, :bat]

Sigil中的插值和转义

除了小写sigil外,Elixir还支持大写sigil来处理转义字符和插值。虽然~s和~S都会返回字符串,但前者允许转义码和插值,而后者则不允许。让我们考虑一个例子来理解这一点:

~s(String with escape codes \x26 #{"inter" <> "polation"})
# "String with escape codes & interpolation"
~S(String without escape codes \x26 without #{interpolation})
# "String without escape codes \\x26 without \#{interpolation}"

自定义Sigil

我们可以轻松创建自己的自定义sigil。在这个例子中,我们将创建一个sigil来将字符串转换为大写。

defmodule CustomSigil do
   def sigil_u(string, []), do: String.upcase(string)
end

import CustomSigil

IO.puts(~u/tutorials point/)

运行上述代码时,会产生以下结果:

TUTORIALS POINT

首先,我们定义一个名为CustomSigil的模块,在这个模块中,我们创建了一个名为sigil_u的函数。由于现有sigil空间中不存在~u sigil,我们将使用它。_u表示我们希望使用u作为波浪号后的字符。函数定义必须接受两个参数:一个输入和一个列表。

Elixir - 列表推导式

列表推导式是Elixir中循环遍历可枚举对象的语法糖。在本章中,我们将使用推导式进行迭代和生成。

基础知识

当我们在可枚举章节中查看Enum模块时,我们遇到了map函数。

Enum.map(1..3, &(&1 * 2))

在这个例子中,我们将传递一个函数作为第二个参数。范围中的每个项目都将传递到函数中,然后将返回一个包含新值的新列表。

映射、过滤和转换在Elixir中非常常见,因此与之前的示例相比,实现相同结果的方法略有不同:

for n <- 1..3, do: n * 2

运行上述代码时,会产生以下结果:

[2, 4, 6]

第二个例子是一个推导式,正如您可能看到的,它只是您可以使用Enum.map函数实现的语法糖。但是,就性能而言,使用推导式与Enum模块中的函数相比,并没有真正的优势。

推导式不限于列表,可以与所有可枚举对象一起使用。

过滤

您可以将过滤器视为推导式的一种守卫。当过滤后的值返回falsenil时,它将从最终列表中排除。让我们遍历一个范围,只关注偶数。我们将使用Integer模块中的is_even函数来检查一个值是偶数还是奇数。

import Integer
IO.puts(for x <- 1..10, is_even(x), do: x)

运行上述代码时,会产生以下结果:

[2, 4, 6, 8, 10]

我们也可以在同一个推导式中使用多个过滤器。在is_even过滤器之后添加另一个您想要的过滤器,用逗号隔开。

:into选项

在上面的例子中,所有推导式都将其结果作为列表返回。但是,可以通过将:into选项传递给推导式,将推导式的结果插入到不同的数据结构中。

例如,可以使用:into选项与bitstring生成器一起使用,以便轻松地删除字符串中的所有空格:

IO.puts(for <<c <- " hello world ">>, c != ?\s, into: "", do: <<c>>)

运行上述代码时,会产生以下结果:

helloworld

上述代码使用c != ?\s过滤器删除字符串中的所有空格,然后使用:into选项,它将所有返回的字符放入一个字符串中。

Elixir - 类型声明

Elixir是一种动态类型语言,因此Elixir中的所有类型都是由运行时推断的。尽管如此,Elixir仍然带有typespec,这是一种用于声明自定义数据类型和声明类型化函数签名(规范)的符号。

函数规范(specs)

默认情况下,Elixir 提供了一些基本类型,例如整数或 pid,以及复杂类型:例如,**round** 函数,它将浮点数四舍五入到最接近的整数,它接受一个数字作为参数(整数或浮点数)并返回一个整数。在相关的文档中,round 的类型签名写为:

round(number) :: integer

上述描述意味着左侧的函数以括号中指定的参数作为输入,并返回 :: 右侧的内容,即 Integer。函数规范使用**@spec** 指令编写,放置在函数定义的正前方。round 函数可以写成:

@spec round(number) :: integer
def round(number), do: # Function implementation
...

类型规范也支持复杂类型,例如,如果要返回一个整数列表,则可以使用**[Integer]**

自定义类型

虽然 Elixir 提供了许多有用的内置类型,但在适当情况下定义自定义类型也很方便。这可以在定义模块时通过 @type 指令完成。让我们考虑一个例子来理解这一点:

defmodule FunnyCalculator do
   @type number_with_joke :: {number, String.t}

   @spec add(number, number) :: number_with_joke
   def add(x, y), do: {x + y, "You need a calculator to do that?"}

   @spec multiply(number, number) :: number_with_joke
   def multiply(x, y), do: {x * y, "It is like addition on steroids."}
end

{result, comment} = FunnyCalculator.add(10, 20)
IO.puts(result)
IO.puts(comment)

运行上述程序时,将产生以下结果:

30
You need a calculator to do that?

**注意** — 通过 @type 定义的自定义类型会被导出,并在定义它们的模块之外可用。如果要将自定义类型保持为私有,可以使用**@typep** 指令代替**@type**。

Elixir - 行为

Elixir(和 Erlang)中的行为是一种将组件的通用部分(成为行为模块)与特定部分(成为回调模块)分离和抽象的方法。行为提供了一种方法:

  • 定义一组必须由模块实现的函数。
  • 确保模块实现该集合中的所有函数。

如果需要,可以将行为视为 Java 等面向对象语言中的接口:模块必须实现的一组函数签名。

定义行为

让我们考虑一个例子来创建我们自己的行为,然后使用这个通用行为来创建一个模块。我们将定义一个行为,它可以用不同的语言向人们问好和道别。

defmodule GreetBehaviour do
   @callback say_hello(name :: string) :: nil
   @callback say_bye(name :: string) :: nil
end

**@callback** 指令用于列出采用模块需要定义的函数。它还指定参数的数量、类型和返回值。

采用行为

我们已经成功定义了一个行为。现在,我们将把它应用并实现到多个模块中。让我们创建两个模块,分别用英语和西班牙语实现此行为。

defmodule GreetBehaviour do
   @callback say_hello(name :: string) :: nil
   @callback say_bye(name :: string) :: nil
end

defmodule EnglishGreet do
   @behaviour GreetBehaviour
   def say_hello(name), do: IO.puts("Hello " <> name)
   def say_bye(name), do: IO.puts("Goodbye, " <> name)
end

defmodule SpanishGreet do
   @behaviour GreetBehaviour
   def say_hello(name), do: IO.puts("Hola " <> name)
   def say_bye(name), do: IO.puts("Adios " <> name)
end

EnglishGreet.say_hello("Ayush")
EnglishGreet.say_bye("Ayush")
SpanishGreet.say_hello("Ayush")
SpanishGreet.say_bye("Ayush")

运行上述程序时,将产生以下结果:

Hello Ayush
Goodbye, Ayush
Hola Ayush
Adios Ayush

正如您已经看到的,我们使用模块中的**@behaviour** 指令采用行为。我们必须为所有 *子* 模块定义行为中实现的所有函数。这大致可以认为等同于 OOP 语言中的接口。

Elixir - 错误处理

Elixir 有三种错误机制:错误、抛出和退出。让我们详细探讨每种机制。

错误

当代码中发生异常情况时,使用错误(或异常)。可以通过尝试将数字添加到字符串中来检索示例错误:

IO.puts(1 + "Hello")

运行上述程序时,会产生以下错误:

** (ArithmeticError) bad argument in arithmetic expression
   :erlang.+(1, "Hello")

这是一个内置错误示例。

引发错误

我们可以使用 raise 函数**引发** 错误。让我们考虑一个例子来理解这一点:

#Runtime Error with just a message
raise "oops"  # ** (RuntimeError) oops

可以使用 raise/2 传递错误名称和关键字参数列表来引发其他错误。

#Other error type with a message
raise ArgumentError, message: "invalid argument foo"

您还可以定义自己的错误并引发它们。考虑以下示例:

defmodule MyError do
   defexception message: "default message"
end

raise MyError  # Raises error with default message
raise MyError, message: "custom message"  # Raises error with custom message

处理错误

我们不希望程序突然退出,而是需要仔细处理错误。为此,我们使用错误处理。我们使用**try/rescue** 结构**处理** 错误。让我们考虑以下示例来理解这一点:

err = try do
   raise "oops"
rescue
   e in RuntimeError -> e
end

IO.puts(err.message)

运行上述程序时,将产生以下结果:

oops

我们使用模式匹配在 rescue 语句中处理了错误。如果我们没有使用错误,只是想将其用于识别目的,我们也可以使用以下形式:

err = try do
   1 + "Hello"
rescue
   RuntimeError -> "You've got a runtime error!"
   ArithmeticError -> "You've got a Argument error!"
end

IO.puts(err)

运行上述程序,会产生以下结果:

You've got a Argument error!

**注意** — Elixir 标准库中的大多数函数都实现了两次,一次返回元组,另一次引发错误。例如,**File.read** 和**File.read!** 函数。如果文件读取成功,第一个函数返回一个元组;如果遇到错误,则使用此元组给出错误原因。第二个函数如果遇到错误则引发错误。

如果我们使用第一个函数方法,那么我们需要使用 case 来匹配模式错误并根据该错误采取行动。在第二种情况下,我们对易出错的代码使用 try rescue 方法并相应地处理错误。

抛出

在 Elixir 中,可以抛出一个值,然后稍后捕获它。Throw 和 Catch 保留用于无法检索值的情况,除非使用 throw 和 catch。

实际上,除了与库交互之外,这些实例很少见。例如,现在让我们假设 Enum 模块没有提供任何查找值的 API,我们需要在数字列表中找到第一个 13 的倍数:

val = try do
   Enum.each 20..100, fn(x) ->
      if rem(x, 13) == 0, do: throw(x)
   end
   "Got nothing"
catch
   x -> "Got #{x}"
end

IO.puts(val)

运行上述程序时,将产生以下结果:

Got 26

退出

当进程因“自然原因”(例如,未处理的异常)死亡时,它会发送退出信号。进程也可以通过显式发送退出信号而死亡。让我们考虑以下示例:

spawn_link fn -> exit(1) end

在上面的示例中,链接的进程通过发送值为 1 的退出信号而死亡。请注意,exit 也可以使用 try/catch“捕获”。例如:

val = try do
   exit "I am exiting"
catch
   :exit, _ -> "not really"
end

IO.puts(val)

运行上述程序时,将产生以下结果:

not really

之后

有时需要确保在可能引发错误的一些操作之后清理资源。try/after 结构允许您这样做。例如,我们可以打开一个文件,并在发生错误时使用 after 子句关闭它。

{:ok, file} = File.open "sample", [:utf8, :write]
try do
   IO.write file, "olá"
   raise "oops, something went wrong"
after
   File.close(file)
end

当我们运行此程序时,它会给我们一个错误。但是**after** 语句将确保在任何此类事件发生时关闭文件描述符。

Elixir - 宏

宏是 Elixir 最高级和最强大的功能之一。与任何语言的所有高级功能一样,应谨慎使用宏。它们使得能够在编译时执行强大的代码转换。我们现在将简要了解宏是什么以及如何在简要说明中使用它们。

引用

在我们开始讨论宏之前,让我们首先看看 Elixir 的内部机制。Elixir 程序可以用它自己的数据结构来表示。Elixir 程序的构建块是一个包含三个元素的元组。例如,函数调用 sum(1, 2, 3) 在内部表示为:

{:sum, [], [1, 2, 3]}

第一个元素是函数名称,第二个是包含元数据的关键字列表,第三个是参数列表。如果您编写以下内容,则可以在 iex shell 中将其作为输出获得:

quote do: sum(1, 2, 3)

运算符也表示为这样的元组。变量也使用这样的三元组表示,只是最后一个元素是原子,而不是列表。当引用更复杂的表达式时,我们可以看到代码是用这样的元组表示的,这些元组通常彼此嵌套在一个类似树的结构中。许多语言会将这种表示称为**抽象语法树 (AST)**。Elixir 将这些引用的表达式称为 quoted expressions。

取消引用

既然我们可以检索代码的内部结构,我们如何修改它呢?要注入新的代码或值,我们使用**unquote**。当我们取消引用表达式时,它将被计算并注入到 AST 中。让我们考虑一个例子(在 iex shell 中)来理解这个概念:

num = 25

quote do: sum(15, num)

quote do: sum(15, unquote(num))

运行上述程序时,将产生以下结果:

{:sum, [], [15, {:num, [], Elixir}]}
{:sum, [], [15, 25]} 

在 quote 表达式的示例中,它没有自动将 num 替换为 25。如果要修改 AST,则需要取消引用此变量。

所以现在我们熟悉了 quote 和 unquote,我们可以使用宏来探索 Elixir 中的元编程。

简单来说,宏是专门设计的函数,用于返回将插入到我们的应用程序代码中的引用表达式。想象一下,宏被替换为引用的表达式,而不是像函数一样被调用。使用宏,我们拥有扩展 Elixir 并动态地向我们的应用程序添加代码所需的一切。

让我们实现 unless 作为宏。我们将首先使用**defmacro** 宏来定义宏。请记住,我们的宏需要返回一个引用的表达式。

defmodule OurMacro do
   defmacro unless(expr, do: block) do
      quote do
         if !unquote(expr), do: unquote(block)
      end
   end
end

require OurMacro

OurMacro.unless true, do: IO.puts "True Expression"

OurMacro.unless false, do: IO.puts "False expression"

运行上述程序时,将产生以下结果:

False expression 

这里发生的事情是我们的代码被 unless 宏返回的引用代码替换。我们取消引用表达式以在当前上下文中对其进行计算,并取消引用 do 块以在其上下文中执行它。此示例向我们展示了使用宏在 elixir 中进行元编程。

宏可用于更复杂的任务,但应谨慎使用。这是因为元编程通常被认为是不好的做法,只有在必要时才应使用。

Elixir - 库

Elixir 提供了与 Erlang 库的出色互操作性。让我们简要讨论一些库。

Binary 模块

内置的 Elixir String 模块处理 UTF-8 编码的二进制数据。当处理并非 UTF-8 编码的二进制数据时,binary 模块非常有用。让我们考虑一个例子来进一步理解 Binary 模块:

# UTF-8
IO.puts(String.to_char_list("Ø"))

# binary
IO.puts(:binary.bin_to_list "Ø")

运行上述程序时,将产生以下结果:

[216]
[195, 152]

上面的例子显示了区别;String 模块返回 UTF-8 代码点,而 :binary 处理原始数据字节。

Crypto 模块

crypto 模块包含哈希函数、数字签名、加密等等。此模块不是 Erlang 标准库的一部分,而是包含在 Erlang 发行版中。这意味着每当使用它时,都必须在项目的应用程序列表中列出 :crypto。让我们看一个使用 crypto 模块的例子:

IO.puts(Base.encode16(:crypto.hash(:sha256, "Elixir")))

运行上述程序时,将产生以下结果:

3315715A7A3AD57428298676C5AE465DADA38D951BDFAC9348A8A31E9C7401CB

Digraph 模块

digraph 模块包含用于处理由顶点和边构成的有向图的函数。构造图后,其中的算法将有助于查找例如两个顶点之间的最短路径或图中的循环。请注意,:digraph 中的函数会间接地作为副作用来更改图的结构,同时返回添加的顶点或边。

digraph = :digraph.new()
coords = [{0.0, 0.0}, {1.0, 0.0}, {1.0, 1.0}]
[v0, v1, v2] = (for c <- coords, do: :digraph.add_vertex(digraph, c))
:digraph.add_edge(digraph, v0, v1)
:digraph.add_edge(digraph, v1, v2)
for point <- :digraph.get_short_path(digraph, v0, v2) do 
   {x, y} = point
   IO.puts("#{x}, #{y}")
end

运行上述程序时,将产生以下结果:

0.0, 0.0
1.0, 0.0
1.0, 1.0

Math 模块

math 模块包含常见的数学运算,涵盖三角函数、指数函数和对数函数。让我们来看下面的例子,了解 Math 模块是如何工作的:

# Value of pi
IO.puts(:math.pi())

# Logarithm
IO.puts(:math.log(7.694785265142018e23))

# Exponentiation
IO.puts(:math.exp(55.0))

#...

运行上述程序时,将产生以下结果:

3.141592653589793
55.0
7.694785265142018e23

队列模块

队列是一种数据结构,它高效地实现了 (双端) FIFO (先进先出) 队列。下面的例子展示了队列模块是如何工作的:

q = :queue.new
q = :queue.in("A", q)
q = :queue.in("B", q)
{{:value, val}, q} = :queue.out(q)
IO.puts(val)
{{:value, val}, q} = :queue.out(q)
IO.puts(val)

运行上述程序时,将产生以下结果:

A
B
广告