【仓颉语言入门 · 第二篇 第 3 课】

【仓颉语言入门 · 第二篇 第 3 课】

上一课结尾说过:我们的程序一直是"哑巴"------所有输入要么写死在代码里,要么靠改文件。真实世界的工具第一件事就是听懂命令行 :tool.exe -i input.log -o out.txt --verbose 这一串是什么?-i 后面跟的是文件路径,--verbose 是个开关,中间还有位置参数------靠 if (args[0] == "-i") 一层层手工劈,代码又长又容易错。这一课的标准库 std.argopt 把这件事变成"声明式":你只管声明"我认识哪些参数",解析、配对、校验、收集一次完成。学完顺手给第 2 课的日志工具装上真正的命令行入口。

本文所有代码、输出与报错文案均在仓颉 SDK 1.2.0 下逐行实测编译运行。


目录(第二篇导航)

第一篇《仓颉语言入门》共 30 课已完结(环境搭建 → 语法 → 函数 → 类型系统 → 工程化 → 并发 → 两场实战)。第二篇在更高的起点上继续,共 17 课、4 个模块:

模块 课次 内容
一、补遗篇 01~06 正则、时间日期、命令行参数、随机数、加密哈希、二进制处理
二、数据库篇 07~10 std.database.sql 入门与 CRUD、预编译与事务、手写 DAO、综合小实战
三、进阶硬核篇 11~14 反射、宏与 deriving、C 互操作 FFI、并发容器与对象池
四、实战项目篇 15~17 HTTP 服务端、多线程日志分析器、简易爬虫
  1. 正则表达式(std.regex)
  2. 时间日期处理(std.time)
  3. 命令行参数解析(本文)
  4. 随机数与概率(std.random)
  5. 加密与哈希(std.crypto)
  6. 二进制数据处理(std.binary)
  7. 数据库入门:std.database.sql 与 CRUD
  8. 预编译、事务与防注入
  9. 手写 DAO 数据访问层
  10. 数据库综合小实战
  11. 反射:在运行时打量类型
  12. 宏与 deriving:让编译器帮你写代码
  13. 与 C 互操作:FFI
  14. 并发容器与对象池
  15. 实战三:HTTP 服务端
  16. 实战四:多线程日志分析器
  17. 实战五:简易爬虫

一、为什么需要参数解析

先看我们一贯的做法。第 29 课的命令行小工具,输入路径是写死在代码里的:

cangjie 复制代码
let path = "data/todo.txt"   // 想换个文件?改代码、重新编译

想灵活一点,就得手工劈 main 的参数数组:

cangjie 复制代码
main(args: Array<String>): Int64 {
    // 手工劈参数:一堆 if,还要自己管下标越界、值配对、开关判断......
    var i = 0
    while (i < args.size) {
        if (args[i] == "-i" && i + 1 < args.size) {
            let path = args[i + 1]
            i += 2
        }
        // -o 呢?--verbose 呢?-i 后面忘给值呢?不认识的参数呢?
    }
    0
}

这一坨还没写完就已经能预见它的下场:每种新选项都要加一个 if ,"带值选项"要自己看下一个元素,"开关"和"带值选项"要分别处理,用户写错了还得自己想报错文案。而真实命令行比你想象的更野:-v -i a.log 和 -vi a.log 是一个意思,--input=a.log 和 --input a.log 也是一个意思------手工劈参数要把这些姿势全都照顾到,不现实。

std.argopt 的思路是声明式 :先用 ArgumentSpec 列一张"我认识哪些参数"的规格表,再把参数数组整个交给 parseArguments,它负责配对、识别各种写法、收集位置参数;不认识的东西直接抛异常,你 catch 住打印用法就行。声明一次,姿势全管。

先认全四个主角(后面逐一展开):

类型/函数 角色
ArgumentSpec 一条参数规格:叫什么名、带不带值
ArgumentMode 取值模式:开关 / 必带值 / 值可带可不带
parseArguments 解析入口:吃进参数数组和规格表,吐出结果
ParsedArguments 解析结果:选项值表 + 位置参数列表

二、第一行参数代码:声明规格,交给解析器

2.1 main 的参数从哪来

先补一个前置事实(第 1 课实测过,这里正式讲):仓颉的入口有两种写法,带参数的版本是 main(args: Array<String>)。args 里没有程序名 ,args[0] 直接就是第一个参数------这和 C/Java 的 argv[0] 是程序名不一样:

cangjie 复制代码
main(args: Array<String>): Int64 {
    println("收到 ${args.size} 个参数")
    0
}

用 cjpm 传参,-- 之后的内容会原样交给程序:

bash 复制代码
cjpm run -- hello.txt
text 复制代码
收到 1 个参数

实测注意 :Windows 下给 cjpm run -- 传中文 参数会报 IllegalArgumentException: Invalid unicode scalar value.(第 1 课踩过的坑,编码链路问题)。本课示例一律用 ASCII 参数;中文内容建议走配置文件或环境变量,第 24 课之后还会再遇到这个话题。

2.2 最小可用例子

直接上代码------一个"指定输入文件 + 可开详细模式"的小程序:

cangjie 复制代码
import std.argopt.*

main(args: Array<String>): Int64 {
    // 1. 先声明"我认识哪些参数":-i/--input 必带值,-v/--verbose 是开关
    let specs = [
        ArgumentSpec.Full("input", r'i', ArgumentMode.RequiredValue),
        ArgumentSpec.Full("verbose", r'v', ArgumentMode.NoValue)
    ]
    // 2. 把命令行参数交给解析器
    let pa = parseArguments(args, specs)
    // 3. 按名字取值:get 返回 Option<String>,没给就是 None
    if (let Some(p) <- pa.options.get("input")) {
        println("输入文件:${p}")
    }
    if (pa.options.get("verbose").isSome()) {
        println("详细模式:开")
    }
    // 4. 位置参数(不带横线的)
    for (n in pa.nonOptions) {
        println("位置参数:${n}")
    }
    0
}

编译运行(在 cjpm 工程里):

bash 复制代码
cjpm run -- -i data/app.log -v extra.txt
text 复制代码
输入文件:data/app.log
详细模式:开
位置参数:extra.txt

逐行拆解:

  • ArgumentSpec.Full("input", r'i', ArgumentMode.RequiredValue):一条完整规格------长选项叫 --input,短选项叫 -i(r'i' 是 Rune 字面量,单个字符),必须带值。三种取值模式下一节细讲。
  • parseArguments(args, specs):两个位置参数------参数数组 + 规格数组,返回 ParsedArguments。
  • pa.options.get("input"):options 是一张"选项名 → 值"的表,get 返回 Option<String>------第 9 课的老朋友,"可能没有"用 Option 显式表达。if (let Some(p) <- ...) 解包。
  • pa.options.get("verbose").isSome():开关型选项的判断方式------出现了就是 Some,没出现就是 None(值本身是空串,判断"有没有"不看值,看键)。
  • pa.nonOptions:所有不带横线的"位置参数",原样收集成 Array<String>。

注意第 2 步开始,"-i 后面跟谁"、"用户写没写 -v"这些配对逻辑全部消失------这就是声明式的价值。


三、三种取值模式:ArgumentMode

ArgumentMode 是一个枚举,只有三个成员,对应三种"这个选项怎么取值":

模式 含义 典型场景
NoValue 纯开关,出现即生效 --verbose(详细输出)、--force(强制执行)
RequiredValue 必须带值,缺值报错 -i 文件路径、-o 输出文件
OptionalValue 值可带可不带 --level(不给值用默认级别,给了用指定级别)

一次看清三种模式的行为(这回不经过命令行,直接把参数数组硬编码传给解析器,方便逐场景对照):

cangjie 复制代码
import std.argopt.*

main(): Int64 {
    let specs = [
        ArgumentSpec.Full("debug", r'd', ArgumentMode.NoValue),
        ArgumentSpec.Full("output", r'o', ArgumentMode.RequiredValue),
        ArgumentSpec.Long("level", ArgumentMode.OptionalValue)
    ]
    // NoValue:不给 -d,get 回 None
    let r1 = parseArguments(["-o", "out.txt"], specs)
    println("没开 debug:${r1.options.get("debug")}")
    println("output:${r1.options.get("output")}")
    // 给了 -d:键出现,值是空串
    let r2 = parseArguments(["-d", "-o", "out.txt"], specs)
    println("开了 debug:${r2.options.get("debug")}")
    // OptionalValue:--level=3 有值;--level 不带值
    let r3 = parseArguments(["--level=3"], specs)
    println("level=3:${r3.options.get("level")}")
    let r4 = parseArguments(["--level"], specs)
    println("level 不带值:${r4.options.get("level")}")
    0
}
text 复制代码
没开 debug:None
output:Some(out.txt)
开了 debug:Some()
level=3:Some(3)
level 不带值:Some()

三个实测结论,都是容易想错的地方:

  1. 开关没开是 None,开了是 Some() 。Some() 看着奇怪,其实是 Some("")------开关型选项"出现"时值是空串 。所以判断开关永远用 isSome(),别去比较值。

  2. OptionalValue 不带值时也是空串 (Some()),带值时才是真正的值。想区分"给了 --level 但没给值"和"压根没给值"------前者是 Some(""),后者是 None。业务上常见写法:

    cangjie 复制代码
    var level = 1
    if (let Some(v) <- pa.options.get("level")) {
        if (v != "") {
            level = Int64.parse(v)   // 给了值才覆盖默认
        }
    }
  3. RequiredValue 缺值不是返回 None,而是直接抛异常------严格模式,下一节马上讲报错文案。

另外注意规格表里混进了新面孔 ArgumentSpec.Long("level", ...)------只声明长选项、不配短选项。四种声明写法下一节集齐。


四、ArgumentSpec 的四种写法

ArgumentSpec 有四个静态构造入口,覆盖全部声明需求:

写法 签名 效果
Full Full(长名, 短字符, 模式) 长短两个名字都认
Long Long(长名, 模式) 只认长选项
Short Short(短字符, 模式) 只认短选项
NonOptions NonOptions(回调) 位置参数的集中处理入口

4.1 Full 的"双键登记"

cangjie 复制代码
import std.argopt.*

main(): Int64 {
    let specs = [
        ArgumentSpec.Full("input", r'i', ArgumentMode.RequiredValue),
        ArgumentSpec.Long("output", ArgumentMode.RequiredValue),
        ArgumentSpec.Short(r'v', ArgumentMode.NoValue)
    ]
    let pa = parseArguments(["-i", "a.log", "--output", "b.log", "-v"], specs)
    // Full:短名、长名各自是一个键,都能取到
    println("用 i 取:${pa.options.get("i")}")
    println("用 input 取:${pa.options.get("input")}")
    // Long:只有长名一个键
    println("output:${pa.options.get("output")}")
    // Short:只有短名一个键
    println("v:${pa.options.get("v")}")
    0
}
text 复制代码
用 i 取:Some(a.log)
用 input 取:Some(a.log)
output:Some(b.log)
v:Some()

Full 声明的选项,短名和长名在结果表里是两个独立的键 ,都指向同一个值。这个设计利弊参半:好处是你用哪个名字取都行;"坏处"是遍历结果表时会看到 i、input 各出现一次(遍历顺序不保证,别依赖顺序),以及判断"用户到底用的短名还是长名"做不到------解析器不区分。按名取值时,习惯统一用长名。

4.2 NonOptions:位置参数的回调

前面 pa.nonOptions 是"事后"拿位置参数;ArgumentSpec.NonOptions 提供"事中"入口------解析时回调收到全部位置参数,适合集中登记:

cangjie 复制代码
import std.argopt.*

main(args: Array<String>): Int64 {
    let specs = [
        ArgumentSpec.Full("verbose", r'v', ArgumentMode.NoValue),
        // NonOptions:收到"全部位置参数"的回调,在这里集中处理
        ArgumentSpec.NonOptions({ files: Array<String> =>
            println("待处理文件 ${files.size} 个:")
            for (f in files) {
                println("  ${f}")
            }
        })
    ]
    let pa = parseArguments(args, specs)
    // 回调之外,nonOptions 属性里同样拿得到
    for (f in pa.nonOptions) {
        println("nonOptions 里也有:${f}")
    }
    0
}

运行(cjpm run -- -v a.txt b.png):

text 复制代码
待处理文件 2 个:
  a.txt
  b.png
nonOptions 里也有:a.txt
nonOptions 里也有:b.png

回调参数是 Array<String>(lambda 参数记得写类型标注 ,第 13 课的规矩)。回调内外两份数据一致,用哪个都行------但回调里不能修改外部的 var 变量 (比如在回调里累加计数会编译报错,报错文案见第九节 FAQ),要做统计就在回调外对着 pa.nonOptions 做。


五、解析器的五条规则

parseArguments 认哪些"姿势"?五条规则,全部实测。还是硬编码参数数组,一个 show 辅助函数逐场景打印:

cangjie 复制代码
import std.argopt.*

func show(tag: String, argv: Array<String>, specs: Array<ArgumentSpec>): Unit {
    println("== ${tag} ==")
    let pa = parseArguments(argv, specs)
    println("  i = ${pa.options.get("i")},v = ${pa.options.get("v")}")
    print("  位置参数:")
    for (n in pa.nonOptions) {
        print("${n} ")
    }
    println("")
}

main(): Int64 {
    let specs = [
        ArgumentSpec.Full("input", r'i', ArgumentMode.RequiredValue),
        ArgumentSpec.Full("verbose", r'v', ArgumentMode.NoValue)
    ]
    // 规则一:长选项可以用等号连值
    show("等号形式", ["--input=a.log"], specs)
    // 规则二:-- 之后的全是位置参数,横线不再特殊
    show("双横线分隔", ["-i", "a.log", "--", "-v", "x.txt"], specs)
    // 规则三:短选项可以连写,带值的排最后
    show("短选项连写", ["-vi", "a.log"], specs)
    // 规则四:短选项的值可以直接贴在选项后面
    show("短选项值连写", ["-ia.log"], specs)
    // 规则五:同名选项重复出现,后者覆盖前者
    show("重复覆盖", ["-i", "a.log", "-i", "b.log"], specs)
    0
}
text 复制代码
== 等号形式 ==
  i = Some(a.log),v = None
  位置参数:
== 双横线分隔 ==
  i = Some(a.log),v = None
  位置参数:-v x.txt
== 短选项连写 ==
  i = Some(a.log),v = Some()
  位置参数:
== 短选项值连写 ==
  i = Some(a.log),v = None
  位置参数:
== 重复覆盖 ==
  i = Some(b.log),v = None
  位置参数:

五条规则汇总:

# 写法 行为
1 --input=a.log 等号连值,与 --input a.log 等效
2 -- 单独出现 之后所有元素原样 进位置参数,-v 也不再当选项
3 -vi a.log 短选项连写:v 是开关,最后一位 i 接后面的 a.log 当值
4 -ia.log 短选项值连写:i 的值就是紧贴着的 a.log
5 -i a -i b 同名重复:后者覆盖前者(想收集全部同名值得自己用位置参数或多个不同名选项)

规则二值得单独说一句:-- 是命令行的老约定,用来保护"长得像选项的参数" ------比如要删一个名叫 -v 的文件,rm -- -v。解析器内建了这个约定,不用你操心。


六、错误处理:ArgumentParseException

parseArguments 是严格模式:不认识的东西不装看不见,直接抛 ArgumentParseException。两种典型报错,实测文案如下:

cangjie 复制代码
import std.argopt.*

func tryParse(tag: String, argv: Array<String>, specs: Array<ArgumentSpec>): Unit {
    println("== ${tag} ==")
    try {
        let pa = parseArguments(argv, specs)
        println("  解析成功:i = ${pa.options.get("i")}")
    } catch (e: ArgumentParseException) {
        println("  参数错误:${e.message}")
    }
}

main(): Int64 {
    let specs = [ArgumentSpec.Full("input", r'i', ArgumentMode.RequiredValue)]
    tryParse("未知选项", ["-z"], specs)
    tryParse("必带值选项缺值", ["-i"], specs)
    tryParse("正常", ["-i", "ok.log"], specs)
    0
}
text 复制代码
== 未知选项 ==
  参数错误:Unknown option: z.
== 必带值选项缺值 ==
  参数错误:Missing option value for i.
== 正常 ==
  解析成功:i = Some(ok.log)

两条文案的规律:

场景 报错文案
规格表里没有的选项 Unknown option: z.(末尾带句号)
RequiredValue 选项没拿到值 Missing option value for i.(末尾带句号)

为什么设计成"直接抛"而不是"把 -z 当位置参数放过"?命令行工具的第一原则是 fail fast :用户打错参数,最负责的做法是立刻停下来告诉他哪里错了,而不是揣着错误参数继续跑。所以标准姿势是 try-catch 包住解析,catch 里打印 e.message + 用法说明,返回非 0 退出码------下一节实战就是这个套路。


七、CIDE 实操:给日志巡检装上命令行入口

第 2 课的日志巡检工具,日志路径是写死的。现在用 std.argopt 给它装上真正的命令行入口,需求:

  1. -l/--level <级别>:要过滤的日志级别,必填;
  2. -i/--input <路径>:日志文件路径,选填,缺省用 data/app.log;
  3. -v/--verbose:开关,打开时额外打印"读了多少条、命中多少条"。

7.1 数据文件

沿用第 2 课的 data/app.log(放在工程根目录):

text 复制代码
2026-10-09 08:30:15 [INFO] auth - 用户 user01 从 192.168.1.23 登录
2026-10-09 08:31:02 [ERROR] db - 连接超时: 192.168.1.10:5432
2026-10-09 08:31:45 [WARN] auth - 密码重试过多: user07
2026-10-09 08:32:20 [INFO] api - 请求 /api/orders 耗时 42ms
2026-10-09 08:33:05 [ERROR] api - 调用支付网关失败: 10.0.0.8
2026-10-09 08:34:10 [INFO] auth - 用户 13812345678 修改了手机号 13998887766

7.2 完整代码

src/main.cj 全文(文件读取是第 22 课的老三样,按行过滤用的是第 6 课的字符串方法):

cangjie 复制代码
import std.argopt.*
import std.fs.*
import std.unicode.*

main(args: Array<String>): Int64 {
    // 1. 参数规格:-i/--input 日志路径(默认 data/app.log)、-l/--level 过滤级别、-v/--verbose 详细输出
    let specs = [
        ArgumentSpec.Full("input", r'i', ArgumentMode.RequiredValue),
        ArgumentSpec.Full("level", r'l', ArgumentMode.RequiredValue),
        ArgumentSpec.Full("verbose", r'v', ArgumentMode.NoValue)
    ]
    try {
        // 2. 解析
        let pa = parseArguments(args, specs)
        // 3. 过滤级别是必填项
        var level = ""
        if (let Some(lv) <- pa.options.get("level")) {
            level = lv
        } else {
            println("缺少过滤级别:-l <INFO|WARN|ERROR>")
            return 1
        }
        // 4. 日志路径:给了 -i 就用,否则用默认
        var path = "data/app.log"
        if (let Some(p) <- pa.options.get("input")) {
            path = p
        }
        let verbose = pa.options.get("verbose").isSome()
        if (verbose) {
            println("日志路径:${path}")
            println("过滤级别:${level}")
            println("----")
        }
        // 5. 读文件、按级别过滤(第 2 课的老三样)
        let raw = String.fromUtf8(File.readFrom(path))
        let tag = "[${level}]"
        var total = 0
        var hit = 0
        for (line0 in raw.split("\n")) {
            let line = line0.trim()
            if (line == "") {
                continue
            }
            total += 1
            if (line.contains(tag)) {
                hit += 1
                println(line)
            }
        }
        if (verbose) {
            println("----")
            println("共 ${total} 条日志,其中 ${level} 级 ${hit} 条")
        }
    } catch (e: ArgumentParseException) {
        // 6. 参数本身不合法(缺值、不认识的选项)
        println("参数错误:${e.message}")
        println("用法:logtool -l <级别> [-i <日志路径>] [-v]")
        return 1
    } catch (e: Exception) {
        // 7. 运行期错误(如文件不存在)
        println("运行出错:${e.message}")
        return 1
    }
    0
}

7.3 五种运行场景

全部实测(cwd 为工程根目录):

bash 复制代码
cjpm run -- -l ERROR -v
text 复制代码
日志路径:data/app.log
过滤级别:ERROR
----
2026-10-09 08:31:02 [ERROR] db - 连接超时: 192.168.1.10:5432
2026-10-09 08:33:05 [ERROR] api - 调用支付网关失败: 10.0.0.8
----
共 6 条日志,其中 ERROR 级 2 条

不带 -v,只出结果:

bash 复制代码
cjpm run -- -l WARN
text 复制代码
2026-10-09 08:31:45 [WARN] auth - 密码重试过多: user07

什么都不传------业务层校验拦住(-l 是必填项):

bash 复制代码
cjpm run --
text 复制代码
缺少过滤级别:-l <INFO|WARN|ERROR>

-l 后面忘给值------解析层抛异常,catch 里打印用法:

bash 复制代码
cjpm run -- -l
text 复制代码
参数错误:Missing option value for l.
用法:logtool -l <级别> [-i <日志路径>] [-v]

指定一个不存在的文件------第二个 catch 接住运行期错误(报错文案里的绝对路径随运行目录而变):

bash 复制代码
cjpm run -- -l ERROR -i nofile.log
text 复制代码
运行出错:The file `C:\...\s2argopt\nofile.log` does not exist or permission denied!

7.4 逐段看懂

规格声明 :三个 Full,长短名成对给-------l 与 --level 等价,用户爱用哪个用哪个。level 用 RequiredValue:解析层保证"写了 -l 就一定有值";"-l 到底写没写"是业务层的事,所以还有 3 号段的手工校验。

双层校验:解析层的异常管"参数语法"(缺值、未知选项),业务层的 if 管"参数语义"(必填没填)。两层各司其职,报错文案也不同------语法错给用法,语义错给提示,这是命令行工具的基本修养。

双 catch :ArgumentParseException 管参数问题,Exception 兜底其他运行期错误(文件不存在就是它接住的)。catch 的顺序无关紧要,两个类型不重叠。

默认值套路 :var path = "data/app.log" 先给默认,if (let Some(p) <- ...) 有覆盖------这是"选填参数"的标准写法,比 OptionalValue 模式更直白,推荐。

对比第 2 课的写死版本:想换日志文件不用改代码了,想只看 ERROR 不用注释 INFO 的处理逻辑了------工具的"可复用",一大半来自参数化。第 29 课的待办小工具,等你做完本课练习就可以用同样的方式升级它。


八、常用 API 速查

API 说明
ArgumentSpec.Full(长名: String, 短字符: Rune, 模式: ArgumentMode) 长短双名选项;结果表里短名、长名各登记一个键
ArgumentSpec.Long(长名: String, 模式: ArgumentMode) 只认长选项
ArgumentSpec.Short(短字符: Rune, 模式: ArgumentMode) 只认短选项
ArgumentSpec.NonOptions(回调: (Array<String>) -> Unit) 位置参数集中处理回调;回调内不能改外部 var
ArgumentMode.NoValue 开关:出现即生效,值登记为空串
ArgumentMode.RequiredValue 必带值;缺值抛 ArgumentParseException
ArgumentMode.OptionalValue 值可带可不带;不带值登记为空串
parseArguments(args: Array<String>, specs: Array<ArgumentSpec>) 解析入口,返回 ParsedArguments;不认识的东西直接抛异常
ParsedArguments.options ReadOnlyMap<String, String>:选项名 → 值(含短名、长名两套键)
ParsedArguments.nonOptions Array<String>:全部位置参数
options.get(名) Option<String>:没出现是 None;判断开关用 .isSome()
ArgumentParseException.message 报错详情:Unknown option: z. / Missing option value for i.

解析器五条姿势(全部内建,无需配置):--名=值 等号连值;-- 之后全部当位置参数;短选项可连写 -vi a.log(带值的排最后);短选项值可连写 -ia.log;同名选项后者覆盖前者。


九、常见问题 FAQ

Q1:网上资料里的 ArgOpt 类(ArgOpt("i:o:v", ...) 那种)能用吗?

能用但别用------它已被标记废弃,1.2.0 编译时会给警告(实测文案):

text 复制代码
warning: class 'ArgOpt' is deprecated. Use global function `public func parseArguments(args: Array<String>, specs: Array<ArgumentSpec>): ParsedArguments` instead.

警告里已经把替代方案写得明明白白:parseArguments + ArgumentSpec,也就是本课教的这套。

Q2:开关型选项的值为什么是空串?判断开没开怎么办?

解析器的登记策略:选项出现就登记键,没值就登记空串。所以 Some()(即 Some(""))代表"出现了",None 代表"没出现"。判断永远用 isSome() ,不要去比较值是不是空串------那是 OptionalValue 区分"给了没值/没给"时才需要的事。

Q3:在 NonOptions 回调里累加计数,报 lambda capturing mutable variables needs to be called directly?

这是仓颉 lambda 的通用规则(第 13 课讲过):lambda 捕获可变变量(var)有严格限制 ,只有"定义后立刻直接调用"的 lambda 才能捕获 var。传给 NonOptions 的回调是被解析器"稍后调用"的,捕不了 var。实测报错:

text 复制代码
error: lambda capturing mutable variables needs to be called directly
note: 'sum' is mutable

解法:回调里只做打印/登记这类只读的事,统计逻辑放在回调外对着 pa.nonOptions 做(见练习 4)。

Q4:args 里为什么没有程序名?想拿程序名怎么办?

仓颉的 main(args: Array<String>) 设计成"纯参数":args[0] 就是第一个用户参数,和 C/Java 的 argv[0] 是程序名不同。一般工具用不到程序名;真需要时用固定字符串打印即可(报错用法里的 logtool 就是手写的)。

Q5:给程序传中文参数为什么报 IllegalArgumentException: Invalid unicode scalar value.?

第 1 课实测过的 Windows 编码链路问题:cjpm run -- 中文参数 在传参过程中会把中文打碎成非法字节序列。规避方式:命令行参数一律用 ASCII(路径、级别名都可以做到);中文内容放进文件让程序去读。这个坑与 std.argopt 无关,是平台链路问题,但实测确实会撞上,提前绕开。


十、课后练习

练习原则照旧:每题只考本课知识点,题面给全提示,先想清楚规格表再动手。前 4 题必做,第 5 题选做。

1.(必做)--help 用法开关。 很多工具都有 -h/--help:给了就打印用法,没给就正常干活。用 Full("help", r'h', ArgumentMode.NoValue) + isSome() 实现。提示:import std.argopt.*,判断用 pa.options.get("help").isSome()。

cangjie 复制代码
import std.argopt.*

main(args: Array<String>): Int64 {
    let specs = [ArgumentSpec.Full("help", r'h', ArgumentMode.NoValue)]
    let pa = parseArguments(args, specs)
    if (pa.options.get("help").isSome()) {
        println("用法:tool [-h]")
        println("  -h, --help  显示本帮助")
    } else {
        println("开始干活")
    }
    0
}

期望输出(两次运行):

text 复制代码
cjpm run -- -h          →
用法:tool [-h]
  -h, --help  显示本帮助

cjpm run --             →
开始干活

2.(必做)问候工具。 实现 -n/--name <名字>:给了就打印 你好,<名字>!,没给就打印 请用 -n 告诉我你的名字。提示:RequiredValue + if (let Some(n) <- pa.options.get("name"));验证时参数用英文(Windows 中文参数坑,见 FAQ Q5)。

期望输出(两次运行,第二次不带参数):

text 复制代码
cjpm run -- -n Cangjie   →
你好,Cangjie!

cjpm run --              →
请用 -n 告诉我你的名字

3.(必做)读程序写输出。 规格表如下(-i/--input 必带值,-v/--verbose 开关):

cangjie 复制代码
let specs = [
    ArgumentSpec.Full("input", r'i', ArgumentMode.RequiredValue),
    ArgumentSpec.Full("verbose", r'v', ArgumentMode.NoValue)
]

分别用三组参数解析后打印 i、v 两个键的值:① ["-ia.log"];② ["-v", "-i", "b.log"];③ ["--input=c.log", "x.txt"](第③组还要打印位置参数个数)。先在纸上写答案,再运行验证------考的是第五节的五条规则。

期望输出:

text 复制代码
① i=Some(a.log) v=None
② i=Some(b.log) v=Some()
③ i=Some(c.log) v=None 位置参数 1 个

4.(必做)位置参数求和。 命令行传来若干个整数(如 cjpm run -- 10 20 30),求总和。提示:ArgumentSpec.NonOptions 回调里打印收到几个数;求和别在回调里做 (回调捕不了 var,见 FAQ Q3),在回调外对 pa.nonOptions 循环,Int64.parse 转整数(记得 import std.convert.*)。

期望输出:

text 复制代码
收到位置参数 3 个
数字个数:3
总和:60

5.(选做)缺值的友好提示。 -o/--output 是 RequiredValue。用户只写了 -o 忘给值时,解析器抛的 Missing option value for o. 对普通用户不够友好------catch 住它,额外打印一行人话提示。提示:第六节的 try-catch 套路,e.message 拿原始文案。

期望输出(两次运行):

text 复制代码
cjpm run -- -o result.txt   →
输出到:result.txt

cjpm run -- -o              →
参数错误:Missing option value for o.
提示:选项 o 需要一个值,例如 -o result.txt

下节预告

程序"听话"之后,下一个需求是"随机":抽奖、模拟、生成测试数据、随机打乱------都绕不开随机数。下一课 随机数与概率(std.random) 讲清楚三件事:怎么取一个随机整数/浮点、怎么从"一半概率"到"按权重抽奖"控制概率分布、以及为什么默认的随机数每次运行都不一样(种子的秘密)。顺手给本课的 logtool 加一个"随机抽查 N 条日志"的功能。


系列说明:本系列基于 Windows 平台 + CIDE + 仓颉 SDK(1.2.0)编写,所有代码均已实际编译运行通过。如遇 SDK 版本差异导致的细节出入,以你本地版本为准,欢迎评论区交流。


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

跟着课程练习时,如果在 SDK 安装、环境变量配置、编译报错或调试上卡住,欢迎扫码加作者企业微信 直接咨询(请备注"仓颉课程"):

离线环境下图片可能加载不出来,也可以在 CIDE 菜单 Help ▸ 联系作者 / Contact 中查看同一张二维码(应用内置兜底图,无需联网)。


📥 工具下载

本系列全程使用的仓颉 IDE ------ CIDE(免费开源、社区版):

  • GitCode 仓库 / 安装包下载 :gitcode.com/wp_upala/ci...
  • 打开页面后进入 发行版(Releases) ,两种包任选其一:
    • 安装版 :下载 CIDE-<版本>-x64-Setup.exe,双击安装,适合日常长期使用;
    • 免安装版(Portable) :下载 CIDE-<版本>-x64-Portable.zip,解压到任意目录即用,不写注册表、不留安装痕迹,拷到 U 盘也能在别的电脑直接运行(包内附《使用说明.txt》)。适合先试用、或在受限电脑上学习本系列课程。
  • 仓颉 SDK 请前往仓颉编程语言官网下载:cangjie-lang.cn
相关推荐
松就是我902982 小时前
3.7 万 Star 的「第二大脑」Khoj,为什么关掉了云服务
后端
夜之眷属2 小时前
PriorityQueue:for-each 遍历和 poll 取出,顺序一样吗?
java·服务器·数据结构·后端·算法
蒜香可达鸭2 小时前
可控视频生成三类技术路线介绍
后端
暗夜行者之光2 小时前
LangGraph + MCP 实战:构建"自然语言查数据"的 ChatBI Agent
前端·后端
一tiao咸鱼2 小时前
还在手动海投?我做了个 AI 求职助手,自动筛选 BOSS 职位再精准投递
前端·后端·面试
码哥字节2 小时前
Spring Boot 4.1 默认开虚拟线程,连接池为什么反而打满了?
spring boot·后端
大白803 小时前
CSS 盒模型精讲:标准盒模型与 IE 怪异盒模型区别
后端
深蓝AI3 小时前
Python 写 JSON 写到一半崩了?用 os.replace 保住上一版配置(附故障注入)
后端
十年Java程序媛3 小时前
String / StringBuilder / StringBuffer|别再乱用字符串拼接,底层原理一次讲透
java·spring boot·后端