Elixir in Action, Third Edition(3)

https://www.manning.com/books/elixir-in-action-third-edition

第2章 构建模块

本章内容包括:

  • 使用交互式 shell
  • 使用变量
  • 组织代码
  • 理解类型系统
  • 使用运算符
  • 理解运行时

是时候开始学习 Elixir 了。本章将介绍该语言的基本构建模块,例如模块、函数和类型系统。这将是一次略显冗长且并不特别令人兴奋的语言特性之旅,但这里介绍的内容非常重要,因为它为探索更有趣、更高级的主题奠定了基础。

在开始之前,请确保您已安装 Elixir 1.15 版本和 Erlang 26 版本。安装 Elixir 的方法有很多种,最好按照 Elixir 官方网站 https://elixir-lang.org/install.html 上的说明进行操作。

一切就绪后,让我们开始 Elixir 之旅。您首先应该了解的是交互式 shell。

详细信息

本书并未提供任何语言或平台特性的详细参考。这样做会占用太多篇幅,而且内容很快就会过时。以下是一些您可以参考的其他资料:

2.1 交互式 shell

体验和学习一门语言特性的最简单方法是通过交互式 shell。您可以通过在命令行运行 iex 命令来启动 Elixir 交互式 shell:

Erlang/OTP 29 [erts-17.0.4] [source] [64-bit] [smp:18:18] [ds:18:18:10] [async-threads:1] [jit:ns]

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

运行 iex 命令会启动一个 BEAM 实例,然后在其中启动一个交互式 Elixir shell。

运行时信息(例如 Erlang 和 Elixir 版本号)会被打印出来,

然后会显示提示符,以便您可以输入 Elixir 表达式:

iex(1)> 1+2
3

输入表达式后,它会被解释并执行。然后,它的返回值会被打印到屏幕上。

注意:Elixir 中的一切都是一个带有返回值的表达式。这不仅包括函数调用,还包括像 if 和 case 这样的结构。

提示:本书中你会大量使用 iex,尤其是在最初的几章中。表达式的结果通常并不特别重要,为了减少干扰,我们会省略它。但请记住,每个表达式都会返回一个结果,当你在 shell 中输入表达式时,它的结果就会显示出来。

你可以输入几乎任何构成有效 Elixir 代码的内容,包括相对复杂的多行表达式:

iex(2)> 2 * (
        3 + 1
        ) / 4
2.0

注意,shell 直到你在最后一行完成表达式才会对其进行求值。在 Elixir 中,你不需要像分号这样的特殊字符来表示表达式的结束。相反,如果表达式已完成,则换行符表示表达式的结束。否则,解析器会等待更多输入,直到表达式完成。如果遇到问题(例如,漏写右括号),可以使用单独一行的 #iex:break 来中止整个表达式:

iex(3)> 1+ (2
        #iex:break
** (TokenMissingError) token missing on iex:3:
error: incomplete expression
└─ iex:3
    (iex 1.20.2) lib/iex/server.ex:402: IEx.Server.__parse__/3
    (stdlib 8.0.3) io_lib.erl:1649: :io_lib.get_until/4
    (kernel 11.0.3) group.erl:407: :group.xterm/3
    (stdlib 8.0.3) gen_statem.erl:3741: :gen_statem.loop_state_callback/11
    (stdlib 8.0.3) proc_lib.erl:333: :proc_lib.init_p_do_apply/3
iex(3)>

退出 shell 最快的方法是按两次 Ctrl-C。这样做会强制终止操作系统进程和所有正在执行的后台任务。由于 shell 主要用于实验,不应用于运行实际的生产系统,因此通常可以这样终止它。但如果您想要更优雅地停止系统,可以使用 System.stop

iex(3)> System.stop
:ok

注意:启动 Elixir 和 Erlang 运行时以及运行 Elixir 程序有多种方法。在本章结束时,您将了解所有这些方法。本书的第一部分主要使用 iex shell,因为它是一种简单高效的语言实验工具。

你可以用 shell 做很多事情,但最常见的是用它来输入表达式,并查看结果。你可以自己研究 shell 还能做什么。可以使用 h 命令获取基本帮助:

iex(1)> h
                                  IEx.Helpers

Welcome to Interactive Elixir. You are currently seeing the documentation for
the module IEx.Helpers which provides many helpers to make Elixir's shell more
joyful to work with.

This message was triggered by invoking the helper h(), usually referred to as
h/0 (since it expects 0 arguments).

You can use the h/1 function to invoke the documentation for any Elixir module
or function:

    iex> h(Enum)
    iex> h(Enum.map)
    iex> h(Enum.reverse/1)

You can also use the i/1 function to introspect any value you have in the
shell:

    iex> i("hello")

There are many other helpers available, here are some examples:

  • b/1             - prints callbacks info and docs for a given module
  • c/1             - compiles a file
  • c/2             - compiles a file and writes bytecode to the given path
  • cd/1            - changes the current directory
  • clear/0         - clears the screen
  • exports/1       - shows all exports (functions + macros) in a module
  • flush/0         - flushes all messages sent to the shell
  • h/0             - prints this help message
  • h/1             - prints help for the given module, function or macro
  • i/0             - prints information about the last value
  • i/1             - prints information about the given term
  • ls/0            - lists the contents of the current directory
  • ls/1            - lists the contents of the specified directory
  • open/1          - opens the source for the given module or function in
    your editor
  • pid/1           - creates a PID from a string
  • pid/3           - creates a PID with the 3 integer arguments passed
  • port/1          - creates a port from a string
  • port/2          - creates a port with the 2 non-negative integers
    passed
  • process_info/1  - returns information about the given process
  • pwd/0           - prints the current working directory
  • r/1             - recompiles the given module's source file
  • recompile/0     - recompiles the current project
  • ref/1           - creates a reference from a string
  • ref/4           - creates a reference with the 4 integer arguments
    passed
  • runtime_info/0  - prints runtime info (versions, memory usage, stats)
  • source/1        - prints the source location for the given module or
    function
  • t/1             - prints the types for the given module or function
  • v/0             - retrieves the last value from the history
  • v/1             - retrieves the nth value from the history

There are also several helpers available when debugging, such as:

  • break!/2        - sets a breakpoint at Module.function/arity
  • breaks/0        - prints all breakpoints to the terminal
  • c/0             - a shortcut for continue/0
  • continue/0      - continues execution of the current process
  • n/0             - a shortcut for next/0
  • next/0          - goes to the next line of the current breakpoint
  • remove_breaks/0 - removes all breakpoints and instrumentation from all
    modules
  • whereami/1      - prints the current location and stacktrace in a pry
    session

Help for all of those functions can be consulted directly from the command line
using the h/1 helper itself. Try:

    iex> h(v/0)

To list all IEx helpers available, which is effectively all exports (functions
and macros) in the IEx.Helpers module:

    iex> exports(IEx.Helpers)

This module also includes helpers for debugging purposes, see IEx.break!/4 for
more information.

To learn more about IEx as a whole, type h(IEx).

在 shell 中输入此命令将输出一整屏与 iex 相关的指令。你还可以查找 IEx 模块的文档,该模块负责 shell 的运行:

 h(IEx)

                                      IEx

Elixir's interactive shell.

Some of the functionalities described here will not be available depending on
your terminal. In particular, if you get a message saying that the smart
terminal could not be run, some of the features described here won't work.

## Helpers

IEx provides a bunch of helpers. They can be accessed by typing h() into the
shell or as a documentation for the IEx.Helpers module.

## Autocomplete

To discover a module's public functions or other modules, type the module name
followed by a dot, then press tab to trigger autocomplete. For example:

    Enum.

A module may export functions that are not meant to be used directly: these
functions won't be autocompleted by IEx. IEx will not autocomplete functions
annotated with @doc false, @impl true, or functions that aren't explicitly
documented and where the function name is in the form of __foo__.

Autocomplete is available by default on Windows shells.

## Encoding and coloring

IEx expects inputs and outputs to be in UTF-8 encoding. This is the default for
most Unix terminals but it may not be the case on Windows. If you are running
on Windows and you see incorrect values printed, you may need to change the
encoding of your current session by running chcp 65001 before calling iex (or
before calling iex.bat if using PowerShell).

Similarly, ANSI coloring is enabled by default on most Unix terminals. They are
also available on Windows consoles from Windows 10.

## Shell history

It is possible to get shell history by passing some options that enable it in
the VM. This can be done on a per-need basis when starting IEx:

    $ iex --erl "-kernel shell_history enabled"

If you would rather enable it on your system as a whole, you can use the
ERL_AFLAGS environment variable and make sure that it is set accordingly on
your terminal/shell configuration.

On Unix-like / Bash:

    $ export ERL_AFLAGS="-kernel shell_history enabled"

On Windows:

    $ set ERL_AFLAGS "-kernel shell_history enabled"

On Windows 10 / PowerShell:

    $ $env:ERL_AFLAGS = "-kernel shell_history enabled"

## Expressions in IEx

As an interactive shell, IEx evaluates expressions. This has some interesting
consequences that are worth discussing.

The first one is that the code is truly evaluated and not compiled. This means
that any benchmarking done in the shell is going to have skewed results. So
never run any profiling nor benchmarks in the shell.

Second, IEx allows you to break an expression into many lines, since this is
common in Elixir. For example:

    iex(1)> "ab
    ...(1)> c"
    "ab\nc"

In the example above, the shell will be expecting more input until it finds the
closing quote. Sometimes it is not obvious which character the shell is
expecting, and the user may find themselves trapped in the state of incomplete
expression with no ability to terminate it other than by exiting the shell.

For such cases, there is a special break-trigger (#iex:break) that when
encountered on a line by itself will force the shell to break out of any
pending expression and return to its normal state:

    iex(1)> ["ab
    ...(1)> c"
    ...(1)> "
    ...(1)> ]
    ...(1)> #iex:break
    ** (TokenMissingError) iex:1: incomplete expression

## Pasting multiline expressions into IEx

IEx evaluates its input line by line in an eager fashion. If at the end of a
line the code seen so far is a complete expression, IEx will evaluate it at
that point.

    iex(1)> [1, [2], 3]
    [1, [2], 3]

To prevent this behavior breaking valid code where the subsequent line begins
with a binary operator, such as |>/2 or ++/2 , IEx automatically treats such
lines as if they were prepended with IEx.Helpers.v/0, which returns the value
of the previous expression, if available.

    iex(1)> [1, [2], 3]
    [1, [2], 3]
    iex(2)> |> List.flatten()
    [1, 2, 3]

The above is equivalent to:

    iex(1)> [1, [2], 3]
    [1, [2], 3]
    iex(2)> v() |> List.flatten()
    [1, 2, 3]

If there are no previous expressions in the history, the pipe operator will
fail:

    iex(1)> |> List.flatten()
    ** (RuntimeError) v(-1) is out of bounds

If the previous expression was a match operation, the pipe operator will also
fail, to prevent an unsolicited break of the match:

    iex(1)> x = 42
    iex(2)> |> IO.puts()
    ** (SyntaxError) iex:2:1: pipe shorthand is not allowed immediately after a match expression in IEx. To make it work, surround the whole pipeline with parentheses ('|>')
        |
      2 | |> IO.puts()
        | ^

Note, however, the above does not work for +/2 and -/2, as they are ambiguous
with the unary +/1 and -/1:

    iex(1)> 1
    1
    iex(2)> + 2
    2

## The BREAK menu

Inside IEx, hitting Ctrl+C will open up the BREAK menu. In this menu you can
quit the shell, see process and ETS tables information and much more.

## Exiting the shell

There are a few ways to quit the IEx shell:

  • via the BREAK menu (available via Ctrl+C) by typing q, pressing enter
  • by hitting Ctrl+C, Ctrl+C
  • by hitting Ctrl+\

If you are connected to remote shell, it remains alive after disconnection.

## `dbg` and breakpoints

IEx integrates with Kernel.dbg/2 and introduces a backend that can pause code
execution. To enable it, you must pass --dbg pry:

    $ iex --dbg pry

For example, take the following function:

    def my_fun(arg1, arg2) do
      dbg(arg1 + arg2)
      ... implementation ...
    end

When the code is executed with iex (most often by calling iex --dbg pry -S
mix), it will ask you permission to use "pry". If you agree, it will start an
IEx shell in the context of the function above, with access to its variables,
imports, and aliases. However, you can only access existing values, it is not
possible to access private functions nor change the execution itself (hence the
name "pry").

When using |> dbg() at the end of a pipeline, you can pry each step of the
pipeline. You can type n whenever you want to jump into the next pipe. Type
continue when you want to execute all of the steps but stay within the pried
process. Type respawn when you want to leave the pried process and start a new
shell.

Alternatively, you can start a pry session directly, without dbg/2 by calling
IEx.pry/0.

IEx also allows you to set breakpoints to start pry sessions on a given module,
function, and arity you have no control of via IEx.break!/4. Similar to
pipelines in dbg(), IEx.break!/4 allows you to debug a function line by line
and access its variables. However, breakpoints do not contain information about
imports and aliases from the source code.

When using dbg or breakpoints with tests, remember to pass the --trace to mix
test to avoid running into timeouts:

    $ iex -S mix test --trace
    $ iex -S mix test path/to/file:line --trace

## The User switch command

Besides the BREAK menu, one can type Ctrl+G to get to the User switch command
menu. When reached, you can type h to get more information.

In this menu, developers are able to start new shells and alternate between
them. Let's give it a try:

    User switch command
     --> s iex
     --> c

The command above will start a new shell and connect to it. Create a new
variable called hello and assign some value to it:

    hello = :world

Now, let's roll back to the first shell:

    User switch command
     --> c 1

Now, try to access the hello variable again:

    hello
    ** (CompileError) undefined variable "hello"

The command above fails because we have switched shells. Since shells are
isolated from each other, you can't access the variables defined in one shell
from the other one.

The User switch command can also be used to terminate an existing session, for
example when the evaluator gets stuck in an infinite loop or when you are stuck
typing an expression:

    User switch command
     --> i
     --> c

The User switch command menu also allows developers to connect to remote shells
using the r command. A topic which we will discuss next.

## Remote shells

IEx allows you to connect to another node in two fashions. First of all, we can
only connect to a shell if we give names both to the current shell and the
shell we want to connect to.

Let's give it a try. First, start a new shell:

    $ iex --sname foo
    iex(foo@HOST)1>

The string between the parentheses in the prompt is the name of your node. We
can retrieve it by calling the node/0 function:

    iex(foo@HOST)1> node()
    :"foo@HOST"
    iex(foo@HOST)2> Node.alive?()
    true

For fun, let's define a simple module in this shell too:

    iex(foo@HOST)3> defmodule Hello do
    ...(foo@HOST)3>   def world, do: "it works!"
    ...(foo@HOST)3> end

Now, let's start another shell, giving it a name as well:

    $ iex --sname bar
    iex(bar@HOST)1>

If we try to dispatch to Hello.world/0, it won't be available as it was defined
only in the other shell:

    iex(bar@HOST)1> Hello.world()
    ** (UndefinedFunctionError) undefined function Hello.world/0

However, we can connect to the other shell remotely. Open up the User switch
command prompt (Ctrl+G) and type:

    User switch command
     --> r 'foo@HOST' 'Elixir.IEx'
     --> c

Now we are connected into the remote node, as the prompt shows us, and we can
access the information and modules defined over there:

    iex(foo@HOST)1> Hello.world()
    "it works!"

In fact, connecting to remote shells is so common that we provide a shortcut
via the command line as well:

    $ iex --sname baz --remsh foo@HOST

Where "remsh" means "remote shell". In general, Elixir supports:

  • remsh from an Elixir node to an Elixir node
  • remsh from a plain Erlang node to an Elixir node (through the ^G menu)
  • remsh from an Elixir node to a plain Erlang node (and get an erl shell
    there)

Connecting an Elixir shell to a remote node without Elixir is not supported.

When remsh halts, the Elixir shell process exits with reason :normal.

## The .iex.exs file

When starting, IEx looks for a configured path, then for a local .iex.exs file
(located in the current working directory), then for a global .iex.exs file
located inside the directory pointed by the IEX_HOME environment variable
(which defaults to ~) and loads the first one it finds (if any).

The code in the chosen .iex.exs file is evaluated line by line in the shell's
context, as if each line were being typed in the shell. For instance, any
modules that are loaded or variables that are bound in the .iex.exs file will
be available in the shell after it has booted.

Take the following .iex.exs file:

    # Load another ".iex.exs" file
    import_file("~/.iex.exs")

    # Import some module from lib that may not yet have been defined
    import_if_available(MyApp.Mod)

    # Print something before the shell starts
    IO.puts("hello world")

    # Bind a variable that'll be accessible in the shell
    value = 13

Running IEx in the directory where the above .iex.exs file is located results
in:

    $ iex
    Erlang/OTP 24 [...]

    hello world
    Interactive Elixir - press Ctrl+C to exit (type h() ENTER for help)
    iex(1)> value
    13

It is possible to load another file by configuring the iex application's
dot_iex value (config :iex, dot_iex: "PATH" or IEx.configure(dot_iex: "PATH"))
or supplying the --dot-iex option to IEx. See iex --help.

In case of remote nodes, the location of the .iex.exs files are taken relative
to the user that started the application, not to the user that is connecting to
the node in case of remote IEx connections.

## Configuring the shell

There are a number of customization options provided by IEx. Take a look at the
docs for the IEx.configure/1 function by typing h IEx.configure/1.

Those options can be configured in your project configuration file or globally
by calling IEx.configure/1 from your ~/.iex.exs file. For example:

    # .iex.exs
    IEx.configure(inspect: [limit: 3])

Now run the shell:

    $ iex
    Erlang/OTP 24 [...]

    Interactive Elixir - press Ctrl+C to exit (type h() ENTER for help)
    iex(1)> [1, 2, 3, 4, 5]
    [1, 2, 3, ...]

你可以在 https://hexdocs.pm/iex 的在线文档中找到相同的帮助。

现在你已经有了一个基本的实验工具,可以开始研究语言的特性了。我们将从变量开始。

2.2 使用变量

Elixir 是一种动态编程语言,这意味着你不需要显式声明变量或其类型。 变量的类型由它当前包含的数据决定。在 Elixir 中,赋值称为绑定。当你用一个值初始化一个变量时,该变量就绑定到该值:

iex(1)> monthly_salary = 10000
10000

Elixir 中的每个表达式都有一个结果。对于等号运算符,结果是运算符右侧的值。表达式求值后,shell 会将结果打印到屏幕上。

现在,你可以引用该变量:

iex(2)> monthly_salary
10000

当然,该变量也可以用于复杂的表达式:

iex(3)> monthly_salary * 12
120000

在 Elixir 中,变量名总是以小写字母或下划线开头 。之后,可以使用字母数字字符和下划线的任意组合。但通常的约定是只使用小写 ASCII 字母、数字和下划线:

valid_variable_name
also_valid_1
validButNotRecommended
NotValid

变量名也可以以问号 (?) 或感叹号 (!) 结尾:

valid_name?
also_ok!

变量可以重新赋值:

iex(1)> monthly_salary = 10000
10000
iex(2)> monthly_salary
10000
iex(3)> monthly_salary = 11000
11000
iex(4)> monthly_salary
11000

重新绑定不会改变现有的内存位置。它会预留新的内存,并将符号名称重新分配给新的位置。

注意:您应该始终记住数据是不可变的。一旦内存位置被数据占用,除非被释放,否则无法对其进行修改。但是,变量可以重新绑定,使其指向不同的内存位置。因此,变量是可变的,但它们指向的数据是不可变的。

Elixir 是一种使用垃圾回收机制的语言,这意味着您无需手动释放内存。当变量超出作用域时,相应的内存将符合垃圾回收的条件,并在未来某个时候被释放,届时垃圾回收器会清理该内存。

2.3 代码组织

Elixir 是一种函数式语言,它高度依赖函数。由于数据的不可变性,一个典型的 Elixir 程序由许多小型函数组成。您将在第 3 章和第 4 章中看到这一点,届时您将开始使用一些典型的函数式编程惯用法。多个函数可以进一步组织成模块。

2.3.1 模块

模块是函数的集合,有点像命名空间。每个 Elixir 函数都必须定义在一个模块中。

Elixir 自带一个标准库,其中提供了许多有用的模块。例如,IO 模块可用于完成各种 I/O 操作。IO 模块中的 puts 函数可用于向屏幕打印消息:

iex(1)> IO.puts("Hello World!你好,世界!")
Hello World!你好,世界!
:ok

:ok 为IO.puts的返回值

如示例所示,要调用模块中的函数,您可以使用以下语法:模块名.函数名(args)。

要定义自己的模块,可以使用 defmodule 表达式。在模块内部,可以使用 def 表达式定义函数。示例 2.1 展示了模块的定义。

示例 2.1 定义模块 (geometry.ex)

defmodule Geometry do # 开始定义模块
  def rectangle_area(a, b) do
    a * b
  end
end # 结束模块定义

# Ends a module definition 为行内注释

您可以使用两种方法来使用此模块。首先,您可以复制此定义并直接粘贴到 iex 中——如前所述,几乎任何内容都可以输入到 shell 中。

第二种方法是告诉 iex 在启动时解释该文件:

$ iex geometry.ex

两种方法的效果相同。代码会被编译,生成的模块会被加载到运行时环境中,并可在 shell 会话中使用。让我们来试试:

iex geometry.ex
Erlang/OTP 29 [erts-17.0.4] [source] [64-bit] [smp:18:18] [ds:18:18:10] [async-threads:1] [jit:ns]

Interactive Elixir (1.20.2) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> Geometry.rectangle_area(6,7)
42

很简单!你创建了一个 Geometry 模块,将其加载到 shell 会话中,并用它来计算矩形的面积。

注意:你可能已经注意到,文件名带有 .ex 扩展名。这是 Elixir 源文件的常见约定。

在源代码中,模块必须在单个文件中定义。单个文件可以包含多个模块定义

defmodule Module1 do
...
end

defmodule Module2 do
...
end

模块名称必须遵循某些规则。它以大写字母开头,通常采用驼峰式命名。模块名称可以包含字母数字字符、下划线和点号 (.)。点号通常用于按层次结构组织模块

defmodule Geometry.Rectangle do
...
end

defmodule Geometry.Circle do
...
end

模块定义可以嵌套:

defmodule Geometry do
 defmodule Rectangle do
   ...
 end
 ...
end

内部模块可以通过 Geometry.Rectangle 引用。

请注意,点号字符并没有什么特殊之处。它只是模块名称中允许的字符之一。编译后的版本不会记录模块之间的任何层级关系。

这通常用于将模块组织成某种有意义的层级结构,以便于阅读代码。此外,这种非正式的作用域划分可以消除可能的名称冲突。例如,假设有两个库,一个实现了 JSON 编码器,另一个实现了 XML 编码器。如果两个库都定义了名为 Encoder 的模块,则无法在同一个项目中同时使用它们。但是,如果模块分别命名为 Json.Encoder 和 Xml.Encoder,则可以避免名称冲突。因此,通常会在项目中的所有模块名称前添加一个通用的前缀。通常情况下,会使用应用程序或库的名称来实现此目的。

2.3.2 函数

函数必须始终是模块的一部分。函数名遵循与变量相同的约定:它们以小写字母或下划线开头,后跟字母数字和下划线的组合。

与变量一样,函数名可以以问号 (?) 和感叹号 (!) 结尾。问号 (?) 通常用于表示返回 true 或 false 的函数。在名称末尾添加感叹号 (!) 表示该函数可能会引发运行时错误。这两种约定都不是规则,但最好遵循它们并尊重社区风格。

可以使用 def 宏定义函数:

defmodule Geometry do
  def rectangle_area(a, b) do  # 函数声明
    ... # 函数体
  end
end

定义以 def 表达式开头,后跟函数名、参数列表和函数体,并用 do…end 块括起来。由于您使用的是动态语言,因此参数没有类型规范。

注意:defmoduledef 并没有被称作关键字。这是因为它们并非关键字!它们是 Elixir 宏的示例。你暂时无需担心它们的工作原理;本章稍后会详细解释。你可以把 defdefmodule 看作关键字,但请注意,这并不完全正确。

如果一个函数没有参数,你可以省略括号:

defmodule Program do
 def run do
   ...
 end
end

那么返回值呢?回想一下,在 Elixir 中,所有有返回值的东西都是一个表达式。函数的返回值是其最后一个表达式的返回值。Elixir 中没有显式的 return 语句。

注意:既然没有显式的 return 语句,你可能会好奇复杂的函数是如何工作的。这将在第 3 章中详细介绍,你将学习分支和条件逻辑。一般的原则是保持函数简洁,这样就可以轻松地计算结果并从最后一个表达式返回它。

你在示例 2.1 中看到了一个返回值的示例,让我们在这里重复一下:

defmodule Geometry do
  def rectangle_area(a, b) do
    a * b   # 计算面积并返回结果
  end
end

现在你可以验证一下。重新启动 shell,然后尝试使用 rectangle_area 函数:

$ iex geometry.ex
 
iex(1)> Geometry.rectangle_area(3, 2)
6

如果函数体仅包含一个表达式,则可以使用简写形式,并将其定义在一行中:

defmodule Geometry do
 def rectangle_area(a, b), do: a * b
end

要调用在另一个模块中定义的函数,请使用模块名,后跟函数名:

iex(1)> Geometry.rectangle_area(3, 2)
6

当然,您始终可以将函数结果存储到变量中:

iex(2)> area = Geometry.rectangle_area(3, 2)
6
 
iex(3)> area
6

在 Elixir 中,括号是可选的,所以你可以省略它们:

iex(4)> Geometry.rectangle_area 3, 2
6

我个人认为省略括号会使代码变得含糊不清,所以我的建议是在调用函数时始终包含括号。

使用代码格式化工具

从 Elixir 1.6 版本开始,它自带代码格式化工具,你可以使用它来以一致的风格格式化你的代码,避免担心底层样式决策,例如布局或括号的使用。

例如,格式化以下代码片段:

defmodule Client
do
def run do
Geometry.rectangle_area 3,2
end
end

之后,你会得到如下美观的代码:

defmodule Client do
 def run do
   Geometry.rectangle_area(3, 2)
 end
end

你可以使用 mix 的格式化任务(https://mix.hexdocs.pm/Mix.Tasks.Format.html )来格式化代码,或者在你喜欢的编辑器中安装一个格式化扩展。

如果被调用的函数位于同一个模块中,则可以省略模块前缀:

defmodule Geometry do
  def rectangle_area(a, b) do
    a * b
  end
 
  def square_area(a) do
    rectangle_area(a, a)
  end
end

鉴于 Elixir 是一种函数式语言,您经常需要组合函数,并将一个函数的结果作为参数传递给下一个函数。Elixir 提供了一个内置运算符 |>,称为管道运算符,它正是用于此目的:

iex(5)> -5 |> abs() |> Integer.to_string() |> IO.puts()
5

这段代码在编译时会被转换为以下形式:

iex(6)> IO.puts(Integer.to_string(abs(-5)))

更一般地说,管道运算符会将前一个调用的结果作为下一个调用的第一个参数。因此,以下代码:

prev(arg1, arg2) |> next(arg3, arg4)

在编译时会被转换为:

next(prev(arg1, arg2), arg3, arg4)

可以说,管道版本更易读,因为执行顺序是从左到右读取的。管道运算符在源文件中看起来尤其优雅,您可以将管道布局在多行上:

-5   
|> abs()
|> Integer.to_string()
|> IO.puts()

在 shell 中使用多行管道

如果您将前面的管道链粘贴到 iex 会话中,您会注意到每个中间结果都会打印到控制台:

iex(1)> -5
-5
iex(2)> |> abs()
5
iex(3)> |> Integer.to_string()
"5"
iex(4)> |> IO.puts()
5

请记住,**iex 会在 Elixir 表达式完成且有效后立即对其进行求值**。在本例中,每一行都完成一个有效的 Elixir 表达式,例如 -5 或 -5 |> abs(),因此,每个中间结果都会被打印出来。

2.3.3 函数元数

元数描述了函数接收的参数数量。一个函数由其所属模块、名称和元数唯一标识。请看以下函数:

defmodule Rectangle do
  def area(a, b) do
    ...
  end
end

函数 Rectangle.area 接收两个参数,因此它被称为元数为 2 的函数。在 Elixir 中,这个函数通常被称为 Rectangle.area/2,其中 /2表示函数的元数。

为什么这很重要?因为两个同名但元数不同的函数是两个不同的函数,如下例所示。

示例 2.2 同名但参数数量不同的函数 (arity_demo.ex)

defmodule Rectangle do
  def area(a), do: area(a, a)
 
  def area(a, b), do: a * b
end

将此模块加载到 shell 中,并尝试以下操作:

iex(1)> Rectangle.area(5)
25
iex(2)> Rectangle.area(5,6)
30

如您所见,这两个函数的行为完全不同。虽然名称可能相同,但它们的参数数量不同,因此我们将它们视为两个不同的函数,每个函数都有自己的实现。

通常情况下,名称相同的不同函数拥有完全不同的实现是没有意义的。更常见的情况是,参数数量较少的函数会委托给参数数量较多的函数,并提供一些默认参数。这就是清单 2.2 中发生的情况,其中 Rectangle.area/1 委托给了 Rectangle.area/2。

让我们来看另一个例子。

示例 2.3 同名函数、不同参数数量和默认参数 (arity_calc.ex)

defmodule Calculator do
  def add(a), do: add(a, 0)
  def add(a, b), do: a + b
end

同样,低元数函数是用高元数函数实现的。这种模式非常常见,以至于 Elixir 允许你使用 \\ 运算符后跟参数的默认值来指定参数的默认值:

defmodule Calculator do
  def add(a, b \\ 0), do: a + b
end

这段定义会生成两个函数,与示例 2.3 中的完全相同。

你可以为任意参数组合设置默认值:

defmodule MyModule do
  def fun(a, b \\ 1, c, d \\ 2) do
    a + b + c + d
  end
end

请始终记住,**默认值会生成多个同名但元数不同的函数。前面的代码生成了三个函数:MyModule.fun/2、MyModule.fun/3 和 MyModule.fun/4,它们的实现如下:

def fun(a, c), do: fun(a, 1, c, 2)
def fun(a, b, c), do: fun(a, b, c, 2)
def fun(a, b, c, d), do: a + b + c + d

由于函数元数,即函数的参数个数(arity)用于区分同名函数,因此无法让一个函数接受可变数量的参数。C 语言的 或 JavaScript 的 arguments 参数没有对应的机制。

2.3.4 函数可见性

使用 def 宏定义函数时,该函数会被设为公共函数——任何人都可以调用它。在 Elixir 术语中,这被称为函数被导出。你也可以使用 defp 宏将函数设为私有函数。私有函数只能在定义它的模块内部使用。以下示例对此进行了演示。

示例 2.4 包含一个公共函数和一个私有函数的模块 (private_fun.ex)

defmodule TestPrivate do
  def double(a) do  # 公有函数
    sum(a, a) # 调用私有函数
  end
 
  defp sum(a, b) do #私有函数
    a + b
  end
end

模块 TestPrivate 定义了两个函数。函数 double 已导出,可以从外部调用。但它内部依赖于私有函数 sum 来完成其工作。

让我们在 shell 中尝试一下。加载该模块,然后执行以下操作:

iex(1)> TestPrivate.double(3)
6

iex(2)> TestPrivate.sum(3, 4)
** (UndefinedFunctionError) function TestPrivate.sum/2
...

如您所见,私有函数无法在模块外部调用。

2.3.5 导入和别名

调用其他模块中的函数有时会很麻烦,因为你需要引用模块名。如果你的模块经常调用其​​他模块中的函数,你可以将该模块导入到你自己的模块中。导入模块允许你直接调用其公共函数,而无需在函数名前加上模块名:

defmodule MyModule do
  import IO # 导入模块
 
  def my_function do 
    puts "Calling imported function." # 使用puts 替代 IO.puts
  end
end

当然,你可以导入多个模块。实际上,标准库的 Kernel 模块会自动导入到每个模块中。Kernel 包含一些常用的函数,因此自动导入可以更方便地访问它们。

注意:你可以通过查看在线文档 https://hexdocs.pm/elixir/Kernel.html 来了解 Kernel 模块中可用的函数。

另一个表达式 alias 允许使用不同的名称引用模块:

defmodule MyModule do
  alias IO, as: MyIO # 为IO创建别名
 
  def my_function do
    MyIO.puts("Calling imported function.")  # 使用IO的别名调用函数puts
  end
end

如果模块名称很长,别名就很有用。例如,如果你的应用程序被划分成很深的模块层次结构,那么通过完全限定名引用模块可能会很麻烦。别名可以帮助解决这个问题。例如,假设你有一个名为 Geometry.Rectangle 的模块。你可以在客户端模块中为其创建别名,并使用一个更短的名称:

defmodule MyModule do
  alias Geometry.Rectangle, as: Rectangle 
 
  def my_function do
    Rectangle.area(...)
  end
end

在前面的示例中,Geometry.Rectangle 的别名是其名称的最后一部分。这是别名alias最常见的用法, Elixir 允许你在这种情况下省略 as 选项:

defmodule MyModule do
  alias Geometry.Rectangle
 
  def my_function do
    Rectangle.area(...)
  end
end

别名可以帮助你减少一些冗余代码,尤其是在多次调用长名称模块中的函数时。

2.3.6 模块属性

模块属性的用途有两个:它们可以用作编译时常量,并且可以注册任何属性,然后在运行时查询这些属性。让我们看一个例子。

以下模块提供了一些用于处理圆的基本函数:

iex(1)> defmodule Circle do
          @pi 3.14159 # 定义模块属性
 
          def area(r), do: r*r*@pi  # 使用模块属性
          def circumference(r), do: 2*r*@pi
        end
 
iex(2)> Circle.area(1)
3.14159
 
iex(3)> Circle.circumference(1)
6.28318

请注意,您是如何直接在 shell 中定义模块的。这是允许的,并且使得无需在磁盘上存储任何文件即可进行实验。

@pi 常量的关键在于它仅在模块编译期间存在,此时对它的引用会被内联。

此外,还可以注册属性,这意味着它将存储在生成的二进制文件中,并且可以在运行时访问。Elixir 默认注册一些模块属性。例如,可以使用 @moduledoc@doc 属性为模块和函数提供文档:

defmodule Circle do
 @moduledoc "圆形基本函数实现"
 @pi 3.14159

 @doc "计算圆的面积"
 def area(r), do: r*r*@pi

 @doc "计算圆的周长"
 def circumference(r), do: 2*r*@pi
end

但是,要尝试此功能,您需要生成一个编译后的文件。以下是一种快速方法。将此代码保存到名为 circle.ex 的文件中,然后运行 ​​elixirc circle.ex。这将生成文件 Elixir.Circle.beam

接下来,从同一文件夹启动 iex shell。

现在您可以在运行时检索属性:

iex(1)> Code.fetch_docs(Circle)
{:docs_v1, 2, :elixir, "text/markdown", %{"en" => "圆形基本函数实现"},
 %{
   behaviours: [],
   source_annos: [{1, 1}]
 },
 [
   {{:function, :area, 1}, 5, ["area(r)"], %{"en" => "计算圆的面积"},
    %{source_annos: [{6, 6}]}},
   {{:function, :circumference, 1}, 8, ["circumference(r)"],
    %{"en" => "计算圆的周长"}, %{source_annos: [{9, 6}]}}
 ]}

值得注意的是,Elixir 生态系统中的其他工具也知道如何使用这些属性。

例如,您可以使用 iex 的帮助功能来查看模块的文档:

iex(3)> h Circle

                                     Circle

圆形基本函数实现

iex(4)> h Circle.area

                                  def area(r)

计算圆的面积

此外,您可以使用 ex_doc 工具(参见 https://hexdocs.pm/ex_doc )为您的项目生成HTML 文档。这是 Elixir 文档的生成方式。如果您计划构建更复杂的项目,尤其是一些会被许多不同客户端使用的项目,则应考虑使用 @moduledoc 和 @doc。

其核心在于,注册的属性可用于将元信息附加到模块,然后,其他 Elixir(甚至 Erlang)工具可以使用这些信息。还有许多其他预注册的属性,您也可以注册自己的自定义属性。有关更多详细信息,请参阅 Module 模块的文档(https://elixir.hexdocs.pm/Module.html )。

类型规范

类型规范(通常称为 typespecs)是另一个基于属性的重要特性。这些属性允许你为函数提供类型信息,之后可以使用名为 Dialyzer 的静态分析工具(https://www.erlang.org/doc/apps/dialyzer/dialyzer.html )进行分析。

以下是如何扩展 Circle 模块以包含类型规范:

defmodule Circle do
  @pi 3.14159
 
  @spec area(number) :: number  # area/1 的类型规范
  def area(r), do: r*r*@pi
 
  @spec circumference(number) :: number # circumference/1 的类型规范
  def circumference(r), do: 2*r*@pi
end

这里,使用 @spec 属性来指示这两个函数都接受并返回一个数字。

类型规范 (typespec) 提供了一种弥补 Elixir 缺少静态类型系统的方法。它可以与 Dialyzer 工具结合使用,对程序进行静态分析。此外,类型规范还能帮助你更好地编写函数文档。请记住,Elixir 是一种动态语言,因此函数的输入和输出无法通过查看函数签名轻松推断出来。类型规范可以显著改善这种情况,我可以证明,如果提供了类型规范,理解他人的代码会容易得多。

例如,查看 Elixir 函数 List.insert_at/3 的类型规范:

@spec insert_at(list, integer, any) :: list

即使不看代码或文档,你也可以合理地推测,该函数会将任意类型的项(第三个参数)插入到列表(第一个参数)的指定位置(第二个参数),并返回一个新列表。

本书中不会使用类型规范,主要是为了尽可能保持代码简洁。但如果您计划构建更复杂的系统,我的建议是认真考虑使用类型规范 (typespecs)。您可以在官方文档中找到详细的参考资料: https://elixir.hexdocs.pm/typespecs.html

2.3.7 注释

Elixir 中的注释以 # 字符开头,表示该行的其余部分是注释:

# 这是注释
a = 3.14 # 这也是注释

Elixir 不支持块注释。如果需要注释多行,请在每行代码前加上 # 字符。

至此,我们已经学习了函数和模块的基础知识。您现在已经了解了主要的代码组织技巧。接下来,我们来看看 Elixir 的类型系统。

2.4 理解类型系统

Elixir 的核心是 Erlang 类型系统。因此,与 Erlang 库的集成通常很简单。类型系统本身也相当简单,但如果您来自传统的面向对象语言,您会发现它与您习惯的语言有很大的不同。本节介绍 Elixir 的基本类型,并讨论一些不可变性的影响。首先,我们来看数字。

2.4.1 数字

数字可以是整数或浮点数,它们的行为大多符合您的预期:

iex(1)> 3 # 整数
3
iex(2)> 0xFF 以十六进制表示的整数
255
iex(3)> 3.14 # 浮点数
3.14
iex(4)> 1.0e-2 # 浮点数,指数表示法
0.01

支持标准算术运算符:

iex(5)> 1 + 2 * 3
7

除法运算符 / 的行为可能与您预期的不同。它始终返回一个浮点值:

iex(6)> 4/2
2.0
iex(7)> 3/2
1.5

要执行整数除法或计算余数,可以使用自动导入的内核函数:

iex(8)> div(5,2)
2
iex(9)> rem(5,2)
1

要添加语法糖,您可以使用下划线字符作为视觉分隔符:

iex(10)> 1_000_000
1000000

整数的大小没有上限,您可以使用任意大的数字:

iex(11)> 999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999

如果您担心内存大小,最好参考 Erlang 官方内存指南,网址为 https://www.erlang.org/doc/system/memory.html 。整数占用的空间取决于数字本身的大小,而浮点数则占用 32 位或 64 位,具体取决于虚拟机的构建架构。浮点数在内部以 IEEE 754-1985(二进制精度)格式表示。

正文完
 0
评论(没有评论)