云计算百科
云计算领域专业知识百科平台

【仓颉语言入门 · 第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)
    未经允许不得转载:网硕互联帮助中心 » 【仓颉语言入门 · 第9课】
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!