【仓颉语言入门 · 第9课】可空类型 ? 与 Option:让"没有值"无法伤人

上节课我们在 HashMap 的输出里反复看到 Some(138-0000-0001)NoneValueException: Value does not exist!——当时只留了句"第 9 课讲"。这节课就来正式认识它们背后的设计:可空类型 ? 与 Option

本文是系列第 9 课:理解仓颉为什么敢说"从类型层面消灭空指针",掌握 Some / NoneisSome()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~20struct/class、构造与属性、接口、枚举与 match 模式匹配、泛型、扩展
五、工程化与标准库21~25cjpm 包管理与多文件、文件 IO、JSON 处理、网络编程、单元测试
六、并发编程26~28线程、Channel 通道与同步原语、并发实战
七、项目实战29~30命令行小工具、GeoJSON 数据处理实战
  1. 环境搭建与第一个仓颉程序
  2. 变量与常量:let / var 与基本数据类型
  3. 运算符与标准输入输出
  4. 分支结构:if 与 match 表达式
  5. 循环结构:while / for / Range
  6. 字符串详解与字符串插值
  7. 数组 Array 与区间 Range
  8. 集合框架:ArrayList、HashMap、HashSet
  9. 可空类型 ? 与 Option(本文)
  10. 错误处理:异常机制与 Result
  11. 函数定义、参数与返回值
  12. Lambda 与高阶函数
  13. 闭包、作用域与函数类型
  14. 迭代器 Iterator 与 Sequence
  15. 结构体 struct 与类 class
  16. 构造函数、属性与方法
  17. 接口 interface 与实现
  18. 枚举 enum、代数数据类型与 match 模式匹配
  19. 泛型编程
  20. 扩展、类型别名与可见性控制
  21. cjpm 包管理与多文件项目组织
  22. 文件与目录 IO
  23. JSON 处理(结合 stdx 扩展库)
  24. 网络编程入门
  25. 单元测试
  26. 并发基础:线程的创建与等待
  27. Channel 通道与同步原语
  28. 并发实战:多线程任务处理
  29. 实战一:带文件持久化的命令行小工具
  30. 实战二: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

?TOption<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

三个要点:

  1. Some 是"快递盒",值要拆开才能用——直接打印看到的是 Some(10) 而不是 10
  2. isSome() / isNone() 是两个判断方法,返回 Bool
  3. 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
    }
}
表达式结果含义
张三?.nicknameSome(Some(小三子))用户有,昵称也有——套了两层
李四?.nicknameSome(None)用户有,但他没填昵称
王五?.nicknameNone根本没这个用户

此时直接 ?? "默认" 不管用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 真实表现):

  1. map / filter / flatMap 都存在,但 andThengetOrElseorElseorElseGetunwrap 均不存在,网上文章用到这些名字时请自行翻译为 flatMap / ?? / match
  2. ?? 直接接在方法调用链后面有时会解析失败(报 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 与 flatMapget 越界与方括号越界对照。

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 调试器里是透明的

  1. 在 ② 的 match (score) 一行打断点,Shift + F9 启动调试;
  2. Variables 面板里 score 直接显示为 Some(92);循环到王五时变成 None,下方行内值也同步变化;
  3. 单步(F8)进入 case Some(s) 分支时,能看到新变量 s = 92——这就是 match 帮你拆好的盒子;
  4. 在 ④ 断住,分别对比 ?. 写法(嵌套两层)与 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(原因) 表达这种场景,配合异常机制一起学。


九、课后练习

  1. 实现 func indexOf(a: Array<Int64>, target: Int64): ?Int64:返回目标第一次出现的下标(Some(下标)),找不到返回 None。用数组 [5, 2, 9, 1, 5, 6] 测试查找 9100,并用 ?? -1 打印"找不到时显示 -1"的风格化结果。
  2. 给第 8 课的通讯录加一个安全查询函数 findPhone(map, name),内部用 get + match:有号码打印号码,没有时打印"查无此人"。故意查一个不存在的名字验证不会崩。
  3. map + ?? 一行完成:给定 let age: ?Int64(分别试 Some(17)Some(20)None),输出"成年"/“未成年”/“年龄未知”。
  4. 定义 class Book,含可空字段 borrower: ?String(借阅人,没人借时为 None);准备一个返回 ?Book 的查询函数,用 flatMap 取出当前借阅人姓名并打印,没人借或书不存在时统一显示"在馆可借"。
  5. 打开 7.1 的程序 Shift + F9 调试,在循环中观察 scoreSome(...)None 间的变化;再到 ④ 对比 ?.nicknameflatMap 后变量的层数差异,截图或口述两者区别。

下节预告

Option 解决了"有没有值",但它有个盲区:None 只能表示"没有",说不出为什么没有。文件读不到、网络失败、JSON 格式错误——这些场景我们还想带上原因甚至错误码。第 10 课学习错误处理:异常机制与 Resultthrow / 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
Logo

一站式 AI 云服务平台

更多推荐