iex 与基本类型:一切皆表达式
解剖第一课不从语法糖开始,从器官开始:这门语言的值长什么样。Elixir 的基本类型不多,但每一种都带着 BEAM 的胎记——整数不会溢出,字符串其实是二进制,还有一种叫「原子」的东西会在你之后写的每一行代码里出现。本章的手术台是 iex:我们边切边看,你也可以直接在页面里的模拟终端上跟着动手。
iex:读一句,算一句,报一句
iex(Interactive Elixir)是一个 REPL:读入一个表达式(Read),求值(Eval),打印结果(Print),循环(Loop)。听起来平平无奇,但注意一个措辞差别:是「表达式」不是「语句」。Elixir 里没有语句,一切都是表达式,一切都有返回值——连 if 都有:
iex(1)> x = if 5 > 3, do: "偏高", else: "正常" "偏高" iex(2)> x "偏高"
「if 的结果可以赋给变量」这个小事实,是函数式语言的第一块多米诺骨牌:既然一切都是值,那么代码的主要工作就是把值变换成另一个值,而不是「执行动作、修改状态」。这块骨牌会在第 4、5 章连环倒下。
iex 还内置了侦察三件套,先记住,全书都用:
h Something—— 打印文档(上一章见过);i value—— 报告一个值的完整「体检信息」:类型、字节数、引用模块;v(n)—— 取回第 n 行的历史结果,v()取上一行。
数字:不会溢出的整数,和一个陷阱除号
Elixir 的整数是任意精度的。没有 int 上限、没有 Long、没有溢出回绕——大到超过机器字长时,BEAM 自动切换到大数表示:
iex(1)> 123_456_789 * 987_654_321 * 1_000_000_000 121932631112635269000000000 iex(2)> 10 / 2 5.0 iex(3)> div(10, 2) 5 iex(4)> rem(10, 3) 1
三个细节。其一,数字里可以插下划线做千分位,1_000_000 编译后就是一百万,和 Kotlin 一样。其二,/ 永远返回浮点数,哪怕整除:10 / 2 是 5.0 不是 5。要整数除法用 div/2,取余用 rem/2。其三,注意 5.0 和 5 是两个不同的值——宽松比较 == 认为它们相等,严格比较 === 不认,下一章模式匹配用的是严格语义,这个伏笔别忘。
原子:遍地都是的小旗子
接下来是 Elixir 最有性格的类型。原子(atom)是一个「名字即值」的常量:写作 :ok、:error、:timeout,它不代表任何别的东西,它就是它自己。
iex(1)> :ok :ok iex(2)> :ok == :ok true iex(3)> is_atom(:急诊) true iex(4)> is_atom(true) true
为什么需要这种东西?想想你平时怎么表达「状态」:Java/Kotlin 里要么定义枚举类,要么用魔法字符串 "OK"。原子取了两者的好处:像字符串一样随写随用,像枚举一样高效安全。虚拟机内部把每个原子登记在一张全局表里,值本身只是表的索引——比较两个原子就是比较两个整数,永远 O(1),不管名字多长。
于是整个生态形成了一套用原子做「信号旗」的惯例,你马上会在每个角落遇到它们:函数成功返回 {:ok, 结果},失败返回 {:error, 原因};true、false、nil 其实也都是原子(:true 的语法糖)。第 3 章的模式匹配和第 6 章的错误处理,整套地基都铺在原子上。
原子表是全局的,而且不参与垃圾回收,默认上限约 104 万个。正常写代码根本碰不到,但有一条铁律:永远不要把外部输入(用户参数、JSON 字段名)动态转换成原子(String.to_atom/1)。否则攻击者可以用海量不同字符串灌满原子表,把整个节点灌死。要转就用 String.to_existing_atom/1,它只接受已经存在的原子。这是 Elixir 面试和代码评审的高频考点。
真与假:两套逻辑运算符
既然 true/false/nil 都是原子,顺手把布尔逻辑讲完。Elixir 有两套逻辑运算符,分工明确:
iex(1)> true and false false iex(2)> 1 and true ** (BadBooleanError) expected a boolean on left-side of "and", got: 1 iex(3)> nil || "默认值" "默认值" iex(4)> 0 && "零也是真" "零也是真"
and/or/not 是严格版,左操作数必须是真正的布尔值,适合写业务判断和 guard;&&/||/! 是宽松版,接受任何值,规则是只有 nil 和 false 算假,其余一切都算真——注意第 4 行,连 0 和 "" 都是真,和 C/JS 的直觉不同。宽松版的高频用法是取默认值:opts[:retries] || 3,配合方括号访问「查不到返回 nil」的行为,一行搞定「有配置用配置,没配置用默认」。两套都短路求值。
字符串:你以为是文本,其实是二进制
Elixir 的字符串用双引号,支持 <> 拼接和 #{} 插值:
iex(1)> bed = 7
7
iex(2)> "床位 #{bed} 号,体征#{if bed > 5, do: "稳定", else: "待查"}"
"床位 7 号,体征稳定"
iex(3)> String.upcase("beam")
"BEAM"
但它的真身值得单独一节:Elixir 字符串就是一段 UTF-8 编码的二进制(binary)。用侦察兵 i 看一眼:
iex> i "九个九"
Term "九个九"
Data type BitString
Byte size 9
Description This is a string: a UTF-8 encoded binary...
三个汉字,九个字节(UTF-8 每个汉字三字节)。这解释了一对新手常混的函数:byte_size("九个九") 是 9(占多少内存),String.length("九个九") 是 3(几个「字」,严格说是几个字素簇)。处理用户昵称长度限制时用错这两个,就是一个线上 bug。「字符串即二进制」还有一层深意:网络协议解析里,你可以用下一章的模式匹配直接按字节位切开一段报文——这是 Erlang 在电信界的看家本领,第 3 章见。
charlist 名场面
单引号(或 ~c 前缀)是另一个物种:字符列表(charlist),即码点组成的整数列表,主要为兼容 Erlang 旧接口而存在:
iex(1)> [104, 105] ~c"hi" iex(2)> ~c"hi" == "hi" false iex(3)> :erlang.system_info(:system_version) ~c"Erlang/OTP 28 [erts-16.0] ...\n"
第一行是名场面:你输入一个普普通通的整数列表 [104, 105],iex 却打印出 ~c"hi"——因为列表里恰好全是可打印字符的码点,iex 猜你可能想要文本。数据没变,只是显示方式吓人。记住两条就够:看到 ~c"..." 就知道是 Erlang 系接口来的列表,用 to_string/1 转成正经字符串;自己写代码永远用双引号。
元组与列表:定长的格子,和不定长的链条
两种最基础的容器,选型直觉一句话:形状固定用元组,数量不定用列表。
iex(1)> sign = {:hr, 72}
{:hr, 72}
iex(2)> elem(sign, 1)
72
iex(3)> beds = [3, 7, 12]
[3, 7, 12]
iex(4)> [1 | beds]
[1, 3, 7, 12]
iex(5)> beds ++ [99]
[3, 7, 12, 99]
元组(tuple)在内存里是连续存放的,按下标取值 O(1),但它表达的是「一条结构固定的记录」:{:ok, value}、{:hr, 72}、{x, y} 坐标。几乎不会有人遍历一个元组。
列表(list)是单向链表——不是数组!没有 list[0] 这种下标语法,取第 n 个元素要从头走 n 步。它的两个 O(1) 操作是:取头部、往头部加一个元素([新头 | 旧列表],读作 cons)。而 ++ 往尾部拼接需要复制整个左侧列表,循环里反复 ++ 是经典性能病。为什么设计成链表?因为链表天然适合不可变共享和递归处理——第 5 章讲 [head | tail] 递归时你会看到这个设计的全部回报。
关键字列表与 Map:两种「键值对」,两种用途
Elixir 有两种键值容器,新手容易迷糊,其实分工非常清楚。
关键字列表(keyword list)本质是「原子键二元组」组成的列表:[a: 1, b: 2] 只是 [{:a, 1}, {:b, 2}] 的糖。它有序、允许重复键、查找 O(n)——这些特性使它专职一件事:函数的选项参数。你在上一节已经用过了:if 5 > 3, do: "...", else: "..." 里的 do: 和 else:,就是一个关键字列表。
Map 才是正经的键值数据结构:任意类型做键、查找高效、无序:
iex(1)> patient = %{bed: 7, hr: 72, dept: "急诊"}
%{bed: 7, dept: "急诊", hr: 72}
iex(2)> patient.hr
72
iex(3)> patient[:spo2]
nil
iex(4)> %{patient | hr: 96}
%{bed: 7, dept: "急诊", hr: 96}
iex(5)> patient.hr
72
四个语法点。①原子键有简写 %{bed: 7},任意键用箭头 %{"科室" => "急诊"}。②patient.hr 点语法只对原子键有效,而且键不存在直接抛错;patient[:spo2] 方括号语法则宽容地返回 nil——「必有的键用点,可选的键用方括号」是社区共识。③%{patient | hr: 96} 是更新语法,只能改已有键,不能加新键(加新键用 Map.put/3)。④注意第 5 行:更新完再看 patient,还是 72——没有任何操作修改了原 Map,「更新」永远是造一个新值。这就是不可变性,第 5 章的主角,这里先混个脸熟。
把这套类型映射回 Kotlin:原子 ≈ 枚举/密封类的对象常量(但免声明);元组 ≈ Pair/Triple(但任意长度);关键字列表 ≈ 具名默认参数在调用侧的样子;Map ≈ Map;而 %{patient | hr: 96} 的手感,和 data class 的 copy(hr = 96) 一模一样——Kotlin 用 copy 鼓励你不可变,Elixir 直接不给你可变的选项。
动手:床旁终端
下面是本章的监护仪:一台受限的 iex 模拟器,本章出现过的表达式都能跑。把每个按钮按一遍,特别留意 10 / 2、[104, 105] 和 %{bed: 3, hr: 72} 的输出。
类型体检总表
| 类型 | 字面量 | 一句话直觉 | 典型用途 |
|---|---|---|---|
| 整数 | 42, 1_000_000 | 任意精度,永不溢出 | 计数、ID |
| 浮点 | 3.14, 5.0 | 64 位;/ 的产物 | 度量值 |
| 原子 | :ok, :error | 名字即值的小旗子 | 状态、标签、键 |
| 字符串 | "文本" | UTF-8 二进制 | 一切文本 |
| charlist | ~c"hi" | 码点整数列表 | Erlang 接口兼容 |
| 元组 | {:ok, 200} | 形状固定的记录 | 返回值、坐标 |
| 列表 | [1, 2, 3] | 单向链表 | 不定长集合 |
| 关键字列表 | [do: ..., else: ...] | 原子键元组的列表 | 函数选项 |
| Map | %{bed: 7} | 正经键值结构 | 业务数据 |
- 不看答案:
7 / 7输出什么?div(7, 7)呢?为什么设计成这样? - 用
i分别体检:ok、"ok"、~c"ok",对照三份报告里的 Data type。 - 造一个病人 Map,用点语法读一个不存在的键,再用方括号读——观察两种失败方式的区别,想想各自适合什么场景。
- 解释给同事听:为什么
[104, 105]会显示成~c"hi"?数据本身变了吗?
器官认完了。下一章讲这门语言的灵魂手术刀——模式匹配,从推翻你对 = 的全部认知开始。