VOL.II · 解剖CH 02监护 02/13

iex 与基本类型:一切皆表达式

解剖第一课不从语法糖开始,从器官开始:这门语言的值长什么样。Elixir 的基本类型不多,但每一种都带着 BEAM 的胎记——整数不会溢出,字符串其实是二进制,还有一种叫「原子」的东西会在你之后写的每一行代码里出现。本章的手术台是 iex:我们边切边看,你也可以直接在页面里的模拟终端上跟着动手。

iex原子二进制字符串元组/列表/Map

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 / 25.0 不是 5。要整数除法用 div/2,取余用 rem/2。其三,注意 5.05 是两个不同的值——宽松比较 == 认为它们相等,严格比较 === 不认,下一章模式匹配用的是严格语义,这个伏笔别忘。

原子:遍地都是的小旗子

接下来是 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, 原因};truefalsenil 其实也都是原子(: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;&&/||/! 是宽松版,接受任何值,规则是只有 nilfalse 算假,其余一切都算真——注意第 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 章的主角,这里先混个脸熟。

▸ Android 类比

把这套类型映射回 Kotlin:原子 ≈ 枚举/密封类的对象常量(但免声明);元组 ≈ Pair/Triple(但任意长度);关键字列表 ≈ 具名默认参数在调用侧的样子;Map ≈ Map;而 %{patient | hr: 96} 的手感,和 data classcopy(hr = 96) 一模一样——Kotlin 用 copy 鼓励你不可变,Elixir 直接不给你可变的选项。

动手:床旁终端

下面是本章的监护仪:一台受限的 iex 模拟器,本章出现过的表达式都能跑。把每个按钮按一遍,特别留意 10 / 2[104, 105]%{bed: 3, hr: 72} 的输出。

DEMO床旁 iex 终端受限模拟器 · 真机体验请本地安装 elixir

类型体检总表

类型字面量一句话直觉典型用途
整数42, 1_000_000任意精度,永不溢出计数、ID
浮点3.14, 5.064 位;/ 的产物度量值
原子:ok, :error名字即值的小旗子状态、标签、键
字符串"文本"UTF-8 二进制一切文本
charlist~c"hi"码点整数列表Erlang 接口兼容
元组{:ok, 200}形状固定的记录返回值、坐标
列表[1, 2, 3]单向链表不定长集合
关键字列表[do: ..., else: ...]原子键元组的列表函数选项
Map%{bed: 7}正经键值结构业务数据
✚ 动手自测
  1. 不看答案:7 / 7 输出什么?div(7, 7) 呢?为什么设计成这样?
  2. i 分别体检 :ok"ok"~c"ok",对照三份报告里的 Data type。
  3. 造一个病人 Map,用点语法读一个不存在的键,再用方括号读——观察两种失败方式的区别,想想各自适合什么场景。
  4. 解释给同事听:为什么 [104, 105] 会显示成 ~c"hi"?数据本身变了吗?

器官认完了。下一章讲这门语言的灵魂手术刀——模式匹配,从推翻你对 = 的全部认知开始。