【仓颉语言入门 · 第10课】错误处理:异常机制与 Result

先说一句"对不起":第 9 课的 Q8 和下节预告明明写着"第 10 课讲 Result<T, E>Ok(值) / Err(原因)"。但笔者在仓颉 SDK 1.1.3 下逐个敲进代码做实测时,编译器直接甩回来三句 undeclared type name 'Result'undeclared identifier 'Ok'——这个版本的标准库里根本还没有内置 Result。官方 V5 文档里的 Result 是更新版本的特性。

所以这节课我们做两件事:第一,扎扎实实地学会仓颉当下唯一成熟的错误处理机制——异常throwtry-catch-finally、多 catch、异常传播、自定义异常;第二,学会 1.1.3 时代表达"可恢复错误"的正确姿势——用 Option 和自己手写的结果枚举达到 Result 同款效果,等你将来升级到带 Result 的 SDK,迁移只需换个名字。

本文是系列第 10 课,所有代码与报错文案均在仓颉 SDK 1.1.3 下实测编译运行。途中会顺手澄清四个网上二手教程的高频坑:NumberFormatException 并不存在、自定义异常打印出来前缀是 Exception、仓颉没有 throws 声明、也没有 defer 关键字。


目录(系列导航)

整套路线共 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 数据处理程序

一、程序出错的两条路线

程序出错从来不稀奇:用户输入了 "abc" 当年龄、数组下标越界、HashMap 里没有这个键、整数除以了零。语言设计上处理这些"出错"有两条经典路线。

路线一:错误码(返回值)。 C 语言的老传统——函数返回一个整数,0 表示成功,非 0 表示错误码。问题在于:调用方完全可以无视返回值,于是错误被悄悄吞掉,程序带着错误状态继续跑,直到十万八千里外莫名其妙地崩溃。

路线二:异常(exception)。 出错时不是"返回",而是用 throw 抛出一个异常对象;当前函数立刻停下来,异常沿着调用链一路向上找接手的人;找到就进入 catch 块处理,找不到程序就终止并打印现场。错误再也无法被"不小心忽略"。

仓颉两条路线都提供,而且把路线一升级成了类型安全的版本:

  • 异常throw / try-catch,适合表示"意外"——程序逻辑没料到、继续往下走已经不安全的情况;
  • Option 与结果枚举:第 9 课的 Option 回答"有没有值";本课第六节会手写一个结果枚举,回答"成功还是失败、为什么失败",让编译器像检查 Option 一样逼着调用方处理失败。

这节课前半段先把异常学透,后半节回到结果枚举。


二、抛出异常:throw 与异常家族

2.1 最常见的几个标准异常

仓颉的异常都继承自 Exception,异常对象里携带一条人能看懂的 message。下面四个是前 10 课里你已经或即将撞到的"老朋友",触发方式和消息文案全部实测:

异常类型触发场景实测 message
IllegalArgumentExceptionInt64.parse("abc") 等转换失败The part of value convert failed.
IndexOutOfBoundsException数组/列表下标越界The length of the array is 3, but the index is 9.
NoneValueExceptionNonegetOrThrow()、用 [] 读不存在的 HashMap 键Value does not exist!
ArithmeticException整数除以零Divided by zero!

另外,文件系统相关的错误由 std.fs 包中的 FSException 体系表示,第 22 课讲文件 IO 时再细聊。

2.2 用 throw 主动抛出

throw 后面直接跟一个异常对象(仓颉没有 new 关键字):

import std.convert.*

func parseAge(text: String): Int64 {
    let age = Int64.parse(text)        // 非数字 -> 抛 IllegalArgumentException
    if (age < 0 || age > 150) {
        throw IllegalArgumentException("年龄 ${age} 不合常理")
    }
    return age
}

注意第二行的 Int64.parse("abc")——

🚫 辟谣:很多从 Java/Kotlin 抄来的二手文章会写 NumberFormatException。仓颉 1.1.3 没有这个类型,字符串转整数失败实际抛的是 IllegalArgumentException,消息为 The part of value convert failed.。catch 时写错异常类型,异常就会从你手边溜过去。

2.3 和 Java 的关键区别:没有 throws 声明

Java 要求函数在签名上声明"我可能抛这些受检异常"(void f() throws IOException),调用方不 catch 就编译不过。仓颉完全没有这套机制

  • 任何普通函数里都可以直接 throw不需要任何声明
  • 调用方不强制 catch——不接,异常就自动向上传播(第五节细讲)。

少写了样板代码,代价是你需要自觉地在"程序边界"处理异常,而不是假装它们不存在。


三、接住异常:try-catch-finally

3.1 基本写法

class AgeException <: Exception {
    init(msg: String) {
        super(msg)
    }
}

main(): Int64 {
    try {
        throw AgeException("测试异常")
    } catch (e: AgeException) {
        println("捕获 AgeException:${e}")
        println("message = ${e.message}")
    } finally {
        println("finally 总是执行")
    }
    return 0
}

运行结果(SDK 1.1.3 实测):

捕获 AgeException:Exception: 测试异常
message = 测试异常
finally 总是执行

三段职责分明:

  • try { ... }:把可能出错的代码放进去;
  • catch (e: 异常类型) { ... }:接住类型匹配的异常,e 就是异常对象,e.message 取出消息字符串;
  • finally { ... }无论是否发生异常都会执行,常用于清理现场。

⚠️ 先记住这个"怪现象":上面输出第一行是 Exception: 测试异常,而不是 AgeException: 测试异常SDK 1.1.3 中自定义异常直接插值打印,前缀固定显示父类名 Exception(内置异常则正常显示自己的名字,见后文)。想在日志里区分异常,可靠的做法是取 e.message。第六节会专门讲这个坑。

3.2 catch (_):我只关心"出事了"

不关心异常内容时,catch 的变量名可以写成下划线 _,语义是"接住,但别给我绑定变量":

func atIndex(a: Array<Int64>, i: Int64): Int64 {
    return a[i]
}

main(): Int64 {
    try {
        println("${atIndex([1, 2, 3], 9)}")
    } catch (_) {
        println("越界了,但我不关心细节")
    }
    return 0
}
越界了,但我不关心细节

3.3 finally:连 return 都拦得住

finally 最硬的一条保证:即使 try 或 catch 里写了 return,返回动作也会先让 finally 执行完

func foo(): Int64 {
    try {
        return 1
    } finally {
        println("finally:即使 return 也会先执行我")
    }
}

main(): Int64 {
    println("foo() = ${foo()}")
    return 0
}
finally:即使 return 也会先执行我
foo() = 1

正因为有这条保证,关闭文件、释放连接这类"必须善后"的代码才适合放进 finally。

📌 三条牢记:① try 块正常走完 → finally 执行;② try 里抛异常被 catch → finally 执行;③ try/catch 里 return先执行 finally,再真正返回。唯一"拦不住"的是进程被杀掉这种极端情况。

🚫 另外提前辟个谣:仓颉 1.1.3 没有 defer 关键字(实测 defer { ... }undeclared identifier 'defer'),也没有 Java 的 try-with-resources 语法。新版本文档里出现的 defer 是后续版本特性,在 1.1.3 上做善后,老老实实用 finally


四、try 是表达式;多 catch 要"先窄后宽"

4.1 try 像 if、match 一样有返回值

第 4、9 课学过 if 和 match 都是表达式,try 也是:try 分支和 catch 分支产生的值,就是整个 try 表达式的值。

main(): Int64 {
    let r = try {
        100 / 2
    } catch (e: Exception) {
        -1
    }
    println("r = ${r}")
    return 0
}
r = 50

注意两个分支的值类型要一致(这里都是 Int64)。一旦除法抛异常,r 就会得到兜底值 -1

4.2 多 catch:一场自上而下的"类型匹配"

一个 try 后面可以挂多个 catch,规则只有一条:从上到下,进入第一个类型匹配的块。因此正确顺序是先具体(窄)、后宽泛(父类)

func atIndex(a: Array<Int64>, i: Int64): Int64 {
    return a[i]
}

main(): Int64 {
    let data = [10, 20, 30]
    try {
        println("${atIndex(data, 9)}")
    } catch (e: IndexOutOfBoundsException) {
        println("窄:下标异常 -> ${e.message}")
    } catch (e: Exception) {
        println("宽:其他异常兜底 -> ${e.message}")
    }
    return 0
}
窄:下标异常 -> The length of the array is 3, but the index is 9.

4.3 顺序写反会怎样?

如果把 catch (e: Exception) 放到最前面,它会把所有异常一网打尽,后面的窄 catch 永远轮不到。编译器不会报 error,但会给一个 warning(实测文案):

warning: useless exception type
 ==> ...src/main.cj:13:17:
   |
13 |     } catch (e: IndexOutOfBoundsException) {
   |                 ^
   |
   # note: this warning can be suppressed by setting the compiler option `-Woff unused`

useless exception type(无用的异常类型)——看到它就知道 catch 顺序写反了。这和第 4 课 match 的"分支要互斥、顺序要从细到粗"是同一种思维。

💡 为什么示例里下标访问要包一层 atIndex 函数?因为仓颉会对编译期就能算出的常量下标做静态检查(第 9 课 6.1 节):直接写 [1,2,3][9],编译器当场报 array index 9 is past the end,根本走不到运行时 catch。把下标变成函数参数,检查就推迟到运行期,我们才能演示 catch。整数除零的演示同理(见 FAQ Q7)。


五、异常的传播:不接,就一路向上

5.1 调用链上的"击鼓传花"

函数 A 调 B、B 调 C,C 里抛出异常,而 B、A 都没 catch 时会怎样?异常会沿着 C → B → A → ... → main 的调用链一路传播,直到某一层接住:

import std.convert.*

func parseAge(text: String): Int64 {
    let age = Int64.parse(text)
    if (age < 0 || age > 150) {
        throw IllegalArgumentException("年龄 ${age} 不合常理")
    }
    return age
}

func inputAge(text: String): Int64 {
    println("inputAge:收到原始输入 ${text}")
    return parseAge(text)        // 不 catch,继续向上抛
}

main(): Int64 {
    for (text in ["25", "abc", "999"]) {
        try {
            let age = inputAge(text)
            println("  录入成功:${age}")
        } catch (e: Exception) {
            println("  main 兜底:${e.message}")
        }
    }
    return 0
}
inputAge:收到原始输入 25
  录入成功:25
inputAge:收到原始输入 abc
  main 兜底:The part of value convert failed.
inputAge:收到原始输入 999
  main 兜底:年龄 999 不合常理

parseAge 抛出的异常穿过毫无防备的 inputAge,最后在 main 的边界被统一接住。这就是异常相比错误码的优势:中间层不用写任何样板代码,错误也不会丢

5.2 谁都不接:默认处理与堆栈

如果一路传到 main 都没人 catch,仓颉运行时会执行默认处理:打印 An exception has occurred:、异常类型与消息,以及完整的调用堆栈,然后终止程序。上面的 "abc" 例子若去掉 try-catch,实测输出为:

An exception has occurred:
IllegalArgumentException: The part of value convert failed.
        at std.convert::parseCompInt(std.core::Array<...>, UInt64, UInt64, UInt64, Bool)(std/convert\parsable.cj:0)
        at std.convert::Int64::parse(std.core::String)(std/convert\parsable.cj:752)
        at lesson10::main()(.../src/main.cj:6)

最后一帧是你的用户代码,文件路径随工程所在位置变化;前两帧来自标准库。堆栈从下往上读:mainInt64::parseparseCompInt,事故现场一目了然。这也是程序崩溃时你第一个该看的东西。

5.3 catch 住又想继续抛:rethrow

有时中间层想"留个记号"(记日志、打点),但并不打算真正处理异常,可以在 catch 块里用 throw 异常对象 把它重新抛出去:

main(): Int64 {
    try {
        try {
            throw IllegalArgumentException("原始问题")
        } catch (e: IllegalArgumentException) {
            println("内层先记下日志,再向外抛")
            throw e                      // 重新抛出
        }
    } catch (e: Exception) {
        println("外层兜底:${e.message}")
    }
    return 0
}
内层先记下日志,再向外抛
外层兜底:原始问题

📌 经验法则:只在你真的能处理(换默认值、重试、提示用户重输)时才 catch;处理不了就让它传播。层层 catch 又层层吞掉,是比不 catch 更糟糕的代码。


六、自定义异常与"可恢复错误"惯用法

6.1 自定义异常:继承 Exception 即可

标准异常表达不了业务语义时(比如"余额不足"“年龄越界”“库存为负”),就自己定义一个。写法和官方库 FSException 完全同款——定义一个 Exception 的子类,构造器把消息转给父类:

class AgeException <: Exception {
    init(msg: String) {
        super(msg)
    }
}

需要的话也可以提供无参构造器(与官方库 FSException 的定义方式一致):

public class AgeException <: Exception {
    public init() {
        super()
    }
    public init(message: String) {
        super(message)
    }
}

两种构造方式分别抛出并捕获,实测打印:

try {
    throw AgeException("年龄非法")
} catch (e: AgeException) {
    println("${e}")
}
try {
    throw AgeException()
} catch (e: AgeException) {
    println("无参构造:[${e}],message 为空串? [${e.message}]")
}
Exception: 年龄非法
无参构造:[Exception],message 为空串? []

⚠️ 再强调一遍这个 1.1.3 实测怪癖:自定义异常对象用 ${e} 插值或打印,显示的前缀是父类名 Exception,而不是你起的类名(内置异常如 ArithmeticException 则会正确显示自己的名字)。无参构造时 message 是空字符串。因此:

  • 给用户看的信息,一律用 e.message,并在消息文本里写足上下文(“年龄 -3 超出 0~150”);
  • 想在日志中区分异常种类,靠 catch 的类型(多 catch 各进各的块),不要依赖打印前缀。

等后续版本修复这个表现后,本节描述可能不再适用——以你本地版本实测为准。

什么时候值得自定义异常?当调用方需要按类型区分对待时:catch (AgeException) 提示重新输入,catch (IllegalArgumentException) 提示格式错误。图省事全抛 IllegalArgumentException 也能跑,只是调用方无法精细分辨。

6.2 1.1.3 没有 Result?三分钟自己造一个

先看"案发现场"。如果你照着新版官方文档或某些教程写:

let r: Result<Int64, String> = Ok(42)   // ❌ 1.1.3 编译不过
let e: Result<Int64, String> = Err("坏了")

编译器三连:

error: undeclared type name 'Result'
error: undeclared identifier 'Ok'
error: undeclared identifier 'Err'

别怀疑自己的环境——Result 在 1.1.3 标准库中确实尚不存在。但 Result 本质上一点也不神秘,它就是一个枚举。我们用第 4 课见过、第 18 课才会细讲的 enum,三行就能造出同款:

enum ParseResult {
    Ok(Int64) | Err(String)
}

语法先照抄(enum 的系统讲解在第 18 课):花括号里列出这个类型所有的"构造器",用 | 分隔;括号里只写载荷的类型,不写名字。Ok(Int64) 表示成功并携带一个整数,Err(String) 表示失败并携带一条原因。

使用时配合异常→枚举的"边界转换"和 match

import std.convert.*

enum ParseResult {
    Ok(Int64) | Err(String)
}

func parseInt(s: String): ParseResult {
    try {
        return ParseResult.Ok(Int64.parse(s))
    } catch (e: IllegalArgumentException) {
        return ParseResult.Err("${s} 不是合法整数")
    }
}

main(): Int64 {
    for (s in ["42", "3.14"]) {
        match (parseInt(s)) {
            case ParseResult.Ok(v) => println("${s} -> Ok(${v})")
            case ParseResult.Err(msg) => println("${s} -> Err(${msg})")
        }
    }
    return 0
}
42 -> Ok(42)
3.14 -> Err(3.14 不是合法整数)

这就是 Result 的全部思想:把失败塞进返回值类型里,match 不写全 Err 分支就编译不过,想忘记处理失败都难——和第 9 课 Option 逼你处理 None 一模一样。将来升级到内置 Result 的 SDK,把自定义枚举换成标准 Result<T, E> 即可,调用处的 match 思路完全不变。

6.3 三种手段怎么选

手段回答的问题适用场景
Option / ?T有没有值?查字典、找元素、可空字段(第 9 课)
手写结果枚举(未来的 Result)成功还是失败?为什么?输入校验、网络/解析等预期内的业务失败,调用方应当逐一处理
异常出意外了,流程已不安全数组越界、整数除零、不变量被破坏等异常情况;或在底层简化控制流,到边界再转换成枚举

一个务实的组合模式(下一节综合实战就是它):底层函数大胆 throw,省去层层判空;在靠近用户的边界函数统一 catch,转换成结果枚举;最外层用 match 平和地处理两种结局。


七、CIDE 实操:健壮的年龄录入程序

7.1 编写程序

新建工程(或单文件),输入下面的程序。它把本节课全部要素串了起来:自定义异常、parse 异常、多 catch(先窄后宽)、finally 善后、异常→手写结果枚举的边界转换、match 处理。

import std.convert.*

// ① 自定义异常:年龄超出业务范围
class AgeException <: Exception {
    init(msg: String) {
        super(msg)
    }
}

// ② 手写"结果枚举":成功携带年龄,失败携带原因
enum AgeResult {
    Ok(Int64) | Err(String)
}

// ③ 核心逻辑:可能抛两种异常
func checkAge(text: String): Int64 {
    let age = Int64.parse(text)                              // 非数字 -> IllegalArgumentException
    if (age < 0 || age > 150) {
        throw AgeException("年龄 ${age} 超出 0~150")          // 业务错误 -> AgeException
    }
    return age
}

// ④ 边界层:把异常"翻译"成 AgeResult,调用方就不用碰异常
func inputAge(text: String): AgeResult {
    try {
        let age = checkAge(text)
        return AgeResult.Ok(age)
    } catch (e: AgeException) {
        return AgeResult.Err("业务校验:${e.message}")
    } catch (e: IllegalArgumentException) {
        return AgeResult.Err("输入格式:${text} 不是整数")
    } finally {
        println("  ([${text}] 处理完毕)")
    }
}

main(): Int64 {
    for (text in ["25", "abc", "-3", "200"]) {
        match (inputAge(text)) {
            case AgeResult.Ok(age) => println("录入成功:${age} 岁")
            case AgeResult.Err(msg) => println("录入失败:${msg}")
        }
    }
    return 0
}

7.2 运行与验证

在 CIDE 中点运行(或 cjpm run),应得到下面的输出(已逐字核对):

  ([25] 处理完毕)
录入成功:25 岁
  ([abc] 处理完毕)
录入失败:输入格式:abc 不是整数
  ([-3] 处理完毕)
录入失败:业务校验:年龄 -3 超出 0~150
  ([200] 处理完毕)
录入失败:业务校验:年龄 200 超出 0~150

注意每行的先后顺序:finally 在函数 return 之前执行,所以"处理完毕"总是先打出来,回到 main 后才打印"录入成功/失败"。

可以自己动手改:

  • 把 ④ 里两个 catch 的顺序对调(IllegalArgumentException 在前),观察类型不同、互不为父子类时输出为何不变;再把其中一个换成父类 Exception 放到最前,重新编译,亲眼看到 warning: useless exception type
  • 删掉 ④ 里任意一个 catch 分支,分别用 "abc""-3" 测试,观察异常如何"漏"到下一层(本例 main 没接,会直接触发默认处理并终止);
  • 删掉 matchcase AgeResult.Err(...) 分支,编译器立即报错——再体会一次"类型系统逼你处理失败";
  • 把 finally 块整段删掉,对比输出顺序,理解 finally 的善后时机。

7.3 用调试器看清异常的传播

  1. 在 ③ 的 throw AgeException(...) 一行打断点,Shift + F9 启动调试;
  2. 循环到 "-3" 时断住,Variables 面板能看到局部变量 age = -3;按 F8 单步,异常抛出后当前函数立即结束,程序不会顺序执行下一行;
  3. 观察左侧 Call Stack(调用堆栈) 面板:checkAgeinputAgemain 的帧依次排列——这就是异常"向上爬"的那条链;双击各帧可以回看每一层的变量值;
  4. 在 ④ 的 catch (e: AgeException) 内打断点,断住时在 Variables 中展开 e,确认 message 的内容正是 "年龄 -3 超出 0~150"
  5. 继续运行(F5)到 "abc",观察这次进入的是另一个 catch 分支。

调试结束点停止按钮。


八、常见问题 FAQ

Q1:为什么写 Result<Int64, String> / Ok(...) / Err(...) 报 undeclared?
因为 SDK 1.1.3 的标准库还没有内置 Result 类型(官方 V5 文档对应的是更新版本)。按 6.2 节用 enum XxxResult { Ok(T) | Err(E) } 手写,配合 match 使用,思想和写法与未来的 Result 完全一致。

Q2:Int64.parse("abc") 抛的是 NumberFormatException 吗?
不是。1.1.3 没有这个异常类型,实际抛出 IllegalArgumentException: The part of value convert failed.(堆栈位于 std/convert/parsable.cj)。catch 时请按真名书写。

Q3:函数需要像 Java 那样声明 throws 吗?调用方不 catch 会编译报错吗?
都不会。仓颉没有受检异常机制:函数内可以直接 throw,无需声明;调用方也不强制 catch。未被接住的异常会沿调用链自动传播,直到被接住或由运行时默认处理终止程序。

Q4:多 catch 时编译器警告 useless exception type 是什么意思?
说明你把父类异常(通常是 Exception)的 catch 写到了子类前面,后面的 catch 永远不会被执行。调整为先具体、后宽泛即可。

Q5:自定义异常打印出来为什么是 Exception: xxx 而不是我的异常类名?
这是 SDK 1.1.3 的实测表现:自定义异常的字符串形式前缀固定为 Exception,内置异常则正常显示自身类名。给用户看的信息请用 e.message,并在消息里写足上下文;区分异常种类靠 catch 的类型而非打印文本。

Q6:仓颉有 defer 或 try-with-resources 吗?资源清理写在哪?
1.1.3 都没有(defer 实测报 undeclared identifier 'defer')。善后清理统一写在 finally 块中,它在正常返回、异常抛出、提前 return 三种情况下都会执行。

Q7:整数除零和浮点除零行为一样吗?
不一样,差别很大:

  • 整数除以零(下标/除数来自运行时变量、绕过编译期检查时)抛 ArithmeticException: Divided by zero!;若编译器能直接算出除数是常量 0,会在编译期就报 error: divide by zero 拒绝编译;
  • 浮点除以零不抛异常10.0 / 0.0 得到 inf0.0 / 0.0 得到 nan(符合 IEEE 754 规范)。

Q8:到底该抛异常还是返回 Option / 结果枚举?
判断标准是"失败是否在预期之内"。用户输入非法、查不到数据、网络瞬时抖动这类调用方本就该处理的可恢复失败,用返回值(Option 或结果枚举),让编译器帮你盯着;数组越界、整数除零、对象处于不可能状态这类程序 bug 或不变量破坏,用异常快速暴露。两者可以配合:底层抛异常简化逻辑,边界层 catch 后转换成结果枚举交给上层。


九、课后练习

  1. 仿照 6.2 节定义 enum ParseResult { Ok(Int64) | Err(String) },实现 func tryParse(s: String): ParseResult:内部用 try-catch 包住 Int64.parse。用 "123""3.14""abc" 三组输入测试,match 打印形如 123 -> Ok(123)abc -> Err(abc 不是合法整数) 的结果。
  2. 自定义 InsufficientBalanceException(继承 Exception,携带消息)。写一个 Account 类,持有余额字段,提供 withdraw(amount: Int64) 方法:余额不足时 throw InsufficientBalanceException("余额只剩 X,却要取 Y");取款成功则扣减并返回新余额。在 main 里准备 100 元的账户,分别取 30 和 200,用 catch 接住异常并只打印 e.message
  3. 准备一个会产生两类异常的程序:用 atIndex 函数制造 IndexOutOfBoundsException,用 Int64.parse 制造 IllegalArgumentException,各挂一个专门的 catch,最后再加 catch (e: Exception) 兜底。确认正常后,把兜底 catch 移到最前面重新编译,记录 useless exception type 警告,再改回正确顺序。
  4. func process(ok: Bool): Int64:try 块中 oktrue 时返回 1,为 false 时抛 IllegalArgumentException("故意失败");finally 块打印 "资源已释放"。分别调用 process(true)process(false)(后者外面套 try-catch),验证两条路径下"资源已释放"都会打印,且正常路径的返回值仍是 1
  5. 打开 7.1 的程序 Shift + F9 调试:在 throw AgeException(...) 行断点,断住后查看 Call Stack 中 checkAge → inputAge → main 三层栈帧;再在两个 catch 分支都打断点,分别用 "-3""abc" 触发,截图或口述两次进入的分支与 Variables 中 e.message 的差异。

下节预告

这节课我们一直在"用"函数(parseAgeinputAgemain),但函数到底有多少讲究,一直没系统讲过。第 11 课学习函数定义、参数与返回值:参数默认值、命名参数、可变参数、多返回值、Unit 返回类型、函数重载,以及仓颉函数"一等公民"特性的第一印象。我们下节课见!


系列说明:本系列基于 Windows 平台 + CIDE + 仓颉 SDK(1.1.3)编写,所有代码均已实际编译运行通过。如遇 SDK 版本差异导致的细节出入(例如新版本已内置 Result、defer 或修复了自定义异常的打印前缀),以你本地版本为准,欢迎评论区交流。


💬 遇到问题?扫码联系作者

跟着课程练习时,如果在 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 云服务平台

更多推荐