Elixir 语法全景
一节课覆盖所有语法点——基于你已知的 Haskell/Clojure/Scala 经验做对照。
这节课的目标是读完能读懂 ex/elixir/lib/ 下任何文件。所有例子都来自真实项目代码。
**预计时间:**45 分钟读完 + 30 分钟做测验 = 75 分钟。
一、值与字面量(Literals)
先记一张表,然后看例子:
| 类型 | 字面量 | 类比其他语言 | 项目例子 |
|---|---|---|---|
| Integer | 42 0x1F 1_000_000 | int (unlimited precision) | 0 (count) |
| Float | 3.14 | double | — |
| Atom | :ok :error :foo_bar | Clojure 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] `[head | tail]` | 链表(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{} |
| Boolean | true false (atoms) | — | — |
| Nil | nil (atom) | null / None | — |
: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(nil 或 false)就用右边。
三、模块(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
注意三点:
alias让你写Repo而不是Pipeline.Repo。等价于 Javaimport x.y.Z as Z。def ... when is_list(...)是 guard(卫语句),运行期做模式匹配的额外断言。defp私有函数——只能本模块调用。等于 Javaprivate。
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 <- xs, y <- 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} <- a(), {:ok, y} <- b(x) do {:ok, x+y} end |
for | 列表推导 | for x <- 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 风格(不是 =)。
- 预期失败 → 返回
{: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 道读代码)
凭记忆作答,不要回看。
下列哪个字面量是 atom?
x = 10; x = 20 在 Elixir 中是?
[1, 2 | [3, 4]] 的值是?
is_struct(m, PipelineExecution) 对 m = %PipelineExecution{} 返回?
下列哪个是 Elixir 风格的"默认参数"?
"abc" |> String.upcase() |> String.length() 结果是?
下列哪个 不是 命名 atom?
use Oban.Worker 的本质是?
列表推导 for x <- 1..3, y <- ["a", "b"], do: {x, y} 结果?
Map.get(%{a: 1}, :b, 99) 结果是?
读代码题
R1. 读下面代码,写出 run("close") 的返回:
R2. 读下面代码,upsert_messages([%{msg_id: "1"}]) 假设数据库 msg_id="1" 已存在,返回值是?
**下一步:**看 Lesson 0002 · 并发与 OTP。