【仓颉语言入门 · 第9课】
【仓颉语言入门 · 第9课】可空类型 ? 与 Option:让"没有值"无法伤人
上节课我们在 HashMap 的输出里反复看到
Some(138-0000-0001)、NoneValueException: Value does not exist!——当时只留了句"第 9 课讲"。这节课就来正式认识它们背后的设计:可空类型?与 Option。本文是系列第 9 课:理解仓颉为什么敢说"从类型层面消灭空指针",掌握
Some/None、isSome()、match解构、??空合并、?.安全调用,以及map/filter/flatMap三个链式工具;所有 API 均在仓颉 SDK 1.1.3 下实测编译运行,并澄清网上旧教程里getOrElse、?:、?[等在本版本根本不存在的写法陷阱。
目录(系列导航)
整套路线共 7 个模块、30 课,按每周 2~3 课的节奏,大约 2~3 个月可以走完一遍:
| 模块 | 课次 | 内容 |
|---|---|---|
| 一、环境与入门 | 01~05 | 环境搭建与 Hello World、变量与基本类型、运算符与输入输出、分支、循环 |
| 二、常用类型与数据组织 | 06~10 | 字符串、数组与区间、ArrayList/HashMap/HashSet、可空类型、错误处理 |
| 三、函数与函数式 | 11~14 | 函数、Lambda 与高阶函数、闭包、迭代器与惰性序列 |
| 四、面向对象与类型系统 | 15~20 | struct/class、构造与属性、接口、枚举与 match 模式匹配、泛型、扩展 |
| 五、工程化与标准库 | 21~25 | cjpm 包管理与多文件、文件 IO、JSON 处理、网络编程、单元测试 |
| 六、并发编程 | 26~28 | 线程、Channel 通道与同步原语、并发实战 |
| 七、项目实战 | 29~30 | 命令行小工具、GeoJSON 数据处理实战 |
- 环境搭建与第一个仓颉程序
- 变量与常量:let / var 与基本数据类型
- 运算符与标准输入输出
- 分支结构:if 与 match 表达式
- 循环结构:while / for / Range
- 字符串详解与字符串插值
- 数组 Array 与区间 Range
- 集合框架:ArrayList、HashMap、HashSet
- 可空类型
?与 Option(本文) - 错误处理:异常机制与 Result
- 函数定义、参数与返回值
- Lambda 与高阶函数
- 闭包、作用域与函数类型
- 迭代器 Iterator 与 Sequence
- 结构体 struct 与类 class
- 构造函数、属性与方法
- 接口 interface 与实现
- 枚举 enum、代数数据类型与 match 模式匹配
- 泛型编程
- 扩展、类型别名与可见性控制
- cjpm 包管理与多文件项目组织
- 文件与目录 IO
- JSON 处理(结合 stdx 扩展库)
- 网络编程入门
- 单元测试
- 并发基础:线程的创建与等待
- Channel 通道与同步原语
- 并发实战:多线程任务处理
- 实战一:带文件持久化的命令行小工具
- 实战二:GeoJSON 数据处理程序
一、空指针:困扰行业半个世纪的"炸弹"
学过 Java、C、JavaScript 的同学多半见过这种崩溃:
NullPointerException // Java
Cannot read property 'x' of undefined // JavaScript
Segmentation fault // C
根源都一样:一个变量有时指向真实数据,有时是一个特殊的"空值"(null / undefined),而编译器不提醒你,等程序跑到那一行才爆炸。它被称为"价值十亿美元的错误"。
仓颉的思路是把话说在类型上:
- 一个普通的
Int64变量,永远有值,你不可能把"空"塞给它; - 如果某个东西允许没有值(比如查字典可能查不到、数组可能越界、用户可能没填昵称),就必须把类型写成
?Int64(读作"可空的 Int64"); - 类型上带了
?,编译器就强迫你先处理"没有"的情况,否则编译都过不了——炸弹被提前拆除。
这就是本课的主角:可空类型 ?,以及它背后的枚举 Option。
二、认识 Option:Some 与 None
?T 是 Option<T> 的简写,二者完全等价。一个 Option 只有两种状态:
Some(值):有值,真实数据包在括号里;None:没有值。
// 在数组里找第一个偶数:找到返回 Some,没有偶数返回 None
func firstEven(a: Array<Int64>): ?Int64 {
for (x in a) {
if (x % 2 == 0) {
return Some(x)
}
}
return None
}
main(): Int64 {
// ① 两种声明方式完全等价
let a: ?Int64 = Some(10)
let b: Option<Int64> = None
println("a = ${a},b = ${b}")
// ② 判空
println("a:isSome=${a.isSome()},isNone=${a.isNone()}")
println("b:isSome=${b.isSome()},isNone=${b.isNone()}")
// ③ 确定有值再取
if (a.isSome()) {
println("a 里的值 = ${a.getOrThrow()}")
}
// ④ ?? 空合并:None 时使用默认值
println("a ?? 0 = ${a ?? 0}")
println("b ?? 0 = ${b ?? 0}")
// ⑤ 函数实战
println("firstEven([1, 3, 4, 5]) = ${firstEven([1, 3, 4, 5])}")
println("firstEven([1, 3, 5]) = ${firstEven([1, 3, 5])}")
return 0
}
运行结果(SDK 1.1.3 实测):
a = Some(10),b = None
a:isSome=true,isNone=false
b:isSome=false,isNone=true
a 里的值 = 10
a ?? 0 = 10
b ?? 0 = 0
firstEven([1, 3, 4, 5]) = Some(4)
firstEven([1, 3, 5]) = None
三个要点:
Some是"快递盒",值要拆开才能用——直接打印看到的是Some(10)而不是10;isSome()/isNone()是两个判断方法,返回Bool;getOrThrow()负责拆盒:有值返回里面的值;对着None调用会抛NoneValueException,所以它前面通常都有判断。这也解释了第 8 课直接用下标读不存在的 HashMap 键为什么炸——底层正是这个异常。
⚠️ 特别提醒:仓颉没有"智能转换"。即使你刚写完
if (a.isSome()) { ... },括号里的a类型依然是?Int64,必须a.getOrThrow()显式取出。把可空值直接传给要非空值的函数,编译器会报mismatched types——这正是类型系统在保护你。
三、三种正确的"拆包裹"姿势
3.1 姿势一:isSome 判断 + getOrThrow
上一节已经见过,适合"有就处理,没有就算了"的简单场景。缺点是判断和取值分两行,写多了啰嗦。
3.2 姿势二:match 解构(最推荐)
第 4 课学的 match 天生适合 Option:两个分支必须写全,漏写任何一个编译不过,想忘记处理 None 都难。
func describe(o: ?Int64): String {
match (o) {
case Some(v) => "拿到 ${v}"
case None => "啥也没有"
}
}
case Some(v) 里的 v 直接就是拆开后的真实值(这里是 Int64),不需要再 getOrThrow()。上节课成绩查询实战会大量使用这种写法。
3.3 姿势三:?? 空合并给默认值
只想在 None 时兜一个默认值,用 ??(两个问号)最短:
let a: ?Int64 = Some(10)
let b: ?Int64 = None
println(a ?? 0) // 10 —— 有值,用盒子里的值
println(b ?? 0) // 0 —— None,用右边的默认值
🚫 注意:仓颉的空合并运算符是
??,不是某些语言的?:(写?:会报expected expression after '?')。本版本也没有getOrElse/orElse这类方法——旧教程里出现这些名字,一律用??代替。
三种姿势对照表:
| 方式 | 适用场景 | None 时 |
|---|---|---|
isSome() + getOrThrow() | 简单的就地判断 | 跳过处理 |
match { Some / None } | 两种情况都要做不同的事(推荐) | 走 None 分支 |
opt ?? 默认值 | 只想兜一个默认值 | 用默认值 |
四、安全调用 ?.:盒子不用拆,操作自动传
假设你有一个可能为空的字符串,想取它的长度。按老办法得先判断再拆;仓颉提供 ?.(问号加点)安全调用:
let s: ?String = Some("hello")
let n: ?String = None
println("${s?.size}") // Some(5)
println("${n?.size}") // None
规则很好记:
- 左边是
Some→ 正常执行.size,结果再包一层 Some; - 左边是
None→ 右边根本不执行,整个表达式直接是None。
链条可以一直点下去,任何一环是 None,整条链子短路成 None,绝不炸程序:
// 用户可能查不到 -> 昵称可能没有 -> 想取昵称长度
println("${foundUser?.nickname?.size}") // 任一环为空都是 None
4.1 一个真实的坑:?. 遇上本身可空的字段,会"套两层盒子"
看这个实测(nickname 字段本身就是 ?String):
class User {
let name: String
let nickname: ?String
init(name: String, nickname: ?String) {
this.name = name
this.nickname = nickname
}
}
| 表达式 | 结果 | 含义 |
|---|---|---|
张三?.nickname | Some(Some(小三子)) | 用户有,昵称也有——套了两层 |
李四?.nickname | Some(None) | 用户有,但他没填昵称 |
王五?.nickname | None | 根本没这个用户 |
此时直接 ?? "默认" 不管用:Some(None) 本身是 Some,?? 认为"有值",会原样给你内层的盒子。要拍平成一层,用下一节的 flatMap。
五、Option 工具箱:map / filter / flatMap
这三个方法都不拆盒子,而是在盒子外面操作,结果仍然是 Option——可以放心链式调用(Lambda 语法 { 参数 => 表达式 } 第 12 课会细讲,这里照抄即可)。
5.1 map:盒子里的值做个变换
let a: ?Int64 = Some(10)
let b: ?Int64 = None
println("${a.map({ x => x * 2 })}") // Some(20)
println("${b.map({ x => x * 2 })}") // None
Some(10) 经过 map 变成 Some(20);None 纹丝不动。
5.2 filter:不符合条件就变成 None
println("${a.filter({ x => x > 5 })}") // Some(10)
println("${a.filter({ x => x > 100 })}") // None
5.3 flatMap:专门对付"嵌套盒子"
4.1 的两层盒子,用 flatMap 一层拍平:
let zs: ?User = Some(User("张三", Some("小三子")))
let ls: ?User = Some(User("李四", None))
let ww: ?User = None
let n1 = zs.flatMap({ u => u.nickname }) // Some(小三子)
let n2 = ls.flatMap({ u => u.nickname }) // None
let n3 = ww.flatMap({ u => u.nickname }) // None
println("${n1 ?? "(无名)"}") // 小三子
println("${n2 ?? "(无名)"}") // (无名)
println("${n3 ?? "(无名)"}") // (无名)
记忆方法:map 用于"变换值",flatMap 用于变换函数本身也返回 Option 的场景(顺手拍平一层)。
📌 两个实测小坑(都是 1.1.3 真实表现):
map/filter/flatMap都存在,但andThen、getOrElse、orElse、orElseGet、unwrap均不存在,网上文章用到这些名字时请自行翻译为flatMap/??/match。??直接接在方法调用链后面有时会解析失败(报cannot use optional chaining),先存成一个变量再??最稳妥:let g = score.map({ s => gradeOf(s) }) // 先存 println(g ?? "缺考") // 再合并
六、Option 就在你身边:标准库的"安全出口"
学完这节课回头看,第 7、8 课接触过的一批方法返回值本来就是 Option——当时觉得奇怪的输出,现在全都通了:
| 操作 | 越界/查不到时的行为 | 返回类型 |
|---|---|---|
数组[i] / ArrayList[i](方括号) | 抛 IndexOutOfBoundsException | 普通值 |
数组.get(i) / list.get(i) | 安静地返回 None | ?T |
map[key](方括号) | 抛 NoneValueException(第 8 课实测) | 普通值 |
map.get(key) | 安静地返回 None | ?V |
一句话:方括号是"快通道",确信下标/键存在时用;get 是"安全通道",拿不准时用。
6.1 一个意外发现:编译器会替你查越界
写代码时你可能会撞到这个报错:
error: array index is out of bounds
仓颉会对编译期就能算出的常量下标做检查:let arr = [1,2,3]; arr.get(9) 甚至 arr[9],编译器一眼看出越界,直接拒绝编译。下标来自循环变量、函数参数等"运行时才知道"的值时,才留到运行期处理。这是加分项,但第一次见会懵——记住它不是 bug。
6.2 自己设计函数:把"可能失败"写进返回类型
最实用的习惯:凡是结果可能没有的函数,让它返回 ?T,调用方就被迫面对失败,而不是接到一个悄悄爆炸的 null。比如除零:
func safeDiv(a: Float64, b: Float64): ?Float64 {
if (b == 0.0) {
return None
}
return Some(a / b)
}
调用方想炸都难:safeDiv(10, 0) 拿到的是 None,配合 ?? 或 match 就能优雅兜底。
七、CIDE 实操:成绩查询 + 学生昵称(综合实战)
7.1 编写程序
新建工程(或单文件),输入下面的程序。它把本节课全部要素串了起来:安全除法、HashMap 查询、match 解构、map + ?? 一行流、嵌套 Option 与 flatMap、get 越界与方括号越界对照。
import std.collection.*
// 安全除法:除数为 0 时返回 None,而不是崩溃
func safeDiv(a: Float64, b: Float64): ?Float64 {
if (b == 0.0) {
return None
}
return Some(a / b)
}
class Student {
let name: String
let nickname: ?String // 昵称可以没有
init(name: String, nickname: ?String) {
this.name = name
this.nickname = nickname
}
}
// 按姓名查学生:可能查不到
func findStudent(list: ArrayList<Student>, target: String): ?Student {
for (s in list) {
if (s.name == target) {
return Some(s)
}
}
return None
}
// 分数评级(拿到的一定是真实分数)
func gradeOf(score: Int64): String {
if (score >= 90) {
return "优秀"
} else if (score >= 60) {
return "及格"
} else {
return "不及格"
}
}
main(): Int64 {
println("------ ① 安全除法 ------")
let r1 = safeDiv(10.0, 2.0)
let r2 = safeDiv(10.0, 0.0)
println("10 / 2 = ${r1}")
println("10 / 0 = ${r2}")
println("平均值 = ${r2 ?? 0.0}")
println("------ ② 成绩查询(缺考的人不在表里)------")
let scores = HashMap<String, Int64>()
scores.add("张三", 92)
scores.add("李四", 58)
for (name in ["张三", "李四", "王五"]) {
let score = scores.get(name)
match (score) {
case Some(s) => println("${name}:${s} 分,${gradeOf(s)}")
case None => println("${name}:缺考")
}
}
println("------ ③ 一行流:map 变换 + ?? 兜底 ------")
for (name in ["张三", "李四", "王五"]) {
let g = scores.get(name).map({ s => gradeOf(s) })
println("${name}:${g ?? "缺考"}")
}
println("------ ④ 学生昵称(嵌套 Option 与 flatMap)------")
let students = ArrayList<Student>([
Student("张三", Some("小三子")),
Student("李四", None)
])
for (name in ["张三", "李四", "王五"]) {
let nick = findStudent(students, name).flatMap({ s => s.nickname })
println("${name} 的昵称:${nick ?? "(没有昵称)"}")
}
println("------ ⑤ 越界访问:get 返回 None,[] 直接抛异常 ------")
let data = [10, 20, 30]
for (i in 0..data.size + 1) {
match (data.get(i)) {
case Some(v) => println("第 ${i} 个:${v}")
case None => println("第 ${i} 个:不存在(但程序还活着)")
}
}
// 对照:方括号下标越界会抛异常(包在 try 里程序才不会退出)
try {
println("${atIndex(data, 3)}")
} catch (e: IndexOutOfBoundsException) {
println("方括号越界被捕获:${e}")
}
return 0
}
// 下标包进函数,隔绝编译器对字面量下标的编译期检查
func atIndex(a: Array<Int64>, i: Int64): Int64 {
return a[i]
}
💡 小提示:Lambda 里如果用
if表达式且写在同一行,两个分支体都要带花括号:{ s => if (s >= 60) { "及格" } else { "不及格" } },否则会报expected '{'。
7.2 运行与验证
在 CIDE 中点运行(或 cjpm run),应得到下面的输出(已逐字核对):
------ ① 安全除法 ------
10 / 2 = Some(5.000000)
10 / 0 = None
平均值 = 0.000000
------ ② 成绩查询(缺考的人不在表里)------
张三:92 分,优秀
李四:58 分,不及格
王五:缺考
------ ③ 一行流:map 变换 + ?? 兜底 ------
张三:优秀
李四:不及格
王五:缺考
------ ④ 学生昵称(嵌套 Option 与 flatMap)------
张三 的昵称:小三子
李四 的昵称:(没有昵称)
王五 的昵称:(没有昵称)
------ ⑤ 越界访问:get 返回 None,[] 直接抛异常 ------
第 0 个:10
第 1 个:20
第 2 个:30
第 3 个:不存在(但程序还活着)
方括号越界被捕获:IndexOutOfBoundsException: The length of the array is 3, but the index is 3.
可以自己动手改:
- 把 ① 的
r2 ?? 0.0改成直接插值打印r2,体会"默认值"和"盒子"的显示差别; - 在 ② 里把
match误删case None分支,编译器会立刻报错——亲眼看看 Option 是怎么"逼着"你处理缺考的; - 把 ④ 的
flatMap换成?.,观察输出如何退化成Some(Some(...))/Some(None); - 给
scores再加一个"赵六"35 分,确认 ③ 的一行流无需任何修改就正确输出评级。
7.3 用调试器看清 Some 盒子
Option 在 CIDE 调试器里是透明的:
- 在 ② 的
match (score)一行打断点,Shift + F9 启动调试; - Variables 面板里
score直接显示为Some(92);循环到王五时变成None,下方行内值也同步变化; - 单步(F8)进入
case Some(s)分支时,能看到新变量s = 92——这就是 match 帮你拆好的盒子; - 在 ④ 断住,分别对比
?.写法(嵌套两层)与flatMap写法(只有一层)在变量面板中的结构差异。
调试结束点停止按钮。
八、常见问题 FAQ
Q1:网上教程写的 getOrElse(0)、orElse { } 为什么报 “not a member of enum ‘Option’”?
SDK 1.1.3 的 Option 没有这些方法。要默认值一律用 ??;要按情况处理用 match;要变换用 map;要链式拍平用 flatMap。本文所有写法均按 1.1.3 实测。
Q2:空合并运算符到底是 ?: 还是 ???
??(两个问号)。仓颉没有 ?: 写法,写了会报 expected expression after '?'。
Q3:安全下标 arr?[9] 存在吗?
1.1.3 不支持,写了报 cannot use optional chaining。要安全访问下标统一用 arr.get(i)(越界返回 None)。
Q4:为什么 x.field 打印出 Some(Some(...)) 这种双层盒子?
因为 x 本身是 Option、field 又恰好是可空类型,?. 只负责跳过第一层 None,不会自动拍平。用 flatMap 把两层合成一层(见 4.1、5.3)。
Q5:明明 if (o.isSome()) 判断过了,为什么分支里用 o 还报类型错误?
仓颉不做"智能转换",判断不会改变变量的静态类型。分支里用 o.getOrThrow() 显式取值。把可空值直接传给要求非空的函数,也会得到 mismatched types。
Q6:None.getOrThrow() 会怎样?
抛 NoneValueException(对象本身打印为 NoneValueException,无额外描述文本)。第 8 课用方括号读不存在的 HashMap 键见到的 Value does not exist! 同源。正常代码里应先用 isSome() / match / ?? 规避,而不是依赖 catch。
Q7:arr.get(9) 为什么编译阶段就报 “array index is out of bounds”?
编译器能对字面量数组 + 常量下标做编译期求值并拦截。下标来自循环变量或函数参数时才留到运行期(6.1 节)。这是安全特性,不是编译器出错。
Q8:Option 和下节课的 Result 有什么区别?
Option 只回答"有没有"——None 不携带任何信息;但很多失败还需要说明"为什么失败"(文件不存在?权限不够?格式错误?)。第 10 课的 Result<T, E> 用 Ok(值) / Err(原因) 表达这种场景,配合异常机制一起学。
九、课后练习
- 实现
func indexOf(a: Array<Int64>, target: Int64): ?Int64:返回目标第一次出现的下标(Some(下标)),找不到返回None。用数组[5, 2, 9, 1, 5, 6]测试查找9与100,并用?? -1打印"找不到时显示 -1"的风格化结果。 - 给第 8 课的通讯录加一个安全查询函数
findPhone(map, name),内部用get+match:有号码打印号码,没有时打印"查无此人"。故意查一个不存在的名字验证不会崩。 - 用
map+??一行完成:给定let age: ?Int64(分别试Some(17)、Some(20)、None),输出"成年"/“未成年”/“年龄未知”。 - 定义
class Book,含可空字段borrower: ?String(借阅人,没人借时为 None);准备一个返回?Book的查询函数,用 flatMap 取出当前借阅人姓名并打印,没人借或书不存在时统一显示"在馆可借"。 - 打开 7.1 的程序 Shift + F9 调试,在循环中观察
score在Some(...)与None间的变化;再到 ④ 对比?.nickname与flatMap后变量的层数差异,截图或口述两者区别。
下节预告
Option 解决了"有没有值",但它有个盲区:None 只能表示"没有",说不出为什么没有。文件读不到、网络失败、JSON 格式错误——这些场景我们还想带上原因甚至错误码。第 10 课学习错误处理:异常机制与 Result:throw / try-catch 怎么用,Result<T, E> 如何把错误也变成编译器帮你检查的返回值,以及仓颉"异常 + Result"双轨制各自适合什么场景。我们下节课见!
系列说明:本系列基于 Windows 平台 + CIDE + 仓颉 SDK(1.1.3)编写,所有代码均已实际编译运行通过。如遇 SDK 版本差异导致的细节出入,以你本地版本为准,欢迎评论区交流。
离线环境下图片可能加载不出来,也可以在 CIDE 菜单 Help ▸ 联系作者 / Contact 中查看同一张二维码(应用内置兜底图,无需联网)。
📥 工具下载
本系列全程使用的仓颉 IDE —— CIDE(免费开源、社区版):
- GitCode 仓库 / 安装包下载:https://gitcode.com/wp_upala/cide
- 打开页面后进入 发行版(Releases),两种包任选其一:
- 安装版:下载
CIDE-<版本>-x64-Setup.exe,双击安装,适合日常长期使用; - 免安装版(Portable):下载
CIDE-<版本>-x64-Portable.zip,解压到任意目录即用,不写注册表、不留安装痕迹,拷到 U 盘也能在别的电脑直接运行(包内附《使用说明.txt》)。适合先试用、或在受限电脑上学习本系列课程。
- 安装版:下载
- 仓颉 SDK 请前往仓颉编程语言官网下载:https://cangjie-lang.cn
更多推荐



所有评论(0)