Learning
VOL. XIII · NO. 06 · Elixir · 01 JAN 1970

Elixir 语法全景

Elixir 编程 · 01 JAN 1970 · 12 min read · 1,569 words
· · ·

一节课覆盖所有语法点——基于你已知的 Haskell/Clojure/Scala 经验做对照。

这节课的目标是读完能读懂 ex/elixir/lib/ 下任何文件。所有例子都来自真实项目代码。

**预计时间:**45 分钟读完 + 30 分钟做测验 = 75 分钟。

学习前提(你已具备)
函数式编程、不可变数据、递归、模式匹配、Haskell/Clojure 风格语法。本课不再解释这些,只讲 Elixir 语法层的特殊点

一、值与字面量(Literals)

先记一张表,然后看例子:

类型字面量类比其他语言项目例子
Integer42 0x1F 1_000_000int (unlimited precision)0 (count)
Float3.14double
Atom:ok :error :foo_barClojure keyword / Haskell Symbol:ok :error
String"hello" (binary)immutable UTF-8"news_digest"
Charlist'hello' (list of ints)Erlang 遗留,几乎不用
Tuple{:ok, value}Haskell tuple / Rust tuple{:ok, run_id}
List[1, 2, 3] `[headtail]`链表(Haskell : 风格)
Map%{a: 1, b: 2}Clojure map / JS object全部配置
Keyword List[a: 1, b: 2] = [{:a, 1}, {:b, 2}]命名参数惯用法[dag_name: "x", limit: 1]
Struct%User{name: "y"}带类型的 Map%PipelineExecution{}
Booleantrue false (atoms)
Nilnil (atom)null / None
关键直觉
Atom 是 Elixir 一切"枚举"和"标签"的根基——:ok:error:running:done 都是 atom。Atom 在 BEAM 内部是直接地址比较,极快。模式匹配时常用。

二、变量、绑定、不可变性

Elixir 的”变量”其实是绑定(binding)——一旦绑定到值,不能再改,可以重新绑定:

x = 10
x = 20          # ✓ 这是"重新绑定"到新值,不是修改
x = x + 1       # ✓ 读 x(20)→ 加 1 → 绑定到 21

list = [1, 2, 3]
new_list = [0 | list]   # ✓ 加到头部,list 本身不变
# list 仍然 = [1, 2, 3]
# new_list = [0, 1, 2, 3]

对比你已知的:跟 Haskell let x = ... 完全一致。跟 C/Java int x = 10; x = 20; 表面像,实际不同——C 是赋值(改内存),Elixir 是绑定(贴新标签)。

**Pin operator ^:**在模式匹配中防止重新绑定:

x = 10
case some_value do
  ^x -> "match the bound value 10"  # 不重新绑定
  20 -> "match literal 20"
  _  -> "anything else"
end

项目例子(lib/pipeline/jobs/news_digest.ex:21):

period = Map.get(args, "period") || Map.get(args, :period, "close")

|| 跟 JS/Go 类似——左边 falsy(nilfalse)就用右边。

三、模块(defmodule)+ 函数定义

每个文件一个模块。函数用 def(公开)或 defp(私有):

defmodule Pipeline.NewsStore do
  alias Pipeline.Repo
  alias Pipeline.FeishuMessage

  def upsert_messages(records) when is_list(records) do
    # 公开函数
  end

  defp some_helper(x) do
    # 私有函数
  end
end

注意三点:

  1. alias 让你写 Repo 而不是 Pipeline.Repo。等价于 Java import x.y.Z as Z
  2. def ... when is_list(...)guard(卫语句),运行期做模式匹配的额外断言。
  3. defp 私有函数——只能本模块调用。等于 Java private
一个常见坑
函数参数中的 x 不是绑定,是模式def f(x), do: x + 1 意味着"匹配任何值并绑定到 x",不是"声明参数类型为 T"。

四、模式匹配(Pattern Matching)

Elixir 跟 Haskell 一样核心机制。每个 def / case / fn 都用模式匹配。区别:Haskell 类型检查器强制穷尽,Elixir 靠 fallback _ 兜底

4.1 函数多子句(multi-clause)

# lib/pipeline/jobs/news_digest.ex:81-86
defp period_to_role("pre_0715"), do: "pre"
defp period_to_role("pre_0915"), do: "pre"
defp period_to_role("trading_am"), do: "trading"
defp period_to_role("trading_pm"), do: "trading"
defp period_to_role("close"), do: "close"
defp period_to_role(_), do: "资讯"

关键点:按定义顺序匹配,_ 是万能匹配,必须放最后,否则永远命中前面的不会到它。

4.2 case 表达式

case result do
  {:ok, value} -> "got: #{value}"
  {:error, :timeout} -> "retry"
  {:error, msg} -> "failed: #{msg}"
end

4.3 cond(多条件分支)

cond do
  x > 100 -> :big
  x > 0   -> :positive
  x == 0  -> :zero
  true    -> :negative  # 兜底必须有
end

4.4 if / unless

if is_closing, do: Context.upsert_today_digest(summary)
unless is_closing, do: ...

跟 Haskell 一样,if 是表达式有返回值,不是语句。

五、字符串、字符串插值、Charlist

双引号是 String (binary),单引号是 Charlist(Erlang 遗留,别用):

name = "Elixir"
"Hello, #{name}!"  # => "Hello, Elixir!"

# 多行
"""
multi
line
string
"""

插值:#{expr},任何 Elixir 表达式都行。跟 JS ${expr} 类似。

字符串函数:

String.upcase("hi")              # => "HI"
String.split("a\nb\nc", "\n")    # => ["a", "b", "c"]
String.split("a,b", ",", trim: true)   # => ["a", "b"]
String.slice("hello", 0, 3)      # => "hel"
String.contains?("hello", "ll")  # => true
"hello" <> " " <> "world"        # => "hello world"  (拼接)
~s|no quotes needed|             # sigil 简化

项目例子(lib/pipeline/llm/dummy.ex:8):

text
|> String.split("\n", trim: true)
|> length()

六、管道 |>(Pipeline Operator)

跟 F# / Elixir / Clojure -> 思路一致。Elixir 用 |>左边的结果作为右边的第一个参数

# 这两个等价
String.upcase(String.trim("  hello  "))
"  hello  " |> String.trim() |> String.upcase()

# 项目例子(lib/pipeline/execution.ex:80-97)
base = from(e in PipelineExecution, order_by: [desc: e.id])
base =
  if dag_name do
    from(e in base, where: e.dag_name == ^dag_name)
  else
    base
  end
# 上面 if 内 Ecto.Query 继续被"管道化"链式追加

注意:第一个参数是最后一个参数。这是管道设计的约定。理解:x |> f(a, b) = f(x, a, b)

七、列表 vs Keyword List vs Map 选型

结构特点何时用
List [1,2,3]链表,prepend O(1),append O(n),随机访问 O(n)数据流、管道结果、枚举
Tuple {:ok, 1}定长 N≤10,O(1) 读写,比 Map 还快返回多值({:ok, x}),模式匹配
Map %{a: 1}hashmap,O(log n),可任意 key键值对、配置、struct 内部
Keyword List [a: 1]== List of {:a, 1} tuples,重复 key 保留命名参数(opts)

项目例子——三种”配置”风格同时出现:

# Keyword List 命名参数(最常见)
opts = [dag_name: "news_pull", limit: 1]
Keyword.get(opts, :dag_name)       # => "news_pull"
Keyword.get(opts, :missing, "default")

# Map 配置
%{
  summary_md: "...",
  stats_json: %{...},
  needs_research: true
}

# Tuple 多返回值
{:ok, run_id} = Pipeline.Execution.start("news_digest")
# ↑ 这就是模式匹配 + 绑定的合体

八、struct = 带类型 tag 的 Map

# lib/pipeline/feishu_message.ex
defmodule Pipeline.FeishuMessage do
  use Ecto.Schema

  @primary_key {:msg_id, :string, autogenerate: false}
  schema "feishu_messages" do
    field(:chat_id, :string)
    field(:sender_id, :string)
    field(:text, :string)
    field(:create_time, :utc_datetime_usec)
  end
end

# 用法
%Pipeline.FeishuMessage{msg_id: "m1", text: "hi"}  # struct 字面量
%FeishuMessage{msg_id: "m1"}  # 用了 alias 之后

m.text            # 访问字段
%{m | text: "new"}  # 更新字段(产生新 struct)
is_struct(m, Pipeline.FeishuMessage)  # true
is_map(m)         # true(struct 也是 Map)

关键点use Ecto.Schema 之后,schema 宏自动生成 defstruct。struct 本质是带 __struct__ 字段的 Map,但模式匹配会检查类型:

case m do
  %Pipeline.FeishuMessage{text: t} -> "got message: #{t}"
  _ -> "not a message"
end

九、Enum 模块(处理列表的瑞士军刀)

Elixir 没有内建 map / filter 函数,它们都在 Enum 模块上:

Enum.map([1, 2, 3], fn x -> x * 2 end)         # => [2, 4, 6]
Enum.filter([1, 2, 3], fn x -> x > 1 end)       # => [2, 3]
Enum.reduce([1, 2, 3], 0, fn x, acc -> x + acc end)  # => 6
Enum.sum([1, 2, 3])                              # => 6
Enum.count([1, 2, 3])                            # => 3
Enum.find([1, 2, 3], fn x -> x > 1 end)         # => 2
Enum.member?([1, 2, 3], 2)                       # => true
Enum.zip([1, 2], [:a, :b])                       # => [{1, :a}, {2, :b}]
Enum.sort([3, 1, 2])                             # => [1, 2, 3]
Enum.uniq([1, 2, 1, 3])                          # => [1, 2, 3]
Enum.take([1, 2, 3, 4], 2)                       # => [1, 2]

捕获运算符 & 简化 lambda:

Enum.map([1, 2, 3], &(&1 * 2))   # => [2, 4, 6]
# &1 是第 1 个参数,&2 是第 2 个...

# 等价 lambda:
# Enum.map([1, 2, 3], fn x -> x * 2 end)

# 引用现有函数
Enum.map(["a", "b"], &String.upcase/1)  # => ["A", "B"]

项目例子(lib/pipeline/dag.ex:49):

def edges do
  for n <- @nodes, from <- n.upstream, do: %{from: from, to: n.id}
end

这是 列表推导(list comprehension),不是 Enum.map。等价:

@nodes
|> Enum.flat_map(fn n -> Enum.map(n.upstream, &%{from: &1, to: n.id}) end)

列表推导更短,类似 SQL:for x &lt;- xs, y &lt;- ys, do: f(x, y) = SELECT * FROM xs, ys

十、控制流总结表

构造用途例子
case x do ... end多模式匹配case result do {:ok, v} -> ... ; {:error, _} -> ... end
cond do ... end布尔分支cond do x > 0 -> :pos ; true -> :neg end
if/unless单分支(也可用 case 替代)if ok, do: ...
with链式 {:ok, x} 解包with {:ok, x} &lt;- a(), {:ok, y} &lt;- b(x) do {:ok, x+y} end
for列表推导for x &lt;- 1..3, do: x * 2
try/rescue异常处理try do ... rescue e -> ... end

十一、Ecto Schema & Changeset 速览

这是 ex/elixir 里到处可见的领域 DSL,看懂就够了:

# lib/pipeline/pipeline_execution.ex
defmodule Pipeline.PipelineExecution do
  use Ecto.Schema

  @schema_prefix Application.compile_env(:pipeline, :schema_prefix, "") <> "public"

  schema "pipeline_execution" do
    field :dag_name, :string
    field :run_id, :string
    field :started_at, :utc_datetime_usec
    field :finished_at, :utc_datetime_usec
    field :status, :string
    field :row_count, :integer
    field :msg, :string
    field :inserted_at, :utc_datetime_usec
  end

  def changeset(schema, attrs) do
    schema
    |> Ecto.Changeset.cast(attrs, [:dag_name, :run_id, :status, :row_count, :msg])
    |> Ecto.Changeset.validate_required([:dag_name, :run_id, :status])
  end
end

# 用法(lib/pipeline/execution.ex)
%PipelineExecution{}
|> PipelineExecution.changeset(%{dag_name: "x", run_id: "r1", status: "running"})
|> Repo.insert!()    # 入库,! = 出错抛异常

关键点:

  • use Ecto.Schema 是一个 (详见 0003),自动给 schema 块里每个 field 生成 struct 字段。
  • Changeset 不是值,是流程描述——cast(只接受白名单字段)+ validate_required(必填校验)→ 返回一个 changeset,再 Repo.insert!/1 提交。

十二、错误处理:{:ok, x} vs try/rescue

Elixir 的预期错误用 tagged tuple,异常try/rescue

# 预期错误(占 80% 场景)
case Repo.get_by(PipelineExecution, run_id: run_id) do
  nil -> {:error, :not_found}        # 显式错误
  record -> {:ok, record}
end

# 异常(占 20% 场景)
try do
  risky_call()
rescue
  e in RuntimeError -> {:error, Exception.message(e)}
end

项目例子(lib/pipeline/execution.ex:40-55):

def finish(run_id, status, row_count \\ 0, msg \\ nil, dag_name \\ nil) do
  case lookup_running_record(run_id, dag_name) do
    nil ->
      {:error, :not_found}

    record ->
      record
      |> PipelineExecution.changeset(%{...})
      |> Repo.update()
  end
end

注意row_count \\ 0默认参数\ 双反斜杠。这是 Elixir 风格(不是 =)。

Elixir 错误处理哲学
"Errors are values, not exceptions."(错误即数据,不是异常。)
  • 预期失败 → 返回 {:ok, x} / {:error, reason}
  • 意外崩溃 → 让进程崩,让 supervisor 重启
  • try/rescue 只在"必须接住外部异常"时用

十三、模块 / 函数 / 常量 速记

把所有语法”糖”放到一起看,速记表:

语法含义
defmodule M do ... end定义模块
def f(x), do: x公开函数(单行)
defp f(x), do: x私有函数
@moduledoc "..."模块文档
@doc "..."函数文档
@callback foo() :: any行为声明(详见 0003)
alias M.N, as: N模块重命名(import 别名)
import M导入 M 的所有函数
require M让 M 的宏可见
use M调用 M 的 __using__/1
@x value模块属性(编译时常量)
x = 1变量绑定
^x模式中用绑定值(pin)
_通配符
x \\ default默认参数值

十四、测验(10 道选择 + 2 道读代码)

凭记忆作答,不要回看

1

下列哪个字面量是 atom

2

x = 10; x = 20 在 Elixir 中是?

3

[1, 2 | [3, 4]] 的值是?

4

is_struct(m, PipelineExecution)m = %PipelineExecution{} 返回?

5

下列哪个是 Elixir 风格的"默认参数"?

6

"abc" |> String.upcase() |> String.length() 结果是?

7

下列哪个 不是 命名 atom?

8

use Oban.Worker 的本质是?

9

列表推导 for x <- 1..3, y <- ["a", "b"], do: {x, y} 结果?

10

Map.get(%{a: 1}, :b, 99) 结果是?

读代码题

R1

R1. 读下面代码,写出 run("close") 的返回:

R2

R2. 读下面代码,upsert_messages([%{msg_id: "1"}]) 假设数据库 msg_id="1" 已存在,返回值是?

**下一步:**看 Lesson 0002 · 并发与 OTP